POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

本記事は、原著者の許諾のもとに翻訳・掲載しております。

要約

この記事では、LinuxカーネルにてLinuxプログラムがどのように関数を呼び出すのかについて紹介していきます。

システムコールを行う様々な方法、システムコールを行うための独自のアセンブリの作成方法(例あり)、システムコールへのカーネルエントリポイント、システムコールからのカーネルイグジットポイント、glibcのラッパ関数、バグなど多くの点について説明します。

Free Deb, RPM, RubyGem, & Python repositories.

システムコールとは?

openforkreadwrite (その他多く)を呼び出すプログラムを実行する場合、あなたはシステムコールを行っていることになります。

システムコールとは、プログラムがカーネルにアクセスしてタスクを実行する方法のことです。プログラムはシステムコールを使ってプロセスを作成したり、ネットワークの操作やファイルの入出力を行ったりと、様々なオペレーションを実行します。

syscalls(2)のマニュアルページ にシステムコール一覧が掲載されています。

ユーザプログラムでシステムコールを行う方法はいくつかあり、それを行うための低水準命令はCPUアーキテクチャによって異なります。

アプリケーション開発者は通常、どれだけ正確にシステムコールが行われているかということについて、深く考える必要はありません。通常の関数と同様に、単にヘッダファイルを適切にインクルードし、呼び出せばいいのです。

glibc は、根本的なコードを抽象化するラッパコードを提供します。この根本的なコードは、あなたが渡した引数を配置しカーネルに入り込みます。

どのようにシステムコールが行われるかを詳しく説明する前に、いくつかの条件を明確にし、後に出てくる核となる概念を検証していきましょう。

必要条件に関する情報

ハードウェアとソフトウェア

この記事では、以下の条件を前提としています。

  • 32ビットもしくは64ビットのIntelまたはAMD CPU。他のシステムを利用している人にとっても、ここで紹介する方法は有益かもしれませんが、使用しているコードのサンプルはCPU特有のものが含まれています。
  • 基本環境は、Linuxカーネルのバージョン3.13.0です。他のバージョンでも同様に機能しますが、正確な行番号、コードの構成、ファイルパスなどが異なります。GitHubに掲載されているカーネル3.13.0バージョンのソースツリーへのリンクを提供しています。
  • glibc または glibc から派生したlibc実装(例えば eglibc など)を使用しています。

この記事でのx86-64とは、x86アーキテクチャに基づいた、64ビットのIntelやAMD CPUを指します。

ユーザプログラム、カーネル、CPUの特権レベル

ユーザプログラム(例えば、エディタやターミナル、sshデーモンなど)は、Linuxカーネルと対話する必要があります。独自で実行することができないユーザプログラムに代わって、カーネルが一連の操作を行うためです。

例えば、ユーザプログラムが openreadwrite といったシステムコールで入出力を行ったり、 mmap`` `や sbrk“`などでアドレス空間を修正したりする必要がある場合、カーネルが起動され、ユーザプログラムに代わってそれらのアクションを完了させます。

では、ユーザプログラム自身がそれらのアクションを行えないのは、何によるものでしょう?

実は、x86-64CPUには、 特権レベル と呼ばれる概念があります。特権レベルはそれだけで記事が書けるほど、複雑なトピックです。この記事では、この特権レベルの概念を以下のように(大幅に)簡素化しています。

  1. 特権レベルとはアクセス制御をする手段で、現在の特権レベルによって、どのCPU命令や入出力が実行できるかが決定されます。
  2. カーネルは、”Ring 0″と呼ばれる最も高い特権レベルで実行され、ユーザプログラムは、それよりも低い”Ring 3″の特権レベルで通常実行されます。

特権的な操作をユーザプログラムが行うには、”Ring 3″から”Ring 0″に特権レベルの変更を行い、カーネルが実行できるようにしなければなりません。

特権レベルを変更し、カーネルがアクションを行えるようにするには、いくつかの方法があります。

それでは、その一般的な方法の1つである、割り込みから見ていきましょう。

割り込み

割り込みは、ハードウェアやソフトウェアによって生成された(または”発生した”)イベントと捉えることができます。

ハードウェア割り込みは、ハードウェアデバイスによって発生し、特定のイベントが起こったということをカーネルに通知します。この種の割り込みの一般的な例として、NICがパケットを受け取った時に生成される割り込みが挙げられます。

ソフトウェア割り込みは、コードを実行することによって発生します。X86-64のシステムでは、ソフトウェア割り込みは、 int 命令を実行することによって発生させることができます。

割り込みは通常、それぞれの割り込みに対して番号が付与されますが、これらの割り込み番号の中には、特別な意味を持つものがあります。

CPUのメモリ内に存在する配列を想像してみてください。配列への各エントリが割り込み番号にマップされます。各エントリには、いくつかのオプションとともに割り込みが受信された際にCPUが実行する関数のアドレスが含まれています。このオプションは、どの特権レベルで割り込みハンドラの関数が実行されるべきかといった内容のものです。

以下は、Intel CPUマニュアルに掲載されている画像で、この配列でのエントリのレイアウトを示しています。

Screenshot of Interrupt Descriptor Table entry diagram for x86_64 CPUs
注釈:割り込み/トラップゲート
予約済み
オフセット 63..32
オフセット 31..16
セグメントセレクタ
オフセット 15..0
DPL:ディスクリプタ特権レベル
オフセット:エントリポイントの手続きへのオフセット
P:セグメントPresentフラグ
セレクタ:行き先のコードセグメントのセグメントセレクタ
IST:割り込みスタックテーブル
図5-7. 64ビットIDTゲートディスクリプタ

図をよく見てみると、DPL(Descriptor Privilege Level)とラベル付けされている2ビットのフィールドがあることが分かります。このフィールドの値は、ハンドラ関数が実行された時にCPUが得る最低限の特権レベルを決めます。

このようにして、特定のタイプのイベントが受信された時にどのアドレスをCPUが実行すべきか、そのイベントに対するハンドラがどの特権レベルで実行されるべきかをCPUが判断します。

実際、x84-64システムには、多くの異なる割り込み方法があります。詳細は、 8259 Programmable Interrupt Controller(割り込みコントローラ)Advanced Interrupt Controllers(高度な割り込みコントローラ)IO Advanced Interrupt Controllers(高度なIO割り込みコントローラ) をご覧ください。

この他にも、ハードウェア・ソフトウェア両方の割り込みを扱う際には、割り込み番号の衝突や再マッピングといったような、複雑な問題が含まれることになります。

システムコールについて説明をする上で、これらの詳細を気にする必要はありません。

モデル固有レジスタ(MSR)

MSRとして知られるモデル固有レジスタは、CPUの特定機能を制御する、特別な目的を持った制御レジスタです。CPUドキュメントは、MSRそれぞれのアドレスをリストしています。

MSRを読み込んだり書き込んだりするには、個々に対して、CPU命令の rdmsrwrmsf を使用することができます。

また、MSRを読み込んだり書き込んだりすることができるコマンドラインツールがありますが、注意して行わなければなりません。値の変更(特にシステムが起動している最中)はとても危険なことなので、このツールを使用することは お勧めしません。

システムを不安定にさせたり、元に戻せないほどデータを破壊したりすることを恐れないのであれば、 msr-tools をインストールし、 msr カーネルモジュールをロードすることで、MSRを読み込みこんだり、書き込んだりすることが可能です。

% sudo apt-get install msr-tools
% sudo modprobe msr
% sudo rdmsr

この後に紹介するいくつかのシステムコールの方法でMSRを活用します。

アセンブリコードでシステムコールを呼び出すことの問題点

独自のアセンブリコードを書いてシステムコールを呼び出すことはいいアイデアとは言えません。

その大きな理由の1つは、いくつかのシステムコールには、システムコールの実行前後にglibcで実行される追加のコードがあるからです。

以下の例では、 exit システムコールを使用します。 atexit を使ったプログラムで exit が呼び出される時、実行するための関数を登録できることが分かっています。

これらの関数はカーネルではなく、glibcから呼び出されます。ですから、以下のように exit を呼び出すために独自のアセンブリを書くと、glibcを介していないため、登録されているハンドラ関数は実行されません。

そうは言っても、アセンブリを用いて手動でシステムコールを行うことは、とてもいい勉強になります。

レガシーシステムコール

Free Deb, RPM, RubyGem, & Python repositories.

前述の必要条件に関する情報から以下の2点が分かっています。

  1. ソフトウェア割り込みを生成することによってカーネルを実行させることができます。
  2. int アセンブリ命令でソフトウェア割り込みを生成することができます。

この2つの概念を組み合わせることで、Linux上のレガシーシステムコールインターフェースへ導かれます。

Linuxカーネルは、ユーザ空間のプログラムがカーネル内でシステムコールを実行するために使用できる特定のソフトウェア割り込み番号を確保します。

Linuxカーネルは割り込み番号128 (0x80)に対する ia32_syscall という割り込みハンドラを登録します。実際にこれを行っているコードを見てみましょう。

以下は、 arch/x86/kernel/traps.c にあるカーネル3.13.0のソース内の trap_init 関数です。

void __init trap_init(void)
{
        /* ..... other code ... */

        set_system_intr_gate(IA32_SYSCALL_VECTOR, ia32_syscall);

IA32_SYSCALL_VECTOR は、 arch/x86/include/asm/irq_vectors.h 内で 0x80 として定義されています。

しかし、カーネルが単一のソフトウェア割り込みを確保し、ユーザ空間のプログラムがカーネルを動作させることができる場合、どのようにしてカーネルは多くのシステムコールのうち実行すべきシステムコールを知るのでしょうか。

ユーザ空間のプログラムは、システムコール番号を eax レジスタに格納することになっています。システムコール自体の引数は、残りの汎用レジスタに格納されるはずです。

これは、 arch/x86/ia32/ia32entry.S のコメント内に記載されています。

* Emulated IA32 system calls via int 0x80.
 *
 * Arguments:
 * %eax System call number.
 * %ebx Arg1
 * %ecx Arg2
 * %edx Arg3
 * %esi Arg4
 * %edi Arg5
 * %ebp Arg6    [note: not saved in the stack frame, should not be touched]
 *

これで、システムコールを行う方法と引数の格納場所が分かりました。インラインアセンブリをいくつか書いてシステムコールを1つ行ってみましょう。

独自のアセンブリを用いたレガシーシステムコールの使用

ほんのわずかなインラインアセンブリを書くだけで、レガシーシステムコールを行うことができます。学習の観点からは興味深いことですが、読者の皆さんには独自のアセンブリを作ってシステムコールを行うことはお勧めしません。

この例では、 exit システムコールを呼び出してみます。これは単一の引数として終了ステータスを受け取ります。

まず、 exit に対するシステムコール番号を探す必要があります。Linuxカーネルにはテーブル内に各システムコールのリストを格納したファイルが含まれています。このファイルはビルド時に様々なスクリプトによって処理され、ユーザプログラムが使用可能なヘッダファイルを生成します。

arch/x86/syscalls/syscall_32.tbl で見つけたテーブルを見てみましょう。

1 i386 exit sys_exit

exit システムコールは、番号 1 です。前述のインターフェースに従って、システムコール番号を eax レジスタへ移し、第1引数(終了ステータス)を ebx へ渡すだけです。

以下は、これを行うインラインアセンブリをいくつか用いたCコードの一部です。終了ステータスを”42″に設定してみましょう。

(この例は簡易化できますが、少し必要以上に冗長にしても面白いと思いました。そうすれば、前にGCCインラインアセンブリを見たことがない人もこれを手本やリファレンスとして使用することができると考えたのです)

int
main(int argc, char *argv[])
{
  unsigned int syscall_nr = 1;
  int exit_status = 42;

  asm ("movl %0, %%eax\n"
             "movl %1, %%ebx\n"
       "int $0x80"
    : /* output parameters, we aren't outputting anything, no none */
      /* (none) */
    : /* input parameters mapped to %0 and %1, repsectively */
      "m" (syscall_nr), "m" (exit_status)
    : /* registers that we are "clobbering", unneeded since we are calling exit */
      "eax", "ebx");
}

次に、コンパイルして実行し、終了ステータスを確認してください。

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功です。ソフトウェア割り込みを発生させることで、レガシーシステムコールメソッドを使用して exit システムコールを呼び出しました。

カーネルサイド: int $0x80 エントリポイント

ここまでユーザ空間のプログラムからシステムコールを動作させる方法を見てきました。それでは、カーネルがシステムコール番号を使ってシステムコールコードを実行する方法を見ていきましょう。

前のセクションでカーネルが ia32_syscall と呼ばれるシステムコールハンドラ関数を登録したのを思い出してください。

この関数は arch/x86/ia32/ia32entry.S 内のアセンブリに実装されており、この関数内で起こっていることをいくつか見ることができます。その中で最も重要なのは実際のシステムコール自体への呼び出しです。

ia32_do_call:
        IA32_ARG_FIXUP
        call *ia32_sys_call_table(,%rax,8) # xxx: rip relative

IA32_ARG_FIXUP は、レガシー引数が現在のシステムコール層によって適切に判断されるように再配置するマクロです。

ia32_sys_call_table 識別子は、 arch/x86/ia32/syscall_ia32.c で定義されているテーブルを参照します。コードの最後の方にある #include 行に注意してください。

const sys_call_ptr_t ia32_sys_call_table[__NR_ia32_syscall_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_ia32_syscall_max] = &compat_ni_syscall,
#include <asm/syscalls_32.h>
};

以前、 arch/x86/syscalls/syscall_32.tbl で定義されたシステムコールテーブルを見たことを思い出してください。

このテーブルを取得し、それから syscalls_32.h ファイルを生成するコンパイル時に実行するスクリプトがいくつかあります。生成されたヘッダファイルは有効なCコードで構成されています。上記の #include を使ってこれを挿入しさえすれば、システムコール番号によるインデックス付き関数アドレスで ia32_sys_call_table を埋めることができます。

これがレガシーシステムコールを介してカーネルを入れる方法です。

iret を使用したレガシーシステムコールからの復帰

ここまで、ソフトウェア割り込みを使ってカーネルに入る方法を見てきました。では、実行後、カーネルはどのようにしてユーザプログラムに戻り、その特権レベルが変更されるのでしょうか?

Intelソフトウェア開発者向けマニュアル には、特権レベルが変更される際、プログラムスタックがどのように配置されるのかを示した分かりやすい図が載っています(大容量のPDFファイルですので、ご注意ください)。

では、見てみましょう。

Screenshot of the Stack Usage on Transfers to Interrupt and Exception-Handling Routines
注釈
タイトル:特権レベルが変更される際のスタックの仕方
Interrupted Procedure’s Stack:割り込まれたプロシージャのスタック
Handler’s Stack:ハンドラのスタック
ESP Before Transfer to Handler:ハンドラへの移行前のESP
ESP After Transfer to Handler:ハンドラへの移行後のESP

ユーザプログラムからのソフトウェア割り込み実行を介して、実行がカーネル関数 ia32_syscall に移行すると、特権レベルに変化が起こります。つまり、 ia32_syscall が入力された際のスタックが上の図のようになるということです。

これは、 ia32_syscall が実行される前に、特権レベル(と、その他)をコード化するCPUフラグと復帰アドレス、そしてその他のものが全て、プログラムスタックに保存されているということを意味します。

そのため、実行を再開するには、カーネルはこれらの値をプログラムスタックから元のレジスタにコピーし直す必要があります。それでユーザ空間で実行が再開されるのです。

それは分かりました。では、どうやって行うのでしょうか?

方法はいくつかありますが、特に簡単なのは、 iret 命令を使う方法です。

Intel命令セットマニュアルでは、「 iret 命令は、復帰アドレスと保存されたレジスタ値を、用意した順にスタックからポップする」と説明されています。

リアルアドレスモードの割り込みからの復帰と同じように、IRET命令は、復帰命令ポインタ、復帰コードセグメントセレクタ、EFLAGSイメージをスタックから、それぞれEIP、CS、EFLAGSレジスタにポップします。そして、割り込みプログラムや割り込み手順の実行を再開します。

Linuxカーネルでこのコードを見つけるのは、複数のマクロに隠れているため、やや難しいと言えます。また、signalやptraceなどのシステムコールのexitを追跡するというような場合には広範囲に注意する必要があります。

結局は、カーネルのアセンブリスタブにあるマクロ全てに、システムコールからユーザプログラムに戻る iret が隠れています。

arch/x86/kernel/entry_64.Sirq_return は、以下のとおりです。

irq_return:
  INTERRUPT_RETURN

ここで、 INTERRUPT_RETURNarch/x86/include/asm/irqflags.h では iretq として定義されています。

これで、レガシーなシステムコールの動きがどのようなものか、お分かりいただけたと思います。

高速システムコール

レガシーな方法はかなり合理的なものに思えますが、システムコールを行うには、ソフトウェア割り込みを使うよりも はるかに高速な 、新しい方法があります。

2通りのより高速な方法はそれぞれ、2つの命令で構成されています。1つはカーネルに入るもの、もう1つはカーネルから離れるためのものです。いずれも、Intel CPU資料には「Fast System Call(高速システムコール)」として記載されています。

残念ながら、IntelとAMDの実装では、CPUが32ビットモードあるいは64ビットモードの場合に有効な方法は何かという点において、いくつか相違があります。

IntelとAMD、両方のCPUでの互換性を最大に確保するため、以下のとおりとします。

  • 32ビットのシステムでは、 sysentersysexit を使う。
  • 64ビットのシステムでは、 syscallsysret を使う。

Free Deb, RPM, RubyGem, & Python repositories.

32ビットの高速システムコール

sysenter / sysexit

システムコールを行うために sysenter を使うことは、レガシーな割り込みを使う方法よりも複雑であり、ユーザプログラム( glibc を介して)とカーネルの間の調整もより多く必要となります。

では、1つずつ詳細を整理してみましょう。まずは、Intel命令セットリファレンス(大容量の PDF ファイルですのでご注意ください)では、 sysenter について、そしてその使い方について、どのように説明されているのか見ていきましょう。

それでは、以下をご覧ください。

SYSENTER命令を実行する前に、ソフトウェアは以下のMSRに値を書き込むことにより、特権レベル0のコードセグメントとコードのエントリポイント、特権レベル0のスタックセグメントとスタックポインタを指定しなければなりません。

  • IA32SYSENTERCS(MSRアドレス174H) – このMSRの下位16ビットは、特権レベル0のコードセグメント向けのセグメントセレクタです。この値は、特権レベル0のスタックセグメントのセグメントセレクタを決定するためにも使われています(「Operation(操作)」の項を参照してください)。この値をnullのセレクタとすることはできません。
  • IA32SYSENTEREIP(MSRアドレス176H) – このMSRの値は、RIPにロードされます(そのため、この値は選択された操作手順やルーチンの最初の命令を参照します)。プロテクトモードでは、31ビットから0ビットのみがロードされます。
  • IA32SYSENTERESP(MSRアドレス175H) – このMSRの値は、RSPにロードされます(そのため、この値には特権レベル0のスタック向けのスタックポインタが含まれています)。この値は、非標準のアドレスを表すことはできません。プロテクトモードでは、31ビットから0ビットのみがロードされます。

つまり、 sysenter でカーネルに入ってくるシステムコールを受け取るために、カーネルは3つのモデル固有レジスタ(MSR)を設定しなければならないのです。今回扱っている環境で最も興味深いと言えるMSRは、 IA32_SYSENTER_EIP (これはアドレス0x176を持っています)です。このMSRは、 sysenter 命令がユーザプログラムで実行される際、実行される関数のアドレスが指定される場所です。

arch/x86/vdso/vdso32-setup.c では、MSRに書き込むLinuxカーネルのコードを見ることができます。

void enable_sep_cpu(void)
{
        /* ... other code ... */

        wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) ia32_sysenter_target, 0);

ここでの MSR_IA32_SYSENTER_EIP は、 arch/x86/include/uapi/asm/msr-index.h 内で 0x00000176 と定義されています。

多くのレガシーなソフトウェア割り込みsyscallのように、 sysenter でシステムコールを行うために定義された規約があります。

これが文書化されている場所は、 arch/x86/ia32/ia32entry.S でのコメントで、以下のとおりです。

* 32bit SYSENTER instruction entry.
 *
 * Arguments:
 * %eax System call number.
 * %ebx Arg1
 * %ecx Arg2
 * %edx Arg3
 * %esi Arg4
 * %edi Arg5
 * %ebp user stack
 * 0(%ebp) Arg6

レガシーなシステムコールの方法には、割り込まれたユーザ空間プログラムに戻るためのメカニズムがあったことを思い出してください。それが iret 命令です。

sysenter を正常に動かすために必要となるロジックを捉えるのは難しいことです。なぜなら、ソフトウェア割り込みとは違って、 sysenter では復帰アドレスが格納されていないのです。

sysenter 命令を実行する前に、カーネルがこうした情報の記録を行う方法は、厳密には、時が経つにつれて変わり得るものです(以下のバグのセクションで分かるとおり、それは変わっています)。

この先の変更に振り回されないよう、ユーザプログラムは __kernel_vsyscall という関数を使うようになっています。この関数はカーネルに実装されていますが、プロセスの開始時に各ユーザプロセスにマップされます。

これは少し奇妙です。カーネルに付随しているコードなのに、ユーザ空間で動作しているのです。

結局、 __kernel_vsyscall は、プログラムがユーザ空間でカーネルのコードを実行できるようにするために存在している仮想動的共有オブジェクト(vDSO)と呼ばれるものの一部なのです。

vDSOとは何か、何をするものか、そしてどのように機能するのか、後ほど徹底的に見ていくことにします。

では、 __kernel_vsyscall の内部を見ていきましょう。

__kernel_vsyscall 内部

arch/x86/vdso/vdso32/sysenter.S では、 sysenter の呼び出し規約をカプセル化する __kernel_vsyscall 関数を見ることができます。

__kernel_vsyscall:
.LSTART_vsyscall:
        push %ecx
.Lpush_ecx:
        push %edx
.Lpush_edx:
        push %ebp
.Lenter_kernel:
        movl %esp,%ebp
        sysenter

__kernel_vsyscall は、動的共有オブジェクト(共有ライブラリとしても知られています)の一部なのですが、ユーザプログラムは実行時にその関数のアドレスの位置をどうやって特定するのでしょうか?

__kernel_vsyscall 関数のアドレスは、ユーザプログラムやライブラリ(一般的には glibc )が見つけて、使うことができる ELF補助ベクタ に書かれています。

ELF補助ベクタを検索するためには、いくつか方法があります。

  1. AT_SYSINFO 引数で [getauxval](http://man7.org/linux/man-pages/man3/getauxval.3.html) を使う方法。
  2. 環境変数の最後まで検索を繰り返して、メモリからパースする方法。

1つ目の選択肢は非常にシンプルですが、2.16以前の glibc に存在しません。ですから、以下に示すコードの例は2つ目の選択肢です。

先に示したコードからお分かりいただけるとおり、 __kernel_vsyscallsysenter を実行する前に、いくつか情報の記録を行います。

従って、 sysenter を使って手動でカーネルに入るために必要となるのは、以下の作業だけです。

  • __kernel_vsyscall のアドレスが書かれている AT_SYSINFO 向けのELF補助ベクタを検索します。
  • 通常のレガシーなシステムコールで行うのと同じように、レジスタにシステムコール番号と引数を入れます。
  • __kernel_vsyscall 関数を呼び出します。

絶対に、独自の sysenter のラッパ関数を書いてはいけません。カーネルが sysenter を使ってシステムコールに入ったり抜けたりするために使う規約は変わることがあり、そうした場合にコードが動かなくなるからです。

必ず __kernel_vsyscall を通して呼び出すことにより、 sysenter システムコールから始めるようにするべきです。

では、やってみましょう。

独自のアセンブリを用いた sysenter システムコールの使用

これまでのレガシーなシステムコールの例を踏まえ、 42 の終了ステータスで exit を呼ぶことにします。

exit のシステムコール番号は 1 です。前述のインターフェースによると、ただシステムコール番号を eax レジスタに、最初の引数(終了ステータス)を ebx に移動させる必要があります。

(この例は簡易化できますが、少し必要以上に冗長にしても面白いと思いました。そうすれば、前にGCCインラインアセンブリを見たことがない人もこれを手本やリファレンスとして使用することができると考えたのです)

#include <stdlib.h>
#include <elf.h>

int
main(int argc, char* argv[], char* envp[])
{
  unsigned int syscall_nr = 1;
  int exit_status = 42;
  Elf32_auxv_t *auxv;

  /* auxilliary vectors are located after the end of the environment
   * variables
   *
   * check this helpful diagram: https://static.lwn.net/images/2012/auxvec.png
   */
  while(*envp++ != NULL);

  /* envp is now pointed at the auxilliary vectors, since we've iterated
   * through the environment variables.
   */
  for (auxv = (Elf32_auxv_t *)envp; auxv->a_type != AT_NULL; auxv++)
  {
    if( auxv->a_type == AT_SYSINFO) {
      break;
    }
  }

  /* NOTE: in glibc 2.16 and higher you can replace the above code with
   * a call to getauxval(3):  getauxval(AT_SYSINFO)
   */

  asm(
      "movl %0,  %%eax    \n"
      "movl %1, %%ebx    \n"
      "call *%2          \n"
      : /* output parameters, we aren't outputting anything, no none */
        /* (none) */
      : /* input parameters mapped to %0 and %1, repsectively */
        "m" (syscall_nr), "m" (exit_status), "m" (auxv->a_un.a_val)
      : /* registers that we are "clobbering", unneeded since we are calling exit */
        "eax", "ebx");
}

次は、コンパイルして、実行し、終了ステータスをチェックします。

$ gcc -m32 -o test test.c
$ ./test
$ echo $?
42

成功しました。ソフトウェア割り込みを伴わずに、レガシーなsysenterを使った方法で exit システムコールを呼び出すことができました。

カーネル側での sysenter エントリポイント

ここまで、 __kernel_vsyscall を介し、 sysenter でユーザ空間プログラムからシステムコールを呼び出す方法を見てきました。では、今度はカーネルがシステムコールのコードを実行するために、システムコール番号をどのように使っているのかを見てみましょう。

前のセクションを思い出してください。カーネルは ia32_sysenter_target と呼ばれるsyscallハンドラ関数をレジスタに入れました。

arch/x86/ia32/ia32entry.S で、この関数がアセンブリに実装されています。eaxレジスタの値がシステムコールを実行するために使われている場所を見てみましょう。

sysenter_dispatch:
        call    *ia32_sys_call_table(,%rax,8)

これは、レガシーなシステムコールのモードで見たものと、同一のコードです。つまり、システムコール番号と共にインデックス付けされている ia32_sys_call_table と名付けられたテーブルです。

必要な情報の記録が全て行われた後、レガシーなシステムコールモデルと sysenter システムコールモデルの両方が、システムコールをディスパッチするために、同じメカニズムとシステムコールテーブルを使っています。

ia32_sys_call_table が定義されている場所、そしてそれがどのように構築されているか学びたい方は、 int $0x80 のエントリポイントのセクション を参照してください。

以上が sysenter システムコールを介してカーネルに入る方法です。

sysexit を使用した sysenter システムコールからの復帰

カーネルはユーザプログラムに戻って実行を再開するために、 sysexit 命令を使うことができます。

この命令を使うやり方は、 iret を使うやり方ほど分かりやすいものではありません。呼び出し側は、 rdx レジスタに復帰アドレスを入れること、さらに rcx レジスタで使うためのプログラムスタックにポインタを置くことが求められます。

これは、あなたのソフトウェアは実行が再開されるべきアドレスを計算し、その値を保持し、 sysexit を呼び出す前にそれを復元しなければならないことを意味します。

arch/x86/ia32/ia32entry.S では、この処理を行うコードを見ることができます。

sysexit_from_sys_call:
        andl    $~TS_COMPAT,TI_status+THREAD_INFO(%rsp,RIP-ARGOFFSET)
        /* clear IF, that popfq doesn't enable interrupts early */
        andl  $~0x200,EFLAGS-R11(%rsp)
        movl    RIP-R11(%rsp),%edx              /* User %eip */
        CFI_REGISTER rip,rdx
        RESTORE_ARGS 0,24,0,0,0,0
        xorq    %r8,%r8
        xorq    %r9,%r9
        xorq    %r10,%r10
        xorq    %r11,%r11
        popfq_cfi
        /*CFI_RESTORE rflags*/
        popq_cfi %rcx                           /* User %esp */
        CFI_REGISTER rsp,rcx
        TRACE_IRQS_ON
        ENABLE_INTERRUPTS_SYSEXIT32

ENABLE_INTERRUPTS_SYSEXIT32 は、 sysexit 命令を含む arch/x86/include/asm/irqflags.h で定義されているマクロです。

さあ、これで32ビットの高速システムコールがどのように機能するか、お分かりいただけたと思います。

64ビットの高速システムコール

次は、64ビットの高速システムコールを見ていきます。これらのシステムコールは、システムコールを起動するため、およびシステムコールから復帰するために、それぞれ syscallsysret 命令を使います。

syscall / sysret

Free Deb, RPM, RubyGem, & Python repositories.

Intel命令セットリファレンス(大容量の PDF ファイルですのでご注意ください)では、 syscall 命令がどのように機能するかについて、以下のように説明されています。

SYSCALLは、特権レベル0でOSシステムコールハンドラを起動します。これは、IA32_LSTAR MSRからRIPをロードすることによって行われます(RCXにSYSCALLに続く命令のアドレスを保存した後)。

つまり、カーネルに入ってくるシステムコールを受け取るために、カーネルは IA32_LSTAR MSRへアドレスを書き込むことによって、システムコールが出された時に実行されるコードのアドレスをレジスタに入れる必要があるのです。

arch/x86/kernel/cpu/common.c では、そのカーネルのコードを見ることができます。

void syscall_init(void)
{
        /* ... other code ... */
        wrmsrl(MSR_LSTAR, system_call);

MSR_LSTAR は、 arch/x86/include/uapi/asm/msr-index.h0xc0000082 として定義されています。

レガシーなソフトウェア割り込みsyscallのように、システムコールを syscall で呼び出すために定義された規約があります。

ユーザ空間プログラムは、システムコール番号を rax レジスタに入れることが求められます。syscallの引数は、汎用レジスタのサブセット内に配置されることが求められます。

以下は、セクションA.2.1の x86-64 ABI に記載されています。

  1. ユーザレベルのアプリケーションは、シーケンスを進めるために、整数レジスタ%rdi、%rsi、%rdx、%rcx、%r8、%r9を使います。カーネルインターフェースは、%rdi、%rsi、%rdx、%r10、%r8、%r9を使います。
  2. システムコールは、syscall命令を介して行われます。カーネルは、レジスタ%rcx、%r11を破棄します。
  3. syscall番号をレジスタ%raxに渡す必要があります。
  4. システムコールは6つの引数に制限されており、スタックに直接渡される引数はありません。
  5. syscallから復帰する際は、レジスタの%raxにシステムコールの結果が含まれています。-4095から-1の間の値は-errnoであり、エラーとなったことを意味します。
  6. INTEGERクラスまたはMEMORYクラスの値のみがカーネルに渡されます。

これは、 arch/x86/kernel/entry_64.S のコメントにも記載されています。

さあ、これでシステムコールの行い方、引数を置くべき場所が分かりました。それでは、インラインアセンブリをいくつか書いて試してみましょう。

独自のアセンブリを用いた syscall システムコールの使用

前述の例に沿って、インラインアセンブリを含んだ小さなCプログラムを書きましょう。アセンブリは、42という終了ステータスを渡すexitシステムコールを実行するものとします。

まず、 exit に対するシステムコール番号を見つけなくてはなりません。このケースでは、 arch/x86/syscalls/syscall_64.tbl にあるテーブルを読む必要があります。

60 common exit sys_exit

exit のシステムコール番号は 60 です。前述のインターフェースによれば、やるべき作業は、 60rax レジスタに、最初の引数(終了ステータス)を rdi にそれぞれ移すことだけです。

以下は、この作業を行うインラインアセンブリを含んだCコードです。前の例と同様、この例は、分かりやすくするため必要以上に冗長に記述しています。

int
main(int argc, char *argv[])
{
  unsigned long syscall_nr = 60;
  long exit_status = 42;

  asm ("movq %0, %%rax\n"
       "movq %1, %%rdi\n"
       "syscall"
    : /* output parameters, we aren't outputting anything, no none */
      /* (none) */
    : /* input parameters mapped to %0 and %1, repsectively */
      "m" (syscall_nr), "m" (exit_status)
    : /* registers that we are "clobbering", unneeded since we are calling exit */
      "rax", "rdi");
}

次に、コンパイルし、実行して、終了ステータスをチェックします。

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功です。 syscall システムコールを使った方法で、 exit システムコールを呼び出すことができました。ソフトウェア割り込みの発生を避けられましたし、(マイクロベンチマークを測定していれば)実行速度もずっと速いことが分かります。

カーネルサイド:システムコールエントリポイント

ユーザ空間プログラムからシステムコールを行う方法を見てきましたので、今度はカーネルがどのようにシステムコール番号を使ってシステムコールのコードを実行するか見てみましょう。

前のセクションで system_call という名前の関数のアドレスが LSTAR MSRに書き出されていたことを思い出してください。

この関数に対するコードがどのように rax を使って実際にシステムコールに実行を渡すのか、 arch/x86/kernel/entry_64.S から見てみましょう。

call *sys_call_table(,%rax,8)  # XXX:    rip relative

レガシーなシステムコールの方法とよく似ているのですが、 sys_call_table はCファイルで定義されているテーブルであり、 #include を使って、スクリプトで生成されるCコードを読み込みます。

以下は arch/x86/kernel/syscall_64.c の抜粋ですが、最後にある #include に注目してください。

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};

先ほど、 arch/x86/syscalls/syscall_64.tbl で定義されたシステムコールテーブルを確認しました。レガシーな割り込みモードと全く同様に、スクリプトはカーネルコンパイル時に動作し、 syscall_64.tbl のテーブルから syscalls_64.h ファイルを生成します。

上記のコードは、生成されたCコードを単純にインクルードしています。そのCコードは、システムコール番号でインデックス付けされた関数ポインタの配列を作成するものです。

このようにして、 syscall システムコールを通じてカーネルに入ることができます。

sysret を使用した syscall システムコールからの復帰

カーネルは sysret 命令によって、ユーザプログラムが syscall で実行を中断した場所に実行を戻すことができます。

実行が再開されるべき場所のアドレスは、 syscall が使われた時に rcx レジスタにコピーされているため、 sysretsysexit よりもシンプルです。

その値をどこかに保持しておき、 sysret を呼び出す前に rcx にリストアする限り、実行は syscall が呼び出される前に中断した場所で再開されます。

sysenter では、さらなるレジスタを上書きすることに加えてこのアドレスを自分で計算する必要がありますから、これは便利です。

実行するコードは arch/x86/kernel/entry_64.S に含まれています。

movq RIP-ARGOFFSET(%rsp),%rcx
CFI_REGISTER    rip,rcx
RESTORE_ARGS 1,-ARG_SKIP,0
/*CFI_REGISTER  rflags,r11*/
movq    PER_CPU_VAR(old_rsp), %rsp
USERGS_SYSRET64

USERGS_SYSRET64 は、 sysret 命令を含む arch/x86/include/asm/irqflags.h で定義されているマクロです。

これで64ビットの高速システムコールの仕組みが分かりました。

syscall(2)による半手動でのsyscallの呼び出し

ここまで、システムコールの数種類の方法について、アセンブリを書いて手動でシステムコールを呼び出すやり方を見てきました。

通常は、独自のアセンブリを書く必要はありません。様々なアセンブリコードを扱ってくれるラッパ関数がglibcから提供されているためです。

しかし、glibcのラッパが存在しないシステムコールもあります。一例としては、高速ユーザ空間ロックのシステムコール futex が挙げられます。

ですが、なぜ futex に対するシステムコールのラッパが存在しない のでしょうか?

futex は、アプリケーションコードではなくライブラリだけに呼び出されることが想定されています。このため、 futex を呼び出すためには、以下の作業が必要となるのです。

  1. サポートしたい全てのプラットフォームに対するアセンブリスタブを作成
  2. glibcから提供される syscall ラッパを使用

ラッパが存在しないシステムコールを呼び出すことが必要になった場合は、断然、2番目の「glibcから提供される関数 syscall を使用」を選ぶべきです。

ではglibcの syscall を使って、終了ステータス 42exit を呼び出してみましょう。

#include <unistd.h>

int
main(int argc, char *argv[])
{
  unsigned long syscall_nr = 60;
  long exit_status = 42;

  syscall(syscall_nr, exit_status);
}

次に、コンパイルし、実行して、終了ステータスを確認します。

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功しました。glibcの syscall ラッパを使って、 exit システムコールを呼び出すことができました。

glibcの syscall ラッパ内部

Free Deb, RPM, RubyGem, & Python repositories.

上記の例で使った syscall ラッパ関数が、glibcでどのような動作をするのか見てみましょう。

以下は sysdeps/unix/sysv/linux/x86_64/syscall.S からの抜粋です。

/* Usage: long syscall (syscall_number, arg1, arg2, arg3, arg4, arg5, arg6)
   We need to do some arg shifting, the syscall_number will be in
   rax.  */


        .text
ENTRY (syscall)
        movq %rdi, %rax         /* Syscall number -> rax.  */
        movq %rsi, %rdi         /* shift arg1 - arg5.  */
        movq %rdx, %rsi
        movq %rcx, %rdx
        movq %r8, %r10
        movq %r9, %r8
        movq 8(%rsp),%r9        /* arg6 is on the stack.  */
        syscall                 /* Do the system call.  */
        cmpq $-4095, %rax       /* Check %rax for error.  */
        jae SYSCALL_ERROR_LABEL /* Jump to error handler if error.  */
L(pseudo_end):
        ret                     /* Return to caller.  */

前の方で、x86_64 ABIのドキュメントから、ユーザ空間とカーネルの両方の呼び出し規約を引用してご紹介しました。

このアセンブリスタブは、 両方の 呼び出し規約が示されている点で優れています。この関数に渡される引数はユーザ空間の呼び出し規約に準じますが、それから、 syscall でカーネルに入る前に、カーネルの呼び出し規約に従うよう異なるレジスタに移されます。

このようにして、glibcのsyscallラッパを使って、デフォルトのラッパが存在しないシステムコールを呼び出すことができます。

仮想システムコール

ここまで、カーネルに入ってシステムコールを行う様々な方法を取り上げ、システムをユーザ空間からカーネルに遷移させるためそれらの呼び出しを手動(または半手動)で行うやり方をご紹介しました。

では、カーネルに全く入ることなく、プログラムが特定のシステムコールを呼び出せるとしたら、どうなるでしょうか?

Linuxに仮想動的共有オブジェクト(vDSO)が存在する理由は、まさにそこにあります。LinuxのvDSOは、カーネルの一部を構成するコードですが、ユーザ空間で実行されるユーザプログラムのアドレス空間にマップされています。

vDSOの目的は、一部のシステムコールをカーネルに入らずに使えるようにすることです。そのような呼び出しの例として、 gettimeofday が挙げられます。

gettimeofday システムコールを呼び出すプログラムは、実際にはカーネルに入りません。その代わりに、カーネルによって 提供 されるもののユーザ空間で実行されるというコードに対し、単純な関数呼び出しを行います。

ソフトウェア割り込みは発生せず、 sysentersyscall の情報を管理するという複雑な作業も必要ありません。 gettimeofday は単なる普通の関数呼び出しなのです。

ldd を使うと、vDSOが最初のエントリとして表示されます。

$ ldd `which bash`
  linux-vdso.so.1 =>  (0x00007fff667ff000)
  libtinfo.so.5 => /lib/x86_64-linux-gnu/libtinfo.so.5 (0x00007f623df7d000)
  libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f623dd79000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f623d9ba000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f623e1ae000)

vDSOがカーネルでどのようにセットアップされるか見てみましょう。

Free Deb, RPM, RubyGem, & Python repositories.

カーネル内でのvDSO

vDSOのソースは arch/x86/vdso/ にあります。アセンブリやCソースファイル、リンカスクリプトで構成されています。

この リンカスクリプト は、一見の価値があります。

以下は、 arch/x86/vdso/vdso.lds.S からの抜粋です。

/*
 * This controls what userland symbols we export from the vDSO.
 */
VERSION {
        LINUX_2.6 {
        global:
                clock_gettime;
                __vdso_clock_gettime;
                gettimeofday;
                __vdso_gettimeofday;
                getcpu;
                __vdso_getcpu;
                time;
                __vdso_time;
        local: *;
        };
}

リンカスクリプトはかなり便利ですが、特によく知られているわけではありません。このリンカスクリプトは、vDSOでエクスポートされるシンボルを配置するものです。

上記のvDSOは4つの異なる関数をエクスポートしており、それぞれに2つの名前があることが分かります。これらの関数のソースは、上記ディレクトリのCファイルにあります。

例えば、 gettimeofday のソースは arch/x86/vdso/vclock_gettime.c にあります。

int gettimeofday(struct timeval *, struct timezone *)
        __attribute__((weak, alias("__vdso_gettimeofday")));

上記は、 __vdso_gettimeofday への weak alias として gettimeofday を定義しています。

同じファイルにある __vdso_gettimeofday 関数は、ユーザプログラムが gettimeofday システムコールを呼び出した時に ユーザ空間で 実行される、実際のソースを含んでいます。

メモリ内でvDSOを検索

アドレス空間配置のランダム化 のため、vDSOはプログラム開始時にランダムなアドレスにロードされます。

ランダムなアドレスにロードされるのであれば、ユーザプログラムはどうやってvDSOを見つけるのでしょうか?

前に sysenter システムコールの方法を見た時は、 sysenter アセンブリコードを自分で書く代わりに、ユーザプログラムが __kernel_vsyscall を呼び出す必要がありましたね。

この関数もまた、vDSOの一部です。

前掲のサンプルコードは、 __kernel_vsyscall のアドレスを含んだタイプ AT_SYSINFO を持つヘッダを見つけるため、 ELF補助ヘッダ を調べて __kernel_vsyscall の位置を特定しました。

同様に、vDSOの位置を特定するため、ユーザプログラムはタイプ AT_SYSINFO_EHDR のELF補助ヘッダを調べることができます。このタイプは、リンカスクリプトから生成されたvDSOのELFヘッダの開始アドレスを含みます。

どちらのケースも、プログラムがロードされる際、カーネルがそのアドレスをELFヘッダに書き出します。こうして、正しいアドレスが AT_SYSINFO_EHDRAT_SYSINFO に必ず格納されるのです。

そのヘッダの位置が特定されれば、ユーザプログラムはELFオブジェクトをパースして(おそらく libelf を使用)、必要に応じてELFオブジェクト内の関数を呼び出すことができます。

vDSOは シンボルバージョニング のようなELFの有用な機能を活用できるということですので、これは便利です。

vDSO内の関数をパースして呼び出す例は、 Documentation/vDSO/ のカーネルドキュメントに掲載されています。

glibc内のvDSO

多くの場合、私たちは知らない間にvDSOにアクセスしています。前述したように、 glibc で使われるインターフェースによって、そのことが見えなくなっているからです。

プログラムがロードされる際、 動的リンカ/ローダ は、そのプログラムが依存するDSOをロードします。これにはvDSOも含まれます。

glibc は、ロードされているプログラムのELFヘッダをパースする際、vDSOの位置に関するデータを格納します。また、実際のシステムコールを行う前に、vDSOを調べてシンボル名を探す、単純なスタブ関数をインクルードします。

例えば下記は、 sysdeps/unix/sysv/linux/x86_64/gettimeofday.c から抜粋した glibcgettimeofday 関数です。

void *gettimeofday_ifunc (void) __asm__ ("__gettimeofday");

void *
gettimeofday_ifunc (void)
{
  PREPARE_VERSION (linux26, "LINUX_2.6", 61765110);

  /* If the vDSO is not available we fall back on the old vsyscall.  */
  return (_dl_vdso_vsym ("gettimeofday", &linux26)
          ?: (void *) VSYSCALL_ADDR_vgettimeofday);
}
__asm (".type __gettimeofday, %gnu_indirect_function");

glibc 内のこのコードは、vDSOを調べて gettimeofday を探し、そのアドレスを返します。最後に indirect function がうまく使われます。

このようにして、 gettimeofday を呼び出すプログラムは、カーネルモードに切り替えたり、特権レベル変更を招いたり、ソフトウェア割り込みを発生させたりすることなく、 glibc を通じてvDSOを使えるのです。

以上で、32ビットか64ビットのIntelかAMDのCPU向けLinuxで利用できるシステムコールの方法を全てご紹介したことになります。

glibc システムコールラッパ

この記事ではシステムコールについて説明していますので、 glibc がシステムコールをどのように扱うかにも簡単に触れておきたいと思います。

多くのシステムコールについて、 glibc が必要とするのは、引数を適切なレジスタへ移してから syscall または int $0x80 命令を実行するか __kernel_vsyscall を呼び出すというラッパ関数だけです。

この際には、テキストファイルで定義された一連のテーブルが使われますが、そのテキストファイルはスクリプトで処理され、Cコードが出力されます。

例えば下記は、 sysdeps/unix/syscalls.list ファイルに記述された一般的なシステムコールです。

access          -       access          i:si    __access        access
acct            -       acct            i:S     acct
chdir           -       chdir           i:s     __chdir         chdir
chmod           -       chmod           i:si    __chmod         chmod

各列についての詳しい情報は、このファイルを処理するスクリプト sysdeps/unix/make-syscalls.sh のコメントを参照してください。

ハンドラを呼び出す exit のように、より複雑なシステムコールは、Cやアセンブリコードによる実際の実装があり、上記のようなテンプレート化されたテキストファイルには含まれていません。

今後のブログ記事では、興味深いシステムコールに対する glibc やLinuxカーネルでの実装について探ってみたいと思います。

興味深いシステムコールに関連したバグ

Free Deb, RPM, RubyGem, & Python repositories.

この機会に、Linuxのシステムコールに関する2つの重大なバグに言及しないわけにはいきません。

以下で見ていきましょう。

CVE-2010-3301

このセキュリティエクスプロイト では、ローカルユーザがroot権限を取得することが可能となります。

原因となっているのは、ユーザプログラムがx86-64システム上でレガシーなシステムコールを行えるようにするアセンブリコードの、小さなバグです。

このエクスプロイトコードはかなり巧妙です。 mmap で特定のアドレスにメモリ領域を確保し、整数オーバーフローを用いて、下記のコードが任意のアドレスに実行を渡すようにします。

(レガシーな割り込みのセクションで登場したこのコードを覚えていますか?)

call *ia32_sys_call_table(,%rax,8)

これがカーネルコードとして動作し、実行プロセスをルート権限に昇格させることができるのです。

Android sysenter におけるABI破損

前の方で、 sysenter ABIをアプリケーションコードにハードコーディングしないようにとお伝えしたことを覚えていますか?

残念ながら、android-x86の開発者はそのミスを犯しました。カーネルABIが変わって突然、android-x86が機能しなくなったのです。

カーネルの開発者は、出回っているAndroidデバイスが、古くなったハードコードの sysenter シーケンスによって動かなくなることを避けるため、以前の sysenter ABIをリストアする羽目になりました。

Linuxカーネルに施された 修正はこちら です。コミットメッセージには、androidソースで元凶となったコミットへのリンクがあります。

注意:決して独自の sysenter アセンブリコードを書いてはいけません。何らかの理由でそのようなコードを直接実装しなければならない場合は、前述の例のようなコードを使い、せめて __kernel_vsyscall は呼び出すようにしてください。

まとめ

Linuxカーネルにおけるシステムコールのインフラは、非常に複雑です。各システムコールについて様々なやり方があり、利点や欠点もそれぞれ異なります。

独自のアセンブリを書いてシステムコールを呼び出すことは、背後のABIが動かなくなる可能性があるので、通常はいい方法とは言えません。カーネルとlibc実装こそが、そのシステム上でシステムコールを行うのに最も速い方法を(おそらく)選んでくれるでしょう。

glibc から提供されるラッパが使えない(または存在しない)場合は、せめて syscall ラッパ関数を使うか、vDSOから提供される __kernel_vsyscall を呼び出すようにすべきです。

今後のブログ記事では、個々のシステムコールとその実装について詳しく取り上げる予定ですので、ご期待ください。

関連ブログ

この記事を読んでみて面白いと感じた方には、低水準技術に関する以下の記事も参考になるかもしれません。