ユーザー空間からカーネルへ:システムコールの入り口とVFSレイヤー
前提知識
- ›第1回:アーキテクチャとディレクトリ構成
- ›第3回:プロセススケジューラの内部構造
- ›x86アセンブリの基礎(レジスタ、呼び出し規約)
- ›Cの関数ポインタ構造体への理解
ユーザー空間からカーネルへ:システムコールの入り口とVFSレイヤー
ユーザー空間のプログラムが read() を呼び出すと、一連の処理がCPUの特権境界を越えてSpectre対策をくぐり抜け、自動生成されたswitch文でディスパッチされます。最終的にVFS(カーネルで最も重要な抽象化レイヤー)を経由してファイルシステム固有の関数に到達します。この記事では、x86-64の SYSCALL 命令からディスクのバイトが読み出されるまでの全経路を追います。
第3回ではスケジューラが「どのタスクを実行するか」を決める仕組みを見ました。今回は、そのタスクがカーネルに何かを依頼したときに何が起きるかを追っていきます。
x86-64 システムコールのアセンブリエントリ
ユーザー空間が SYSCALL を発行すると、CPUはアトミックに以下を行います。
RIPをRCXに、RFLAGSをR11に保存するLSTARMSR(Model-Specific Register)からRIPをロードするFMASKMSR でRFLAGSをマスクする- リング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-L230のSYSCALL_DEFINEマクロは、CでシステムコールをI宣言するための仕組みです。SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)は、トレーシングと監査に必要なメタデータを含む実際の関数を生成します。
VFS:カーネルの中心的な抽象化
システムコールのディスパッチャはサブシステム固有のコードを呼び出します。read、write、open、close、mmap といったファイル操作については、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() 呼び出しを追ってみましょう。
- ユーザー空間が
read(fd, buf, count)を呼び出す - システムコールエントリ(上述)が
sys_read()にディスパッチする sys_read()がfdget_pos()を呼び出し、ファイルディスクリプタテーブルからstruct fileを取得するvfs_read()がパーミッションを確認し、file->f_op->read_iter()(現代的なパス)またはfile->f_op->read()(レガシーパス)を呼び出す- ファイルシステムの実装——たとえば
ext4_file_read_iter()——が実際のI/Oを処理する - データはページキャッシュを経由し、必要に応じてブロックI/Oがディスクに対して発行される
- 結果は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操作をサブミット・完了させる仕組みです。