ゼロからOS自作入門 13章

マルチタスク(1)
おぉついに複数のアプリを動かすための基盤を作るのか

マルチタスクとコンテキスト
マルチタスク
コンピュータにおいて複数のタスク(プロセス)を切り替えて実行できるシステムのことである。マルチプログラミングという語は複数のプログラムを動かすという点に着目した語である。逆に、同時に一つのタスクしか実行できない方式をシングルタスクという。
マルチタスクはいろいろな実現方法がある
- 並列処理:実行するCPUコアを分けることで完全に並列処理を行う
- 並行処理:実行するCPUコアは同じだが、実行する時間を分離することで仮想的に並列処理を行う
- 時分割方式
並列処理:CPUコア分割 | 並行処理:時分割方式 |
---|---|
![]() |
![]() |
並行処理:時分割方式 |
---|
![]() |
CPUコア分割で完全に分離させていれば、(メモリの問題はあるが)管理は非常に簡単である。一方、時分割方式の場合はリソースの管理だけでなく、優先順位や処理が重いタスクへの対応など多くの課題がある。

この2つの違いはコンテキストで説明できる
コンテキスト
ある作業をするために必要なデータや変数をまとめたもの
- アプリの実行バイナリ
- コマンドライン引数
- 環境変数
- スタックメモリ
- 各レジスタの値など
アプリケーションはそれぞれ独自のコンテキストを持ち、その中で動作する。あるコンテキストの中では他のコンテキストのことは考慮することはない。そのため、複数のアプリケーションが同時並行で動くことが出来る。
並行処理と並列処理はどちらか一方を使うのではなく、両方を組み合わせて仕様する

コンテキストの切り替えに挑戦
コンテキストスイッチを実装する
コンテキストスイッチ
複数のプロセスが1つのCPUを共有できるように、CPUの状態(コンテキスト)を保存したり復元したりする過程のこと
コンテキストスイッチは以下の順で処理を行う
- タスクAの処理を行う
- タスクAのコンテキストをCPUレジスタからタスクAのスタックに退避する
- タスクBのコンテキストをCPUレジスタに復帰させる
- タスクBの処理を行う
さらに3.では次の設定が必要(参考:X86アセンブラ/x86アーキテクチャ)
- RIP(プログラムカウンタ)をタスクBの先頭アドレスに書き換える
- RSPをタスクBのスタックポインタに書き換える
- タスクBに引数がある場合にはレジスタに値を設定する
- 今回はRDI/RSI
- RSI/ESI/SI
- ソースレジスタ」と呼ばれる
- ストリーム操作コマンド(たとえば MOV命令)でのソース(入力元)へのポインタとして使用される
- RDI/EDI/DI
- デスティネーションレジスタと呼ばれる
- ストリーム操作コマンドでの転送先へのポインタとして使用される
値を退避する場合には「RIPの値を保存すること」がポイントである。これによって元の処理に戻ることが出来る
これがマルチスレッドにおけるリソース競合問題が起きる原因かと
ここでのポイントは「CPUはRIPで指定されているアドレスの処理を実行しているに過ぎない」ということである。つまり、CPUからするとコンテキストの切り替わりが起ころうと起こらまいとRIPで指定されている処理を実行しているに過ぎない

main関数で InitializeTaskBWindow();
を呼び忘れていた。そりゃダメだ。
今日は眠いので、明日はここの部分を理解する

struct TaskContext {
uint64_t cr3, rip, rflags, reserved1; // offset 0x00
uint64_t cs, ss, fs, gs; // offset 0x20
uint64_t rax, rbx, rcx, rdx, rdi, rsi, rsp, rbp; // offset 0x40
uint64_t r8, r9, r10, r11, r12, r13, r14, r15; // offset 0x80
std::array<uint8_t, 512> fxsave_area; // offset 0xc0
} __attribute__((packed));
alignas(16) TaskContext task_b_ctx, task_a_ctx;
これがタスクコンテキストか。つまり、状態を復元するためのスタックに詰め込むデータ。今回は2つのタスクなので、それぞれ領域を確保する

void TaskB(int task_id, int data) {
printk("TaskB: task_id=%d, data=%d\n", task_id, data);
char str[128];
int count = 0;
while (true) {
++count;
sprintf(str, "%010d", count);
FillRectangle(*task_b_window->Writer(), {24, 28}, {8 * 10, 16}, {0xc6, 0xc6, 0xc6});
WriteString(*task_b_window->Writer(), {24, 28}, str, {0, 0, 0});
layer_manager->Draw(task_b_window_layer_id);
SwitchContext(&task_a_ctx, &task_b_ctx);
}
}
SwitchContext
関数のシグニチャはこう
void SwitchContext(void* next_ctx, void* current_ctx);
次のスタックの領域と退避先の現在のスタックを与える

ただし、コンテキストスイッチを切り替える処理はC++にはないのでアセンブリ言語で表現する
global SwitchContext
SwitchContext: ; void SwitchContext(void* next_ctx, void* current_ctx);
mov [rsi + 0x40], rax
mov [rsi + 0x48], rbx
mov [rsi + 0x50], rcx
mov [rsi + 0x58], rdx
mov [rsi + 0x60], rdi
mov [rsi + 0x68], rsi
lea rax, [rsp + 8]
mov [rsi + 0x70], rax ; RSP
mov [rsi + 0x78], rbp
mov [rsi + 0x80], r8
mov [rsi + 0x88], r9
mov [rsi + 0x90], r10
mov [rsi + 0x98], r11
mov [rsi + 0xa0], r12
mov [rsi + 0xa8], r13
mov [rsi + 0xb0], r14
mov [rsi + 0xb8], r15
mov rax, cr3
mov [rsi + 0x00], rax ; CR3
mov rax, [rsp]
mov [rsi + 0x08], rax ; RIP
pushfq
pop qword [rsi + 0x10] ; RFLAGS
mov ax, cs
mov [rsi + 0x20], rax
mov bx, ss
mov [rsi + 0x28], rbx
mov cx, fs
mov [rsi + 0x30], rcx
mov dx, gs
mov [rsi + 0x38], rdx
fxsave [rsi + 0xc0]
; iret 用のスタックフレーム
push qword [rdi + 0x28] ; SS
push qword [rdi + 0x70] ; RSP
push qword [rdi + 0x10] ; RFLAGS
push qword [rdi + 0x20] ; CS
push qword [rdi + 0x08] ; RIP
; コンテキストの復帰
fxrstor [rdi + 0xc0]
mov rax, [rdi + 0x00]
mov cr3, rax
mov rax, [rdi + 0x30]
mov fs, ax
mov rax, [rdi + 0x38]
mov gs, ax
mov rax, [rdi + 0x40]
mov rbx, [rdi + 0x48]
mov rcx, [rdi + 0x50]
mov rdx, [rdi + 0x58]
mov rsi, [rdi + 0x68]
mov rbp, [rdi + 0x78]
mov r8, [rdi + 0x80]
mov r9, [rdi + 0x88]
mov r10, [rdi + 0x90]
mov r11, [rdi + 0x98]
mov r12, [rdi + 0xa0]
mov r13, [rdi + 0xa8]
mov r14, [rdi + 0xb0]
mov r15, [rdi + 0xb8]
mov rdi, [rdi + 0x60]
o64 iret

で、タスクBは自分でタスクAに切り替えている。だから、タスクA、つまり、メインスレッドではタスクBに切り替える必要がある
__asm__("cli");
if (main_queue->size() == 0) {
__asm__("sti");
SwitchContext(&task_b_ctx, &task_a_ctx);
continue;
}

global GetCR3 ; uint64_t GetCR3();
GetCR3:
mov rax, cr3
ret
ここでCR3レジスタの値を取得する。ここかx86_64アーキテクチャ

ページ機構のエントリポイント、すなわち、4-levelページングの場合でいう、PML4 tableのアドレスは、CR3レジスタに入っている(キャッシュの項にCR3レジスタのビット割り当て例がある)。そして、このことは、CR3レジスタの値を書き換えることで、ページ機構をまるまる別のものに入れ替える事ができることを意味する。実際にOSは、プロセス毎に仮想アドレス空間(ie. リニアアドレス)を用意するためにこの仕組みを用いている。プロセス毎にページ機構を用意して、プロセスの切り替えのたびにCR3レジスタを書き換えているのだ。こうすることで、アプリケーションから見れば(ie. 論理アドレスでは)同一のアドレスのメモリを、アプリケーション毎に別のページで保持する事が可能になる。

Basic program execution registers
汎用レジスタが16に増加しました。汎用レジスタは64bit幅でWord, Dword, Qword整数の演算をサポートします。バイトレジスタへのアクセスは、一番下の8ビットに一律に行われます。 命令ポインタレジスタは64ビットになります。EFLAGSレジスタは64ビット幅に拡張され、RFLAGSレジスタと呼ばれます。 RFLAGSの上位32ビットは予約されています。 RFLAGSの下位32ビットはEFLAGSと同じです。

次はSwitchContext()の処理と
SwitchContext()の直前のコンテキスト構造体 |
---|
![]() |
ここに書いたように第一引数がCPUレジスタにロードするスタックのアドレス、第二引数が現在のCPUレジスタを保存する先
直前の状態はCPUレジスタのRIP/RSPはMainの処理を指している
SwitchContext()の直後のコンテキスト構造体 |
---|
![]() |
直後はTaskBの処理を指している

細かいアセンブリの処理は難しい

コンテキストスイッチの自動化
協調的マルチタスク(ノンプリエンプティブマルチタスク)
一つの処理装置(CPU)で並行して複数の処理を進め、OSがCPUを管理しない方式。タスク制御をアプリケーション側に依存してます。1つのアプリケーションが実行している間は他のアプリケーションの実行が制限されます。昔の性能の低いコンピュータで実用的に使用できますがそのアプリケーションの処理を終了しなければ他のタスクを実行出来ません。その為、そのアプリの処理中にエラー等が発生するとシステム全体が停止する事も有りました。
プリエンプティブルマルチタスク
OSがCPUやシステム資源を管理し、CPU使用時間や優先度などによりタスクを実行状態や実行可能状態に切り替える方式。OS側の処理が複雑になりますが、前記の様にアプリケーションがエラーで停止しても他のアプリに影響を与えずに処理を行います。

ノンプリエンプティブマルチタスクは実行するタスクがコンテキストを切り替えあう 協調動作 をおこなう。これではタスクを追加する毎に呼びあう必要があるので拡張性が低い。
そこで、このコンテキストスイッチをOSに任せるプリエンプティブルマルチタスクに移行することで拡張性が向上させる。

NotifyEndOfInterrupt()
でイベントハンドラを作って、SwitchTask()
でコンテキストを切り替えるってやつか。
で、このコンテキスト切り替えをする関数をタイマの割り込みに入れ込むのか

マルチタスクの検証
切り替えの周期を観察できるレベルにするのか
- 修正前(timer.hpp)
// define task timer
const int kTaskTimerPeriod = static_cast<int>(kTimerFreq * 0.02);
const int kTaskTimerValue = std::numeric_limits<int>::min();
- 修正前(timer.hpp)
// define task timer
const int kTaskTimerPeriod = static_cast<int>(kTimerFreq * 1.0);
const int kTaskTimerValue = std::numeric_limits<int>::min();
切り替えの周期を変えるとで可視化か。なるほど。

タスクを増やす
確かにタスクを増やす毎に TaskContext
の変数を増やすのは面倒

個々のタスクをクラスで表現するのか
class Task {
public:
static const size_t kDefaultStackBytes = 4096;
Task(uint64_t id);
Task& InitContext(TaskFunc* f, int64_t data);
TaskContext& Context();
private:
uint64_t id_;
std::vector<uint64_t> stack_;
alignas(16) TaskContext context_;
};
確かにこの3つのプロパティがあればタスク自体は表現できる

using TaskFunc = void (uint64_t, int64_t);
へぇusing宣言で関数ポインタみたいの作れるんだ

class TaskManager {
public:
TaskManager();
Task& NewTask();
void SwitchTask();
private:
std::vector<std::unique_ptr<Task>> tasks_{};
uint64_t latest_id_{0};
size_t current_task_index_{0};
};
TaskManager は面白い。個別の変数からVectorにすることで可変長のタスクを扱えるようにしているんだ。

タスク切換えの関数も良いな。楽しい。
void TaskManager::SwitchTask() {
size_t next_task_index = current_task_index_ + 1;
if (next_task_index >= tasks_.size()) {
next_task_index = 0;
}
Task& current_task = *tasks_[current_task_index_];
Task& next_task = *tasks_[next_task_index];
current_task_index_ = next_task_index;
SwitchContext(&next_task.Context(), ¤t_task.Context());
}

InitializeTask();
task_manager->NewTask().InitContext(TaskB, 45);
task_manager->NewTask().InitContext(TaskIdle, 0xdeadbeef);
task_manager->NewTask().InitContext(TaskIdle, 0xcafebabe);
ここでタスクを3つ生成して、計4つのタスクにしているんだ
- メインタスク
- TaskB
- TaskIdle1
- TaskIdle2

いやぁこの章は面白かった。