Linuxカーネルに関する技術情報を集めていくプロジェクトです。現在、Linuxカーネル2.6解読室の第2章までを公開中。
プロセスが切り替わるイメージを持つことはなかなか難しいため、実際のプロセスディスパッチャのコードを少しのぞいてみることにしましょう。ここでは、Intel x86用Linuxのコードを参照します。
Linuxカーネルのプロセスディスパッチャのコードは、context_switch関数にあります(リスト1-1)。context_switch関数は、プロセス空間の切り替え処理(switch_mm関数の<1>)と、各種レジスタの切り替え処理(switch_to関数の<2>)から成ります。プロセス空間の切り替え処理を効率化するために、その前後ではさまざまな処理を行っています。これらの処理については、「13.6.4 空間の切り替え」で詳しく説明します。
- task_t * context_switch(runqueue_t *rq, task_t *prev, task_t *next)
- {
- struct mm_struct *mm = next->mm;
- struct mm_struct *oldmm = prev->active_mm;
- if (unlikely(!mm)) {
- next->active_mm = oldmm;
- atomic_inc(&oldmm->mm_count);
- enter_lazy_tlb(oldmm, next);
- } else
- switch_mm(oldmm, mm, next); ――<1>
- if (unlikely(!prev->mm)) {
- prev->active_mm = NULL;
- WARN_ON(rq->prev_mm);
- rq->prev_mm = oldmm;
- }
- switch_to(prev, next, prev); ――<2>
- return prev;
- }
リスト1-2のswitch_toマクロが、プロセス切り替え処理の中で一番の核心となる部分です。switch_toマクロは、各種レジスタの切り替えを行います。このレジスタ切り替え処理をもう少し詳しく見てみます。とくに、EIPレジスタ(命令ポインタ)とESPレジスタ(スタックポインタ)の切り替えに注目してください。
- #define switch_to(prev,next,last) do { \
- unsigned long esi,edi; \
- asm volatile("pushfl\n\t" \ ――<3>
- "pushl %%ebp\n\t" \ ――<4>
- "movl %%esp,%0\n\t" \ ――<5>
- "movl %5,%%esp\n\t" \ ――<6>
- "movl $1f,%1\n\t" \ ――<7>
- "pushl %6\n\t" \ ――<8>
- "jmp __switch_to\n" \ ――<9>
- "1:\t" \ ――<10>
- "popl %%ebp\n\t" \ ――<11>
- "popfl" \ ――<12>
- :"=m" (prev->thread.esp),"=m" (prev->thread.eip), \
- "=a" (last),"=S" (esi),"=D" (edi) \
- :"m" (next->thread.esp),"m" (next->thread.eip), \
- "2" (prev), "d" (next)); \
- } while (0)
引数prevは、現在動作中のプロセスのtask_struct構造体を指しています。引数nextは、次に動作させるプロセスのtask_struct構造体を指しています。task_struct構造体中のthread.eipメンバーは、EIPレジスタを保存する領域です。また、thread.espメンバーは、ESPレジスタを保存する領域です。switch_toマクロは、prevで示されるプロセスのESPレジスタを退避し(<5>)、nextで示されるプロセスのESPレジスタを復帰しています(<6>)。これら処理によって、プロセスのスタック領域の切り替えが実現します。
プロセスprevが次回動作を再開するとき、<10>の個所から処理を行うようにEIPレジスタの退避域(thread.eip)に<10>のアドレスを退避します(<7>)。次に動作するプロセスnextの実行再開個所は、nextのレジスタの退避域(thread.eip)から取り出したEIPレジスタの値をスタック上に置いておきます。__switch_to関数を呼び出した後、__switch_to関数(<9>)から戻るとき、スタック上に積んだEIPレジスタの値が自動的にCPUに読み込まれます*1。その瞬間、プロセスnextの中断地点から、CPUは実行を再開することになります。
またしばらく後に、プロセスprevは再度選択され、実行権が与えられます。そのときは、先ほどプロセスprevの復帰アドレスとしてthread.eipに退避した個所<10>から実行を再開します。
ここのコードだけ見ていると、すべてのプロセスが同じ地点<10>から処理を再開することになるように見えますが、実はいくつか例外があります。forkシステムコールによって生まれたばかりの子プロセスは、forkシステムコールの後半処理から動作を始めます。この子プロセスの__switch_to関数からの戻り番地として、forkシステムコールの後半処理を開始アドレスとしてEIPレジスタの退避域(thread.eip)に登録しておきます。子プロセスはswitch_to関数の前半の処理(<3>~<9>)を実行していません。突然__switch_to関数の中からわき出てきたように動き始めます。
また、生成されたばかりのカーネルスレッドも同様に、独自の番地から実行を開始するようにEIPレジスタの退避域(thread.eip)を設定しています<8>。カーネルスレッドも__switch_to関数の中からわき出てきたように動き始めます。
ところで、このswitch_toマクロには3つ目の引数lastが必要なのでしょうか? switch_toマクロの前半にあるprevは、実行権を手放すプロセスを指しています。一方、このプロセスが__switch_to関数から戻って再スケジューリングされたとき、prevとnextもプロセス切り替え前の状態に戻ってしまうため、last引数がない場合、実際にどのプロセスから切り替わったかという情報が失われます。そこで、lastにプロセス切り替え前のprevの値を覚えさせておくことで、どのプロセスから切り替わったかを判別できるようになります。これによって、switch_toマクロを利用しているschedule関数が期待どおり動作するようになります。
__switch_to関数(リスト1-3)は、EIPとESP以外のレジスタの切り替えを行います(<9>)。プロセス切り替え処理を少しでも効率化するために、必要のないときはなるべくレジスタへのアクセスを減らす努力がなされています。最も特徴的な個所は、FPUレジスタ(浮動小数点演算レジスタ)の遅延切り替え機能です。
- struct task_struct fastcall *
- __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
- {
- struct thread_struct *prev = &prev_p->thread, *next = &next_p->thread;
- int cpu = smp_processor_id();
- struct tss_struct *tss = &per_cpu(init_tss, cpu);
- __unlazy_fpu(prev_p); ――<13>
- load_esp0(tss, next); ――<14>
- savesegment(fs, prev->fs); ――<15>
- savesegment(gs, prev->gs); ――<16>
- load_TLS(next, cpu); ――<17>
- if (unlikely(prev->fs | next->fs))
- loadsegment(fs, next->fs); ――<18>
- if (prev->gs | next->gs)
- loadsegment(gs, next->gs); ――<19>
- if (unlikely(prev->iopl != next->iopl))
- set_iopl_mask(next->iopl); ――<20>
- if (unlikely(next->debugreg[7])) {
- set_debugreg(next->debugreg[0], 0);
- set_debugreg(next->debugreg[1], 1);
- set_debugreg(next->debugreg[2], 2);
- set_debugreg(next->debugreg[3], 3);
- set_debugreg(next->debugreg[6], 6);
- set_debugreg(next->debugreg[7], 7);
- }
- if (unlikely(prev->io_bitmap_ptr || next->io_bitmap_ptr))
- handle_io_bitmap(next, tss);――<21>
- disable_tsc(prev_p, next_p);――<22>
- return prev_p;
- }
プロセス切り替えでは、FPUレジスタの値も切り替える必要があります。__unlazy_fpu関数は、そのための関数です(<13>)。__unlazy_fpu関数は、呼び出された瞬間にはFPUレジスタを切り替えません。FPUレジスタには大きなレジスタが複数存在し、レジスタ値の退避/復帰にかかるコストが大きいため、少々トリッキーな手段を使って性能劣化を緩和しています。
具体的には、__unlazy_fpu関数は、プロセスprevがFPUレジスタを利用している(プロセスprevが実行権を得てから、実行権を手放すまでにFPUレジスタを利用した)場合、プロセスprevのFPUレジスタ値の退避を行います。しかし、プロセスnextのFPUレジスタ値の復帰は行いません。その代わりに、FPUレジスタへのアクセスを禁止状態に変更します。この状態でプロセスnextがFPUレジスタにアクセスしようとすると、例外が発生し、その例外処理中でFPUレジスタの復帰処理を行います。プロセスnextが、FPUレジスタにアクセスしなかった場合は、例外が発生せず、FPUレジスタの復帰処理を省くことができます。FPUレジスタの復帰処理は、この先で浮動小数点演算を行うプロセスが動作するときまで行われません。実際にFPUレジスタを利用するプロセスは限られているため、FPUレジスタの切り替え回数を大幅に減らすことができます。
ところで、一見FPUレジスタの退避処理も遅延させられそうに思えますが、なぜ遅延させていないのでしょうか? 理由の1つは、マルチプロセッサシステムの場合、このプロセスprevがほかのCPU上で再スケジューリングされてしまうことがあるためです。仮にそうなると、異なるCPU上のFPUレジスタ群をプロセスprevのもので初期化する必要があり、その処理は非常に苦労することになります。
ほかの特権レジスタの切り替えについては、表1-1にまとめてありますので、ご参照ください。
ステップ | 説明 |
13 | 浮動小数点レジスタの遅延切り替え |
14 | システムコール発行時、割り込み発生時に利用するカーネルスタックの底を設定する |
17 | スレッドごとに個有データ(Tread-Local Storage)を持つことができる。その個有データへアクセスするためのディスクリプタを切り替える |
15, 16, 18, 19 | GSセグメント、FSセグメントの切り替え |
20, 22 | I/Oポートへのアクセス権の設定。x86では、eflagsレジスタのIOPL(I/O ppriviledge Level)による制御(20)と、TSSのI/O permission bitmapによる制御(22)がある。特権的なプロセスには、ここでI/Oポートを操作する権限が与えられる |
21 | デバッグ関連のレジスタの切り替え |
23 | セキュアコンピューティングモードのプロセスは、TSCレジスタをアクセスできないようにする。たとえば暗号化処理などの実行時間から、その動きを推測することを難しくできる |
ここまで見てきて気が付いた人もいると思いますが、汎用レジスタはいつ切り替えたのでしょうか? 実は、Intel x86ではこのタイミングで切り替えることは不要です。必要な情報はすでにスタック上に退避されています。もちろんこれはCPUアーキテクチャ依存(もちろん、コンパイラにも依存)です。関数呼び出しにおいて、呼び出された側でレジスタ値が壊れないことを保証しなければならないアーキテクチャである場合、それらのレジスタ値を退避する必要があります。
この節では、Intel x86アーキテクチャ用のプロセス切り替えのコードを見てきました。このプロセス切り替えのコードは、CPUアーキテクチャごとに用意されています。しかし、その本質はIntel x86のものと同じです。Intel x86のコードと同じ視点から、ほかのCPUアーキテクチャ用のコードも理解することが可能だと思います。