このページの目次

Linux x86-64 システムコール ABI

手書きアセンブリ、インラインアセンブリ、seccompのデバッグ、または逆アセンブリの読解において、最も混乱しやすいのは以下の3つの異なるルールセットです。

  1. System V AMD64 C ABI:通常のユーザー空間関数における引数渡し、戻り値、およびレジスタの保存方法。
  2. Linux x86-64 syscall ABI:ユーザー空間がシステムコール番号と最大6つの引数をカーネルに渡す方法。
  3. CPUの SYSCALL/SYSRET 命令セマンティクス⁠:特権レベルの切替時にハードウェアが実際にどのレジスタを変更するか。

本稿ではこれら3つの層を分離し、現在のLinux x86-64のエントリコードを基準に解説します。カーネルの実装は進化するため、ソースコードの断片をコピーする際は、特定バージョンの詳細を恒久的なABIとして扱うのではなく、バージョンを明記してください。

1. Linux syscall レジスタ規約

用途レジスタ
システムコール番号rax
引数 1–3rdi, rsi, rdx
引数 4–6r10, r8, r9
戻り値rax
ハードウェアによる破壊rcx, r11

典型的な呼び出し例:

mov $1, %rax          # __NR_write
mov $1, %rdi          # fd = STDOUT_FILENO
lea message(%rip), %rsi
mov $6, %rdx
syscall

Linuxカーネルは、負でない結果または -errno を直接返します。glibcなどのlibcラッパーは、-errno-1 に変換し、スレッドローカルの errno を設定します。

なぜ第4引数は rcx ではなく r10 なのか

通常のSystem V関数では、第4の整数引数に rcx が使用されます。しかし、SYSCALL 命令はユーザー空間の戻りアドレスを rcx に書き込む必要があります。そのため、Linux syscall ABIでは第4引数が r10 に移動されています。

これが、インラインアセンブリで rcxr11 のclobberを宣言しなければならない理由です。

static inline long raw_write(int fd, const void *buf, unsigned long count)
{
    register long rax __asm__("rax") = 1;   /* __NR_write */
    register long rdi __asm__("rdi") = fd;
    register long rsi __asm__("rsi") = (long)buf;
    register long rdx __asm__("rdx") = count;

    __asm__ volatile (
        "syscall"
        : "+a"(rax)
        : "D"(rdi), "S"(rsi), "d"(rdx)
        : "rcx", "r11", "memory"
    );
    return rax;
}

引数が4〜6個の場合、r10r8r9 を明示的にバインドする必要があります。本番コードでは、libcの呼び出しを優先すべきです。raw syscallは、キャンセルポイント、互換性ラッパー、およびlibcのエラー処理をバイパスします。

2. SYSCALL 命令の動作

64ビットモードで SYSCALL を実行すると、CPUは主に以下の処理を行います。

RCX    <- 次のユーザー空間命令アドレス
R11    <- ユーザー空間の RFLAGS
RIP    <- IA32_LSTAR
RFLAGS <- RFLAGS & ~IA32_FMASK
CS/SS  <- IA32_STAR から派生したカーネルセグメントセレクタ
CPL    <- 0

重要な結論は2つあります。

  • rcxr11 はハードウェアによって占有されるため、syscallを跨いで値を保存するために使用できません。
  • SYSCALLユーザー空間の rsp を保存せず、自動的にカーネルスタックに切り替えることもありません⁠。

RFLAGS は単に「変更されない」わけではありません。カーネルは IA32_FMASK でクリアするフラグビットを構成しており、Linuxのエントリコードは安全な状態を確立してから割り込みを管理します。

3. Linux entry_SYSCALL_64

現在のx86-64エントリの主要な順序は以下の通りです。

# arch/x86/entry/entry_64.S(セキュリティ強化と注釈は省略)
entry_SYSCALL_64:
    swapgs
    movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)  # ユーザー rsp を一時保存
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
    movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

    pushq $__USER_DS
    pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    pushq %r11
    pushq $__USER_CS
    pushq %rcx
    pushq %rax
    # struct pt_regs の構築を続行し、その後 do_syscall_64 を呼び出す

ここで言う tss.sp2 は、ハードウェアによって自動的にスタックが切り替えられる従来の sp0 フローではなく、Linuxが使用するper-CPUのスクラッチスロットです。順序も重要です。まずユーザー rsp を保存し、次にカーネルページテーブルとカーネルスタックに切り替え、最後にカーネルスタック上で struct pt_regs を構築します。

したがって、ユーザー空間のred zoneは、通常のsyscallエントリでのレジスタフレームによって上書きされません。フレームはカーネルスタック上にあり、「ユーザー rsp の下」にはありません。

4. 戻り値:SYSRET 高速パスと IRET フォールバック

システムコール終了後、Linuxは戻りコンテキストが SYSRET に適しているかどうかを確認します。アドレス、セグメントレジスタ、フラグビットなどの条件がすべて安全な場合にのみ高速パスが使用され、そうでない場合は IRET が使用されます。

これは単なるパフォーマンスの選択ではありません。AMDとIntelの SYSRET は、非canonicalアドレスなどの境界ケースで扱いにくい動作をするため、カーネルはユーザー制御可能な pt_regs を事前に検証する必要があります。

ユーザー空間から観察すると、両方の戻りパスは同じsyscall ABIを満たす必要があります。カーネルが必ず SYSRET を使用すると仮定してはいけません。

5. Red zone とシステムコール

System V AMD64 ABIでは、ユーザー rsp の下に128バイトのred zoneが定義されています。リーフ関数は、rsp を調整せずにこの領域を一時的に使用できます。

以下の3つのケースを区別する必要があります。

  • 通常のsyscall/割り込みによるカーネル進入⁠:Linuxはカーネルスタックに切り替わり、エントリフレームはユーザーred zoneに書き込みません。
  • ユーザー空間へのシグナル配信⁠:カーネルはシグナルフレームを構築する際に、red zoneのスペースを確保します。
  • カーネルコード自体⁠:カーネルは -mno-red-zone でコンパイルされるため、ユーザー空間ABIのred zoneに依存できません。

手書きのインラインアセンブリにおいて、"memory" clobberはコンパイラのメモリ再配置を制約するだけであり、syscallが特定の固定メモリ範囲のみアクセスすることを意味するものではありません。カーネルに渡されるポインタがどのバッファを指すかは、具体的なsyscallの定義によります。

6. vDSO は「ユーザー空間でsyscallを実行する」ものではない

vDSOは、プロセスのアドレス空間にマッピングされたカーネルの小さなELFコードです。一部のlibc APIはまずvDSOを呼び出し、カーネルが管理する読み取り専用データから結果を計算することで、実際の SYSCALL の実行を回避できます。

一般的なシンボルには以下が含まれます。

  • __vdso_clock_gettime
  • __vdso_gettimeofday
  • __vdso_time
  • __vdso_getcpu
  • 新世代カーネルにおける __vdso_getrandom

これらのシンボルが存在するか、リクエストを処理できるかは、アーキテクチャとカーネルバージョンに依存します。libcは、実際のsyscallへのフォールバックを準備しておく必要があります。

現在のプロセスのvDSOを確認するには:

grep '\[vdso\]' /proc/self/maps

# glibc環境では、実際の呼び出しが依然としてカーネルに入るかどうかを観察できる
strace -e trace=clock_gettime,gettimeofday /bin/date

7. int 0x80sysenter、および64ビット syscall

これらは異なるエントリメカニズムであり、単一の固定「サイクル数」テーブルだけで比較することはできません。

メカニズム主な使用シーンエントリ設定
int 0x80従来のi386 ABI;x86-64でも互換性エントリありIDT
sysenter/sysexit32ビット高速システムコールSYSENTER MSR
syscall/sysretネイティブx86-64 Linux syscall ABISTAR/LSTAR/FMASK MSR

実際のレイテンシは、CPUマイクロアーキテクチャ、KPTI、緩和策、仮想化、周波数、およびカーネルバージョンの影響を受けます。パフォーマンスデータが必要な場合は、対象マシンで測定し、環境を報告してください。

perf stat -r 20 ./syscall-benchmark

古いCPU上での 50–70 cycles を、クロスプラットフォームの事実として扱わないでください。

8. 完全なアセンブリ例

.section .rodata
message:
    .ascii "hello\n"

.section .text
.global _start
_start:
    mov $1, %rax          # __NR_write
    mov $1, %rdi          # stdout
    lea message(%rip), %rsi
    mov $6, %rdx
    syscall

    test %rax, %rax
    js .error

    xor %rdi, %rdi
    jmp .exit

.error:
    mov $1, %rdi
.exit:
    mov $60, %rax         # __NR_exit
    syscall

ビルドと観察:

as -o syscall.o syscall.S
ld -o syscall syscall.o
strace ./syscall
objdump -drwC -Mintel ./syscall

9. 権威ある資料

推奨される読書順序は、まず syscall(2) でLinux ABIを確認し、次にプロセッサマニュアルで SYSCALL/SYSRET 命令のセマンティクスを確認し、最後にターゲットカーネルバージョンの entry_64.S と照合することです。これにより、CPUのルール、ユーザー空間のC ABI、および特定バージョンのカーネル実装を混同することがなくなります。