タイマーで周期割り込みを使うことができたので、簡単なスケジューラを実装してみます。
この章では、章を通して「030_sched」のサンプルディレクトリの内容を作っていきます。
この章では簡単なスケジューラを実装しマルチタスクを実現してみます。
この章で実装するスケジューラはざっくりと以下のようなものとします。
実行中のタスク(コンテキスト)を別のタスクへ切り替える(コンテキストスイッチ)の方法やタスクの構造などの細かい実装については後ほど説明します。
また、一般的にはコンテキストスイッチの前に、次に実行するタスクを決める「ディスパッチ」の処理があります。今回のスケジューラの場合、タスク数は全体で2つなので、ディスパッチ処理は「今実行していない方のタスクを選択する」だけです。
まず、スケジューラを実装していく「sched.c」(そして「sched.h」)を用意します。
ここではまず、「sched_init」という関数を用意して、5ms周期の周期タイマーをセットアップし、「sched_start」関数で周期タイマーを開始させるようにしておきます(リスト3.1)。
リスト3.1: 030_sched/sched.c
/* 追加(ここから) */ #include <hpet.h> #include <pic.h> #include <fbcon.h> #define SCHED_PERIOD (5 * MS_TO_US) void schedule(void) { set_pic_eoi(HPET_INTR_NO); putc('.'); } void sched_init(void) { /* 5ms周期の周期タイマー設定 */ ptimer_setup(SCHED_PERIOD, schedule); } void sched_start(void) { /* 周期タイマースタート */ ptimer_start(); } /* 追加(ここまで) */
5ms周期で実行されるハンドラ関数の名前は「schedule」としておきます。今後、このschedule関数でコンテキストスイッチを行うようにしていきます。そのため、今回実装するスケジューラのタイムスライスは5msです。
ここまでで動作確認のため、main.cへsched_init関数とsched_start関数呼び出しを追加します(リスト3.2)。
リスト3.2: 030_sched/main.c
/* ・・・ 省略 ・・・ */ #include <hpet.h> #include <sched.h> /* 追加 */ #include <common.h> /* ・・・ 省略 ・・・ */ void start_kernel(void *_t __attribute__((unused)), struct platform_info *pi, void *_fs_start) { /* ・・・ 省略 ・・・ */ /* ファイルシステムの初期化 */ fs_init(_fs_start); /* スケジューラの初期化 */ /* 追加 */ sched_init(); /* 追加 */ /* CPUの割り込み有効化 */ enable_cpu_intr(); /* スケジューラの開始 */ /* 追加 */ sched_start(); /* 追加 */ /* haltして待つ */ while (1) cpu_halt(); }
sched.hを作成してsched_init関数とsched_start関数のプロトタイプ宣言を行い(リスト3.3)、MakefileのOBJSへsched.oを追加すると(リスト3.4)、make、実行できます。
リスト3.3: 030_sched/include/sched.h
/* 追加(ここから) */ #ifndef _SCHED_H_ #define _SCHED_H_ void sched_init(void); void sched_start(void); #endif /* 追加(ここまで) */
リスト3.4: 030_sched/Makefile
TARGET = kernel.bin CFLAGS = -Wall -Wextra -nostdinc -nostdlib -fno-builtin -fno-common -Iinclude LDFLAGS = -Map kernel.map -s -x -T kernel.ld OBJS = main.o iv.o fbcon.o fb.o font.o kbc.o x86.o intr.o pic.o \ sched.o hpet.o acpi.o handler.o fs.o common.o # sched.oを追加 # ・・・ 省略 ・・・
実行すると5ms周期で'.'が描画されることを確認できます。
まず、コンテキストスイッチでやらなければならないことはざっくりと以下の3つです。
ある地点から実行を再開できるようにするために退避しておかなければならない情報は、割り込みの時と同じで、CPUが扱う各種レジスタの値です。それらの情報をスタックへpushすることで退避しておきます。この時、次に実行する命令のアドレス(戻り先アドレス)もpushしておきます。そして、pushした後のスタックポインタをこれまで実行していたタスクに紐づく情報として別途用意するタスク毎の構造へ保存しておきます。
次に実行するタスクを復帰する際は、タスク構造へ保存しておいたスタックポインタをロードし、各種レジスタの値をスタックからpopするだけです。
最後に、スタックにpushしておいた実行再開のアドレスをpopし、そのアドレスで実行を再開するとコンテキストスイッチ完了です。
本書ではHPETの周期タイマーからiretqするタイミングでコンテキストスイッチします。具体的な流れは以下の通りです。
まず、「4. kernel: 汎用レジスタをスタックに積む」をですが、これはhandler.sのHPETの割り込みハンドラ(hpet_handler)で実装済みです。
次に「5. kernel: 現在のスタックポインタ(RSP)を現在のタスク構造へ保存」について、前章でHPETの割り込みハンドラを追加する際、割り込みハンドラhpet_handlerの入り口で全ての汎用レジスタを積み終わった後のスタックポインタを後続のハンドラ関数へ引数として渡すようにしていました。
ptimer_setup関数等に与えるユーザー側のハンドラでも引数でそのスタックポインタを受け取れるようにしているため、スケジューラ側で周期割り込み時に呼び出される関数を用意し、その関数内でスタックポインタをタスクの構造へ保存するようにすれば良いです。
本書で扱う範囲において、タスクとして保持しておくべき情報はスタックポインタ以外に無いため、タスク構造はスタックポインタを格納する単なる配列とします。
以上を踏まえてスタックポインタ保存を実装するとリスト3.5の通りです。
リスト3.5: 030_sched/sched.c
#include <hpet.h> #include <pic.h> #include <fbcon.h> #define SCHED_PERIOD (5 * MS_TO_US) /* 追加(ここから) */ #define NUM_TASKS 2 unsigned long long task_sp[NUM_TASKS]; volatile unsigned int current_task = 0; /* 追加(ここまで) */ /* 変更(ここから) */ void schedule(unsigned long long current_rsp) { task_sp[current_task] = current_rsp; current_task = (current_task + 1) % NUM_TASKS; set_pic_eoi(HPET_INTR_NO); } /* 変更(ここまで) */ void sched_init(void) { /* 5ms周期の周期タイマー設定 */ ptimer_setup(SCHED_PERIOD, schedule); } /* ・・・ 省略 ・・・ */
配列task_spが、タスク毎のスタックポインタを格納する配列です。
また、タスク番号を指すグローバル変数current_taskの値を次に実行するタスク番号を指すように更新しています(ディスパッチ)。
続いて「6. kernel: 次のタスクの構造に保存されているスタックポインタを復帰」を実装します。
先ほど更新したcurrent_taskを使ってtask_sp[current_task]のようにすることで、次に実行するタスクのスタックポインタを取得できます。ただし、今はまだtask_sp[1]に初期値を設定していないのでどんな値が復帰されるかは不定です。(タスク番号1、すなわち今回新たに追加するタスクの初期設定は後ほどやります。)
スタックポインタ復帰はインラインアセンブラで実装します(リスト3.6)。
リスト3.6: 030_sched/sched.c
/* ・・・ 省略 ・・・ */ void schedule(unsigned long long current_rsp) { task_sp[current_task] = current_rsp; current_task = (current_task + 1) % NUM_TASKS; set_pic_eoi(HPET_INTR_NO); asm volatile ("mov %[sp], %%rsp" /* 追加 */ :: [sp]"a"(task_sp[current_task])); /* 追加 */ } /* ・・・ 省略 ・・・ */
task_sp[current_task]をRSPレジスタへ格納しているだけです。
あとは、「7. kernel: 汎用レジスタをpop」と「8. kernel: iretq命令を実行」です。
handler.sのhpet_handlerのスタックポインタを退避していたので、その地点からiretqまでを再現します。汎用レジスタをpopして、iretqします(リスト3.7)。
リスト3.7: 030_sched/sched.c
/* ・・・ 省略 ・・・ */ void schedule(unsigned long long current_rsp) { task_sp[current_task] = current_rsp; current_task = (current_task + 1) % NUM_TASKS; set_pic_eoi(HPET_INTR_NO); asm volatile ("mov %[sp], %%rsp" :: [sp]"a"(task_sp[current_task])); /* 追加(ここから) */ asm volatile ( "pop %rdi\n" "pop %rsi\n" "pop %rbp\n" "pop %rbx\n" "pop %rdx\n" "pop %rcx\n" "pop %rax\n" "iretq\n"); /* 追加(ここまで) */ } /* ・・・ 省略 ・・・ */
ここまでで、コンテキストスイッチの流れは実装完了です。
今回のサンプルでは、「'A'を出力し続けるタスク(タスクA)」と「'B'を出力し続けるタスク(タスクB)」の2つで実験してみることにします。
実験程度なので、簡単のため、タスクAはmain.cに(030_task_a)、タスクBはsched.cに(030_task_b)実装してしまうことにします。
リスト3.8: 030_sched/main.c
/* ・・・ 省略 ・・・ */ void do_taskA(void); /* 追加 */ void start_kernel(void *_t __attribute__((unused)), struct platform_info *pi, void *_fs_start) { /* ・・・ 省略 ・・・ */ /* スケジューラの初期化 */ sched_start(); /* タスクAの開始 */ /* 追加 */ do_taskA(); /* 追加 */ /* haltして待つ */ while (1) cpu_halt(); } /* 追加(ここから) */ void do_taskA(void) { while (1) { putc('A'); volatile unsigned long long wait = 10000000; while (wait--); } } /* 追加(ここまで) */
リスト3.9: 030_sched/sched.c
/* ・・・ 省略 ・・・ */ #define SCHED_PERIOD (5 * MS_TO_US) #define NUM_TASKS 2 #define TASK_B_STASK_BYTES 4096 /* 追加 */ unsigned long long task_sp[2]; volatile unsigned int current_task = 0; /* 追加(ここから) */ unsigned char taskB_stack[TASK_B_STASK_BYTES]; void do_taskB(void) { while (1) { putc('B'); unsigned long long wait = 1000000; while (wait--); } } /* 追加(ここまで) */ /* ・・・ 省略 ・・・ */
リスト3.9では、タスクBのスタックの確保も行っていて、実装方法としてはグローバル配列の定義で代用しています。(メモリを動的に確保する機能がまだ無いので。)
また、do_taskA関数でもそうでしたが、do_taskB関数でsleepを使用していないのは、sleepが複数のコンテキストから呼ばれることに対応していないためです*1。
[*1] whileで待っている箇所をsleepへ置き換えても動きますが、引数に与える時間に関わらず固定の時間(体感的には一瞬)しか待機してくれないです。(おそらくロックの問題だと思います。)ただ、今回の場合は「文字が出すぎない様に」のためのものなので現状のsleepでも問題ありません。
最後に、タスクBのスタックを初期化すれば終わりです。
新たに生成されたタスクのスタックには、hpet_handlerで汎用レジスタをスタックに積み終わった直後の状態を再現できれば、既に動いているタスクと整合性がとれます。
結論としてはリスト3.10のようにスタックを積んでおけば良いです。
リスト3.10: 030_sched/sched.c
/* ・・・ 省略 ・・・ */ void sched_init(void) { /* 5ms周期の周期タイマー設定 */ ptimer_setup(SCHED_PERIOD, schedule); /* 追加(ここから) */ /* 予めTaskBのスタックを適切に積んでおき、スタックポインタを揃える */ unsigned long long *sp = (unsigned long long *)((unsigned char *)taskB_stack + TASK_B_STASK_BYTES); unsigned long long old_sp = (unsigned long long)sp; /* push SS */ --sp; *sp = 0x10; /* push old RSP */ --sp; *sp = old_sp; /* push RFLAGS */ --sp; *sp = 0x202; /* push CS */ --sp; *sp = 8; /* push RIP */ --sp; *sp = (unsigned long long)do_taskB; /* push GR */ unsigned char i; for (i = 0; i < 7; i++) { --sp; *sp = 0; } task_sp[1] = (unsigned long long)sp; /* 追加(ここまで) */ } /* ・・・ 省略 ・・・ */
HPETの割り込みハンドラ(hpet_handler)にて、割り込みハンドラ入り口で汎用レジスタをスタックへ積んだ直後のRSPをコンテキストスイッチ時にタスクの構造へ保存しておくようにしていました。それと整合性を取るようにするには、「割り込み発生直後、スタックに汎用レジスタを積んだ状態」を再現すれば良いです。
汎用レジスタをどう積むかは、自身で割り込みハンドラに書いたものと同じように積めば良いです。
問題は、割り込みハンドラ呼び出し直後のスタックの状態がどうなっているか、ですが、これはCPUのデータシートで決められているもので、その通りにスタックを積むとリスト3.10のようになります。
以上でソースコードの追加・変更は完了です。
ビルドし実行すると、図3.1のように、スケジューラにより'A'と'B'が出力されます。
図3.1: 030_schedの実行結果