システムコールの裏側——ユーザー空間からカーネルへの遷移をアセンブリで追う
はじめに
前回はスタックフレームの構造を見ました。今回は「ユーザー空間のプログラムがカーネルの機能を呼び出す瞬間」に何が起きているかを、アセンブリレベルで追ってみます。
普段何気なく使っている write() や exit() の先で、CPU がどうモードを切り替えているのかが見えてきました。
システムコールとは
プログラムが「画面に文字を出す」「ファイルを読む」「プロセスを終了する」といった操作をするには、カーネルに依頼する必要があります。この依頼の仕組みがシステムコールです。
ユーザー空間 カーネル空間
┌──────────┐ ┌──────────┐
│ printf() │ │ │
│ ↓ │ │ write │
│ write() │── syscall ──→│ 処理 │
│ │←─ return ───│ │
└──────────┘ └──────────┘
Ring 3 Ring 0
通常の関数呼び出し(call 命令)とは根本的に異なり、CPU の特権レベルが変わります。
3アーキテクチャのシステムコール命令
x86_64
mov eax, 1 ; syscall番号(write = 1)
mov edi, 1 ; 第1引数: fd = stdout
lea rsi, [rel msg] ; 第2引数: バッファのアドレス
mov edx, 14 ; 第3引数: 長さ
syscall ; カーネルへ遷移
syscall 命令が実行されると、CPUは以下を行います:
- RCX に戻りアドレス(次の命令のRIP)を保存
- R11 に RFLAGS を保存
- MSR(Model Specific Register)に設定されたカーネルのエントリポイントにジャンプ
- 特権レベルを Ring 0 に変更
古い方式の int 0x80(ソフトウェア割り込み)と比べると、syscall はIDT(割り込みディスクリプタテーブル)を経由しないため高速です。
AArch64
mov x8, #64 // syscall番号(write = 64)
mov x0, #1 // 第1引数: fd = stdout
ldr x1, =msg // 第2引数: バッファのアドレス
mov x2, #14 // 第3引数: 長さ
svc #0 // カーネルへ遷移(Supervisor Call)
svc #0 は例外(exception)を発生させ、CPU を EL0(ユーザー)から EL1(カーネル)に遷移させます。ARMの例外レベルモデルは x86 のリング保護よりシンプルで、EL0〜EL3 の4段階です。
z/Architecture
lghi %r1, 4 # syscall番号(write = 4)
lghi %r2, 1 # 第1引数: fd = stdout
larl %r3, msg # 第2引数: バッファのアドレス
lghi %r4, 14 # 第3引数: 長さ
svc 0 # カーネルへ遷移(Supervisor Call)
z/Architecture も svc(Supervisor Call)命令を使います。AArch64 の svc とは全く別の命令ですが、名前も役割も同じなのが面白いところです。
syscall 規約の比較
| x86_64 | AArch64 | z/Architecture | |
|---|---|---|---|
| 命令 | syscall |
svc #0 |
svc 0 |
| 番号の置き場 | eax | x8 | r1 |
| 引数レジスタ | rdi, rsi, rdx, r10, r8, r9 | x0〜x5 | r2〜r7 |
| 戻り値 | rax | x0 | r2 |
| 遷移先の決定 | MSR(LSTAR) | 例外ベクタテーブル | 割り込みハンドラ |
注目したのは、syscall番号が通常の関数呼び出し規約とは異なるレジスタに入る点です。x86_64 の通常の関数なら第1引数は rdi ですが、syscall番号は eax。AArch64 なら通常は x0 に第1引数が入りますが、syscall番号は x8。カーネルとユーザー空間で名前空間を分離する設計になっています。
もう一つ気になったのは、syscall番号がアーキテクチャ間で全く異なること。write は x86_64 では 1、AArch64 では 64、s390x では 4。POSIX でインタフェースが標準化されていても、ABI レベルでは完全にアーキテクチャ依存でした。
Hello World をシステムコール直叩きで書く
x86_64(Docker + NASM)
docker run --rm -it --platform linux/amd64 ubuntu:24.04 bash
apt update && apt install -y nasm
; hello.asm
section .data
msg db "Hello, World!", 10 ; 10 = 改行
len equ $ - msg
section .text
global _start
_start:
; write(1, msg, len)
mov eax, 1 ; sys_write
mov edi, 1 ; stdout
lea rsi, [rel msg] ; バッファ
mov edx, len ; 長さ
syscall
; exit(0)
xor edi, edi ; 終了コード 0
mov eax, 60 ; sys_exit
syscall
nasm -f elf64 hello.asm && ld -o hello hello.o
./hello
# Hello, World!
mov eax, 1 で eax に書き込むことで rax の上位32ビットがゼロクリアされるので、mov rax, 1 と書く必要はありません。xor edi, edi も同様に、rdi を 0 にするイディオムです。
AArch64(Docker / Linux)
Apple Silicon Mac のネイティブ環境は macOS なので syscall 番号が異なります。Linux の syscall を試すには Docker を使います。
docker run --rm -it --platform linux/arm64 ubuntu:24.04 bash
apt update && apt install -y gcc binutils
// hello.s
.data
msg: .ascii "Hello, World!\n"
len = . - msg
.text
.global _start
_start:
// write(1, msg, len)
mov x8, #64 // sys_write
mov x0, #1 // stdout
adr x1, msg // バッファ(PC相対アドレス)
mov x2, #len // 長さ
svc #0
// exit(0)
mov x8, #93 // sys_exit
mov x0, #0 // 終了コード
svc #0
as -o hello.o hello.s && ld -o hello hello.o
./hello
# Hello, World!
z/Architecture(Docker + QEMU)
docker run --rm -it --platform linux/s390x ubuntu:24.04 bash
apt update && apt install -y gcc binutils
# hello.s
.data
msg: .ascii "Hello, World!\n"
len = . - msg
.text
.global _start
_start:
# write(1, msg, len)
lghi %r1, 4 # sys_write
lghi %r2, 1 # stdout
larl %r3, msg # バッファ(PC相対アドレス)
lghi %r4, 14 # 長さ
svc 0
# exit(0)
lghi %r1, 1 # sys_exit
lghi %r2, 0 # 終了コード
svc 0
as -o hello.o hello.s && ld -o hello hello.o
./hello
# Hello, World!
strace で実際の syscall を観察する
アセンブリを書かなくても、strace を使えば任意のプログラムの syscall を観察できます。
strace ./hello
execve("./hello", ["./hello"], ...) = 0
write(1, "Hello, World!\n", 14) = 14
exit(0) = ?
自分で書いた Hello World がちょうど2回の syscall(write と exit)だけを呼んでいるのが確認できました。libc を使わない分、余計な syscall がなくすっきりしています。
Cで同じプログラムを書いて strace すると、起動時の動的リンクだけで数十行の syscall が出力されます。
# 比較のため
cat << 'EOF' > hello.c
#include <stdio.h>
int main() { puts("Hello, World!"); return 0; }
EOF
gcc -o hello_c hello.c
strace ./hello_c 2>&1 | wc -l
# → 30行以上
遷移のコスト
syscall は通常の関数呼び出しより高コストです。特権レベルの切り替え、TLBの一部フラッシュ、カーネルのエントリ処理などが発生します。
このコスト差は Spectre/Meltdown 以降の KPTI(Kernel Page Table Isolation)パッチでさらに大きくなりました。これが io_uring(syscall を減らすための仕組み)や vDSO(カーネルに遷移せずに一部の情報を取得する仕組み)が注目される背景です。
vDSO:syscall を避ける最適化
gettimeofday() のような頻繁に呼ばれる syscall は、実は本当の syscall を経由しないことがあります。
# vDSO のマッピングを確認
cat /proc/self/maps | grep vdso
# 7fff12345000-7fff12346000 r-xp ... [vdso]
vDSO(virtual Dynamic Shared Object)はカーネルがユーザー空間にマッピングする共有ライブラリで、特権遷移なしで時刻取得などを実行できます。strace で gettimeofday が見えないのはこのためでした。
まとめ
| x86_64 | AArch64 | z/Architecture | |
|---|---|---|---|
| 遷移命令 | syscall |
svc #0 |
svc 0 |
| 特権レベル | Ring 3 → Ring 0 | EL0 → EL1 | Problem State → Supervisor |
| 戻りアドレスの保存先 | RCX | ELR_EL1(専用レジスタ) | PSW(プログラム状態ワード) |
| フラグの保存先 | R11 | SPSR_EL1(専用レジスタ) | PSW |
3つのアーキテクチャで命令の名前や番号は異なりますが、「専用命令で特権レベルを切り替え、カーネルのハンドラにジャンプする」という構造は共通していました。
通常の関数呼び出しとの一番の違いは、戻りアドレスの保存先が汎用レジスタ(call → スタック、bl → x30)ではなく、ユーザー空間からアクセスできない専用レジスタ(ELR_EL1 など)や MSR に保存される点です。カーネルの状態をユーザー空間から改ざんできないようにする設計だと理解しました。
次回は SIMD・ベクトル命令を見ていきます。
Discussion