システムコールを実装するに当たり、まずはシステムコールの仕組みを説明します。
システムコールは一般的にソフトウェア割り込みで実現されます。システムコールを使ってアプリからカーネルの機能を呼び出す大まかな流れは図1.1、図1.2、図1.3の通りです。
別バイナリであるアプリとカーネル間の情報の受け渡しには汎用レジスタを使います。後述しますが、関数の第1から第4引数に使われるRDI・RSI・RDX・RCXを使うことで、割り込みハンドラを通ってそのままC言語の関数へ渡るようにしています。
まずはソフトウェア割り込みを使えるように割り込みハンドラを実装します。
この項のサンプルディレクトリは「011_softirq」です。
まずhandler.sに、割り込みハンドラの入口/出口となるアセンブリコード部分を実装します(リスト1.1)。
"syscall_handler"というラベルを追加し、他の割り込みハンドラと同様に、call命令でC言語の関数(割り込みのメイン処理)を呼び出す前後で汎用レジスタの退避(push)と復帰(pop)を行っています。
復帰処理(pop処理)の最後で、RCXを2回popしている箇所がありますが、これは、RAXをスタックに退避した分を読み捨てるためです*1。
[*1] 「捨てるくらいなら最初からRAXを退避しなければ良いのでは?」という考えもありますが、C言語の関数を呼び出す際のスタックのアライメント調整のためにこの回数のスタック退避を行っています(詳しくは前著コラム参照)。
RAXはシステムコール呼び出し元へ戻り値を返すのに使うため、syscall_handlerに入ってきたときの値を復帰せず、do_syscall_interrupt関数内で設定されたものをそのまま返すようにしています。
次に、"syscall.c"というファイルを追加し、"do_syscall_interrupt"関数を実装します(リスト1.2)。
ソフトウェア割り込み時の処理としては、ここでは、渡された全ての引数の値をダンプし、戻り値としては実験用の値として適当な値を返すようにしています。
また、システムコールに使う割り込み番号は0x80(128)番とし*2、return直前でPICへ割り込み処理の終了を通知しています。
[*2] ハードウェアや例外の番号と被っていなければ何番でも良いです。本書ではLinux等でもよく使っている0x80番をシステムコールの割り込み番号としています。
最後にシステムコールの初期化関数"syscall_init"を作成します。
この関数でやることは、ここまでで実装した割り込みハンドラを割り込みの機能へ登録するだけです(リスト1.3)。
main.cのstart_kernel関数から呼び出せるように、syscall.hを用意しておきます(リスト1.4)。
ここまででソフトウェア割り込みを使うための準備は完了です。
あとは、main.cのstart_kernel関数からソフトウェア割り込みを呼び出してみるために以下を行います。
大した行数は無いので、書いてしまうとリスト1.5の通りです。
HPETやKBCの初期化を行った直後にシステムコールの初期化を追加しています*3。
[*3] 同じ割り込みハンドラの設定なのでこの辺りに追加しました。
そして、実験として適当な値を引数にセットして、ソフトウェア割り込み0x80番を呼び出してみています。
関数の引数は第1引数から順にRDI、RSI、RDX、RCXのレジスタを使います。アセンブラ部分の割り込みハンドラ(syscall_handler)が呼び出されてから、C言語関数(do_syscall_interrupt関数)が呼ばれるまでの間でこれらのレジスタを変更しては居ないので、ここで設定した値がそのままdo_syscall_interrupt関数の第1から第4引数に渡ります。ここでは"1"、"2"、"3"、"4"という数値を第1から第4引数に渡しています。
ソフトウェア割り込みの呼び出しは"int"命令です。オペランドでは0x80番の割り込みを発生させることを指定しています。
int命令により0x80番の割り込みが発生し、syscall_handlerが呼び出されて、そこから戻ってくると、戻り値であるRAXレジスタの内容をsoftirq_ret変数へ格納します。
その後、softirq_retをダンプして、その後は無限ループで固めています。
あとは、Makefileへsyscall.oをOBJSに追加すれば*4、ビルドできます。
[*4] OBJS変数へsyscall.oを追加するだけなので、コードを引用しての説明はしません。実装を見てみたい場合はサンプルコードを参照してください。
ビルドし*5、実行すると、図1.4のように画面に表示されます。
[*5] ビルド・実行方法は前前著「x86_64自作OS(パート1)」を参照してください。
do_syscall_interrupt関数内で渡された全ての引数のダンプ結果として第1から第4引数に渡した値(1、2、3、4)が出力され、do_syscall_interrupt関数の戻り値をダンプした結果として"BEEFCAFE01234567"が出力されています。
意図通りに動いていて、良さそうです。
次に、カーネルから外部の実行バイナリをアプリとして起動してみます。そして、起動されたアプリからソフトウェア割り込みをシステムコールとして実行してみます。
この項のサンプルディレクトリは「012_exec_syscall」です。
ファイルシステムを用意しファイルを扱う方法は前々著「x86_64自作OS(パート1)」で紹介しました。ここでは、ファイルポインタ*6を渡すとそのファイルを実行ファイルとして実行する"exec"関数を実装します。
[*6] ファイルシステム上のファイルの実体へのポインタ。open関数でファイルを開くと得られる。
「実行ファイルとして実行する」と言っても、やることは単にそのファイルの先頭へcall命令でジャンプするだけです(図1.5)。本書で作るOSがサポートする実行ファイルは、「ファイルの先頭から機械語の実行バイナリ列が並んでいるもの」とします(後程そのようにアプリを作ります)。
"proc.c"というソースファイルを新たに作成し、その中にexec関数を実装してみます(リスト1.6)。
ファイルのデータ部分の先頭アドレスをstart_addrに格納し、call命令でそこへジャンプしているだけです。
そして、他のファイルから呼び出せるようにexec関数のプロトタイプ宣言をinclude/proc.hに書いておきます。また、Makefileの"OBJS ="へもproc.oを追加しておいてください。(共にコードでの説明は省略)
この節の最後にmain.cのstart_kernel関数から実験として"test"という実行バイナリを呼び出すようにしておきます(リスト1.7)。
前節で追加したソフトウェア割り込みを実行する実験コードは削除しておきます(リスト1.7)。
exec関数で呼び出される実行ファイルを作ります。
アプリのソースコードを格納するディレクトリとして"apps"というディレクトリを作成します。アプリは実験用に複数作るので、その中にサブディレクトリとして"01_softirq"というディレクトリを作っておきます。
ここでは、apps/01_softirq/app.cに、実験用のアプリとして、適当な引数でソフトウェア割り込みを呼び出すだけのアプリを作成します(リスト1.8)。
puth関数やcpu_halt関数が無い事を除いて、内容はstart_kernel関数に実装していたものと同じです。
そして、テキストセクションを先頭に持ってくるようにリンカスクリプトを作成します(リスト1.9)。
リンカスクリプトは本書で登場するすべてのアプリで共通なのでappsディレクトリ直下に置いておきます。
この節の最後にこのアプリをビルドするMakefileを作成します(リスト1.10)。
deployというターゲットを用意して、ファイルシステム作成スクリプトを呼び出すようにしています。そのため、ソースディレクトリ直下に"tools"ディレクトリを作成し、その中に前前著で作成したスクリプト*7を配置しておいてください。
[*7] https://github.com/cupnes/x86_64_jisaku_os_samples/の052_fs_create_fs/create_fs.sh
アプリのMakefileでは、CFLAGSに"-fPIE"を、LDFLAGSに"-pie"を指定していました。PIEとは"Position Independent Executable(位置独立実行形式)"の略です。
ファイルシステム上の実行ファイルの先頭へジャンプするだけで実行できる理由が、このPIEです。PIE指定でコンパイルすることで、メモリ空間上のどこに配置されても実行できる実行ファイルになります。
それでは、PIEを使わない場合はどうするのかというと、普通はMMU(Memory Management Unit、メモリ管理ユニット)にページングの設定を行い、アプリのバイナリが実際に配置されている物理アドレスをどのアプリでも共通のどこかの仮想アドレスへマップするようにします。CPUは仮想アドレスを見て動作するので、どのアプリも仮想アドレス上は同じアドレスにマップされていれば、PIEで無くとも(この仮想アドレスを想定してリンクされていれば)問題無い訳です。
本書のシリーズでもCPU設定としてページングは有効です。ただ、UEFIが設定してくれた「物理アドレス=仮想アドレス」のマッピングを使っているので、実質、アドレスの変換は行われていません。
実行する際は、あらかじめ、apps/01_softirqディレクトリで"make deploy"を実行してください。これにより、生成物を配置するfsディレクトリにアプリの実行バイナリを含んだファイルシステムイメージ(fs.img)が配置されます。
実行結果として画面に出力される内容は、ソフトウェア割り込みからの戻り値の出力が無い事を除いて前項と同じですので、画面のスクリーンショットは省略します。
ここまで作ってきたソフトウェア割り込みの仕組みを使ってシステムコールを実装します。ここでは渡された文字を画面に表示するputcをシステムコール化してみます。
この項のサンプルディレクトリは「013_syscall_putc」です。
まず、カーネル側のsyscall.cへputcシステムコールの処理を実装します。
putcシステムコールの番号を決める定義を用意し、そのシステムコール番号で呼び出された場合に与えられた引数でputc関数を呼び出すだけです(リスト1.11)。
この項で作るアプリのディレクトリは「02_putc」とします。
ライブラリとして使う関数は、ひとまず"lib.c"にまとめる事とし、リスト1.12のようにputcのシステムコールを呼び出すライブラリを用意します。
また、併せて"02_putc/include/lib.h"を作成し、リスト1.12で定義したputc関数のプロトタイプ宣言を書いておいてください。
そして、app.cでは、ライブラリ化したputc関数を呼び出してみます(リスト1.13)。
ビルドするためには、02_putcのMakefileには、"OBJS ="に"lib.o"を追加しておいてください。
実はリスト1.13ではアプリの最後に無限ループを設置せず、returnしています。
この場合、何処にreturnするのかというと、カーネル側のstart_kernel関数内でexec関数を呼び出したところへ戻ります。そして、後続のwhileループを実行し始め、cpu_halt関数によりCPUを休ませます。
これはexec関数でインラインアセンブラによりジャンプする際にcall命令を使用しているためです。call命令は戻り先のアドレスをスタックへ積んでからジャンプするため、returnで戻ってくることができます。
ビルドし、実行すると、図1.6のように画面に'A'が描画されることを確認できます。
結果は地味ですが、ここまでで、カーネルの機能をシステムコール化し、アプリ側ではシステムコール呼び出しを、ライブラリ化*8することができました。
[*8] ライブラリとはいっても別ファイルへ分けただけですが。静的ライブラリについては本シリーズの「UEFIベアメタルプログラミング(ブートローダ編)」で扱っていますので、参考にしてみてください。動的ライブラリについては、、、まだ扱ったことがないのでググってみてください。。
あとは同じ要領でシステムコールを増やしていけば、アプリ側で他のカーネル機能を呼び出せるようになります。