# 从零写OS内核 | 系统调用——用户态是怎么触发内核代码的

你在程序里写了一行 `write(fd, “hello”, 5);`,这只是 libc 里的一个 C 函数调用——它是怎么一步步到达内核,最终把字符写到屏幕或文件的?

这就是系统调用(System Call)的世界:**用户态程序请求内核服务的唯一入口**。它不像普通函数调用那样简单——用户态和内核态之间有一道硬件级别的墙(Ring 切换),系统调用就是穿过这道墙的唯一合法通道。

今天,我们来搞清楚这道墙是怎么建的,以及为什么 write 和 open 必须走系统调用,而 printf 只需要在用户态工作。

## 1. 为什么不能直接跳转到内核

普通函数调用:`call func` → 跳转到函数地址 → 执行 → `ret` 返回

这条路径完全在用户态执行,Ring 3 的代码只能跳转到同样 Ring 3 的代码。如果你想跳转到 Ring 0 的内核代码呢?

**直接跳转不行**。原因:

1. **CPU 保护模式**:段描述符(Selector)里有 DPL(Descriptor Privilege Level)字段,用户态代码的 CS/DS 选择子 DPL=3,内核代码的 DPL=0。如果尝试用 `jmp` 或 `call` 跳转到 DPL=0 的代码,CPU 会触发 #GP(通用保护异常)

2. **页级权限**:内核页在页表中标记为 Supervisor 模式(U/S=0),用户态(U/S=1)的代码访问这些页会触发 #PF

3. **隔离需要**:如果任何用户态代码都能跳到内核,整个系统安全模型就崩溃了——恶意程序可以直接读写任意内存

所以系统调用必须走一条**CPU 专门为此设计的通道**。

## 2. 系统调用的三种实现方式

x86 上历史上出现过三种系统调用方式:

### 2.1 软件中断(INT 0x80)—— 最经典

“`
Linux 2.6 之前的系统调用方式:

用户态:
mov eax, 4 ; 系统调用号(write = 4)
mov ebx, fd ; 参数1
mov ecx, buf ; 参数2
mov edx, len ; 参数3
int 0x80 ; 触发软中断,CPU 自动查 IDT[0x80]

内核态(IDT[0x80] 指向 sys_call_table):
handler_0x80:
push registers…
call [sys_call_table + eax*4] ; 调用号做索引
pop registers…
iret ; 返回用户态
“`

INT 0x80 的工作流程:
1. CPU 检查 IF 标志(确保中断开启)
2. 从 IDT[0x80] 读取门描述符
3. 验证 CPL <= DPL(当前权限级别 <= 门的描述符权限) 4. 如果是中断门,自动关闭 IF 5. 保存 CS/EIP/EFLAGS 到栈(如果是任务门则切换栈) 6. 跳转到内核 sys_call_table **缺点**:INT 是双向门(能进能出),但每次系统调用都要查 IDT、验证权限,额外开销较大 ### 2.2 SYSENTER / SYSCALL —— 快速系统调用 CPU 厂商意识到软中断太慢,专门设计了快速调用指令: ``` SYSENTER(Intel)/ SYSCALL(AMD): 用户态(设置调用参数后): mov eax, 4 ; 系统调用号 mov ebx, fd mov ecx, buf mov edx, len sysenter ; Intel 专用,快速跳转 内核提前设置(MSR 寄存器): IA32_SYSENTER_EIP = sys_call_handler 地址 IA32_SYSENTER_CS = 内核代码段选择子 IA32_SYSENTER_ESP = 内核栈指针 SYSENTER 执行时: → 直接跳转到 IA32_SYSENTER_EIP(无需查 IDT) → CS = IA32_SYSENTER_CS → ESP = IA32_SYSENTER_ESP(切换到内核栈) → CPL 变为 0(进入内核态) ``` 和 INT 0x80 相比: - 省去 IDT 查找(直接用 MSR) - 省去权限检查(CPU 保证) - 不自动关闭 IF(快,但需要软件自己管理) - 只能 Ring 0 → Ring 0,不能用户态直接用 ### 2.3 VDSO:消除系统调用的开销 即使是最快的 SYSENTER/SYSCALL,也需要一次 Ring 切换。Linux 用了**VDSO(Virtual Dynamic Shared Object)**来消除某些"假系统调用"的成本: ``` VDSO 工作原理: 内核把一个特殊的 .so 文件映射到每个进程的地址空间 这个文件本身在内核里,但进程通过页表映射能直接访问它 有些系统调用(gettimeofday, clock_gettime)不需要真正进内核: → 程序调用 VDSO 里的函数 → VDSO 代码检查是否可以用本地 CPU 指令(RDTSC) → 如果可以,直接返回时间戳,根本不触发系统调用 → 只有在需要真正内核服务时才走 SYSENTER 这叫"vsyscall"优化,VDSO 是它的现代化身 ``` --- ## 3. 系统调用号与sys_call_table 内核维护一张**系统调用表**(sys_call_table),每个系统调用有一个唯一编号: ``` Linux x86 系统调用号(部分): 0 sys_read 1 sys_write 2 sys_open 3 sys_close 4 sys_stat 5 sys_fstat 6 sys_lstat 7 sys_poll 8 sys_lseek 9 sys_mmap 10 sys_mprotect ... x86_64 系统调用号(和 32 位不同): 0 read 1 write 2 open 3 close 4 stat 5 fstat ... 查看本机系统调用表: cat /usr/include/asm/unistd_64.h | head -30 ``` sys_call_table 本质上是一个函数指针数组: ```c // sys_call_table 的定义(简化) void *sys_call_table[512] = { [__NR_read] = sys_read, [__NR_write] = sys_write, [__NR_open] = sys_open, [__NR_close] = sys_close, // ... }; ``` 调用时:`sys_call_table[nr](arg1, arg2, arg3)` — 系统调用号做索引,参数直接传递。 --- ## 4. 系统调用参数传递 x86_64 上,系统调用参数通过寄存器传递(按顺序): ``` 参数传递约定(x86_64): rax = 系统调用号(调用前) rdi = 第1个参数 rsi = 第2个参数 rdx = 第3个参数 r10 = 第4个参数(注意:不是 rcx,rcx 在 SYSCALL 后被破坏) r8 = 第5个参数 r9 = 第6个参数 返回值: rax = 返回值(负数表示错误,如 -EFAULT) 用户态 libc 代码(write 为例): mov $4, %rax ; write 系统调用号 mov %edi, %rdi ; fd mov %rsi, %rsi ; buf mov %edx, %edx ; len syscall ; x86_64 用 syscall ``` 为什么第 4 个参数用 r10 而不是 rcx?因为 SYSCALL 指令会破坏 rcx(它存的是返回地址 RIP),所以 Linux 规定 r10 传第 4 参数。 --- ## 5. 从glibc到内核的完整旅程 以 `printf("hello")` 为例,走一遍完整路径: ``` 用户程序:printf("hello") ↓ glibc(用户态): → 分析格式字符串,发现不需要系统调用 → 写入 stdout buffer(用户态内存操作) ↓(最后需要输出时) write(fd=1, buf="hellon", len=6) ↓ glibc write wrapper: mov $1, %eax ; 32位:__NR_write = 1 mov %edi, %edi ; fd = 1 mov %rsi, %rsi ; buf mov %edx, %edx ; len syscall ; x86_64 快速系统调用 ↓ 内核 sys_read/write(真正在这里): → 从 rdi/rsi/rdx 取参数 → 调用 vfs_write() → 写文件/屏幕 ↓ 返回值(rax): 成功:返回写入字节数 失败:返回 -errno(负数) ↓ glibc 检查返回值,转换为 -1 + errno ↓ 用户程序 if (ret == -1) { perror(); } ``` 注意:`printf` 本身不需要系统调用——它只是把数据写入 stdout 的 buffer。真正触发系统调用的是 `write`,或者 `fflush`(强制刷新缓冲区)时。 --- ## 6. Linux 实践:观察系统调用 ```bash # 用 strace 追踪系统调用 strace -e trace=write,open,close ls /tmp # 统计每种系统调用被调用的次数 strace -c ls /tmp # 系统调用耗时排序 strace -T ls /tmp 2>&1 | grep -v "= -1" # 实时追踪某个进程的所有系统调用 strace -p $(pgrep -f firefox) # 查看系统调用表(本机) cat /proc/kallsyms | grep sys_call_table cat /proc/sys/kernel/osrelease ``` ```bash # 用 perf 观察系统调用热点 perf record -e 'syscalls:sys_enter_*' -a sleep 5 perf report # 查看某个程序做了多少次系统调用 strace -c -p $(pgrep -f myprogram) ``` ```bash # 用 seccomp 限制系统调用(安全加固) # 允许 only read/write/exit seccomp-tools exec ./myprogram -- ./myprogram ``` --- ## 7. 从零实现:wandos 的系统调用 **⚠️ wandos 当前状态**:`kernel/core/` 下有系统调用入口,wandos 通过 IDT 设置系统调用门。以下分析其实现。 ### 7.1 系统调用初始化(kernel/core/syscall.c) ```cpp // wandos 系统调用实现 // 源码:kernel/core/syscall.c // 系统调用表 static void *syscall_table[256]; static unsigned int num_syscalls = 0; // 注册系统调用 void syscall_register(int nr, void *handler) { if (nr >= 256) return; syscall_table[nr] = handler; if (nr >= num_syscalls) num_syscalls = nr + 1; } // 系统调用分发 void syscall_dispatch(uint64_t syscall_nr, uint64_t arg1, uint64_t arg2, uint64_t arg3) { if (syscall_nr >= num_syscalls) { // 返回错误码 set_return_value(-ENOSYS); return; } // 取函数指针,调用 typedef uint64_t (*syscall_fn)(uint64_t, uint64_t, uint64_t); syscall_fn fn = (syscall_fn)syscall_table[syscall_nr]; uint64_t ret = fn(arg1, arg2, arg3); set_return_value(ret); } ``` ### 7.2 write 系统调用的实现 ```cpp // write 系统调用 static uint64_t sys_write(uint64_t fd, uint64_t buf_ptr, uint64_t count) { if (fd == 1 || fd == 2) { // stdout/stderr → 写 VGA 文本内存 char *buf = (char *)buf_ptr; for (uint64_t i = 0; i < count; i++) { vga_putchar(buf[i]); } return count; } // 文件描述符 → VFS 层 return vfs_write(fd, (void *)buf_ptr, count); } // 注册到系统调用表 void syscall_init() { syscall_register(1, sys_write); // Linux __NR_write = 1 // 其他系统调用... } ``` ### 7.3 wandos 和 Linux 的主要差异 1. **没有 vsyscall/VDSO**:wandos 不支持快速系统调用优化,每次 write 都走完整 Ring 切换 2. **没有标准文件描述符 0/1/2**:stdin/stdout/stderr 需要手动初始化,Linux 在进程创建时自动继承 3. **没有 errno**:错误码直接返回负数,不通过 libc 的 errno 机制 4. **参数验证不完整**:Linux 会检查指针是否指向用户态地址(access_ok),wandos 缺少这个检查 --- ## 8. 动手环节:实现一个 getpid 系统调用 **今天的目标**:在 `os-kernel-from-scratch` 里实现一个最简单的系统调用 `getpid`,然后用 `strace` 验证它被调用了。 ### 任务 1:在 sys_call_table 里添加 getpid 实现 `sys_getpid()` 函数,返回当前进程的 PID(用一个全局变量 `current_pid` 维护,初值为 1)。 ### 任务 2:写一个用户态测试程序 ```c // test_getpid.c #include <stdio.h> int main() { for (int i = 0; i < 5; i++) { int pid = getpid(); printf("my pid = %dn", pid); } return 0; } ``` 用汇编内联或 syscall 直接调用(不用 libc 的 getpid)。 ### 验收标准 - 程序输出 "my pid = X" 五次,X 相同 - `strace -e trace=getpid ./test_getpid` 能看到 5 次 `getpid()` 调用 --- ## 9. 踩坑与注意事项 **坑 1:系统调用号在 32 位和 64 位不兼容** `__NR_write` 在 x86 是 4,在 x86_64 是 1。混用会导致"明明调了 write,内核却执行了 unlink"。写跨平台代码时注意用正确的头文件:`#include ` vs `#include `。

**坑 2:系统调用参数没检查空指针**

如果用户传了一个非法指针(内核地址或未映射地址),Linux 会返回 `-EFAULT`。wandos 如果直接解引用会触发 #PF,严重时 kernel panic。要在每个系统调用入口加 `access_ok()` 检查。

**坑 3:SYSCALL 后 rcx 和 r11 被破坏**

x86_64 的 SYSCALL 指令会覆盖 `rcx`(存返回地址 RIP)和 `r11`(存 RFLAGS)。写汇编时如果继续用这两个寄存器,会拿到错误的值。注意:`syscall` 之后 `rcx = rax`(返回地址),`r11 = rflags`——不是你想的。

**坑 4:返回值是负数但被当作成功**

系统调用失败时返回 `-errno`(负数),成功时返回非负整数。如果代码写成 `if (ret < 0)` 而不是 `if (ret < 0 && ret > -4096)`,可能被 -1(EPERM)骗过。Linux 定义了最大错误码 -4095(-ERANGE)。

## 写在最后

系统调用是用户态和内核态之间唯一的合法桥梁——它用硬件机制(Ring 切换、段权限、页级保护)保证了用户代码永远无法直接跳到内核,必须通过这张”门卡”(系统调用号)才能进去。

理解系统调用,你才算真正理解为什么 `fork` 能创建一个新进程(它是一次特殊的系统调用,返回值在父子进程中不同),以及为什么所有 I/O 操作最终都归结为那几十个系统调用号。

下篇预告:**进程与调度——从 fork 到 schedule**。

## 相关阅读

– Linux Kernel Source: `arch/x86/entry/entry_64.S`(系统调用入口)
– Linux Kernel Source: `kernel/sys.c`(系统调用实现)
– glibc 源码: `sysdeps/unix/sysv/linux/x86_64/syscall.S`
– wandos: `kernel/core/syscall.c`
– wandos: `kernel/core/kernel_main.cpp`
– 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/syscall/)
– https://github.com/zhangfuwen/wandos — Linux 内核教程
– 下一篇:《从零写OS内核 | 进程与调度——从fork到schedule》

## 动手环节

想深入理解本文内容?动手实践是最好的方式:

**今天的目标**:下载 wandos 代码仓库,添加一个自定义系统调用,理解 Linux vs wandos 的差异。

1. **下载 wandos**:
“`bash
git clone https://github.com/zhangfuwen/wandos.git
cd wandos
“`

2. **找到对应模块**:查看 `kernel/core/syscall.c`

3. **实现作业**:根据文中”动手环节”章节的要求,完成代码编写

4. **提交作业**:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request

**仓库:https://github.com/golang12306/os-kernel-from-scratch**

最后修改: 2024年5月7日

作者

评论

发表评论

您的邮箱地址不会被公开。