系统调用
系统调用
复习定位
用户程序不能直接操作硬件、修改页表、禁止中断——这些操作需要CPU的Ring0特权。所以用户程序必须通过一个特殊的入口——系统调用——请求内核代劳。理解syscall指令的硬件行为(自动切换栈、保存RIP和RFLAGS、切换到内核代码段)是理解"用户态到内核态切换"的唯一正确通道。
系统调用与普通函数调用的本质差异
普通函数调用只是在同一个地址空间内跳转——call指令压栈返回地址——在用户栈上分配局部变量——ret指令弹栈返回。全程保持在用户态Ring3——不需要切换CPU特权级。
系统调用完全不同:用户程序在执行时处于Ring3——只能执行非特权指令。当需要操作系统服务(如read()读文件)时——用户程序通过syscall(x86-64)/int 0x80(旧x86)/svc(ARM)触发一次软件中断——CPU硬件自动切换到Ring0、切换到内核栈、保存用户态的RIP(返回到用户态所需的地址)。内核执行完系统调用后用sysret/iret恢复用户态并返回。
这一切换涉及:保存和恢复寄存器(至少15个通用寄存器+一些控制寄存器)、切换栈(用户栈→内核栈)、切换页表(用户页表→内核页表)。切换开销约为100-300个CPU周期——比一次函数调用多2个数量级——这就是"系统调用慢"的原因。
x86-64的syscall指令细节
在64位Linux上系统调用流程:
用户态:
rax = 系统调用号(0=read, 1=write, ...)
rdi = 第一个参数(fd)
rsi = 第二个参数(buf)
rdx = 第三个参数(count)
syscall ← 触发切换到内核态
内核态(syscall入口: entry_SYSCALL_64):
1. CPU自动: RCX=用户态的RIP, R11=用户态的RFLAGS
2. CPU从MSR_LSTAR加载内核syscall入口地址
3. 切换到Ring0、内核栈
4. 在入口处保存所有通用寄存器用于后续恢复
5. 根据rax中的系统调用号查sys_call_table
6. 调用对应的内核函数(__x64_sys_read等)
7. 函数完成, 返回值在rax
8. 恢复所有寄存器
9. sysretq(RCX→RIP, R11→RFLAGS, 返回Ring3)syscall指令不是一条普通的CPU指令——它在CPU微码层面完成了从Ring3到Ring0的权限提升。它不会在用户栈上压返回地址——而是直接加载MSR_LSTAR寄存器中的内核入口。这保证了用户态不能伪造返回地址——因为RIP值直接保存在RCX中由硬件管理。
系统调用列表
Linux x86-64的常见系统调用:
| 系统调用号 | 名称 | 参数 | 描述 |
|---|---|---|---|
| 0 | read | fd, buf, count | 从文件描述符读 |
| 1 | write | fd, buf, count | 向文件描述符写 |
| 2 | open | pathname, flags, mode | 打开/创建文件 |
| 3 | close | fd | 关闭文件描述符 |
| 4 | stat | pathname, buf | 获取文件状态信息 |
| 5 | fstat | fd, buf | 获取已打开文件的状态 |
| 9 | mmap | addr, length, prot, flags, fd, offset | 内存映射文件 |
| 10 | mprotect | addr, len, prot | 设置内存区域的访问权限 |
| 12 | brk | addr | 改变数据段大小(堆) |
| 39 | getpid | 无 | 获取进程ID |
| 56 | clone | flags, stack, parent_tid, child_tid, tls | 创建(线程)子进程 |
| 57 | fork | 无 | 创建子进程 |
| 59 | execve | filename, argv, envp | 执行新程序 |
| 60 | exit | status | 终止当前进程 |
| 61 | wait4 | pid, wstatus, options, rusage | 等待子进程结束 |
| 62 | kill | pid, sig | 向进程发送信号 |
| 202 | getcpu | cpu, node, tcache | 获取当前CPU和NUMA节点 |
| 231 | exit_group | status | 终止线程组中的所有线程 |
常用的库函数(如printf、scanf、fread)底层也是通过write和read的系统调用完成——C标准库的fopen→open系统调用和fread→read系统调用之间的缓冲区管理(malloc+copy)提供了性能优化的基础。
用户态和内核态的数据传递
内核不能直接读取用户态指针指向的内存(用户进程的虚拟地址可能还未映射或指向恶意构造的地址)。因此Linux提供了专门的API:
copy_from_user(to_kernel, from_user, size)——从用户空间安全复制到内核空间copy_to_user(to_user, from_kernel, size)——从内核空间安全复制到用户空间strncpy_from_user——从用户空间安全复制字符串clear_user——安全清零用户空间
这些API在复制之前检查目标/源地址是否合法——是否在当前进程的VMA内?如果用户程序传入了伪造的非法指针——copy_from_user返回非零值(复制失败)而非oops——内核可以安全地向用户程序返回EFAULT错误。如果内核直接使用用户传入的指针——用户程序可能通过传入指向内核数据的指针实现信息泄露。
复习检查
syscall指令执行后——CPU自动做了哪几步保存工作——它为什么不使用用户栈保存返回地址?为什么系统调用比函数调用慢2-3个数量级?这其中那部分开销(保存寄存器/切换栈/切换页表TLB刷新)是主要的?
copy_from_user在复制之前对用户指针做什么检查——如果用户传入的内核地址会发生什么?系统调用号在Linux的哪个文件中定义?如果用户程序调用了不存在编号的系统调用——内核会怎么返回?
write系统调用的完整调用路径——从用户代码到磁盘写入——涉及多少个函数调用层和多少次上下文的切换?