Read OSS

ユーザー空間からカーネルへ:システムコールの入り口とVFSレイヤー

上級

前提知識

  • 第1回:アーキテクチャとディレクトリ構成
  • 第3回:プロセススケジューラの内部構造
  • x86アセンブリの基礎(レジスタ、呼び出し規約)
  • Cの関数ポインタ構造体への理解

ユーザー空間からカーネルへ:システムコールの入り口とVFSレイヤー

ユーザー空間のプログラムが read() を呼び出すと、一連の処理がCPUの特権境界を越えてSpectre対策をくぐり抜け、自動生成されたswitch文でディスパッチされます。最終的にVFS(カーネルで最も重要な抽象化レイヤー)を経由してファイルシステム固有の関数に到達します。この記事では、x86-64の SYSCALL 命令からディスクのバイトが読み出されるまでの全経路を追います。

第3回ではスケジューラが「どのタスクを実行するか」を決める仕組みを見ました。今回は、そのタスクがカーネルに何かを依頼したときに何が起きるかを追っていきます。

x86-64 システムコールのアセンブリエントリ

ユーザー空間が SYSCALL を発行すると、CPUはアトミックに以下を行います。

  1. RIPRCX に、RFLAGSR11 に保存する
  2. LSTAR MSR(Model-Specific Register)から RIP をロードする
  3. FMASK MSR で RFLAGS をマスクする
  4. リング0(カーネルモード)に切り替える

LSTAR MSR は entry_SYSCALL_64 を指しています。

arch/x86/entry/entry_64.S#L87-L170

SYM_CODE_START(entry_SYSCALL_64)
    UNWIND_HINT_ENTRY
    ENDBR

    swapgs
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
    movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp
sequenceDiagram
    participant U as Userspace
    participant CPU as CPU Hardware
    participant ASM as entry_SYSCALL_64
    participant C as do_syscall_64()

    U->>CPU: SYSCALL instruction
    CPU->>CPU: Save RIP→RCX, RFLAGS→R11
    CPU->>CPU: Load LSTAR→RIP, ring 0
    CPU->>ASM: Jump to entry_SYSCALL_64
    ASM->>ASM: swapgs (load kernel GS base)
    ASM->>ASM: Switch to kernel stack
    ASM->>ASM: SWITCH_TO_KERNEL_CR3
    ASM->>ASM: Construct pt_regs on stack
    ASM->>ASM: IBRS_ENTER, UNTRAIN_RET
    ASM->>C: call do_syscall_64
    C-->>ASM: return (true=SYSRET, false=IRET)
    ASM->>U: SYSRET or IRET to userspace

最初の実命令は swapgs で、CPUのGSベースレジスタをユーザー空間の値とカーネルの値で交換します。これにより、カーネルはCPUごとのデータにアクセスできるようになります。次に、現在のユーザースタックポインタを保存し、カーネルスタックに切り替えます。

SWITCH_TO_KERNEL_CR3 はMeltdown/KPTIへの対策です。カーネルメモリをマッピングしたページテーブルに切り替えます。KPTIが有効な場合、ユーザー空間はカーネルが完全にアンマップされたページテーブルで動作します。

アセンブリコードは続いて、Cのコードが期待するレイアウト通りにすべてのユーザーレジスタをプッシュし、スタック上に struct pt_regs を構築します。

    pushq   $__USER_DS              /* pt_regs->ss */
    pushq   PER_CPU_VAR(...)        /* pt_regs->sp */
    pushq   %r11                    /* pt_regs->flags */
    pushq   $__USER_CS              /* pt_regs->cs */
    pushq   %rcx                    /* pt_regs->ip */
    pushq   %rax                    /* pt_regs->orig_ax */
    PUSH_AND_CLEAR_REGS rax=$-ENOSYS

rax=$-ENOSYS に注目してください。すべての汎用レジスタはクリアされます(投機的実行に対する多層防御)。また、%rax はデフォルトの「未実装」戻り値として -ENOSYS にあらかじめセットされています。

続いてSpectre対策が入ります。

    IBRS_ENTER
    UNTRAIN_RET
    CLEAR_BRANCH_HISTORY
    call    do_syscall_64

IBRS_ENTER は間接分岐の投機実行を制限します。UNTRAIN_RET はRetbleedを緩和します。CLEAR_BRANCH_HISTORY はBranch History Injectionから保護します。この3行は、長年にわたるハードウェア脆弱性への対応の積み重ねを体現しています。

do_syscall_64() とディスパッチ

Cのディスパッチ関数はシンプルで、よくドキュメント化されています。

arch/x86/entry/syscall_64.c#L87-L141

__visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr)
{
    add_random_kstack_offset();
    nr = syscall_enter_from_user_mode(regs, nr);

    instrumentation_begin();

    if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
        regs->ax = __x64_sys_ni_syscall(regs);
    }

    instrumentation_end();
    syscall_exit_to_user_mode(regs);
    ...

add_random_kstack_offset() はカーネルスタックの位置をランダム化します——これも悪用防止のための対策です。syscall_enter_from_user_mode() はトレーシング、seccompフィルタ、監査を処理します。

実際のディスパッチには、自動生成された switch 文が使われます。

arch/x86/entry/syscall_64.c#L34-L41

#define __SYSCALL(nr, sym) case nr: return __x64_##sym(regs);
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
    switch (nr) {
    #include <asm/syscalls_64.h>
    default: return __x64_sys_ni_syscall(regs);
    }
}
flowchart TD
    A["do_syscall_64(regs, nr)"] --> B["add_random_kstack_offset()"]
    B --> C["syscall_enter_from_user_mode()<br/>(seccomp, audit, tracing)"]
    C --> D["do_syscall_x64(regs, nr)"]
    D --> E["x64_sys_call: switch(nr)"]
    E --> F["__x64_sys_read(regs)<br/>or any syscall handler"]
    F --> G["syscall_exit_to_user_mode()"]
    G --> H{"SYSRET safe?"}
    H -->|Yes| I["Fast: SYSRET to userspace"]
    H -->|No| J["Slow: IRET to userspace"]

Xマクロのテクニック(#define __SYSCALL(nr, sym) case nr: return __x64_##sym(regs);)と #include <asm/syscalls_64.h> を組み合わせることで、コンパイル時に完全な switch 文が生成されます。これは、array_index_nospec() によるSpectre安全性向上のため、以前の配列ベースのディスパッチから置き換えられました。

do_syscall_64 の戻り値はboolean型です。true は「SYSRETを使用する」(高速パス)、false は「IRETを使用する」(低速だが安全なパス)を意味します。SYSRETの安全条件は明示的にチェックされます——RCXはRIPと等しくなければならず、R11はRFLAGSと等しく、命令ポインタは正規のユーザーアドレス空間内になければなりません。Intelのアーキテクチャには、非正規のRCXでSYSRETを実行するとカーネルモードでフォールトが発生するというハードウェアバグがあり、ユーザーがカーネルスタックを制御できてしまう危険があります。

Tip: include/linux/syscalls.h#L217-L230SYSCALL_DEFINE マクロは、CでシステムコールをI宣言するための仕組みです。SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) は、トレーシングと監査に必要なメタデータを含む実際の関数を生成します。

VFS:カーネルの中心的な抽象化

システムコールのディスパッチャはサブシステム固有のコードを呼び出します。readwriteopenclosemmap といったファイル操作については、Virtual Filesystem Switch(VFS)がその役割を担います。

VFSはカーネルにおけるCのvtableパターンの最重要な応用例です。ストレージがext4、XFS、NFS、procfs、FUSEファイルシステムのいずれであっても、VFSは単一のシステムコールハンドラ群を同一のインターフェースで提供します。この抽象化は、3つの主要な操作構造体によって実現されています。

flowchart TD
    subgraph "Userspace"
        app["Application: read(fd, buf, count)"]
    end
    subgraph "Syscall Layer"
        sys["sys_read()"]
    end
    subgraph "VFS Layer"
        vfs["vfs_read() → file->f_op->read_iter()"]
    end
    subgraph "Filesystem Implementations"
        ext4["ext4_file_read_iter()"]
        xfs["xfs_file_read_iter()"]
        nfs["nfs_file_read()"]
        proc["proc_reg_read_iter()"]
    end
    app --> sys --> vfs
    vfs --> ext4
    vfs --> xfs
    vfs --> nfs
    vfs --> proc

操作構造体の詳細

VFSの3つのコア操作構造体は、汎用VFSレイヤーと個々のファイルシステム実装との間のインターフェースを定義します。

struct file_operations

include/linux/fs.h#L1926-L1970

struct file_operations {
    struct module *owner;
    fop_flags_t fop_flags;
    loff_t (*llseek)(struct file *, loff_t, int);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*read_iter)(struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);
    __poll_t (*poll)(struct file *, struct poll_table_struct *);
    int (*mmap)(struct file *, struct vm_area_struct *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    int (*fsync)(struct file *, loff_t, loff_t, int datasync);
    int (*uring_cmd)(struct io_uring_cmd *ioucmd, unsigned int issue_flags);
    ...
} __randomize_layout;

システム上のすべてのオープンファイルは、そのファイルシステムの file_operations を指す struct file を持っています。read() を呼び出すと、カーネルは最終的に file->f_op->read_iter() を呼び出します。uring_cmd 関数ポインタに注目してください——これは第5回で詳しく見るio_uringの統合ポイントです。

classDiagram
    class file_operations {
        +llseek()
        +read()
        +write()
        +read_iter()
        +write_iter()
        +poll()
        +mmap()
        +open()
        +release()
        +fsync()
        +uring_cmd()
    }
    class inode_operations {
        +lookup()
        +create()
        +link()
        +unlink()
        +mkdir()
        +rmdir()
        +rename()
        +setattr()
        +getattr()
    }
    class super_operations {
        +alloc_inode()
        +destroy_inode()
        +dirty_inode()
        +write_inode()
        +drop_inode()
        +put_super()
        +sync_fs()
        +statfs()
    }
    file_operations <-- inode_operations : "per-inode"
    inode_operations <-- super_operations : "per-filesystem"

struct inode_operations

include/linux/fs.h#L2001-L2025

inode operationsは名前空間に関わる操作を担当します。ディレクトリ内のファイルの検索、ファイルの作成、ディレクトリの作成、リネーム、拡張属性の管理などです。これらはファイルの内容ではなく、ディレクトリツリーの構造に対して操作します。

struct super_operations

include/linux/fs/super_types.h#L83-L112

superblock operationsはファイルシステム全体を管理します。inodeの割り当て、ダーティinodeの書き戻し、ファイルシステムの同期、statfs によるディスク使用量の報告などを担います。

各ファイルシステムはこれら3つの構造体を実装します(ページキャッシュとの統合には struct address_space_operations が使われることもあります)。VFSはファイルシステムのコードを直接呼び出しません——必ずこれらの関数ポインタを経由します。こうして、Linuxは50以上のファイルシステムを単一のシステムコール群でサポートしています。

VFSを通じた open() → read() → write() の追跡

VFSを通じた read() 呼び出しを追ってみましょう。

  1. ユーザー空間read(fd, buf, count) を呼び出す
  2. システムコールエントリ(上述)が sys_read() にディスパッチする
  3. sys_read()fdget_pos() を呼び出し、ファイルディスクリプタテーブルから struct file を取得する
  4. vfs_read() がパーミッションを確認し、file->f_op->read_iter()(現代的なパス)または file->f_op->read()(レガシーパス)を呼び出す
  5. ファイルシステムの実装——たとえば ext4_file_read_iter()——が実際のI/Oを処理する
  6. データはページキャッシュを経由し、必要に応じてブロックI/Oがディスクに対して発行される
  7. 結果はVFSを通じてユーザー空間に返る
sequenceDiagram
    participant U as Userspace
    participant S as sys_read()
    participant V as vfs_read()
    participant F as file->f_op->read_iter()
    participant PC as Page Cache
    participant BIO as Block I/O

    U->>S: read(fd, buf, count)
    S->>S: fdget_pos(fd) → struct file
    S->>V: vfs_read(file, buf, count, &pos)
    V->>V: Permission checks
    V->>F: f_op->read_iter(kiocb, iov_iter)
    F->>PC: Look up page cache
    alt Cache hit
        PC-->>F: Return cached data
    else Cache miss
        PC->>BIO: Submit block I/O
        BIO-->>PC: Data from disk
        PC-->>F: Return data
    end
    F-->>V: Bytes read
    V-->>S: Bytes read
    S-->>U: Return to userspace

open() のパスは特に興味深い箇所です。ここで file_operations ポインタが割り当てられるからです。open() の処理中、VFSは inode->i_op->lookup() を呼び出してパスを解決し、マウントポイントからファイルシステムの種類を判定し、struct file を確保して file->f_op にそのファイルシステムの file_operations をセットします。以降、そのファイルディスクリプタへのすべての read()write()mmap() 呼び出しは、ファイルシステムのvtable経由でディスパッチされます。

Tip: 特定のファイルシステムの動作を理解したいなら、その file_operations の定義を探しましょう。ext4の場合は grep "struct file_operations" fs/ext4/*.c で確認できます。関数ポインタを見れば、各操作をどの関数が担当しているかが一目でわかります。

次回予告

ユーザー空間からファイルシステムまでの全経路を追い終えました。次回は、io_uringがこのシステムコールの流れをどのように回避するかを見ていきます——カーネルとユーザー空間の間でメモリを共有し、SYSCALL 命令を一切使わずにI/O操作をサブミット・完了させる仕組みです。