Advent Calendar 2018: advos
Day 24: 非特権タスクとマルチタスキング(その2)
今日はマルチタスキングの残りの部分を実装します。
タスクの切り替え
17日目にRing 3に遷移するときに説明しましたが,Ring 0からRing 3に遷移する際はiretq
を使います。逆にRing 3からRing 0へは割り込みハンドラや例外により遷移します。
タスクの切り替えは,タイマ割り込みハンドラの後に行うことにします。割り込みハンドラの後に別のタスクを再実行するため,割り込みハンドラでは,すべてのレジスタをスタックに保存します。今回は簡単のため,浮動小数点は扱わない前提で整数レジスタのみを保存します。このスタックに保存されたレジスタを スタックフレーム(Stackframe) と呼びます。スタックにrax
, rbx
, ..., gs
の順で積んでいった場合,スタックフレームは以下の構造体のようになります。割り込みハンドラが呼ばれる際にCPLが変わる場合は,%ss
, %rsp
, %rflags
, %cs
, %rip
をそれぞれ64ビット領域としてスタックに積むので,一番下にはこれらの値が格納されています。
struct stackframe64 {
/* Segment registers */
uint16_t gs;
uint16_t fs;
/* Base pointer */
uint64_t bp;
/* Index registers */
uint64_t di;
uint64_t si;
/* Generic registers */
uint64_t r15;
uint64_t r14;
uint64_t r13;
uint64_t r12;
uint64_t r11;
uint64_t r10;
uint64_t r9;
uint64_t r8;
uint64_t dx;
uint64_t cx;
uint64_t bx;
uint64_t ax;
/* Restored by `iretq' instruction */
uint64_t ip; /* Instruction pointer */
uint64_t cs; /* Code segment */
uint64_t flags; /* Flags */
uint64_t sp; /* Stack pointer */
uint64_t ss; /* Stack segment */
} __attribute__ ((packed));
各タスクは,このスタックフレームとページテーブル(仮想メモリ情報)をコンテキストとして保持します。つまり,タスクの切り替えは,スタックフレームの切り替えとページテーブルの切り替えを割り込みハンドラないで行うことで実現できます。今回は,ページテーブルの切り替えは面倒なので,同じページテーブルを共有します。Unix系OSで言うところのスレッドのように実現します。また,メモリの権限管理を追加するにはメモリアロケータも変更する必要があるため,今回は簡単のために,17日目同様にすべてのページへのアクセス権をRing 3に付与しました。
マルチタスキングの実装
今回は,17日目と同様にBSPのみで実装します。16日目にBSP以外のプロセッサAPを起動しましたが,どうやら時間の都合でBSPのみしか扱うことができないようです。基本的にAP固有の初期化は,PAEの有効化,スタックの設定,BSPで設定したGDT,IDTの読み込み,ページテーブルのセット,Local APICタイマの設定だけです。プロセッサ間通信にはシグナリングにはIPIを使い,データは共有メモリ上で行います。注意点は,メモリ管理構造体などは複数のプロセッサから並行して使用できるようになっていないため,適宜ロックをかけることくらいです。後日,アドベントカレンダー外で扱うかもしれません(気が向いたら)。
まず,タスクスイッチを行うために,タスクのコンテキスト情報を定義します。今回はページテーブルは共有なので,以下のようにスタックフレームとTSSのSP0,ユーザスタック,カーネルスタックを定義します。
struct arch_task {
/* Restart point (stackframe) */
struct stackframe64 *rp;
/* SP0 for TSS */
uint64_t sp0;
/* User stack */
void *ustack;
/* Kernel stack */
void *kstack;
} __attribute__ ((packed));
このタスクを切り替えるために,プロセッサに現在実行中のタスクとタスクスイッチで切り替えられる次のタスクを以下の構造体で保持します。cur_task
が現在実行中のタスク,next_task
が次に切り替えられるタスクです。
struct arch_cpu_data {
struct arch_task *cur_task;
struct arch_task *next_task;
} __attribute__ ((packed));
Start | End | Description |
---|---|---|
00000500 | 00007bff | ブートローダのスタック領域 |
00007c00 | 00007dff | MBR |
00007e00 | 00007fff | 未使用 |
00008000 | 00008fff | ブート情報(ドライブ番号など) |
00009000 | 0000cfff | ブートモニタ (16 KiB) |
0000d000 | 0000ffff | 未使用 (ブートモニタ拡張用に予約) |
00010000 | 0002ffff | カーネル (最大128 KiB) |
00030000 | 00067fff | 予約 |
00068000 | 00068fff | グローバル変数領域 |
00069000 | 0006ffff | 移動済み: カーネルのベースページテーブル |
00070000 | 00073fff | trampoline (16 KiB) |
00074000 | 00075fff | GDT (8 KiB) |
00076000 | 00077fff | IDT (8 KiB) |
00078000 | 00078fff | TSS |
00079000 | 0007ffff | 移動済み: ロングモード用ページテーブル (24 KiB = 6 * 4 KiB以上必要) |
専用のスペースを割り当てた0x00060000-0x0006ffff
のグローバル変数領域のうち0x68e00-0x6efff
をCPUのタスクスイッチ用に使います。この領域にstruct arch_cpu_data
を置きます(仮想アドレスを使うので,struct arch_cpu_data *cpu = 0xc0068e00;
とします)。
サンプルタスクの実装
今回は以下の3種類のタスクを定義します。簡単のためにグローバル変数を使います。
struct arch_task *taska;
struct arch_task *taskb;
struct arch_task *taski;
この3つのタスクの実態は以下の3つのタスクです。taska
(task_a
)は22行目にカウントアップし,値を22行目に出力する無限ループです。taskb
(task_b
)もほぼ同じタスクで,23行目に出力します。taski
(task_idle
)は実行可能なタスクがない場合にCPUを停止するためにhlt
するタスクです。今回はhlt
が呼ばれたことを確認するために,他のタスクと同様に21行目にカウンタを出力します。こちらは,hlt
命令でCPUが停止するので,このタスク実行中の 割り込みごとにカウントアップします。
void
task_a(void)
{
uint16_t *base;
uint64_t cnt = 0;
while ( 1 ) {
base = (uint16_t *)0xc00b8000;
base += 80 * 22;
print_hex(base, cnt, 8);
cnt++;
}
}
void
task_b(void)
{
uint16_t *base;
uint64_t cnt = 0;
while ( 1 ) {
base = (uint16_t *)0xc00b8000;
base += 80 * 23;
print_hex(base, cnt, 8);
cnt++;
}
}
void
task_idle(void)
{
uint16_t *base;
uint64_t cnt = 0;
while ( 1 ) {
base = (uint16_t *)0xc00b8000;
base += 80 * 21;
print_hex(base, cnt, 8);
cnt++;
__asm__ __volatile__ ("hlt");
}
}
タスクの初期化
これらのタスクは以下のコードで初期化します。kmalloc()
も簡易的に実装しました。src/kernel/arch/x86_64/arch.cを参照してください。
static int
_prepare_multitasking(void)
{
struct arch_cpu_data *cpu;
/* Task A */
taska = kmalloc(sizeof(struct arch_task));
if ( NULL == taska ) {
return -1;
}
taska->kstack = kmalloc(4096);
if ( NULL == taska->kstack ) {
return -1;
}
taska->ustack = kmalloc(4096);
if ( NULL == taska->ustack ) {
return -1;
}
taska->rp = taska->kstack + 4096 - 16 - sizeof(struct stackframe64);
kmemset(taska->rp, 0, sizeof(struct stackframe64));
taska->sp0 = (uint64_t)taska->kstack + 4096 - 16;
taska->rp->sp = (uint64_t)taska->ustack + 4096 - 16;
taska->rp->ip = (uint64_t)task_a;
taska->rp->cs = GDT_RING3_CODE64_SEL + 3;
taska->rp->ss = GDT_RING3_DATA64_SEL + 3;
taska->rp->fs = GDT_RING3_DATA64_SEL + 3;
taska->rp->gs = GDT_RING3_DATA64_SEL + 3;
taska->rp->flags = 0x202;
/* Task B */
taskb = kmalloc(sizeof(struct arch_task));
if ( NULL == taskb ) {
return -1;
}
taskb->kstack = kmalloc(4096);
if ( NULL == taskb->kstack ) {
return -1;
}
taskb->ustack = kmalloc(4096);
if ( NULL == taskb->ustack ) {
return -1;
}
taskb->rp = taskb->kstack + 4096 - 16 - sizeof(struct stackframe64);
kmemset(taskb->rp, 0, sizeof(struct stackframe64));
taskb->sp0 = (uint64_t)taskb->kstack + 4096 - 16;
taskb->rp->sp = (uint64_t)taskb->ustack + 4096 - 16;
taskb->rp->ip = (uint64_t)task_b;
taskb->rp->cs = GDT_RING3_CODE64_SEL + 3;
taskb->rp->ss = GDT_RING3_DATA64_SEL + 3;
taskb->rp->fs = GDT_RING3_DATA64_SEL + 3;
taskb->rp->gs = GDT_RING3_DATA64_SEL + 3;
taskb->rp->flags = 0x202;
/* Idle task */
taski = kmalloc(sizeof(struct arch_task));
if ( NULL == taski ) {
return -1;
}
taski->kstack = kmalloc(4096);
if ( NULL == taski->kstack ) {
return -1;
}
taski->ustack = kmalloc(4096);
if ( NULL == taski->ustack ) {
return -1;
}
taski->rp = taski->kstack + 4096 - 16 - sizeof(struct stackframe64);
kmemset(taski->rp, 0, sizeof(struct stackframe64));
taski->sp0 = (uint64_t)taski->kstack + 4096 - 16;
taski->rp->sp = (uint64_t)taski->ustack + 4096 - 16;
taski->rp->ip = (uint64_t)task_idle;
taski->rp->cs = GDT_RING0_CODE_SEL;
taski->rp->ss = GDT_RING0_DATA_SEL;
taski->rp->fs = GDT_RING0_DATA_SEL;
taski->rp->gs = GDT_RING0_DATA_SEL;
taski->rp->flags = 0x202;
/* Set the task A as the initial task */
cpu = (struct arch_cpu_data *)0xc0068e00;
cpu->cur_task = NULL;
cpu->next_task = taska;
return 0;
}
Task Aについてのみ解説します。
まず,Task Aの構造体にメモリを割り当てます。
taska = kmalloc(sizeof(struct arch_task));
if ( NULL == taska ) {
return -1;
}
次にカーネルスタックとユーザスタックをそれぞれ4096バイトずつ割り当てます。本来はユーザスタックは溢れたときにpage faultを起こすようなページテーブルエントリをスタックの下に割り当てることが望ましいですが,今回はそのような処理は省略しています。
taska->kstack = kmalloc(4096);
if ( NULL == taska->kstack ) {
return -1;
}
taska->ustack = kmalloc(4096);
if ( NULL == taska->ustack ) {
return -1;
}
ここから,タスクの初期状態を定義するためのスタックフレームの設定を行います。スタックフレームはカーネルからユーザランドに戻るときに使用するため,カーネルスタックに配置します。スタックはアドレスの上位から下位に積まれるため,割り当てたスタック領域の上位側のアドレスをセットします。以下のコードでカーネルスタックの最後の16バイトは空白にして,その前の領域にスタックフレームを配置し,スタックフレームをゼロ初期化します。
taska->rp = taska->kstack + 4096 - 16 - sizeof(struct stackframe64);
kmemset(taska->rp, 0, sizeof(struct stackframe64));
17日目に設定したように,Ring 3から割り込みや例外が呼ばれたときのスタックとして,カーネルスタックをTSSのSP0
にセットします。そのセットするSP0の値を以下のようにこのタスクに割り当てられたカーネルスタックとします。このアドレスはスタックフレームを取り出してタスクが実行されたときのカーネルスタックポインタと同一のものです(同一である必要はありませんが)。
taska->sp0 = (uint64_t)taska->kstack + 4096 - 16;
以下のコードでスタックフレームの各レジスタを設定します。スタックポインタはユーザスタックの上位アドレス(こちらも16バイト空白を空けています)を設定します。また,プログラムカウンタ(%rip
)はtask_a
関数にします。これにより,task_a
関数がタスクとして実行されます。ただし,スタックにはret
命令で戻るポインタがない(つまりcall
ではなくjmp
したときのような挙動)ため,今回はタスクはwhile (1)
でret
が呼ばれないようにしています。プロセスなどを実装するときは,プロセスを実行するための初期化ルーチンをこれにセットすることになります。cs
にはRing 3用の64ビットコードセグメント(セレクタ)を設定します。CPL=3なので,3
を足しています。ss
, fs
, gs
にはそれぞれ64ビットデータセグメント(セレクタ)を指定します。こちらも同様にCPL=3となります。flags
には割り込みを許可するために0x202
(IF=1
)をセットします。
taska->rp->sp = (uint64_t)taska->ustack + 4096 - 16;
taska->rp->ip = (uint64_t)task_a;
taska->rp->cs = GDT_RING3_CODE64_SEL + 3;
taska->rp->ss = GDT_RING3_DATA64_SEL + 3;
taska->rp->fs = GDT_RING3_DATA64_SEL + 3;
taska->rp->gs = GDT_RING3_DATA64_SEL + 3;
taska->rp->flags = 0x202;
これでタスクの設定が完了しました。
_prepare_multitasking()
関数の最後の以下のコードでtaska
をスケジュールします。
cpu = (struct arch_cpu_data *)0xc0068e00;
cpu->cur_task = NULL;
cpu->next_task = taska;
タスクの切り替え・再開
スタックフレームからタスクを再開する処理は17日目に実装したchcs
と同様にアセンブリで記述します。以下のコードは複雑に見えますが,処理自体は単純です。struct arch_cpu_data
構造体0xc0068e00
からnext_task
を取り出し,NULLでなければこのタスクのスタックフレームをスタックポインタに設定し,各種レジスタをpopしiretq
命令を呼ぶことでスタックフレームに保存されたタスクを再開します。
/* Restart a task */
_task_restart:
/* Task base address (struct arch_task *) */
movq $0xc0068e00,%rbp
/* If the next task is not scheduled, immediately restart this task. */
cmpq $0,8(%rbp) /* next_task */
jz 2f
movq 8(%rbp),%rax
/* If the current task is null, then do not need to save anything. */
cmpq $0,0(%rbp) /* cur_task */
jz 1f
/* Save the stack pointer (restart point) */
movq 0(%rbp),%rax
movq %rsp,0(%rax) /* cur_task->rp */
1:
/* Notify that the current task is switched (to the kernel) */
movq 0(%rbp),%rdi
movq 8(%rbp),%rsi
/* Task switch (set the stack frame of the new task) */
movq 8(%rbp),%rax /* next_task */
movq %rax,0(%rbp) /* cur_task */
movq 0(%rax),%rsp /* next_task->rp */
movq $0,8(%rbp) /* next_task */
/* Setup sp0 in TSS */
movq 0(%rbp),%rax /* cur_task */
movq 8(%rax),%rdx /* cur_task->sp0 */
movq $0xc0078000,%rax /* TSS */
movq %rdx,TSS_SP0(%rax)
2:
/* Pop all registers from the stackframe */
popw %gs
popw %fs
popq %rbp
popq %rdi
popq %rsi
popq %r15
popq %r14
popq %r13
popq %r12
popq %r11
popq %r10
popq %r9
popq %r8
popq %rdx
popq %rcx
popq %rbx
popq %rax
iretq
上記コードの12-13行目は,cur_task
が空で無かった場合は,現在のタスクのスタックフレームを保存し,タスクを切り替えられるようにしています。cur_task
がNULLの場合(初期状態)は,現在のタスクのスタックフレームの保存部分が実行されないため,単純にnext_task
を開始する処理になります。つまり,src/kernel/arch/x86_64/arch.c
のbsp_start()
の最後の以下の処理は,taska
を実行する処理になります。そのため,これより下のコードは実行されません。
task_restart();
割り込みハンドラからのタスクの再開
APICタイマ割り込みハンドラを以下のように変更しました。
/* Timer interrupt of Local APIC */
_intr_apic_loc_tmr:
/* Push all registers to the stackframe */
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq %r8
pushq %r9
pushq %r10
pushq %r11
pushq %r12
pushq %r13
pushq %r14
pushq %r15
pushq %rsi
pushq %rdi
pushq %rbp
pushw %fs
pushw %gs
/* Call a function */
call _ksignal_clock
/* APIC EOI */
movq $MSR_APIC_BASE,%rcx
rdmsr /* Read APIC info to [%edx:%eax]; N.B., higer */
/* 32 bits of %rax and %rdx are cleared */
/* bit [35:12]: APIC Base, [11]: EN */
/* [10]: EXTD, and [8]:BSP */
shlq $32,%rdx
addq %rax,%rdx
andq $0xfffffffffffff000,%rdx /* APIC Base */
movl $0,0x0b0(%rdx) /* EOI */
jmp _task_restart
前半はスタックフレームに各種レジスタを保存しています。その後に,C言語で実装したksignal_clock
を呼び出し、APICのEnd-of-Interruptをシグナルします。最後に_task_restart
を呼ぶことで,先ほど実装したnext_task
で指定されたタスクへの切り替えを行います。
スケジューラの実装
今回は昨日説明したラウンドロビンスケジューラをハードコーディングして実装します。APICタイマの割り込みハンドラを以下のように実装しました。
void
ksignal_clock(void)
{
uint16_t *base;
static uint64_t cnt = 0;
base = (uint16_t *)0xc00b8000;
base += 80 * 24;
print_hex(base, cnt / 100, 8);
cnt++;
/* Schedule next task */
struct arch_cpu_data *cpu;
cpu = (struct arch_cpu_data *)0xc0068e00;
if ( cpu->cur_task == taska ) {
cpu->next_task = taskb;
} else if ( cpu->cur_task == taskb ) {
cpu->next_task = taski;
} else {
cpu->next_task = taska;
}
}
以下の部分がスケジューラのハードコーディングです。つまり現在のタスクがtaska
であればtaskb
,taskb
であればtaski
,taski
であればtaska
をスケジュールします。
if ( cpu->cur_task == taska ) {
cpu->next_task = taskb;
} else if ( cpu->cur_task == taskb ) {
cpu->next_task = taski;
} else {
cpu->next_task = taska;
}
実行
このコードをdocker-compose run qemu
で実行すると以下のように画面に表示されると思います。
Welcome to advos (64-bit)! !
000000003ba281a0 !
00000000c0224240 !
00000000c0224280 !
00000000c0224280 !
# of CPU cores = 0x00000008 !
Base Length Domain !
0000000000000000 00000000000a0000 0000000000000000
0000000000100000 000000001ff00000 0000000000000000
0000000020000000 0000000020000000 0000000000000001
----------
System memory map; # of entries = 0x0006
Base Length Type Attribute
0000000000000000 000000000009fc00 00000001 00000001
000000000009fc00 0000000000000400 00000002 00000001
00000000000f0000 0000000000010000 00000002 00000001
0000000000100000 000000003fedf000 00000001 00000001
000000003ffdf000 0000000000021000 00000002 00000001
00000000fffc0000 0000000000040000 00000002 00000001
000000000000006d
00000000000a0cf5
00000000000ad036
0000000000000003
この下の4行のカウンタが上からそれぞれ,
task_idle
でカウントアップされるカウンタ(毎回hlt
が呼ばれるため,このタスクに切り替わる度にカウントアップされる)task_a
の無限ループでカウントアップされるカウンタtask_b
の無限ループでカウントアップされるカウンタ- 昨日のLocal APICタイマのカウンタ(100 Hzのタイマのカウンタを100で割っているため,約1秒に1回カウントアップされる) となります。真ん中の2行が同時にカウントアップされているように見え,正しくマルチタスキングが実装できていることがわかると思います。
まとめ
24日間でできるだけことをしました。OSの基礎的な部分は実装できたと思いますが,システムコールとプロセス,ドライバが扱えませんでした。あと3-4日足せばできると思うので,4週間あればOSの基本的な部分は実装できると思います。大学の講義だと通期26回あれば実装できそうです。仕事終わりの時間で文章を書きながら実装していたので,あまり時間が取れなかったので,要望があれば,不足している解説や背景,関連技術などを追加して電子書籍(自費出版的なもの)か何かにでもまとめようと思います。そんな要望はないと思いますが。もし要望があれば,教えてください。
振り返ってみると,ブートローダとメモリ管理に大半を割いてしまいました。OSについては,理論やアルゴリズムは座学で理解していても,いざ実装してみるとハードウェアの仕様を調べたり,実装テクニックが必要であったりします。今日実装したタスクスイッチも,スタックフレームなどは座学では決して出てこないと思います。今回扱えなかったシステムコールやマルチコアのスタック,プロセスの取り扱いなどは,理論では抽象化されて簡単に扱われますが,実装(例えば,forkシステムコールでのメモリのcopy-on-writeの実装など)は非常に複雑になってきます。そもそもforkは自力で実装してみると3日くらいかかると思います。来年のアドベントカレンダーで続きをしようかな……