前章では、UEFIの機能を使用してAPを制御する方法を紹介しました。この章では、UEFIの世界を抜けてカーネルへジャンプしてきた後でAPを制御する方法を紹介します。
前章の最後で、APをカーネルへジャンプさせました。これで、BSP同様に、カーネルへ来ることはできているのですが、カーネル側のエントリー関数である現状のstart_kernel()には、「BSPであるかAPであるか」の条件分岐が無いです。そのため、BSP/APに関わらずカーネルの初期化処理が実施されます。デバドラの初期化など、カーネルの初期化処理はたいてい、BSP側で一度実施しておけば良いので、start_kernel()冒頭にBSP/APの条件分岐を入れます。
BSPかAPかの判別にプロセッサ番号を見たいところですが、UEFIの世界は既に抜けているので、WhoAmI()は使えません。実は、プロセッサ番号は「Local APIC」と呼ばれるICが持っています。ここでは、Local APICの情報からAP自身のプロセッサ番号を確認してみます。
この項のサンプルディレクトリは「021_lapic_id」です。
「APIC」は「Advanced Programmable Interrupt Controller」の略で、旧来の割り込みコントローラ(PIC)の進化版です。各プロセッサに一つずつ付いているため「Local APIC」と呼ばれています。(LocalではないAPICが居たりする訳ではないです。)APIC周りの構成例をIntel SDM*1から引用します(図2.1)。
[*1] Intelが提供している開発者向けのマニュアル(Intel Software Developer's Manual)です。配布場所などは本書末尾の「参考情報」を参照してください。
前章でWhoAmI()で取得したプロセッサ番号の実体は「Local APIC ID」と呼ばれるもので、プロセッサ毎のLocal APICのレジスタに格納されています。
それでは、Local APICのレジスタにアクセスし、Local APIC IDを取得してみます。
まず、Local APICのレジスタへはメモリへマップされたアドレスでアクセスします。
Local APICの領域の先頭アドレスは0xfee00000です。これは物理アドレスなのですが、このシリーズの自作OSはページテーブルを、UEFIが設定しておいてくれるまま、物理アドレス=仮想アドレスで使用しているので、そのまま0xfee00000の領域にアクセスすれば良いです。
そして、Local APIC IDが格納されているレジスタは0xfee00020の「Local APIC ID Register」です。4バイトのレジスタで、上位8ビットに実際のLocal APIC IDが格納されています(その他は予約ビットです)。
これを踏まえて、プロセッサ番号(=Local APIC ID)を取得する関数「get_pnum」を「apic.c」というファイルを作って追加してみます(リスト2.1)。
0xfee00020からunsigned int(4バイト)でレジスタ値を取得し、24ビット右シフトすることで上位8バイトを得ています。
また、併せてincludeディレクトリに「apic.h」を追加し、get_pnum()のプロトタイプ宣言を追加しておきます(コードは割愛)。
加えて、"acpi.o"をMakefileの"OBJS"へ追加しておいてください(こちらもコードは割愛)。
get_pnum()を実装したことで、カーネル側でもBSP/AP自身がプロセッサ番号を取得できるようになりました。
これを利用して、start_kernel()にAPの場合の条件分岐を追加します。BSP/APそれぞれで、自身のプロセッサ番号を表示させるようにしてみました(リスト2.2)。
start_kernel()冒頭に、プロセッサ番号を確認して0(BSP)でなければAP用の処理を実施する処理を追加しました。BSP/AP共に、実験として、自身のプロセッサ番号を表示した後、無限ループで止めています。BSPの場合のみ、コンソールの初期化をしてからプロセッサ番号表示を行います。
なお、自作カーネルにはまだロック機構が無いため、ここでは、BSPと各APが文字表示を順番に行うように「con_access_perm」変数で、「今コンソールへアクセスして良いプロセッサ番号」を管理しています。プロセッサ番号は、BSPが0、APは1以降となりますので、con_access_permを0から始め、各プロセッサで文字表示し次第、con_access_permをインクリメントすることで、各プロセッサが順番にコンソールへ出力するようにしています。なお、これはあくまでも仮の実装です。ロックについては次項以降で改めて説明、実装します。
実行すると、プロセッサの数だけ0から順に番号が表示されます(図2.2)。
筆者の環境の場合、プロセッサは4つなので「0123」と表示されています。
前項では、BSP/APでのコンソールへの同時アクセスを防ぐため、順番にアクセスする仕組みを入れていました。
汎用的なロックを実装する前に、そもそもAP側で何らかのリソースを使うとき、自身で初期化しないなら誰か(主にBSP)が初期化するのを待つ必要があります。この項では、APがコンソール初期化を待つ処理を追加してみます。
この項のサンプルディレクトリは「022_wait_con_init」です。
コンソールの初期化が完了したことを示すフラグ変数「is_con_inited」を追加し、APはこのフラグがセットされるのを待つようにします(リスト2.3)。
実行すると図2.3のように表示されました。
APがコンソールの初期化を待つようにはなったのですが、put系の関数を同時に呼び出すため、文字と文字の出力が被って表示が壊れてしまっています。
それでは、コンソールへのアクセスを保護するロック機構を実装します。
この項のサンプルディレクトリは「023_spin_lock」です。
なお、ここで保護する対象は、「putc()による1文字の出力」とします。それにより、前項で確認した「文字出力同士がかぶり、文字が壊れる事態」を防ぎます。文字を出力する順序に対しては特になにもしないので、同時にputc()が呼ばれたときの文字の表示順は任意となります。
ソフトウェアの処理において、「ここの処理は、別のスレッド等から同時実行されると困る」場合があります。今回のputc()が正にそれで、putc()である1文字を描画中にputc()が呼ばれると、同じ位置に文字を書こうとして文字の表示が壊れます。そのような場合に特定の処理の実行を順番待ちさせる仕組みが「ロック」です。
ロックにはいくつか種類があるのですが、ここでは比較的シンプルでIntel SDMにも実装例がある「スピンロック(Spin Lock)」を実装してみます。スピンロックは、ある区間を誰かがロックを取って実行中の時、その区間を実行しようとした2つ目以降のプロセッサをロックが解放されるまでビジーループで待たせる方式です。シンプルなものなので詳しくは実装で説明します。
さっそくコードを紹介します(リスト2.4)。
spin_lock()がロックを取得する関数で、spin_unlock()で解放します。ロック状態は変数で管理し、これらの関数へポインタで渡します(lockvar変数)。
冒頭のコメントが、Intel SDM記載のサンプルで、spin_lock()とspin_unlock()は、C言語で書き下したものです。
spin_lock()/spin_unlock()共にロック状態を保持する変数のポインタを渡すようになっています。
spin_lock()で行っていることは以下の3つです。
1.について、「CPU_PAUSE()」は今回追加したマクロで、x86.hへリスト2.5の様にマクロ定義を追加しました。
「pause」という命令を呼び出すだけのマクロです。pause命令は、CPUへビジーループがあることを伝えるヒントとして機能する命令です。コレを入れておくとCPUがよしなにウェイト時間を設けて、省電力化へ寄与します。これまで知らなかったので使っていませんでしたが、ビジーループで待機させている箇所にはpause命令を入れておくと良さそうです。
そして、2.について、「atomic命令」は、他の一般的な命令では複数の命令になってしまうものを1命令で行ってくれるものです。1命令(不可分、atomic)なのでその間に割り込まれる事が無いと保証できます。
今回使用しているatomic命令は「xchg(Exchange Register/Memory with Register)」です。これはレジスタあるいはメモリの内容を別のレジスタと入れ換える命令です。インラインアセンブラの書き方についてここでは説明しませんが、この様に書くことでlockvar変数とlock変数の内容の入れ替えを1命令で行うことができます*2。lock変数には予め、ロックを示す値(1)を入れているので、入れ替えによってlockvarへ1がセットされます。
[*2] xchg命令は「メモリ領域とレジスタ」あるいは「レジスタとレジスタ」の入れ替えしかできません。ここでは「lockvar(メモリ領域)とlockの内容を格納したレジスタ」の入れ替えとなるようにインラインアセンブラを書いています。asm volatile ()のコロンで区切られた2つ目に、「lock」に対しては「+r(レジスタ)」を、「lockvar」に対しては「+m(メモリ)」を指定しているのがそうです。
なぜこのような方法でlockvarの書き換えを行っているのかというと、これによってロックを取得できたのか否かを確実に知る事ができるからです。確実にロックを取得する上で問題となるのが1.でロックが解放済み(0)であることを確認した後xchg命令を実行するまでの間で、他のプロセッサ等が先にxchg命令を実行してロックを取ってしまうことです。そんな場合でもlockvar自体は1になるので、ロックが取れたと思って目的の処理を実施すると先にロックを取ったプロセッサ側と処理が競合します。
ロックが取れなかった時は取れなかったと知るためにlock変数側を使います。別のプロセッサ等によりロックが取られてしまっている時は、xchg命令実行時、既にlockvarは1になっています。そのためxchgの実行後、lockの内容は1になります。他方、ロックを正常に取得できた場合、xchg命令実行時、lockvarは0なので、lockの内容は0になります。
そのため、最後に3.ではlockの内容を確認し、その内容が1(ロックを取得できなかった)ならば、もう一度1.から繰り返すようにしています。そうではなく、lockが0ならばロックを取得できたとしてgot_lockに1を代入してwhileを抜け、関数からもreturnします。
spin_unlock()はもっと単純で、単にlockvarの指す先に0を代入するだけです。spin_unlock()を実行する時、既にロックは取っているので、lockvarを書き換える何かが競合してくることはありません。心置きなくロックを解放するだけです。
あとは、x86.cへ追加したspin_lock()/spin_unlock()のプロトタイプ宣言をx86.hへ追加しておきます(コードを引用しての紹介は割愛)。
それでは、spin_lock()/spin_unlock()を使ってみます。
文字出力系の関数で最終的に1文字を描画するputc()にロック処理を追加します(リスト2.6)。
putc()のロック状態を管理する変数としてputc_lockをグローバル変数として用意し、putc()の冒頭にspin_lock()を、末尾にspin_unlock()を追加しました。これで、putc()を複数のプロセッサから同時に呼び出された場合も、先にspin_lock()を取得した側でのみputc()の中身が実行され、取得できなかった方はspin_lock()内のビジーループで待ち続ける様になります。
実行すると、今度は文字が壊れること無くBSPと各APのプロセッサ番号が表示されるようになりました(図2.4)。
ここまでで、AP側をカーネルへ遷移させ、カーネル内のコードを実行させることができました。
次に、APへ外部アプリを実行させてみます。
この項のサンプルディレクトリは「024_exec」です。
外部アプリは、何らかの処理を行うためにシステムコールを発行します。システムコールはソフトウェア割り込みなので、外部アプリを実行するにはAP側でも割り込みの設定を行う必要があります。
そろそろAPに関する処理は別ファイルへ切り出そうと思います。ap.cを作成しそこに初期化処理(ap_init())を実装します(リスト2.7)。
BSP同様にGDT/IDTの初期化と、システムコールの初期化を行いました。syscall_init()の中でソフトウェア割り込みの割り込みハンドラの設定を行っています。
次にAPが外部アプリを実行する処理を「ap_run」という関数で追加します(リスト2.8)。
APに実行させる外部アプリ(タスク)を登録するための変数「ap_task」を用意し、ap_run()では、自分用のタスクが登録され次第、exec()で実行します。exec()は同期型で外部アプリを実行するので、外部アプリの実行が終わり、戻ってきたらap_taskを空(NULL)に戻します。
また、タスク登録用の関数「ap_enq_task」も追加しておきます(リスト2.9)。
空きがあれば登録し、無ければ何もせずエラーを返すだけです。
この関数も複数のコンテキストから呼ばれる可能性があるので、スピンロックでロックをとるようにしています。
以上でap.cへの関数追加は完了です。併せてinclude/ap.hを作成してこれらの関数のプロトタイプ宣言と、Makefileの"OBJS="への"ap.o"の追加を行っておいてください(共にコードは割愛)。
作成した関数を使用してAPへ外部アプリを実行させてみます(リスト2.10)。
start_kernel()冒頭のAP用の条件分岐で、APの初期化(ap_init())と実行(ap_run())を行っています。
その後、APはap_run()の中でタスク登録待ちになりますので、BSP側はファイルシステム初期化(fs_init())後、プロセッサ番号1のAPに外部アプリを実行させます。繰り返し実行できることの確認で2度実行しています。
最後にMakefileのOBJSへap.oを追加すれば完了です(コードの紹介は割愛)。
動作確認用の外部アプリには、本シリーズのパート3「システムコールの薄い本」で作成した「02_putc」というアプリを使用します。putcシステムコールの実験アプリで、「A」という1文字を画面表示するだけのアプリです。このアプリのソースコードも「024_exec」内に置いています。
このディレクトリへ移動した後、「make」コマンドでアプリケーションバイナリができあがります。
$ make gcc -Wall -Wextra -nostdinc -nostdlib -fno-builtin -fno-common -Iinclude -fPIE \ -c -o app.o app.c gcc -Wall -Wextra -nostdinc -nostdlib -fno-builtin -fno-common -Iinclude -fPIE \ -c -o lib.o lib.c ld -Map app.map -s -x -T ../app.ld -pie -o test app.o lib.o
「test」というバイナリ名で生成されますので、「puta」へリネームしてください。
$ ls test test $ mv test puta $ ls puta puta
そして、ファイルシステムイメージを生成するスクリプトも「024_exec」に含めています。以下のようにファイルシステムイメージ「fs.img」を生成します。
$ ../../tools/create_fs.sh puta $ ls fs.img fs.img
このfs.imgをカーネル(kernel.bin)と同じ階層に配置してください。ファイルシステムイメージについて詳しくは、当シリーズのパート1である「フルスクラッチで作る!x86_64自作OS」をご覧ください。
実行すると、図2.5の様に「A」が2回表示される事が確認できました。putaを2回実行できているので良さそうです。
今回まででカーネルと外部アプリをAPへ実行させることができるようになりました。
AP側でカーネルやアプリを実行する際も、BSP側と同様に、それらが配置されているアドレスへジャンプする方針で進めてきました。ただ、ここまで作ってきた自作OSは、実行コンテキスト毎にメモリ空間を分けていたりもしないので*3、グローバル変数やスタティック変数等のように、実行バイナリ内にその変数の実体がある変数は注意が必要です。BSP/AP間で共に同じアドレスへアクセスすることになるので変数の内容がプロセッサ間で共有されます。スタックに実体がある関数内のローカル変数等では、当然、そのようなことは起きません。
[*3] URFIが用意してくれた「物理アドレス=仮想アドレス」のページテーブルをそのまま使っているので、プロセス毎に別々のアドレス空間を用意していたりはしません。
本書では、その事をわかった上で、BSP/AP間で同時に実行されるカーネルやアプリでは、プロセッサ間で共有してほしくない変数はスタックから確保されるように実装しています*4。
[*4] カーネルは、世の中のものも、BSPとAPでアドレス空間を分けていたりはしない(同じアドレスで動作しカーネルバイナリ内にBSP/APの分岐がある)のかと思いますが、アプリは流石にBSP/APを意識していちいち実装するのは面倒です。なので、やはりプロセス毎のメモリ空間の分離は実現しておいた方が便利です。が、今回に関して言えば、複数のプロセッサで同時に実行する際は、そのアプリをファイルごとコピーして物理的に別々のアドレスにする、という方法でも実現可能です。
前項で追加したAPでの外部アプリ実行の仕組みをシステムコール化します。
この項のサンプルディレクトリは「025_syscall」です。
「SYSCALL_EXEC_AP」というシステムコールのエントリをsyscall.cに追加します(リスト2.11)。
前項で追加したap_enq_task()をシステムコール経由で呼び出せるようにしました。システムコールの第1・第2引数をap_enq_task()の第1・第2引数に渡し、ap_enq_task()の戻り値をシステムコールの戻り値として返すようにしています。
併せて、main.cのstart_kernel()でプロセッサ番号1のAPに外部アプリを実行させて止めていた処理を削除し、通常通りスケジューラまで実行されるようにします(リスト2.15)。
それでは、追加したSYSCALL_EXEC_APシステムコールを使ってみます。
システムコールを呼び出すアプリケーションはサンプルディレクトリ内の以下の場所に作成することにします。
ここでは、前著「ぼくらのイーサネットフレーム」で最後に作成した「06_beef_server」からの差分のみ紹介します。(06_beef_serverもappsディレクトリに入っています。)
まず、アプリ側のライブラリ関数を定義しているlib.cへ、SYSCALL_EXEC_APを関数化した「exec_ap()」を追加します(リスト2.13)。
定数SYSCALL_EXEC_APと、exec_ap()を追加し、exec_ap()ではsyscall()を使ってシステムコールを呼び出しています*5。
[*5] システムコールの仕組みについて詳しくは、当シリーズのパート3である「システムコールの薄い本」をご覧下さい。
また、この関数追加に併せて、include/lib.hへexec_ap()のプロトタイプ宣言を追加しておいてください(コードは割愛)。
そして、アプリ本体であるapp.cはリスト2.14のように作成します。
exec_ap()が2回成功するまで繰り返しています。実行する外部アプリは前項と同じくputaです。
このアプリはBSP側に実行させます。このアプリからreturnしてくると、haltして待機し続けます(リスト2.15)。
07_exec_appのビルドも02_putcと同様です。BSPで実行するinitアプリ名(INIT_APP定数)は「test」なので、生成されるtestバイナリをリネームする必要はありません。
ただし、fs.imgにはtestとputaの両方を含めておく必要があるので、testとputaを同じディレクトリに配置し、以下のようにcreate_fs.shを実行します。(create_fs.shへの相対パスは適宜変更してください。)
$ ls test puta test puta $ ../../tools/create_fs.sh test puta $ ls fs.img fs.img
実行すると、前項と同様にAが2回表示されます。(前項と同じなので実行結果の画像は割愛)