356|系统调用:用户态怎么触发内核代码

金句:用户程序永远不可能直接读写硬件,哪怕只是打印一个字符,也要请内核代劳。系统调用,就是这场”代劳”的唯一入口。


1. 问题:用户态程序想打印字符怎么办?

用户在 shell 里敲 echo hello,背后发生的事情:

Bash
shell(用户态)
  → write() 函数(glibc 库函数)
  → 内核 sys_write(系统调用)
  → 终端驱动(内核)
  → 屏幕

write 是个系统调用(System Call)。

用户态程序以为自己调用了一个普通函数,但实际上:

  • CPU 从用户态(CPL=3)切入内核态(CPL=0)
  • 执行内核代码,访问受保护的硬件
  • 返回时恢复用户态上下文,继续执行

系统调用 = 用户态和内核态之间唯一的受控通道。


2. x86 上的系统调用进化史

x86 架构上触发系统调用的方式经历过三代:

2.1 实模式时代:INT 0x80

最早的方式是软中断:

C
// 用户态代码(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

Bash
# 32位:用 SYSENTER(Intel)
sysexit:
    mov ecx, edx        // 返回地址
    mov edx, esp        // 用户栈
    sysenter            // 跳入内核(无中断查表)
    ; 返回到 ecx

# 64位:用 SYSCALL(AMD)
syscall
    ; AMD 发明,Intel 后来也支持
    ; 更快:不用查 IDT,直接读 MSR

SYSENTER/SYSCALL 比 INT 0x80 快 3-5 倍,因为:

  • 不查中断描述符表
  • 直接从 MSR(Model Specific Register)读内核入口地址
  • 跳转是确定性的,没有查找开销

2.3 现代 Linux:都用 SYSCALL

Linux x86-64 统一用 syscall 指令,入口在 MSR IA32_LSTAR(地址 0xC0000082):

C
// 内核初始化时设置
wrmsr(0xC0000082, (unsigned long)syscall_entry);
// 从此 syscall 指令直接跳到这里

用户态代码:

C
// 直接用 syscall
mov rax, 1          // sys_exit 系统调用号
mov rdi, 0          // exit code
syscall             // 进入内核 syscall_entry

3. 系统调用号:怎么知道该跳到哪个函数?

Linux 定义了一张系统调用号表:

Bash
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) 内部:

C
// 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;
}

内核收到后:

C
// 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_return

sys_call_table 是一个函数指针数组,索引是系统调用号:

C
// 内核源码
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)

C
// 相当于:
// 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 为例:

Bash
用户态
┌─────────────────────────┐
│ 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 在执行每条指令前检查权限:

Bash
当执行 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. 为什么不用函数指针直接调用?

用户程序理论上可以:

C
// 假设我们知道 sys_write 地址
void *sys_write_addr = 0xffffffff81000000;
((int (*)(int, char*, int))sys_write_addr)(1, "hello", 5);

这在用户态执行会立刻崩溃吗?不会立刻崩溃,但不会有效果

  1. CPL=3,尝试执行 CPL=0 的指令 → #GP
  2. 即使躲过这一关,访问内核数据页 → #PF
  3. 即使绕过内存检查,写入内核数据结构 → 导致内核 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不着急」,回复”资料”获取内核学习路线图。

最后修改: 2024年4月7日

作者

评论

发表评论

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