🚪

システムコールの裏側——ユーザー空間からカーネルへの遷移をアセンブリで追う

に公開

はじめに

前回はスタックフレームの構造を見ました。今回は「ユーザー空間のプログラムがカーネルの機能を呼び出す瞬間」に何が起きているかを、アセンブリレベルで追ってみます。

普段何気なく使っている 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は以下を行います:

  1. RCX に戻りアドレス(次の命令のRIP)を保存
  2. R11 に RFLAGS を保存
  3. MSR(Model Specific Register)に設定されたカーネルのエントリポイントにジャンプ
  4. 特権レベルを 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(writeexit)だけを呼んでいるのが確認できました。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)はカーネルがユーザー空間にマッピングする共有ライブラリで、特権遷移なしで時刻取得などを実行できます。stracegettimeofday が見えないのはこのためでした。

まとめ

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