xv6 initプロセス ことはじめ
あるプロセスには親プロセスという生みの親がいます。その親にも親がいます。
そして、プロセスの系譜をさかのぼり続けると一つのプロセスに行き着きます。
それはinit
プロセス。Unix及びUnix系のOSではこの名前がついています。*1
またタイトルにあるxv6
とは、2006年MITで開発された教育用OSです*2。
ANSI Cで書かれておりソースコードリーディングに適しています。
今回はあらゆるプロセスの祖先といえるinit
プロセスの生成と実行はじめまでを、xv6
のソースコードを読み、観察してみましょう。(同じ内容のスライドがあります。こちらはブログと比べスタックの動的な操作がわかりやすいです。)
あらすじ
xv6
のプロセスinit
プロセスの生成- スケジューラが
init
プロセスを選択 - スケジューラから
init
へのコンテキストスイッチ init
の実行
注意
init
プロセスに話をしぼるため、ブートローダーやカーネルの設定や初期化は完了済とします。- 掲載するソースコードのなかで今回の話に不要と思われる記述は修正や省略をしました。例えば
xv6
はマルチプロセッサに対応しており、排他制御を行うコードも存在します。しかし私の理解不足と紙面の都合上意図的に省略しました。 - 以下に登場するメモリ領域の図は上が high アドレスとなっており、スタックは下に積まれます。
xv6のプロセス
早速init
プロセスの生成を見ていきたいところですが、その前にxv6
ではプロセスをどのように実装しているのでしょう。
それはproc.h
にproc
構造体として定義されています。
struct proc { pde_t* pgdir; char *kstack; enum procstate state; int pid; struct proc *parent; struct trapframe *tf; struct context *context; };
フィールド | 説明 |
---|---|
pgdir |
仮想メモリと物理メモリのマッピングを定める |
kstack |
tf やcontext を格納する領域 |
state |
プロセスの状態を表す。proc.h にenum型として定義される |
pid |
プロセスに割り振られる一意な整数値 |
parent |
親プロセス |
tf |
トラップの発生時に使用される。今回はinit プロセスの実行に必要なレジスタを格納する |
context |
コンテキストスイッチ時にレジスタを格納する、または復元するために使用される |
init
もプロセスであるため、その生成はproc
構造体のインスタンスとして定義されます。
Cの構造体は各メンバが連続したメモリに配置されます。
つまりinitの生成は以下のようなメモリ領域を作成することと同じです。
init
プロセスの生成
この節では上に挙げたメモリ領域をinit
のために作成することが目標です。
すでにカーネルの設定は終わっているためmain
関数の中で今回の話と関係のある関数はuserinit
とmpmain
の2つです。
int main(void) { \\ カーネル、割り込みの設定のため略 userinit(); mpmain(); }
userinit
userinit
は次の4つの仕事を行います。
- initプロセスの生成
- initのカーネル空間のpagingの設定
- initのプログラムをメモリに展開
- トラップフレームの設定
そのうち上3つを以下の関数がuserinit
内で呼ばれることで、それぞれ請け負います。
allocproc
setupkvm
inituvm
次のコードはuserinit
の前半部分です。早速allocproc
が呼ばれていることがわかります。
void userinit(void) { struct proc *p; p = allocproc(); if((p->pgdir = setupkvm()) == 0) panic("userinit: out of memory?"); inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size); ..... }
allocproc
allocproc
の主な仕事は次の3つです。
UNUSED
なプロセスをテーブルから探す- そのプロセスの設定
- pid, state, trapframe, etc…
- 設定したプロセスを返す
なおallocproc
は1つのプロセスを生成するので、userinit
からだけではなくfork
からも呼び出されます。
まずプロセステーブルの中からUNUSED
なプロセスを探す処理が次のコードです。
static struct proc* allocproc(void) { struct proc *p; for (p = ptable.proc; p < &ptable.proc[NPROC]; p++) if (p->state == UNUSED) goto found; return 0; found: ... }
プロセステーブルptable
はproc.c
にグローバル変数として定義されており、64
個のプロセスエントリを格納します。
コードではプロセステーブルの先頭から最後まで見て、UNUSED
なプロセスを見つけた場合found
ラベルへと飛びます。
found
ラベル以降は次のようなコードです。
found: p->state = EMBRYO; p->pid = nextpid++; // Allocate kernel stack. if ((p->kstack = kalloc()) == 0){ p->state = UNUSED; return 0; } .....
今UNUSED
なプロセスが見つかったためその初期化を行います。まずstate
をEMBRYO
にしています。
EMBRYOは「生まれたて」という意味があります。またpid
の割り当てを行い、グローバル変数であるnextpid
をインクリメントします。これにより次回のallocproc
呼び出しではプラス1された整数値がpid
として割り当てられます。
次にkalloc
を呼び出しp
のカーネルスタックを割り当てます。これにより1ページ(4096バイト)分のメモリ領域がカーネルスタックの領域として確保されました。この領域はトラップフレームやコンテキストを格納するためにあります。ここまでの処理により次のようなメモリ領域の図になります。
カーネルスタックの設定
次に確保したカーネルスタック領域を複数の部分領域へ区切ります。後の処理によりそこにトラップフレームやコンテキストを格納できるようにします。
具体的なコードは次のようなものです。
sp = p->kstack + KSTACKSIZE; // Leave room for trap frame. sp -= sizeof *p->tf; p->tf = (struct trapframe*)sp; .....
まずsp
をカーネルスタックの最上部を指すようにします。そしてsizeof *p->tf;
によりsp
をトラップフレームのメモリ領域分を押し下げ、そのアドレスをp->tf
に代入しています。
sp -= 4; *(uint*)sp = (uint)trapret; sp -= sizeof *p->context; p->context = (struct context*)sp; memset(p->context, 0, sizeof *p->context); p->context->eip = (uint)forkret; return p;
同様にまずsp
を4バイト下げ、関数trapret
の関数ポインタを格納します。
さらにsp
をコンテキスト分下げ、eip
の領域に関数forkret
を格納します。
関数trapret
とforkret
はコンテキストスイッチが起こり、スケジューラからプロセスへ制御が渡された後すぐに実行されます。
最後にp
をリターンして関数を抜けます。ここまでの処理によりinit
は次のようなメモリ領域を構築できました。
userinit
再びuserinit
に処理が戻ってきました。次は以下の3行です。
if((p->pgdir = setupkvm()) == 0) panic("userinit: out of memory?"); inituvm(p->pgdir, _binary_initcode_start, _binary_initcode_size);
まずsetupkvm
によりinit
用のページテーブルを設定します。ページテーブルにより仮想アドレスと物理アドレスのマッピングが設定されます。inituvm
はこのマッピングにもとづいて仮想アドレスの0番地にinit
用のプログラムinitcode
を展開します。
次にinit
のトラップフレームの設定を行います。
p->tf->cs = (SEG_UCODE << 3) | DPL_USER; p->tf->ds = (SEG_UDATA << 3) | DPL_USER; p->tf->es = p->tf->ds; p->tf->ss = p->tf->ds; p->tf->eflags = FL_IF; p->tf->esp = PGSIZE; p->tf->eip = 0; // beginning of initcode.S
コメントにもある通りp->tf->eip = 0;
でinitcode
が格納されている0番地をeip
が来るようにしています。eip
はx86アーキテクチャの32bit版命令ポインタで、実行する次のアドレスを格納します。今p->tf->eip
が0となっているため、コンテキストスイッチ後に0番地から実行できるよう下準備をしました。
userinit
の最後はpの状態をRUNNABLE
にして関数を抜けます。
p->state = RUNNABLE;
これまで作成したinitのメモリは次のようになります。
スケジューラがinit
プロセスを選択
xv6
のスケジューラはシンプルに実装されています。プロセステーブルを先頭から調べstate
がRUNNABLE
なプロセスをみつけたらそのプロセスへとコンテキストスイッチします。
以下がスケジューラのコードの一部です。for文が入れ子になっており、外側のループは無限ループになっています。
void scheduler(void) { struct proc *p; struct cpu *c = mycpu(); c->proc = 0; for(;;) { ... for (p = ptable.proc; p < &ptable.proc[NPROC]; p++) { if (p->state != RUNNABLE) continue; c->proc = p; switchuvm(p); p->state = RUNNING; swtch(&(c->scheduler), p->context); ... } } }
RUNNABLE
なプロセスを見つけると、まずswitchuvm(p);
が呼ばれます。今RUNNABLE
なプロセスはinit
しかいません。
そのためswtchuvm
ではinit
のページング設定をcr3
レジスタにロードし、スケジューラのアドレス空間からinit
プロセスのアドレス空間へ切り替えます。
次にinit
をRUNNNIG
状態にします。そしてようやくswtch
関数によるコンテキストスイッチです。
swtch
の第一引数と第二引数には、それぞれ古いコンテキスト(のアドレス、理由は後述)と新しいコンテキストを指定します。
今swtch(&(c->scheduler), p->context);
であるためスケジューラからinit
プロセスへとコンテキストスイッチすることがわかります。
スケジューラからinit
へのコンテキストスイッチ
swtch
関数swtch
はswtch.S
にアセンブリで書かれています。
.glonl swtch swtch: # load `old` movl 4(%esp), %eax # load `new` movl 8(%esp), %edx # Save old callee-saved registers pushl %ebp pushl %ebx pushl %esi pushl %edi # Switch stacks movl %esp, (%eax) movl %edx, %esp # Load new callee-saved registers popl %edi popl %esi popl %ebx popl %ebp ret
swtch
呼び出し直後はスケジューラのカーネルスタックに、第二引数p-context
, 第一引数&(c->scheduler)
, swtchの戻り先のアドレス
の順に積みます。
まず先頭2行のアセンブリでeax
とedx
に、それぞれスイッチ先のプロセスのコンテキストとスケジューラのアドレスを代入します。
# load `old` movl 4(%esp), %eax # load `new` movl 8(%esp), %edx
次の4つのpushl
命令によりスケジューラのコンテキストを保存します。
# Save old callee-saved registers pushl %ebp pushl %ebx pushl %esi pushl %edi
次に2つのmovl
命令が続きます。
movl %esp, (%eax) movl %edx, %esp
1つ目の命令movl %esp, (%eax)
によりスケジューラのコンテキストを保存します。(実際はスケジューラのコンテキストの先頭を指すスタックポインタesp
の値を保存します。スケジューラのコンテキストの保存先は&(c->scheduler)
で定まる特定のアドレスであり、このためにアンパサンドがついていました。)
2つ目の命令movl %edx, %esp
がコンテキストスイッチの肝です。今までスケジューラのスタックを指していたesp
をプロセスのスタックを指すようにします。つまり
から
へとesp
が移動しました。
残りは下準備したinit
のコンテキストへ切り替えるためpop
命令を繰り返します。
# Load new callee-saved registers popl %edi popl %esi popl %ebx popl %ebp ret
4度のpopl
によりesp
は次の位置まであがります。
ret
命令により、eip
に格納されているforkret
関数ポインタをポップし、そこにジャンプします。
forkret
はC言語の関数として定義されていますが、関数を抜ける際にスタックをポップし、
そこへとジャンプすることは変わりません。よってtrapret
関数の関数ポインタがポップされ、関数の先頭へジャンプします。
残るトラップフレームのポップはtrapret
によって行われます。trapret
は次のようなアセンブリで書かれています。
.globl trapret trapret: popal popl %gs popl %fs popl %es popl %ds addl $0x8, %esp # trapno and errcode iret
トラップフレーム内のレジスタに注目するため、一つ上に載せたカーネルスタックからトラップフレームの部分を拡大します。それは次のような図になります。今esp
はトラップフレームの最下部を指していることに注意してください。
まずpopal
により汎用レジスタを復元します。次の4つのpopl
命令によりセグメントレジスタを復元します。そして次のaddl
命令によりesp
を8バイト押し上げ、trapno
とerrcode
を無視します。最後のiret
により残るレジスタの復元を行います。具体的にはeip
, cs
, eflags
, esp
, ss
が復元されます。特にeip
とesp
の復元により、initcode
(init
プロセスの実行プログラム)のために格納した4096バイトの最下部と最上部を、それぞれeip
とesp
が指すようになり、無事initcode
の実行が始まります。
参考文献
- xv6
- book-rev11.pdf
- xv6実装の詳解(マルチタスク処理 switching編)
*1:https://ja.wikipedia.org/wiki/Init
*2:https://pdos.csail.mit.edu/6.828/2019/xv6.html
*3:本テーマと不要なメンバは省略しています