跳转至

一次 read 系统调用的完整旅程

考虑一个普通进程读取本地文件:

char buffer[4096];
ssize_t n = read(fd, buffer, sizeof(buffer));

源代码很短,但路径会根据页缓存、文件偏移、用户缓冲区映射、设备状态和信号发生分叉。最重要的结论是:调用 read 不等于一定读取物理磁盘。

场景与假设

先采用一个最小场景:

  • fd 已指向本地普通文件。
  • 文件偏移处仍有至少 4096 字节。
  • buffer 是合法可写用户缓冲区。
  • 不使用直接 I/O。
  • 暂不考虑压缩、加密、网络文件系统和特殊文件。

明确假设可以避免把所有文件系统实现细节混在一起。后文再逐项放宽。

全局路径

用户代码
  -> C 库包装与系统调用指令
  -> 内核系统调用入口
  -> fd 表与打开文件对象
  -> VFS 与具体文件系统
  -> 页缓存查询
      ├─ 命中:复制到用户缓冲区
      └─ 未命中:构造块 I/O
           -> 块层与设备驱动
           -> DMA 把数据写入内存
           -> 中断/轮询完成
           -> 唤醒读取线程
           -> 复制到用户缓冲区
  -> 更新偏移和返回值
  -> 返回用户态

这不是每个系统的源码调用栈,而是跨实现稳定的概念层次。

第1步:用户态包装

应用调用的 read 通常是 C 库包装。包装层按照 ABI 把系统调用号、fd、缓冲区地址和长度放入规定寄存器,然后执行系统调用指令。

此时:

  • buffer 仍是用户虚拟地址。
  • fd 仍只是当前进程 fd 表索引。
  • 内核尚未相信任何参数。

库函数可能把内核负错误码转换成 -1 并设置线程局部 errno。这属于用户可见接口适配,不是文件数据路径本身。

第2步:受控进入内核

系统调用指令使 CPU:

  1. 保存返回用户态所需的程序计数器和状态。
  2. 切换到内核特权级和受控入口。
  3. 进入当前线程的内核执行路径。

这里发生了用户态到内核态转换,但仍是同一个线程,也未必发生调度。只有后面阻塞或被抢占时,才可能切换到其他线程。

内核先检查:

  • 系统调用号是否合法。
  • fd 是否在范围内且当前仍打开。
  • 请求长度是否溢出或超过接口限制。
  • 用户缓冲区是否可按写方向访问。

用户内存可能在真正复制时才触发缺页,因此一次早期范围检查不能取代安全复制机制。

第3步:fd 到文件对象

内核用 fd 查询当前进程的描述符表,取得打开文件对象。该对象包含:

  • 对底层 inode/文件对象的引用。
  • 当前文件偏移。
  • 打开标志和访问模式。
  • 具体文件操作方法。

若 fd 已关闭、不是可读模式或指向不支持普通读取的对象,路径会在这里返回错误。

多个 fd 可能共享同一个打开文件对象,因而共享偏移。并发读取时,偏移读取、数据读取和偏移更新的原子要求由接口与实现保证;需要独立位置时可使用带显式偏移的读取接口。

第4步:VFS 分派

VFS 提供统一读取入口,再调用具体文件系统实现。普通本地文件、管道、终端和套接字都可以有 fd,但读取语义不同。

普通文件路径根据 inode 和偏移确定涉及哪些文件页:

\[ page\ index=\left\lfloor\frac{offset}{page\ size}\right\rfloor. \]

若读取跨页,可能分别处理多个缓存页;文件尾部还会截短返回长度。

分支 A:页缓存命中

若目标文件页已经在页缓存且状态可用:

  1. 内核锁定或引用缓存页,防止读取期间被回收。
  2. 计算页内偏移和可复制长度。
  3. 把数据复制到用户缓冲区。
  4. 更新文件偏移和访问统计。
  5. 释放临时引用并返回字节数。
文件页缓存(内存) -> copy_to_user -> 用户 buffer

没有块设备请求、DMA 或磁盘中断。性能主要受系统调用、缓存查找、内存复制、页表/TLB 和锁竞争影响。

用户缓冲区可能缺页

即使文件页已缓存,buffer 对应用户页也可能尚未实际分配。内核安全复制到用户地址时可能触发可处理的用户页缺页:

  1. 暂停复制。
  2. 分配用户匿名页或处理 COW。
  3. 建立页表映射。
  4. 继续复制。

因此“文件缓存命中”不表示整个调用绝无缺页;需要区分文件源页和用户目标页。

分支 B:页缓存未命中

若文件页不在缓存,文件系统需要把它读入。

1. 建立缓存页与读取请求

内核为目标索引创建或取得页缓存页,标记其正在读取,依据 inode 的块映射找到文件逻辑块对应的设备区域。

顺序访问时,预读逻辑可能同时提交后续页面,试图让下一次 read 命中缓存。随机访问时,过度预读会浪费带宽和缓存。

2. 进入块层

文件系统把读取转换为块请求。块层可能:

  • 合并相邻请求。
  • 拆分超过设备限制的请求。
  • 进行调度、公平和优先级处理。
  • 把请求送入某个硬件队列。

对 SSD/NVMe,请求可在多个队列并行;对 HDD,顺序和邻近性对寻道更重要。

3. 驱动提交设备

驱动准备 DMA 映射和描述符,写入提交队列并通知控制器。设备获得的是被授权的 DMA 地址,不是用户虚拟地址。

若使用 IOMMU,DMA 地址还会通过 IOMMU 页表翻译和权限检查。

4. 当前线程睡眠

数据尚未到达,继续占用 CPU 没有意义。读取线程被放入等待队列并标记睡眠,调度器选择其他可运行线程。

read 线程:running -> waiting for I/O
其他线程:ready -> running

这是操作系统用并发隐藏 I/O 延迟的关键。read 在应用看来阻塞,但 CPU 不必空闲。

5. DMA 传输

控制器从介质读取数据,通过 DMA 写入内核管理的页缓存页。CPU 不逐字节搬运,但仍参与请求提交、地址映射和完成处理。

6. 完成通知

设备通过中断或被轮询发现完成。快速中断路径确认设备并安排延后处理;完成处理检查状态、结束 DMA 映射、把缓存页标记为最新,并唤醒等待该页的线程。

若设备返回错误,页被标记失败,等待线程最终收到错误或短读。

7. 线程重新就绪

唤醒只把线程从等待变为就绪,不代表立即运行。它还要等待调度器分配 CPU:

I/O complete -> waiting to ready -> scheduler chooses -> running

因此 I/O 完成时间与系统调用返回时间之间可能有调度延迟。

8. 复制并返回

线程恢复内核路径,确认缓存页成功,复制到用户缓冲区,更新偏移并返回。

返回值为什么可能小于 4096

read 返回短读并不一定是错误:

  • 到达文件末尾,只剩更少字节。
  • 管道、终端或套接字当前只有部分数据。
  • 调用被信号影响,已经传输了部分数据。
  • 某些设备或文件类型按自身记录边界返回。

返回 0 对普通文件通常表示 EOF。可靠代码应循环处理剩余长度,同时避免把 EOF 当作“稍后重试”。

size_t done = 0;
while (done < sizeof(buffer)) {
    ssize_t n = read(fd, buffer + done, sizeof(buffer) - done);
    if (n > 0) done += (size_t)n;
    else if (n == 0) break;
    else handle_error_or_retry();
}

对于交互流,“必须填满 4096 才处理”可能增加延迟,循环策略应符合对象语义。

并发和锁在哪里

路径中可能涉及:

  • fd 表和打开文件对象引用管理。
  • 共享文件偏移同步。
  • inode 元数据和块映射锁。
  • 页缓存页状态与等待队列。
  • 块请求队列和驱动描述符环。
  • 用户页缺页与页表锁。

系统通常避免持有高层锁等待慢速 I/O,否则其他无关操作会长时间阻塞。常见模式是建立稳定引用、标记“正在读取”、释放高层锁后提交/等待,再在完成后验证状态。

mmap 的区别

mmap 把文件页映射到进程地址空间。应用首次加载某地址时触发缺页,内核把页缓存页映射进去;之后 CPU 可直接读取,不需要每次 read 复制到另一个用户缓冲。

优点:

  • 减少显式复制和系统调用次数。
  • 随机访问表达自然。

代价:

  • 缺页延迟发生在普通加载指令中。
  • 文件截断、错误和映射生命周期更复杂。
  • 小量顺序读取未必比成熟 read + 预读更快。

readmmap 常共享同一页缓存,不是两份完全独立文件数据。

与直接 I/O 的区别

直接 I/O 尝试绕过普通页缓存,把设备数据 DMA 到符合条件的用户缓冲或中间映射。它减少双重缓存,适合数据库自管理缓存,但需要对齐、固定页和更复杂错误处理。

即便使用直接 I/O,仍要经过系统调用、权限检查、文件块映射、块层、驱动、DMA 和完成通知。省掉的是普通页缓存路径,不是整个内核。

性能分解

缓存命中时可粗略分解:

\[ T_{hit}=T_{syscall}+T_{lookup}+T_{copy}+T_{return}. \]

缓存未命中时:

\[ T_{miss}=T_{submit}+T_{queue}+T_{device}+T_{completion}+T_{schedule}+T_{copy}. \]

这些只是定位维度。很多阶段可重叠,锁竞争、缺页和预读也会改变路径。

若只测总延迟,无法知道瓶颈在设备还是调度;需要结合系统调用耗时、主要缺页、块设备延迟、队列深度和 off-CPU 时间。

安全检查

  • fd 权限在打开和后续操作中按接口实施。
  • 用户地址必须通过安全复制,不能直接信任。
  • 读取长度和偏移运算要防溢出。
  • 文件系统解析设备数据和元数据时要防损坏输入。
  • DMA 必须限制到授权页,避免设备覆盖任意内存。

这条路径跨越多个信任边界,任何一层的长度或生命周期错误都可能成为漏洞。

四条容易记错的结论

  1. 进入内核态不等于切换进程。
  2. read 不等于磁盘读取,页缓存命中时没有设备 I/O。
  3. I/O 完成中断不等于等待线程立即运行,只表示它可以被唤醒。
  4. 系统调用返回数据不等于相关写数据已经持久化;读取和持久化是不同问题。

自测

  1. 页缓存命中时,路径仍可能在哪一步缺页?
  2. 缓存未命中后,读取线程为什么要睡眠?
  3. DMA 完成后为什么还需要 CPU 处理中断或完成队列?
  4. 设备中断到达后,原线程为什么可能暂时仍不运行?
  5. mmapread 是否通常使用完全独立的文件缓存?
  6. read 返回 0 与返回 -1 的一般含义有何不同?
参考思路

复制到尚未驻留的用户缓冲页时可缺页。睡眠让 CPU 执行其他任务。CPU 要回收描述符、检查错误、更新页状态和唤醒等待者。线程唤醒后只进入就绪队列。二者通常共享页缓存。普通文件返回 0 表示 EOF,-1 表示错误并通过错误码说明原因。