チュートリアル – システムコールの書き方

しばらく前に私は、「C言語でシェルを書く方法」というタイトルで、皆さんが日常的に使っているツールの内部動作を理解するのに役立つチュートリアルを書きました。単純なシェルであっても、数例を挙げるだけでもreadforkexecwaitwriteそれからchdirなど多数のシステムコールが呼び出されていました。この探索に続く次なる旅として、今回はLinux環境においてシステムコールがどのように実装されているのかについて学んでいきましょう。

システムコールとは何か

システムコールを実装するに当たって、それが何なのかをまずきちんと理解しておきましょう。そう遠くない昔の私がそうでしたが、素直なプログラマならシステムコールをCライブラリで提供されている関数のことだと定義するかもしれません。しかしこれは全く正しくありません。確かにCライブラリに含まれる関数群はシステムコールときれいに対応するものが多い一方(例:chdir)、オペレーティングシステム(OS)に対して特定の動作を要求する以上に色々な処理が行われるものもあります(例:forkfprintf)。またこれらの他に、qsortstrtokに代表されるような、OSの機能は特に使わずにプログラミングのための機能を提供するような関数もあります。

実は、システムコールには非常に明確な定義があります。それは、「ユーザーが実行したい操作をユーザーの代わりに実行するように、OSのカーネルに対して要求する手段」です。文字列のtokenizeなどの操作にはカーネルとのやりとりは不要ですが、デバイスやファイル、プロセスなどが関与する場合は、カーネルとのやりとりが必要になります。

実はシステムコールは通常の関数とは異なる動作をします。通常の関数呼び出しの場合はあなたのプログラムから当該関数のコードへjumpするような動作をしますが、システムコールの場合はCPUに対してカーネルモードに切り替えることを要求する必要がありますし、その後カーネル内であらかじめ定義された場所にジャンプして、そこでやっとあなたからの要求を処理できるようになります。この動作を実現するためにはいくつかの方法が用意されていて、CPU割り込みを発生させたり、syscallsysenterのような特別な命令を発行する方法などがあります。Linux環境においてシステムコールを実行するモダンな方法としては、システムコールを発生させるための関数をカーネルがユーザ空間に提供するVDSOと呼ばれる仕組みがあります。このトピックに関して、「Stack Overflow」サイトに興味深い議論があったので紹介しておきます。

以上のような複雑な仕組みが、ありがたいことに私達システムコール開発者のために提供されています。システムコールがどんな方法で発行されるにせよ、最終的にはあるテーブルから特定のシステムコール番号を見つけ出し、呼び出すべき適切なカーネル関数のアドレスを探すという話になります。要はそのテーブル上のエントリと関数の番地が存在すればいいということになりますから、自作のシステムコールを実装するのは、実はとても簡単です。以下で実際に試してみましょう。

仮想マシンの設定

カーネル開発に関する私のこれまでの記事とは違って、システムコールの実装とはカーネルモジュールの中で実装できる類のことはできません。自らLinuxソースコードを取得・改造した上でコンパイルし起動しなければなりません。この実験は、(Linuxを使っている場合は)皆さんがメインで使っている実物のコンピュータ上で直接実行しても差し支えないものですが、恐らく仮想マシン(VM)上で試してみるのが良いでしょう。今回の例題ではVirtualBoxを使いますので、手元にこの環境がない方は、まずインストールしてください。

仮想マシンの構成をいろいろ試してみるのも悪くありませんが、事前に全て設定済みの(pre-installed)VMを単純にダウンロードするのが早くて便利です。なお、事前に設定済みのArch Linuxマシンはここからダウンロードできます。この記事では、201608 CLI バージョンを使います。ダウンロードして解凍してみましょう。VirtualBoxを起動してVMを新規作成していき、仮想ハードドライブについて聞かれたらダウンロードしたvdiファイルを選択します。VMを作成して起動すると、CLIのログイン画面が表示されます。rootのパスワードは、LinuxをダウンロードしたWebサイトのページ(例えば私の場合はosboxes.org)に記載されているはずです。

*注:マルチコアのマシンを使用する場合、VMの設定を編集して、複数コアの使用を許可するオプションを指定しておくことをお勧めします。こうすると、makeコマンドを実行する場面でmake -jNのようにコマンドを実行すれば、コンパイル時間を格段に短縮することができます。(※N=VMに利用を許可したコア数)

そして最初に用意するべきものは、bcコマンドです。bcはLinuxのビルド時に必要なコマンドとなっており、VMには含まれていません。残念ながらこの操作のために、のっけからVMをアップデートしなければなりません。なお、この記事で私が挙げるコマンドは全てroot権限で実行しなければならないことに注意してください。そもそも今回立ち上げたばかりのVMにはrootユーザしか存在しないので、特に難しいことではないと思いますが。

$ pacman -Syu
$ pacman -S bc
$ reboot

カーネルがほぼ間違いなく更新されますから、ここで一度再起動しておきましょう。

ソースコードを入手する

開発用VMの準備ができたら、次のステップはカーネルのソースコードをダウンロードすることです。大部分の開発者はコードを入手する必要がある時に反射的にGitにアクセスしますが、今回は別の方法がよさそうです。Linux Gitリポジトリは非常に大規模なので、それを丸ごとcloneしてくることは恐らく時間の無駄です。こういう時は、自分のカーネルバージョンに対応したソースのtarballをダウンロードするのが得策です。カーネルバージョンはuname -rで確認できますので、できるだけそのバージョンに近いものをkernel.orgから選んでダウンロードしてください。VM内では、ソースのダウンロードはcurlで以下のように行います。

# -O -J will set the output filename based on the URL
$ curl -O -J https://www.kernel.org/pub/linux/kernel/v4.x/linux-VERSION.tar.xz

そして、次のようにtarballを展開します。

$ tar xvf linux-VERSION.tar.xz
$ cd linux-VERSION

カーネルを設定する

Linuxカーネルは尋常ではなく設定できる項目が多くなっており、沢山の機能について有効・無効を切り替えできますし、ビルドパラメータの設定にしても同様のことが言えます。もし全ての設定を自分で選んでいく場合は、丸1日費やすことになってしまいます。ここでは、利用中のカーネルの設定を単純にコピーすることで、このステップを省略することをお薦めします。便利なことに、現在の設定は(大抵のLinuxマシンでは)圧縮ファイル/proc/config.gzに格納されています。この設定を新しいカーネルに適用するには、以下のコマンドを使います。

$ zcat /proc/config.gz > .config

全ての設定変数の値が確実に含まれるようにするため、make oldconfigを実行してください。この際には多分、設定に関する質問はされないでしょう。

唯一変更しなければならない設定項目はカーネル名です。これは、インストールされている既存のカーネルの名前と競合しないようにするためです。Arch Linuxでは、カーネルは-ARCHというサフィックス付きでビルドされています。このサフィックスを何か独自のものに変更しておきましょう。私は-stephenを使いました。この作業を行う最も単純な方法は、.configをテキストエディタで開いて、該当行を直接変更することです。その場所は「General setup」(全般的なセットアップ)という見出しのすぐ下で、ファイル中のそれほど後ろの方ではありません。

CONFIG_LOCALVERSION="-ARCH"

自作のシステムコールを追加する

カーネルの設定ができましたので、カーネルのコンパイルをすぐに始めることもできなくはありません。しかし、システムコールを作成するには、非常に膨大な量のコードからインクルードされるテーブルを編集する必要があります。コンパイルには結構時間が掛かりますので、今すぐコンパイルすると、長い時間を無駄にすることになるでしょう。そこで、いい方法があります。自分でシステムコールを書くのです。

Linuxのコードには、割り込みやシステムコールなどの処理のためにアーキテクチャ固有のコードが存在しています。このためシステムコールのテーブルというのは、プロセッサ固有のディレクトリが切られておりそこに配置されています。ここでは、x86_64について見ていきましょう。

システムコールテーブル

x86_64向けのシステムコールテーブルを含むファイルは、arch/x86/entry/syscalls/syscall_64.tblに置かれています。このテーブルは、スクリプトに読み取られ、ボイラープレートコードの一部を生成するのに使われますが、実際このボイラープレートコードのおかげでかなり楽ができてたりします。最初のグループの終わりに移動して(バージョン4.7.1ではシステムコール328番が最後)、次の行を追加します。

329        common  stephen sys_stephen

各列の間は(スペースではなく)タブで区切られていることに注意してください。最初のカラムはシステムコール番号です。私は、テーブル中で次に使用可能な番号として、今回は329を選びました。皆さんが選ぶときは、329番ではなくても構いませんよ!2番目のカラムは、このシステムコールが32ビットと64ビットのCPUに共通であることを示しています。3番目のカラムはシステムコールの名前、4番目はそのシステムコールを実装する関数の名前です。システムコールの名前に単純にsys_というプレフィックスを付けた名前を関数の名前とするのがならわしになってます。私は、自分のシステムコール名にstephenを使いましたが、皆さんは好きな名前を使ってください。

システムコール関数

最後のステップは、システムコールの関数を書くことです。システムコールの動作についてはまだ説明していませんでしたが、今回は何かしらの単純な動作を試して観察することができればよいでしょう。簡単にできるのは、printk()を使ったカーネルログへの書き出しです。よって、私たちのシステムコールは1個の文字列を引数に取り、それをカーネルログに書き出すものとしましょう。

システムコールはどこにでも実装できますが、雑多なシステムコールはkernel/sys.cファイルに入れられる傾向があります。このファイルのどこかに書きましょう。

SYSCALL_DEFINE1(stephen, char *, msg)
{
  char buf[256];
  long copied = strncpy_from_user(buf, msg, sizeof(buf));
  if (copied < 0 || copied == sizeof(buf))
    return -EFAULT;
  printk(KERN_INFO "stephen syscall called with \"%s\"\n", buf);
  return 0;
}

SYSCALL_DEFINENは、N個の引数を取るシステムコールを定義しやすくするマクロ群です。このマクロの最初の引数は、システムコールの名前(sys_が前に付いていない状態)です。残りの引数は、パラメータのタイプと名前のペアです。私たちのシステムコールは引数が1個ですのでSYSCALL_DEFINE1を使っており、char *型のmsgという引数をとることにします。

すぐに、興味深い問題に直面します。生成されたmsgポインタを直接使えないということです。こうなる理由はいくつかあるのですが、説明がこれだけではスッキリしませんね!

  • いたずらにカーネル空間を指しているアドレスが渡されてそれを出力してしまう可能性があるため。
  • 別プロセスのアドレス空間を指しているアドレスが渡されてそれを読み取ろうとする可能性があるため。
  • 私たちがメモリの読み取り/書き込み/実行権限を考慮する必要もあるため。

これらの問題に対処するには、標準のstrncpyのように振る舞う便利なstrncpy_from_user()関数を使いますが、先にユーザー空間のメモリアドレスをチェックしましょう。文字列が長すぎるかコピーに問題があった場合は、EFAULTを返します(ただし、長すぎる文字列向けにはEINVALを返した方がよいかもしれません)。

最後に、printkKERN_INFOのログレベルで使います。これは実のところ、文字列リテラルに解決されるマクロです。コンパイラがそれを書式指定文字列と連結したものを、printk()が使ってログレベルを決定されます。この後で、%sが使われていることからも分かるように、printkprintf()に似た書式変換を行います。

カーネルをコンパイルして起動しよう

注:このステップは少し複雑になっています。このセクションを読んでいく間は、まだコマンドを実行する必要はありません。説明の最後で、その全てを実行できる便利なbashスクリプトをご紹介します。

最初のステップは、カーネルとそのモジュールをコンパイルすることです。主要なカーネルイメージはmakeを実行するとコンパイルされます。その結果、arch/x86_64/boot/bzImageというファイルが生成されます。このバージョンに対応したカーネルモジュールは、make modules_installを実行した時に/lib/modules/KERNEL_VERSIONにコンパイル、コピーされます。例えば、本記事でここまで作成してきた設定によれば、コンパイル済みのモジュールが/lib/modules/linux-4.7.1-stephen/に配置されるでしょう。

カーネルとそのモジュールのコンパイルが終わりましたが、起動するためにはもう少し作業が必要です。まず、コンパイルされたカーネルイメージを/bootディレクトリにコピーする必要があります。

$ cp arch/x86_64/boot/bzImage /boot/vmlinuz-linux-stephen

次に、私たちにとってあまり重要ではない理由なのですが、「initramfs」を作成しなければなりません。これは2つのステップで実行できます。最初に、自分の旧プリセットに基づいてプリセットを作成します。

$ sed s/linux/linux-stephen/g </etc/mknitcpio.d/linux.preset >/etc/mkinitcpio.d/linux-stephen.preset

次に、実際のイメージを生成します。

$ mkinitcpio -p linux-stephen

最後に、ブートローダ(今回のVMの例ではGRUB)に対して、新しいカーネルを起動するよう命令する必要があります。GRUBは/bootディレクトリで自動的にカーネルイメージを見つけることができますので、私たちが行わなくてはならないのは、GRUBの設定を再生成することだけです。

$ grub-mkconfig -o /boot/grub/grub.cfg

よって、このセクションのステップをまとめると、以下のスクリプトのようになります。

#!/usr/bin/bash
# Compile and "deploy" a new custom kernel from source on Arch Linux

# Change this if you'd like. It has no relation
# to the suffix set in the kernel config.
SUFFIX="-stephen"

# This causes the script to exit if an error occurs
set -e

# Compile the kernel
make
# Compile and install modules
make modules_install

# Install kernel image
cp arch/x86_64/boot/bzImage /boot/vmlinuz-linux$SUFFIX

# Create preset and build initramfs
sed s/linux/linux$SUFFIX/g \
    </etc/mkinitcpio.d/linux.preset \
    >/etc/mkinitcpio.d/linux$SUFFIX.preset
mkinitcpio -p linux$SUFFIX

# Update bootloader entries with new kernels.
grub-mkconfig -o /boot/grub/grub.cfg

これをdeploy.shという名前でカーネルソースのメインディレクトリに保存し、chmod u+x deploy.shで実行権限をセットすると、あとはこの1つのスクリプトを実行して再起動するだけでカーネルをビルド、デプロイできます。コンパイルにはしばらく時間が掛かるかもしれません。

スクリプトの実行が完了したら、rebootを実行します。GRUBが表示されますので、「Advanced Options for Arch Linux」(Arch Linux向け上級オプション)を選びましょう。すると、使用可能なカーネルを一覧表示したメニューが出るはずです。自分のカスタムカーネルを選んで起動してください。何か問題が起こった場合は、いつでもオリジナルに戻ることができます。

全て順調にいけば、以下のようなログイン画面が表示されるはずです。

syscall_kernel_booted

4.7.1-stephenというテキストがカーネルバージョンですので、改変したカーネルバージョンを実行しているということが見た目にはっきり分かります。何らかの理由で起動時にカーネルバージョンを見ることができなかった場合は、uname -rでいつでもチェック可能です。

自作のシステムコールをテストする

ここまで、独自のカスタマイズを施したカーネルをコンパイル、起動してきました。しばし時間を取って、成功したことを喜びましょう! この作業を成し遂げた人はそれほど多くありません。ですが、全体の中で最も面白い部分は、自作のシステムコールを実行できるようになることです。では、一体どうやって実行するのでしょう?

大部分のシステムコールはCライブラリがラップしてくれますので、実際に割り込みを呼び出すことについて考える必要は全くありません。私たちが使用できないシステムコールについては、GNU Cライブラリのsyscall()関数を使って、番号でどれでも呼び出すことができます。下記は、その仕組みを使って自作のシステムコールを呼び出す小さなプログラムです。

/*
 * Test the stephen syscall (#329)
 */
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
/*
 * Put your syscall number here.
 */
#define SYS_stephen 329
int main(int argc, char **argv)
{
  if (argc <= 1) {
    printf("Must provide a string to give to system call.\n");
    return -1;
  }
  char *arg = argv[1];
  printf("Making system call with \"%s\".\n", arg);
  long res = syscall(SYS_stephen, arg);
  printf("System call returned %ld.\n", res);
  return res;
}

これをtest.cというファイル名で保存して、gcc -o test test.cでコンパイルしましょう。これで、あなたの最初の「hello world」システムコールを以下のように実行することができます。

$ ./test 'Hello World!'
# use single quotes if you have an exclamation point :)

ここで出力される長いメッセージを見るには、dmesgコマンドを使います。dmesgは端末に出力される大量の情報を表示しますので、dmesg | tailを使ってログの最後の何行かを抜き出すとよいかもしれません。自作のシステムコールのテキストがログに出ているはずです。以下は私のマシンで表示した様子です。

syscall_dmesg_output

終わりに

おめでとうございます! 独自のシステムコールを実装してテストすることができました。これで、カーネル開発の全世界があなたの前に開けたのです。自作のシステムコールの動作に変更を加えて、前掲の便利なデプロイスクリプトで全てをリビルドできるようになりました。あるいは、別のシステムコールを見つけてその動作を編集したら、(もしかすると)恐ろしい結果につながるかもしれません。その救済措置として、いつでも旧カーネルで再起動することが可能です。

このチュートリアルに沿って最後まで試した皆さんが、独自にカスタマイズしたカーネルとシステムコールを実行できるようになることを心から願っています。成功した方は、ぜひコメントをお寄せください。

心に留めておいていただきたいのは、私がカーネルの専門家ではなく、本記事でご紹介したことはどれも最善の方法である保証はないということです。例えば、あなたが普段QEMUで本格的な開発を行っている場合は、VirtualBoxではなくQEMUを使った方が多くの時間を節約できるでしょう。

最後に、この記事を気に入っていただけたようでしたら、私が以前に書いたカーネル開発に関する2本の記事、エピソード1エピソード2もどうぞご一読ください。カーネルにエラーを起こさせる方法を面白い形でご紹介しています。