Dev Study
OSと低レイヤのしくみ コース

5. システムコールの入口 — ユーザーからカーネルへ制御が渡る瞬間

write() のようなライブラリ関数を呼んでから、カーネルの中の処理が始まるまでに何が起きているのかを図で追っていきましょう。

① 番号をレジスタに置いて、専用命令を実行する

ユーザー空間側の準備 — 引数は「関数呼び出し」ではなくレジスタに直接置かれる
アプリのコードwrite(1, buf, n)
libc のラッパ関数へ
glibc の write()薄いラッパ。引数をレジスタへ並べ替えるだけ
レジスタへ配置
x86-64番号は eax、引数は rdi, rsi, rdx, r10, r8, r9
引数は最大6個
RISC-V番号は a7、引数は a0〜a5
引数は最大6個
専用命令を1つ実行
syscall (x86-64)CPUがカーネルへ制御を移す
ecall (RISC-V)Environment Call 例外を発生させる
  • ・syscall(2) の表では、x86-64 は syscall 命令で番号を eax に置き、戻り値は rax(第2戻り値は rdx)
  • ・同じ表で RISC-V は ecall 命令で番号を a7 に置き、戻り値は a0(第2戻り値は a1)
  • ・引数は「スタックに積む」のではなくレジスタに直接置く。カーネルはユーザーのスタックを信用せずに済む

ライブラリ関数は計算をしません。番号と引数を決められたレジスタに置き、専用命令を1つ実行するだけです。

② 特権モードが切り替わる

命令1つでCPUの特権レベルが変わる
実行前x86-64: CPL = 3(ユーザー) / RISC-V: U-mode
syscall / ecall
x86-64 の syscallCPL を 0 にし、CS セレクタを IA32_STAR[47:32] から作る
RISC-V の ecallEnvironment Call 例外を発生させ S-mode へ入る
飛び先はソフトが決められない
x86-64: RIP ← IA32_LSTARカーネルが起動時に設定したMSR
入口は1点に固定
RISC-V: pc ← stvecトラップハンドラのベースアドレス
入口は1点に固定
実行後
カーネルコードを特権レベルで実行中
  • ・Intel SDM の SYSCALL の擬似コードでは、RIP は IA32_LSTAR MSR からロードされ、CPL は 0 に設定される
  • ・CS セレクタは IA32_STAR[47:32] AND FFFCH から作られ、CS の残りのフィールドは固定値が設定される
  • ・RISC-V Privileged Spec では、トラップを取ると pc が stvec の示す先へ移り、SPP に元の特権モードが記録される
  • ・飛び先レジスタ(LSTAR / stvec)はユーザーモードからは書けない。だから「任意のカーネルコードへ飛ぶ」ことはできない

重要なのは、ジャンプ先をユーザーが指定できないことです。特権の獲得とジャンプ先の固定が同時に起きるので、権限昇格の抜け道になりません。

③ 戻り先を記録し、割り込みを止める

ハードウェアが自動で保存するもの
x86-64 の syscall が保存するもの
ハードウェアが自動実行
rcx ← 次の命令のアドレスsyscall の直後に戻るため
r11 ← RFLAGSフラグを丸ごと退避
RFLAGS を IA32_FMASK でマスクFMASK でセットされたビットをクリア
最後に実行される
RISC-V の ecall が保存するもの
ハードウェアが自動実行
sepc ← ecall 命令自身のアドレス次の命令ではない
scause ← 8Environment call from U-mode
SPP←元のモード / SPIE←SIE / SIE←0割り込みを禁止
  • ・Intel SDM の SYSCALL は RCX ← RIP、RIP ← IA32_LSTAR、R11 ← RFLAGS、RFLAGS ← RFLAGS AND NOT(IA32_FMASK) の順で動く。戻り先の保存が先で、マスクは最後
  • ・どのフラグを落とすかはハードウェアではなくカーネルが決める。Linux の syscall_init は IA32_FMASK に TF/DF/IF/IOPL/AC/NT を書き込むので、IF が落ちて割り込みが禁止される
  • ・RISC-V Privileged Spec では、ECALL は受け側の epc にその ECALL 命令自身のアドレスを設定する。ハードウェアは加算しない
  • ・そのため Linux の do_trap_ecall_u は regs->epc += 4 と固定値を足す。命令長を測っているわけではない

x86-64 は戻り先を保存してからフラグをマスクします。RISC-V が「命令自身」を保存するのは、ページフォルトのように命令をやり直したい例外と同じ仕組みを使い回しているからです。

④ x86-64 で第4引数だけ r10 になる理由

通常の関数呼び出し規約と1か所だけずれている
System V の関数呼び出し規約rdi, rsi, rdx, rcx, r8, r9
同じ並びを使いたいが…
syscall 命令が rcx を上書き戻り先アドレスの保存先として使われる
syscall 命令が r11 を上書きRFLAGS の保存先として使われる
rcx に置いた引数は消えてしまう
第4引数の置き場所を rcx から r10 へ変更r10 は命令に触られない
結果
システムコールの引数順rdi, rsi, rdx, r10, r8, r9
rcx / r11 は復帰時に破壊される呼び出し側は当てにできない
  • ・syscall(2) の x86-64 の行は arg4 に r10 を挙げており、通常の関数呼び出し規約の rcx とは異なる
  • ・Intel SDM の SYSCALL の擬似コードどおり、rcx は戻り先、r11 は RFLAGS の保存先としてハードウェアに使われる
  • ・RISC-V の ecall は汎用レジスタを書き換えないため、a0〜a5 が引数のまま素直に使える

ソフトウェアの都合ではなく、syscall 命令というハードウェアの仕様が rcx を奪うから、引数の並びが1か所だけずれています。

⑤ スタックを切り替え、レジスタをトラップフレームへ退避する

ソフトウェア(カーネルの入口コード)が最初にやること
入口に着いた直後の状態特権はカーネル、しかし rsp はまだユーザースタックを指している
per-CPU 領域へ切り替え
swapgsGS ベースをユーザー用からカーネル用へ交換
これで per-CPU 変数が読める
スタックを差し替え
ユーザーの rsp を退避あとで戻すために保存
rsp ← カーネルスタックの先頭per-CPU の cpu_current_top_of_stack
全レジスタを積む
struct pt_regs を構築ユーザーのレジスタ一式をカーネルスタック上に並べる
以降のC関数は pt_regs へのポインタを受け取る
  • ・Linux x86-64 の入口は arch/x86/entry/entry_64.S の entry_SYSCALL_64
  • ・swapgs は GS ベースを入れ替える命令で、Linux はこれで per-CPU 領域にアクセスする
  • ・カーネルスタックはユーザーからは触れない領域。ユーザースタックをそのまま使わないのは、ユーザーが不正なアドレスを rsp に入れていてもカーネルが壊れないようにするため

ハードウェアは特権とジャンプ先しか面倒を見てくれません。スタックの切り替えとレジスタの退避はカーネルのアセンブリコードの仕事です。

⑥ 番号で表を引き、ハンドラへ分岐する

番号 → 関数ポインタの配列引き
退避済みの番号を取り出すpt_regs から読む
まず範囲チェック
番号 < NR_syscalls ?範囲外なら -ENOSYS を返して終了
配列外アクセスを防ぐ
境界検査が先
表を引く
sys_call_table[番号]関数ポインタの配列
呼び出し
該当ハンドラを実行pt_regs から引数を取り出して渡す
  • ・Linux では do_syscall_64 がアセンブリからCへの橋渡しをし、pt_regs へのポインタと番号を受け取る
  • ・番号が NR_syscalls 以上なら -ENOSYS(Function not implemented)を返す
  • ・分岐は if の連鎖ではなく配列インデックス1回。番号がいくつあっても引く手間は一定

カーネル側は巨大な分岐を書いているのではなく、番号を添字にして関数ポインタの表を1回引くだけです。だから番号が歯抜けでも性能は変わりません。

⑦ 戻り値をレジスタに載せて帰る

復路 — 退避したものを逆順に戻す
ハンドラが戻り値を返す成功なら0以上、失敗なら負のエラー番号
戻り値レジスタの枠へ書き込む
pt_regs の rax(RISC-V は a0)を上書き引数として入っていた値は捨てられる
pt_regs から全レジスタを復元
x86-64: sysret 系で復帰rcx→RIP、r11→RFLAGS、CPL を 3 へ
RISC-V: 加算済みの sepc へ sretecall の次の命令へ戻る
ユーザー空間に戻る
glibc のラッパが戻り値を判定負なら errno を設定して -1 を返す
  • ・syscall(2) の表のとおり、戻り値は x86-64 が rax、RISC-V が a0
  • ・カーネルが返すのは負のエラー番号そのもの。errno という変数はカーネル側には存在せず、libc のラッパが設定する

往路で保存したものを逆順に戻すだけで復路になります。カーネルとユーザーの境界を越えるのは、あくまでレジスタに載った1つの整数です。

公式ドキュメントで詳しく ↗