system - calls

02. 系统调用

0. 本章先解决什么问题

普通应用不能直接操作硬件,也不能直接修改内核数据结构。

但应用又必须做这些事:

  • 读写文件。
  • 创建进程。
  • 申请内存映射。
  • 发网络请求。
  • 等待事件。
  • 获取时间。
  • 使用锁等待和唤醒。

系统调用解决的问题是:

应用如何安全地请求内核帮自己操作受保护资源?

本章讲系统调用的路径、成本、阻塞、缓冲区、错误和实际排查。

系统调用边界

这张图怎么读

这张图展示了系统调用的本质:应用在用户态准备参数,通过受控入口跨过权限边界,请内核检查权限、操作内核对象或设备,然后返回结果或错误码。它不是普通函数调用,成本也不只是一行代码的成本。

1. 系统调用是什么

系统调用是用户态程序进入内核服务的正式入口。

用户态应用
-> 系统调用入口
-> CPU 切到内核态
-> 内核执行服务
-> 返回用户态

它不是普通函数调用。

普通函数调用仍在同一权限级别内执行。系统调用会触发权限切换,让 CPU 进入内核态。

2. 为什么不能直接操作硬件

如果应用可以随便操作硬件和内核:

  • 可以读取别的程序内存。
  • 可以覆盖磁盘任意位置。
  • 可以修改页表破坏隔离。
  • 可以占用设备不释放。
  • 可以绕过权限检查。
  • 一个 bug 可能拖垮整台机器。

所以危险操作由内核统一管理。

应用只能发出请求:

我想读这个文件
我想发这段数据
我想等待这个事件

内核检查权限和状态后执行。

3. 系统调用大致流程

一次系统调用通常包括:

  1. 应用准备参数。
  2. 进入系统调用指令或约定入口。
  3. CPU 切换到内核态。
  4. 保存必要上下文。
  5. 内核检查系统调用号和参数。
  6. 内核检查权限和资源状态。
  7. 执行对应内核逻辑。
  8. 可能阻塞等待 IO 或事件。
  9. 设置返回值和错误码。
  10. 切回用户态继续执行。

所以系统调用有固定开销。

如果系统调用还触发 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
-> 协议栈
-> 网卡

这解释了两件事:

  1. write 返回不一定代表对端收到。
  2. 数据复制和缓冲区排队会影响性能和延迟。

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

最小实验可以这样设计:

  1. 用小块反复读同一个大文件,记录总耗时。
  2. 用更大块读取同一个文件,记录总耗时。
  3. 立即重复第二次读取,比较是否更快。

如果大块读取更快,说明系统调用次数和拷贝开销可能明显;如果第二次更快,说明页缓存参与了性能差异。这个实验能把“文件读取慢”拆成 syscall 频率、缓存命中和底层 IO 三类问题。

12. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 系统调用和普通函数调用有什么区别?
  2. 用户态为什么不能直接操作硬件?
  3. 一次系统调用大致经过哪些步骤?
  4. 文件描述符为什么能代表文件、Socket、管道和设备?
  5. 系统调用为什么有成本?
  6. 阻塞系统调用如何让线程等待和被唤醒?
  7. 为什么 read/write/send/recv 要检查返回值?
  8. 为什么零拷贝能减少数据搬运?
  9. 文件或网络 IO 慢时,如何从系统调用路径排查?

系统调用是应用和内核之间的边界。理解这条边界,才能理解很多 IO、性能和权限问题。

延伸阅读