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

このページの目次

手書きアセンブリ、inline asm、seccompのデバッグ、または逆アセンブリを読む際、最も混乱しやすいのは以下の3つの異なるルールセットです。

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

本稿ではこれら3層をそれぞれ解説し、ネイティブなLP64 ABIについて議論します。x32 ABIは含まれません。実装例としてLinux v6.18の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

ここで write について、成功時は書き込まれたバイト数が返され、失敗時は -errno が返されます。ネイティブなLinux ABIでは、エラーは -4095 から -1 の範囲で表現されます。すべてのシステムコールの成功結果を一律にバイト数として解釈してはいけません。glibcのラッパー関数(公開されている syscall() を含む)は、カーネルのエラーを -1 に変換し、スレッドローカルの errno を設定します。

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

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

これがinline asmで rcx と r11 の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番目の引数については、r10、r8、r9 を明示的にバインドする必要があります。本番コードでは、libcの呼び出しを優先すべきです。raw syscallはキャンセルポイント、互換ラッパー、libcのエラー処理をバイパスします。

六引数 mmap:Cの呼び出し規約はシステムコール規約に変換する必要がある 通常のC呼び出し 第4引数:rcx 第5/6引数:r8 / r9 syscallの第4引数:r10 この例では MAP_PRIVATE | MAP_ANONYMOUS r8 = -1、r9 = 0 fd と offset rax = SYS_mmap rcx は命令によって戻りRIPが書き込まれ、r11 はフラグ情報が書き込まれるため、呼び出し間の一時値として使用できません。 図はネイティブなLinux x86-64 LP64に限定されています。他のアーキテクチャやx32の汎用ラッパーではありません。

完全な六引数の例:匿名メモリページを確保して解放する

raw-mmap.c として保存し、ネイティブなLinux x86-64上でGCCまたはClangでコンパイルしてください。この例では、印刷とページサイズ照会のためにlibcを保持していますが、mmap/munmapはinline syscallを通じて実行されます。SYS_* マクロはネイティブターゲットのヘッダーファイルから取得され、ある呼び出し番号を誤って別のABIに持ち越さないようにしています。

#include <errno.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>

static long raw6(long number, long a1, long a2, long a3,
                 long a4, long a5, long a6) {
    register long r10 __asm__("r10") = a4;
    register long r8 __asm__("r8") = a5;
    register long r9 __asm__("r9") = a6;
    long result;
    __asm__ volatile("syscall"
        : "=a"(result)
        : "0"(number), "D"(a1), "S"(a2), "d"(a3),
          "r"(r10), "r"(r8), "r"(r9)
        : "rcx", "r11", "memory");
    return result;
}
static int raw_error(long result) {
    return (unsigned long)result >= (unsigned long)-4095L;
}
int main(void) {
    long size = sysconf(_SC_PAGESIZE);
    if (size <= 0) return 1;
    long address = raw6(SYS_mmap, 0, size, PROT_READ | PROT_WRITE,
                       MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (raw_error(address)) {
        fprintf(stderr, "mmap error=%ld\n", -address);
        return 1;
    }
    unsigned char *page = (unsigned char *)address;
    page[0] = 7;
    page[size - 1] = 9;
    int valid = page[0] == 7 && page[size - 1] == 9;
    long released = raw6(SYS_munmap, address, size, 0, 0, 0, 0);
    if (raw_error(released) || !valid) return 1;

    errno = 0;
    long bad = raw6(SYS_mmap, 0, 0, PROT_READ,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    int unchanged = errno == 0;
    if (bad != -EINVAL || !unchanged) return 1;
    printf("page checked; raw error=%ld; errno unchanged=%d\n",
           -bad, unchanged);
    return 0;
}
cc -std=gnu11 -Wall -Wextra -O2 raw-mmap.c -o raw-mmap
./raw-mmap
objdump -d -Mintel raw-mmap

最初の呼び出しのフラグは第4引数であり、r10に入る必要があります。fd=-1 と offset=0 はそれぞれ r8、r9 にあります。匿名マッピングはゼロ初期化されたメモリを返すため、この例では先頭と末尾のバイトを書き込んでから解放します。長さが0の2回目のmmapは -EINVAL を返すはずであり、呼び出し元の errno を設定してはいけません。mmap(2)

エラー検出は、まず unsigned long に変換し、-4095 の符号なし表現と比較して、上位の予約領域をエラーとして解釈します。アドレス結果をチェックする前に有効なポインタとして逆参照しないでください。また、errno がカーネルによって自動的に設定されるとは限らない点にも注意してください。それはlibcのラッパー層の役割です。raw6 はこの例での同期呼び出しのデモンストレーションに過ぎず、スタック/制御フローを変更する clone、rt_sigreturn などのインターフェースに修正なしで使用するものではありません。syscall(2)

ローカルレジスタ変数と拡張asm制約を組み合わせることで、入力を指定されたレジスタに配置します。宣言自体がレジスタの永続的な独占を意味するわけではありません。そのため、パラメータ計算を完了してから直ちにasmを実行し、その間に呼び出し元保存レジスタを上書きする可能性のある関数呼び出しを挿入してはいけません。GCC ローカルレジスタ変数

戻り値は呼び出しごとに解釈する必要がある

元の結果対応する状況呼び出し側の次のステップ
mmap がエラーでないアドレスを返すマッピングが確立された要求された長さおよび権限に従って使用する
read が 0 を返す通常ファイル/バイトストリームではEOFの可能性あり読み取りを終了し、-errno として解釈しない
write が正の値を返すが、要求より少ないバイトの一部が送信済みポインタと残りの長さを進める
-EINTRこの呼び出しが中断され、エラーとして報告されたそのインターフェースのセマンティクスに従って再試行するかどうかを決定
-EAGAIN現在即時に完了できない準備ができるまで待つか、スケジューラに返してビジーウェイトを避ける

例えば6バイトを書き込む場合、2、-EINTR、4 と順に返された場合、2回目の呼び出しでポインタをさらに2進めてはいけません。正の戻り値のみが今回の進捗を確認します。本稿末尾のアセンブリループでは rsi/rdx を保持し、-EINTR に遭遇した場合は残りの部分に対して再試行し、短書き込みの場合のみこれらのレジスタを更新します。

rcx の2つの正体は同時に保持できない

ここでは2つのレジスタ表を暗記する必要はありません。第4引数が rcx→r10 の経路を移動し、ハードウェアによる rcx の上書きを観察すれば、なぜ2つのABIが接続しなければならないのかがわかります。RFLAGS* は命令が規定する処理後のフラグ値を示し、RFの詳細は次のセクションで説明します。

図を準備しています
rcx の2つの正体は同時に保持できない

C ABI は rcx を使用して第4引数を渡すが、SYSCALL は rcx を上書きし、Linux は r10 から第4引数を読み取る。

2. SYSCALL 命令は何を行うか

64ビットモードで SYSCALL を実行すると、CPUは主に以下のことを完了します。

SYSCALL:ハードウェアエントリとソフトウェアスタック構築は2段階 ユーザー空間で SYSCALL を実行 引数はLinux ABIに従って配置済み 戻り情報の保存 RCX ← 次のRIP;R11 ← RFLAGS 設定されたカーネルエントリへ進入 RIP ← LSTAR;CS/SS は STAR から取得;CPL=0 指定されたフラグビットをマスク RFLAGS ← RFLAGS & ~FMASK 上記のハードウェア動作は RSP を保存せず、pt_regs を自動的にカーネルスタック上に構築もしません。

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

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

上記の図ではRFの特殊処理が省略されています。ハードウェアはまずRFをクリアし、その後対応するフラグ状態をR11に保存します。RFLAGS は単に「変更されない」わけではありません。カーネルは IA32_FMASK でクリアするフラグビットを設定し、Linuxのエントリコードは安全な状態を構築してから割り込みを管理します。

3. Linux entry_SYSCALL_64

Linux v6.18 の 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 はLinuxが使用するper-CPUのスクラッチスロットであり、ハードウェアによって自動的にスタックが切り替わる従来の sp0 フローではありません。順序も重要です。まずユーザー rsp を保存し、次にカーネルページテーブルとカーネルスタックに切り替え、最後にカーネルスタック上で struct pt_regs を構築します。

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

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

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

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

ユーザー空間から観察すると、2つの戻りパスは同じ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に依存できません。

手書きのinline asmでは、"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を確認します。

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

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

7. int 0x80、sysenter、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:
    lea message(%rip), %rsi
    mov $6, %rdx
.write:
    mov $1, %rax          # __NR_write
    mov $1, %rdi          # stdout
    syscall
    cmp $-4, %rax        # -EINTR:今回は書き込まれていないため再試行
    je .write
    test %rax, %rax
    jle .error           # 負値または進展なし;この例では EAGAIN を待たない
    add %rax, %rsi
    sub %rax, %rdx
    jnz .write           # 短書き込み:残りの部分を書き続ける
    xor %rdi, %rdi
    jmp .exit
.error:
    mov $1, %rdi
.exit:
    mov $60, %rax        # __NR_exit:現在のスレッドを終了
    syscall
.section .note.GNU-stack,"",@progbits

この例はlibcのないシングルスレッドプログラムです。元の exit は現在のスレッドを終了し、スレッドグループ全体を終了するには exit_group です。stdoutが読み取り側が閉じられたパイプの場合、デフォルトのSIGPIPEによってプロセスが直接終了する可能性もあります。

ビルドと観察:

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、および特定バージョンのカーネル実装を混同することがなくなります。