# 从零写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
**坑 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**
评论