OS徒然草 (2)

執筆者 : 小田 逸郎


コンテキストスイッチ

OSの開発をしていると、必然的にシステムコールを使用したプログラムを作成することになります。誰しも始めたての頃は、fork(2)システムコールを不思議に思うのではないかと思います。

pid_t pid;

pid = fork();
if (pid == 0) {
   /* child. child start here */
} else if (pid == -1) {
   /* error */
} else {
   /* parent */
}

forkの復帰値が親と子で違っていて、それで自分が親か子かを区別できるというのは、どんな仕掛けになっているのだろうと気になりますよね*1

似たような例としては、setjmp(3)、longjmp(3)関数があります。プログラム例を見てみましょう。

      1 /* SPDX-License-Identifier: BSD-2-Clause
      2  * Copyright(c) 2024 Itsuro Oda
      3  */
      4 #include <stdio.h>
      5 #include <setjmp.h>
      6 
      7 static jmp_buf env;
      8 
      9 static void c(void)
     10 {
     11         printf("func c called\n");
     12         longjmp(env, 1);
     13         printf("not reach here\n");
     14 }
     15 
     16 static void b(void)
     17 {
     18         printf("func b called\n");
     19         c();
     20         printf("not reach here\n");
     21 }
     22 
     23 static void a(void)
     24 {
     25         printf("func a called\n");
     26         b();
     27         printf("not reach here\n");
     28 }
     29 
     30 int main(void)
     31 {
     32         if (setjmp(env) == 1) {
     33                 printf("return\n");
     34                 return 0;
     35         }
     36 
     37         a();
     38 
     39         printf("not reach here\n");
     40         return 1;
     41 }

参考: コードは、github(こちら)にも置いてあります。githubの方には、本ブログの補足的説明もありますので、参照してください。

このプログラムを実行すると、以下の出力が得られます。

func a called
func b called
func c called
return

12行目のlongjmpを実行すると、何故か、32行目から再開されるというのがなんとも不思議です。 setjmp/longjmpの実装をアセンブルコードで見てみるとその謎が解けます。前回宣言したとおり、RISC-Vを例に取ることにします。

setjmp:
 1  sd      s0, 0(a0)
 2  sd      s1, 8(a0)
 3  sd      s2, 16(a0)
 4  sd      s3, 24(a0)
 5  sd      s4, 32(a0)
 6  sd      s5, 40(a0)
 7  sd      s6, 48(a0)
 8  sd      s7, 56(a0)
 9  sd      s8, 64(a0)
10  sd      s9, 72(a0)
11  sd      s10, 80(a0)
12  sd      s11, 88(a0)
13  sd      sp, 96(a0)     setjmp呼び出し時のスタックポインタ
14  sd      ra, 104(a0)    setjmpからのリターンアドレス
15  fsd     fs0, 112(a0)
16  fsd     fs1, 120(a0)
17  fsd     fs2, 128(a0)
18  fsd     fs3, 136(a0)
19  fsd     fs4, 144(a0)
20  fsd     fs5, 152(a0)
21  fsd     fs6, 160(a0)
22  fsd     fs7, 168(a0)
23  fsd     fs8, 176(a0)
24  fsd     fs9, 184(a0)
25  fsd     fs10, 192(a0)
26  fsd     fs11, 200(a0)
27  li      a0, 0
28  ret                    jump ra

longjmp:
29  ld      s0, 0(a0)
30  ld      s1, 8(a0)
31  ld      s2, 16(a0)
32  ld      s3, 24(a0)
33  ld      s4, 32(a0)
34  ld      s5, 40(a0)
35  ld      s6, 48(a0)
36  ld      s7, 56(a0)
37  ld      s8, 64(a0)
38  ld      s9, 72(a0)
39  ld      s10, 80(a0)
40  ld      s11, 88(a0)
41  ld      sp, 96(a0)     setjmp呼び出し時のスタックポインタ
42  ld      ra, 104(a0)    setjmpからのリターンアドレス
43  fld     fs0, 112(a0)
44  fld     fs1, 120(a0)
45  fld     fs2, 128(a0)
46  fld     fs3, 136(a0)
47  fld     fs4, 144(a0)
48  fld     fs5, 152(a0)
49  fld     fs6, 160(a0)
50  fld     fs7, 168(a0)
51  fld     fs8, 176(a0)
52  fld     fs9, 184(a0)
53  fld     fs10, 192(a0)
54  fld     fs11, 200(a0)
55  seqz    a0, a1         -+ val = val == 0 ? 1 : val
56  add     a0, a0, a1     -+
57  ret                    jump ra

コーリングコンベンションに関して、以下に補足しておきます。

  • 関数呼び出し時のa0、a1は、関数の第一引数、第二引数。
  • 関数復帰時のa0は、返り値。
  • s0~s11、fs0~fs11は、呼び出された関数側で退避が必要なレジスタ。言い換えると、関数実行の前後で値が変わっていないことを保証しなければならないレジスタ。
    補足: それ以外のレジスタは、壊されても問題ないように呼び出し側で対処されているはずなので、退避の必要はない。なお、退避しても問題はない。
  • spはスタックポインタ。raは、リターンアドレス。

処理を簡単に説明すると、以下のとおりです。

  • setjmpでは、レジスタをenvに退避し(1~26行目)、0を返す(27、28行目)。
  • longjmpでは、レジスタをenvから戻し(29~54行目)、val(第二引数)を返す(55~57行目)。
    補足: 55、56行目は、valに0を指定された場合は1と見なすという仕様の実装

要するにlongjmpでは、まさにsetjmpから復帰するところのレジスタ状態を再現し、その上で復帰値だけは違うものにしているということです。

筆者がまだ駆け出しの頃、先輩から、setjmp/longjmpなんて、レジスタの退避、復元してるだけだよ、と教えられて、へぇー、と感心した記憶を思い出しながら、本稿を書いています。実は、基本的には、レジスタ(とpc)を復元すれば、任意の箇所から再開できるのですが、それが分かったときに大変感動した覚えがあります。今となっては当たり前すぎて、果たして他の人も感動するものなのかどうかが定かではなくなっていますが、これからも筆者が昔に心を動かした記憶を思い出しながら取り上げていくつもりです。

それにしても、最近は感動する出来事がなくなってきている気がします。感受性が衰えているという節もありますが、新しい技術が出てきていないというのも事実かと思います。これまでの技術の改善であったり、実は昔からあった技術の最発明(再発見)であったりがほとんどな気がします。

閑話休題。

一連の命令列のことをスレッドと言います。もう少し正確には、ある関数を起点とし、関数呼び出しによる一連の命令実行列のことをスレッドと言います。通常のプログラムは、main関数を起点とするひとつのスレッドで構成されていると考えることができます。

ひとつのプログラムに複数のスレッドを存在させることもできます。プログラム例を紹介しましょう。(少々長いので折りたたんでいます。)

▼クリックして内容を表示: プログラム例(cswitch.c)

      1 /* SPDX-License-Identifier: BSD-2-Clause
      2  * Copyright(c) 2024 Itsuro Oda
      3  */
      4 #include <stdio.h>
      5 #include <ucontext.h>
      6 #include <stdlib.h>
      7 
      8 struct func_context {
      9         struct func_context *next;
     10         ucontext_t ucontext;
     11 };
     12 
     13 static struct func_context *head, *tail;
     14 #define NUM_THREAD 2
     15 static struct func_context func_context[NUM_THREAD];
     16 static ucontext_t con_sched;
     17 
     18 /* CAUTION: must be sure the stack size is enough or
     19  * should add to handle stack overflow.
     20  */ 
     21 #define STACK_SIZE 10000
     22 static char stack[NUM_THREAD][STACK_SIZE];
     23 
     24 static void put_sched_q(struct func_context *con)
     25 {
     26         con->next = NULL;
     27         if (tail == NULL) {
     28                 head = tail = con;
     29         } else {
     30                 tail->next = con;
     31                 tail = con;
     32         }
     33 }
     34 
     35 static struct func_context *get_sched_q(void)
     36 {
     37         struct func_context *con = head;
     38 
     39         if (head) {
     40                 head = head->next;
     41                 if (tail == con) {
     42                         tail = NULL;
     43                 }
     44         }
     45 
     46         return con;
     47 }
     48 
     49 static int sched(void)
     50 {
     51         struct func_context *con;
     52 
     53         if (getcontext(&con_sched) == -1) {
     54                 perror("sched: getcontext");
     55                 return 1;
     56         }
     57         con = get_sched_q();
     58         if (con) {
     59                 if (setcontext(&con->ucontext) == -1) {
     60                         perror("sched: setcontext");
     61                         return 1;
     62                 }
     63         }
     64         /* no thead. return */
     65         return 0;
     66 }
     67 
     68 static void yeild(int id)
     69 {
     70         struct func_context *con = &func_context[id];
     71 
     72         put_sched_q(con);
     73         if (swapcontext(&con->ucontext, &con_sched) == -1) {
     74                 perror("swapcontest");
     75                 exit(1);
     76         }
     77         /* note: swapcontext returns 0 when resumed. */
     78 }
     79 
     80 static void func(int id)
     81 {
     82         for (int i = 0; i < 3; i++) {
     83                 printf("func_%d work (%d)\n", id, i);
     84                 yeild(id);
     85         }
     86 }
     87 
     88 static int create_thread(int id)
     89 {
     90         struct func_context *con = &func_context[id];
     91         ucontext_t *ucon = &con->ucontext;
     92 
     93         if (getcontext(ucon) == -1) {
     94                 perror("getcontext");
     95                 return 1;
     96         }
     97         ucon->uc_stack.ss_sp = stack[id];
     98         ucon->uc_stack.ss_size = sizeof(stack[id]);
     99         ucon->uc_link = &con_sched;
    100         makecontext(ucon, (void (*)())func, 1, id);
    101 
    102         put_sched_q(con);
    103         return 0;
    104 }
    105 
    106 int main(void)
    107 {
    108         for (int i = 0; i < NUM_THREAD; i++) {
    109                 if (create_thread(i) == 1) {
    110                         return 1;
    111                 }
    112         }
    113 
    114         return sched();
    115 }

参考: コードは、github(こちら)にも置いてあります。githubの方には、本ブログの補足的説明もありますので、参照してください。

このプログラムでは、func関数によるスレッドを2つ作成し、切り替えながら動作していきます。プログラムを実行すると以下の出力が得られます。

func_0 work (0)
func_1 work (0)
func_0 work (1)
func_1 work (1)
func_0 work (2)
func_1 work (2)

スレッドの切り替えの様子を図示すると以下のようになります。

スレッド切り替えの様子

マルチスレッドと言えば、pthreadを使うのが一般的ですが、ここでは、もう少し原始的なgetcontext(3)等を使用しています。getcontextでレジスタの退避、setcontextでレジスタの復元を行っています。setcontextするとあたかもgetcontextが復帰したかのように再開するというところが、慣れないと戸惑うところかと思います。

図中で、レジスタを退避(もしくは作成)している箇所を赤、復元している箇所を青で示しています。丸、四角、三角でスレッドの区別をしています。

なお、プログラムでは、funcスレッドからschedスレッドへの切り替えにswapcontextを使用していますが、これは、内部的にgetcontextとsetcontextを行っていると思って良いです。

スレッドの作成のポイントとなるのがmakecontextです。makecontextは、ざっくり言ってしまうと、「さあこれからfuncを実行するぞ」という状態のコンテキストを作り出すものです。なお、スレッドというものを考える上で重要なことですが、スレッドにはそれ用のスタックが必要です。makecontextでは、新しく確保したスレッドを使用するよう設定します。もう一点補足すると、makecontextでは、funcからの復帰時にuc_linkで設定したコンテキストに切り替わるような仕掛けが施されます。

おっと、ついにコンテキストという用語を使ってしまいました。コンテキストという言葉もコンテキスト依存な傾向にありますが、ここで言うコンテキストとは、スレッド切り替えのために必要な情報のことを指します。ざっくり言ってしまえば、コンテキストとはレジスタのことなのです。もちろん、正確にはさらに様々な情報が含まれますが、まずは、コンテキストとはレジスタのことだと看破してしまうのが理解への近道ではないかと思います。

OSのコンテキストスイッチの一番奥底のコードは、難所のひとつですが、ここまでのところに違和感がなくなっていれば、難所に挑む準備はできたと言えるでしょう。

本章の最後に少しだけ補足しておくと、CPUの立場からすると、一連の命令列を実行しているだけで、何かを切り替えているという意識はないということに注意してください。スレッドというものは、あくまでもプログラミングにおけるテクニック的なものであり、命令列を論理的に分離したものであると言えます。

割り込み

普通のユーザは、そもそもOSの存在自体を意識していないと思いますが、もしOSというものが存在していることに気が付いた際には、OS自身はいつどうやって動いているのだろうと不思議に思うのではないでしょうか。

実は、OSは元祖イベントドリブンプログラムです。またまた昔話になって恐縮ですが、筆者が駆け出しの頃は、GUIなどというものは存在せず、80文字×24行のターミナルで作業を行っていました。UNIXのguruと呼ばれる先輩がXウインドウなるものを密かに移植しようとしていましたが、使えるようになるのはもう少し先の話です。後にGUIプログラミングでイベントドリブンという手法があると知ったのですが、え、それってそもそもOSは、最初からイベントドリブンじゃん、と思った覚えがあります。

OSを駆動するイベントというのは、割り込み*2のことです。割り込みとは、CPUが実行しているスレッドを中断し、別のスレッドに強制的に切り替える機構です。

どんな割り込みがあるのかは、アーキテクチャによって異なりますが、大きくは以下の3つに分類できます。

  • 外部割込み
    周辺装置からの信号を受けての割り込み。タイマーからの割り込みなど。
  • 例外
    不正な命令の実行、不正なアドレスをアクセス、など、命令の実行を契機とした割り込み。
  • システムコール
    プログラムからOSに対して処理を要請するための割り込み。

どのアーキテクチャでも、割り込みごとに、その割り込みが発生したときに実行する命令のアドレスを設定できるようになっています。これは、OSが起動した直後の初期化時にOS自身が設定します。言うまでもないことですが、その割り込みに応じた処理(の先頭命令アドレス)が設定されています。

割り込みが発生すると、そのときのコンテキストを保存し、設定した命令に実行を移します。どの程度のコンテキストをハード的に保存してくれるかはアーキテクチャにより異なりますが、いずれにせよ、割り込み処理の冒頭は、OSが自身の管理する領域にコンテキストの退避を行うというコードになっているはずです。

割り込みに応じた処理が終わった後は、その割り込みで中断したスレッドを再開するのが基本ですが、割り込み種別によっては、再開できないものもありますし、別のスレッドの実行を行う場合もあります。割り込みの契機がプロセスのタイムスライス切れだった場合とかがそれにあたります。このあたりは、OSの実装により様々です。

ところで、CPUで実行すべきスレッドが何もなくなってしまったらどうすればよいのでしょう。大体、どのアーキテクチャでも、「割り込みがあるまでCPUを休止する」というような命令が用意されています。OSは、何もすることがないときに、この命令を実行すればよいことになります。

スーパバイザモード

CPUには、ユーザモードとスーパバイザモードの二つのモードがあって*3、通常のプログラムはユーザモードで、OSはスーパバイザモードで動作します。

モード*4とは、CPUが実行可能な命令を区別するためのものです。モードにより、実行できる命令の範囲が違います。スーパバイザモードでは、全ての命令が実行可能ですが、ユーザモードでは、実行できない命令があります。CPUには、CPUの動作をいろいろと制御するためのコントロールレジスタと呼ばれるレジスタ群があります。例えば、コントロールレジスタを変更する命令は、スーパバイザモードでは実行できますが、ユーザモードでは実行できません(実行しようとすると例外が発生します)。なお、基本命令セットは、もちろんユーザモードで実行できます。

OSは通常のプログラムを実行する際にユーザモードで動作するように制御します。OSの立場的には、自分はどんな命令でも実行できるが、普通のプログラムには当たり障りのない命令しか実行させないようにしているという訳です。

スーパバイザモードをユーザモードに切り替えるのはOSが制御していますが、ユーザモードからスーパバイザモードの切り替えはどうしているのでしょう。ユーザモードからスーパバイザモードへの移行は、割り込みが契機となって行われます。というか契機は割り込みのみです。OSは、割り込みを契機に動作すると上で説明したとおりですが、その際自動的にスーパバイザモードになっているという訳です。

実は、CPU起動後は、スーパバイザモードで動作します。最初に動いたプログラム*5であるOSが、以降、自分が動かすプログラムは、ユーザモードで動作するよう制御するという訳です。

あとがき

今回はプログラムが多くなってしまいました。エンジニアは自然言語を使うよりコンピュータ言語の方が通じやすいという傾向もありますので、まあいいですよね。ではまた次回。

*1:forkの話はただの頭出しです。復帰値が異なる仕掛けについては、本ブログを読んだ後には想像が付くようになっているでしょう。forkについてはまた別の機会に話題にするかもしれません

*2:ここでは、割り込みという用語をOSを駆動するイベントの意味で使っています。割り込みという用語も若干文脈依存なところがあります。文脈によっては、外部割込みのことを指す場合もありますので、文脈によって判断してください

*3:これも分かり易さのため、単純化しています。アーキテクチャによってはもっとモードがあったりしますし、最近では、仮想化支援のためにモードが増えていたりします。

*4:モードの実体は、あるコントロールレジスタの特定のビットが立っているかどうかということです。

*5:正確には、OSをロードするためのブートローダーがOSよりも先に動いていたりしますが。