356|系统调用:用户态怎么触发内核代码
金句:用户程序永远不可能直接读写硬件,哪怕只是打印一个字符,也要请内核代劳。系统调用,就是这场”代劳”的唯一入口。
1. 问题:用户态程序想打印字符怎么办?
用户在 shell 里敲 echo hello,背后发生的事情:
shell(用户态)
→ write() 函数(glibc 库函数)
→ 内核 sys_write(系统调用)
→ 终端驱动(内核)
→ 屏幕write 是个系统调用(System Call)。
用户态程序以为自己调用了一个普通函数,但实际上:
- CPU 从用户态(CPL=3)切入内核态(CPL=0)
- 执行内核代码,访问受保护的硬件
- 返回时恢复用户态上下文,继续执行
系统调用 = 用户态和内核态之间唯一的受控通道。
2. x86 上的系统调用进化史
x86 架构上触发系统调用的方式经历过三代:
2.1 实模式时代:INT 0x80
最早的方式是软中断:
// 用户态代码(32位保护模式)
mov eax, 4 // sys_write 系统调用号
mov ebx, 1 // fd = stdout
mov ecx, msg // 缓冲区地址
mov edx, len // 长度
int 0x80 // 触发中断,CPU 自动跳到 IDT[0x80]CPU 查 IDT(中断描述符表) 的 0x80 号门,得到内核函数地址,跳过去执行。
缺点:慢,每次调用都要查 IDT、压栈、保存上下文。
2.2 保护模式早期:SYSENTER / SYSCALL
# 32位:用 SYSENTER(Intel)
sysexit:
mov ecx, edx // 返回地址
mov edx, esp // 用户栈
sysenter // 跳入内核(无中断查表)
; 返回到 ecx
# 64位:用 SYSCALL(AMD)
syscall
; AMD 发明,Intel 后来也支持
; 更快:不用查 IDT,直接读 MSRSYSENTER/SYSCALL 比 INT 0x80 快 3-5 倍,因为:
- 不查中断描述符表
- 直接从 MSR(Model Specific Register)读内核入口地址
- 跳转是确定性的,没有查找开销
2.3 现代 Linux:都用 SYSCALL
Linux x86-64 统一用 syscall 指令,入口在 MSR IA32_LSTAR(地址 0xC0000082):
// 内核初始化时设置
wrmsr(0xC0000082, (unsigned long)syscall_entry);
// 从此 syscall 指令直接跳到这里用户态代码:
// 直接用 syscall
mov rax, 1 // sys_exit 系统调用号
mov rdi, 0 // exit code
syscall // 进入内核 syscall_entry3. 系统调用号:怎么知道该跳到哪个函数?
Linux 定义了一张系统调用号表:
0: sys_read
1: sys_write
2: sys_open
3: sys_close
4: sys_newfstat
...
60: sys_exit
231: sys_socketcall // 嵌套调用用户态 glibc 的 write(fd, buf, len) 内部:
// glibc 源码简化
ssize_t write(int fd, const void *buf, size_t count) {
ssize_t ret;
__asm__ volatile(
"movq $1, %%raxt" // syscall 号 = 1
"syscall"
: "=a"(ret)
: "D"(fd), "S"(buf), "d"(count)
: "memory", "rcx", "r11"
);
return ret;
}内核收到后:
// syscall_entry(arch/x86/entry/entry_64.S)
ENTRY(syscall_entry)
// 保存用户态寄存器到栈
push %r11 // rflags
push %rcx // 返回地址
push %rax // 系统调用号
// 调用号作为索引进入 sys_call_table
movq %rax, %rcx
shlq $3, %rcx // 每个 entry 8 字节
leaq sys_call_table(%rip), %rax
movq (%rax,%rcx), %rax // 取函数指针
call *%rax // 调用 sys_write
// 返回值在 rax,恢复用户态
jmp syscall_returnsys_call_table 是一个函数指针数组,索引是系统调用号:
// 内核源码
const sys_call_ptr_t sys_call_table[] = {
[0] = __x64_sys_read,
[1] = __x64_sys_write,
[2] = __x64_sys_open,
...
[60] = __x64_sys_exit,
};4. 参数传递:6 个寄存器,够用吗?
x86-64 系统调用用这 6 个寄存器传递参数(不需要栈):
| 寄存器 | 用途 |
|---|---|
rdi |
第 1 个参数 |
rsi |
第 2 个参数 |
rdx |
第 3 个参数 |
r10 |
第 4 个参数(注意不是 rcx) |
r8 |
第 5 个参数 |
r9 |
第 6 个参数 |
rax |
系统调用号 + 返回值 |
为什么 r10 而不是 rcx?因为
syscall指令本身会用 rcx 存储返回地址(RIP),所以参数不能用 rcx 传。
示例 read(fd, buf, count):
// 相当于:
// rax=0 (sys_read), rdi=fd, rsi=buf, rdx=count
mov rax, 0
mov rdi, fd
mov rsi, buf
mov rdx, count
syscall
// 返回值在 rax(正数=读到的字节数,负数=错误码)5. 从用户态到内核态:完整的跳转路径
以 printf("hello") → write() → syscall 为例:
用户态
┌─────────────────────────┐
│ main() │
│ printf("hello"); │ ← glibc 库函数
│ → write(1, buf, 5) │ ← syscall 触发(C函数)
│ mov rax, 1 │ ← 写系统调用号
│ syscall │ ← CPU 从 CPL=3 跳到 CPL=0
└─────────────────────────┘
↓
CPU 自动做的事(硬件级):
1. 保存 RIP、CS、RFLAGS、RSP、SS 到内核栈
2. 加载 CS(内核代码段)→ CPL=0
3. 从 MSR 读内核入口地址,跳过去
内核态
┌─────────────────────────┐
│ syscall_entry: │ ← entry_64.S
│ SAVE_ALL │ ← 保存所有寄存器到栈
│ call *sys_call_table │ ← sys_write
│ → sys_write() │ ← fs/read_write.c
│ → fd_ops.write │ ← file_operations 结构
│ → tty_write │ ← 终端驱动
│ → uart_write │ ← 串口/屏幕
└─────────────────────────┘
↓
syscall_return:
RESTORE_ALL ← 恢复寄存器
sysretq ← 返回用户态
↓
用户态恢复执行6. 权限检查:用户态凭什么能调用内核函数?
关键在于 CPL(Current Privilege Level)。
CPU 在执行每条指令前检查权限:
当执行 syscall 时:
CPU 检查目标代码段的 DPL(Descriptor Privilege Level)
要求:DPL <= CPL(即:内核段 DPL=0,用户段 DPL=3)
如果不满足 → #GP(General Protection Fault)
当访问内存页时:
CPU 检查页表 entry 的 U/S 位
如果当前是用户态(U/S=0 的页)→ #PF所以:
- 用户态代码无法伪造系统调用:CPU 硬件强制检查 CPL
- 用户态代码无法访问内核页:MMU 在页表层面拦截
- 即使写了
int 0x80,也会被 IDT 表权限限制
7. 为什么不用函数指针直接调用?
用户程序理论上可以:
// 假设我们知道 sys_write 地址
void *sys_write_addr = 0xffffffff81000000;
((int (*)(int, char*, int))sys_write_addr)(1, "hello", 5);这在用户态执行会立刻崩溃吗?不会立刻崩溃,但不会有效果:
- CPL=3,尝试执行 CPL=0 的指令 → #GP
- 即使躲过这一关,访问内核数据页 → #PF
- 即使绕过内存检查,写入内核数据结构 → 导致内核 panic
硬件级别的权限墙,是系统调用机制的核心:不是靠软件检查,而是硬件保证。
8. 总结
| 知识点 | 关键结论 |
|---|---|
| 系统调用是唯一通道 | 用户态和内核态之间的受控入口,只有 syscall 一条路 |
| 触发方式 | x86-64 用 syscall 指令,最快 |
| 入口配置 | 内核在 MSR IA32_LSTAR 写入 syscall_entry 地址 |
| 调用号索引 | sys_call_table[rax] 找到对应的内核函数 |
| 参数寄存器 | rdi, rsi, rdx, r10, r8, r9(共 6 个) |
| 权限保证 | CPL + U/S 位,硬件级隔离,无法伪造 |
| 返回值 | 通过 rax 返回,正数=成功,负数=错误码(-errno) |
下篇预告(357):Buddy System 伙伴系统——物理内存分配最经典的算法,从 2 的幂次阶开始,理解内核怎么把物理内存切成一块块分配出去。
关注公众号「AI不着急」,回复”资料”获取内核学习路线图。
评论