system - calls
02. 系统调用
0. 本章先解决什么问题
普通应用不能直接操作硬件,也不能直接修改内核数据结构。
但应用又必须做这些事:
- 读写文件。
- 创建进程。
- 申请内存映射。
- 发网络请求。
- 等待事件。
- 获取时间。
- 使用锁等待和唤醒。
系统调用解决的问题是:
应用如何安全地请求内核帮自己操作受保护资源?
本章讲系统调用的路径、成本、阻塞、缓冲区、错误和实际排查。
这张图怎么读
这张图展示了系统调用的本质:应用在用户态准备参数,通过受控入口跨过权限边界,请内核检查权限、操作内核对象或设备,然后返回结果或错误码。它不是普通函数调用,成本也不只是一行代码的成本。
1. 系统调用是什么
系统调用是用户态程序进入内核服务的正式入口。
用户态应用
-> 系统调用入口
-> CPU 切到内核态
-> 内核执行服务
-> 返回用户态
它不是普通函数调用。
普通函数调用仍在同一权限级别内执行。系统调用会触发权限切换,让 CPU 进入内核态。
2. 为什么不能直接操作硬件
如果应用可以随便操作硬件和内核:
- 可以读取别的程序内存。
- 可以覆盖磁盘任意位置。
- 可以修改页表破坏隔离。
- 可以占用设备不释放。
- 可以绕过权限检查。
- 一个 bug 可能拖垮整台机器。
所以危险操作由内核统一管理。
应用只能发出请求:
我想读这个文件
我想发这段数据
我想等待这个事件
内核检查权限和状态后执行。
3. 系统调用大致流程
一次系统调用通常包括:
- 应用准备参数。
- 进入系统调用指令或约定入口。
- CPU 切换到内核态。
- 保存必要上下文。
- 内核检查系统调用号和参数。
- 内核检查权限和资源状态。
- 执行对应内核逻辑。
- 可能阻塞等待 IO 或事件。
- 设置返回值和错误码。
- 切回用户态继续执行。
所以系统调用有固定开销。
如果系统调用还触发 IO,成本可能更高。
机制深挖:系统调用不是“慢函数”,而是一次受控边界穿越
普通函数调用只是在同一个程序、同一个权限级别里跳转:
保存返回地址
跳到函数入口
执行
返回
系统调用多了几层不可省略的工作:
| 阶段 | 为什么必须有 | 如果省掉会怎样 |
|---|---|---|
| 进入受控入口 | 防止应用跳到任意内核位置 | 任意代码可借内核权限执行 |
| 切换权限级别 | 让 CPU 允许执行特权操作 | 用户态无法访问设备/页表等资源 |
| 参数复制/校验 | 用户指针可能无效或恶意 | 内核可能读写非法地址 |
| 权限检查 | 文件、Socket、进程都有权限边界 | 任意程序可读写别人的资源 |
| 内核对象查找 | fd 只是句柄,真实对象在内核 | 无法知道 fd 指向文件、管道还是 Socket |
| 返回值/错误码 | 调用可能失败、部分成功或被中断 | 应用会把失败当成功 |
系统调用优化不是“讨厌 syscall”,而是减少无意义的边界穿越,把必须进入内核的工作做得更批量、更清楚。
4. 常见系统调用类别
| 类别 | 做什么 |
|---|---|
| 进程 | 创建、退出、等待、发送信号 |
| 文件 | 打开、读取、写入、关闭、获取元数据 |
| 内存 | 扩展堆、映射文件、共享内存 |
| 网络 | 创建 Socket、连接、发送、接收 |
| 时间 | 获取时间、睡眠、定时 |
| 同步 | 等待和唤醒线程 |
| 设备 | 设备控制、驱动交互 |
很多高级库调用最终会落到这些类别。
5. 文件描述符
OS 常用文件描述符表示打开的资源。
它可以指向:
- 普通文件。
- 目录。
- Socket。
- 管道。
- 设备。
- 事件对象。
这就是为什么很多系统里:
read(fd)
write(fd)
close(fd)
可以用于不同资源。
文件描述符是用户程序持有内核对象的句柄。
6. 系统调用为什么有成本
成本包括:
| 成本 | 说明 |
|---|---|
| 权限切换 | 用户态进入内核态再返回 |
| 上下文保存 | 保存必要寄存器和状态 |
| 参数检查 | 防止非法地址和非法权限 |
| 内核路径 | 查找内核对象、更新数据结构 |
| 调度影响 | 可能阻塞并触发线程切换 |
| 缓存影响 | 进入内核和切换可能破坏局部性 |
所以大量小系统调用可能很慢。
优化方向常是:
- 批量读写。
- 缓冲。
- 减少频繁 open/close。
- 减少用户态和内核态来回拷贝。
- 使用多路复用或异步模型。
7. 阻塞系统调用
系统调用可能立即返回,也可能阻塞。
| 调用方向 | 可能等待什么 |
|---|---|
| read | 数据到达、磁盘读取 |
| write | 缓冲区有空间、设备可写 |
| accept | 新连接到来 |
| connect | 连接建立或超时 |
| sleep | 时间到达 |
| wait | 子进程结束 |
| lock wait | 锁释放或条件满足 |
阻塞时,线程让出 CPU,进入等待状态。
事件发生后,内核唤醒线程,它重新参与调度。
8. 用户缓冲区和内核缓冲区
应用和内核通常有不同缓冲区。
读文件大致:
磁盘
-> 内核页缓存
-> 用户缓冲区
-> 应用数据结构
写网络大致:
应用 buffer
-> 内核 socket buffer
-> 协议栈
-> 网卡
这解释了两件事:
write返回不一定代表对端收到。- 数据复制和缓冲区排队会影响性能和延迟。
9. 错误码和部分成功
系统调用可能失败,也可能部分成功。
例如写入:
请求写 1000 bytes
实际只写 400 bytes
应用必须检查返回值。
常见错误方向:
| 错误 | 含义 |
|---|---|
| 权限不足 | 没有读写或执行权限 |
| 资源不存在 | 文件、路径、fd 无效 |
| 资源忙 | 设备或文件被占用 |
| 会阻塞 | 非阻塞模式下暂时不可读写 |
| 被中断 | 等待时收到信号 |
| 空间不足 | 磁盘、内存、缓冲区不够 |
系统调用不是“调用了就一定完成”。必须看返回值和错误。
边界条件:部分成功比直接失败更容易被忽略
很多系统调用的返回值不是布尔值,而是“实际完成了多少”。例如:
read(fd, buffer, 4096) -> 1200
write(fd, buffer, 4096) -> 1800
send(fd, buffer, 4096) -> would-block
这些都不是“完整成功”。健壮代码要能处理:
| 情况 | 应用该怎么想 |
|---|---|
| 返回 0 | 可能 EOF、对端关闭,或请求长度本来为 0 |
| 返回正数但小于请求 | 只完成了一部分,剩余要继续处理 |
| 返回 would-block | 非阻塞对象暂时不可读写,等待下一次就绪 |
| 返回 interrupted | 被信号打断,是否重试要看语义 |
| 返回错误 | 根据错误类型决定重试、降级、失败或关闭 |
系统调用的正确使用习惯是:
看返回值
看错误码
确认完成量
定义重试策略
API 名字表达意图,返回值才表达事实。
10. 零拷贝的系统调用直觉
如果应用只是把文件发送出去,普通路径可能是:
文件 -> 内核页缓存 -> 用户缓冲区 -> 内核 socket buffer -> 网卡
零拷贝类优化尝试减少:
- 用户态/内核态复制。
- 上下文切换。
- CPU 搬运数据。
更像:
文件页缓存 -> 网卡
核心思想:
应用不需要看数据内容时,就不要让数据绕进应用内存。
11. 联系实际:一次读文件为什么慢
一次文件读取可能经历:
应用 read
-> 系统调用进入内核
-> 检查 fd 和权限
-> 查页缓存
-> 命中: 从页缓存复制到用户缓冲区
-> 未命中: 发起磁盘 IO,线程阻塞
-> 磁盘完成,中断通知
-> 数据进入页缓存
-> 复制到用户缓冲区
-> 返回用户态
如果第二次读快,可能是页缓存命中。
如果读很慢,可能是:
- 磁盘慢。
- 小 read 太多。
- 页缓存未命中。
- 文件系统元数据查找慢。
- 线程阻塞等待。
- 内存压力导致页缓存被回收。
排障卡:一次 IO 慢,先分清慢在用户态还是内核态
看到“读写慢”时,可以按系统调用边界拆证据:
| 问题 | 如果答案是“是” | 下一步 |
|---|---|---|
| 系统调用次数是否很多 | 大量小 read/write/open/close | 批量化、缓冲、复用 fd |
| 单次系统调用是否阻塞很久 | 线程进入等待 | 查磁盘、网络、锁、事件源 |
| 返回值是否部分成功 | 一次没写完或没读完 | 循环处理剩余数据 |
| 错误码是否被忽略 | 程序以为成功,实际失败 | 补返回值和错误路径 |
| 数据是否来回复制太多 | CPU 花在搬运数据 | 考虑缓冲、零拷贝、减少中间层 |
| 第二次读是否明显更快 | 页缓存命中 | 说明第一次可能慢在底层 IO |
最小实验可以这样设计:
- 用小块反复读同一个大文件,记录总耗时。
- 用更大块读取同一个文件,记录总耗时。
- 立即重复第二次读取,比较是否更快。
如果大块读取更快,说明系统调用次数和拷贝开销可能明显;如果第二次更快,说明页缓存参与了性能差异。这个实验能把“文件读取慢”拆成 syscall 频率、缓存命中和底层 IO 三类问题。
12. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 系统调用和普通函数调用有什么区别?
- 用户态为什么不能直接操作硬件?
- 一次系统调用大致经过哪些步骤?
- 文件描述符为什么能代表文件、Socket、管道和设备?
- 系统调用为什么有成本?
- 阻塞系统调用如何让线程等待和被唤醒?
- 为什么 read/write/send/recv 要检查返回值?
- 为什么零拷贝能减少数据搬运?
- 文件或网络 IO 慢时,如何从系统调用路径排查?
系统调用是应用和内核之间的边界。理解这条边界,才能理解很多 IO、性能和权限问题。