当你在终端敲下 cat file.txt 并按下回车,从字符出现在屏幕上的那一刻起,中间发生了什么?本文从最外层的 Shell 命令开始,一路深入到内核态的 DMA 传输,完整梳理一次文件读取的全流程。
零、从 cat file.txt 到 read()
1. Shell 读取并解析命令
你敲击回车后,Shell(bash/zsh 等)首先通过 read() 从标准输入(你的终端)读取一行字符串 "cat file.txt\n"。
接下来 Shell 进行词法分析(Lexical Analysis),把这行字符串切分成 token:
"cat file.txt\n"
↓
[ "cat" ] [ "file.txt" ]
2. 查找并启动 cat 程序
Shell 发现 cat 不是内置命令(builtin),需要启动外部程序。于是执行以下步骤:
Shell 进程
↓
fork() ← 创建子进程(复制 Shell 的地址空间)
↓
子进程中调用 execve("/usr/bin/cat", argv, envp)
↓
加载器(ld-linux.so)加载 cat 可执行文件和依赖的动态库
↓
跳转到 cat 的 _start → __libc_start_main → main()
关键点 :
fork()只创建进程,不加载程序;execve()用cat的可执行文件替换 当前进程的地址空间。最终cat进程的用户态栈、堆、代码段完全独立于 Shell。
3. cat 程序的执行链路
cat 的源码逻辑大致如下:
c
int main(int argc, char *argv[]) {
FILE *fp = fopen(argv[1], "r"); // argv[1] = "file.txt"
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
fwrite(buf, 1, n, stdout);
}
fclose(fp);
return 0;
}
这里有几个标准库函数,它们内部会调用系统调用:
fopen() → open() 系统调用
fopen("file.txt", "r")
↓
分配 FILE 结构体(含缓冲区指针、fd 字段、缓冲策略等)
↓
open("file.txt", O_RDONLY) ← 系统调用,进入内核
↓
内核执行路径解析(见下一节路径 A)
↓
返回 fd(如 3),存入 FILE->fileno
FILE 结构体是 glibc 在用户空间维护的流对象,它持有 fd,并管理一个用户态缓冲区 。fopen() 不仅打开文件,还分配了默认 4096 字节的缓冲区。
fread() → read() 系统调用(带缓冲)
fread(buf, 1, 4096, fp)
↓
检查 FILE 缓冲区是否还有未读数据
↓
有 → 直接从 FILE 缓冲区拷贝到用户 buf(零系统调用)
无 → 调用 read(fd, FILE_buf, FILE_buf_size) 批量读入 FILE 缓冲区
↓
再从 FILE 缓冲区拷贝到用户 buf
关键点 :fread() 不是每次读一个字节就调用一次 read()。它先把数据批量读入 FILE 结构体的内部缓冲区,后续的 fread() 优先从缓冲区取数,减少系统调用次数,提升性能。这就是标准 I/O 缓冲(stdio buffering)。
fwrite() → write() 系统调用(带缓冲)
fwrite(buf, 1, n, stdout)
↓
检查 stdout 的 FILE 缓冲区是否写满
↓
未满 → 数据写入 FILE 缓冲区(延迟写)
写满 / 遇到换行 / fflush → 调用 write(fd=1, FILE_buf, len)
↓
进入内核,写入终端缓冲区
注意 :
stdout默认采用行缓冲(Line Buffering) 。当输出中包含换行符\n时,缓冲区会被立即刷新(flush),触发write()系统调用。这就是printf("hello\n")能立即看到输出的原因。如果去掉\n,数据会暂存在FILE缓冲区,直到缓冲区满或程序退出才刷出。
4. cat 退出,Shell 恢复
cat 进程 fclose() → close(fd)
↓
cat 调用 exit(0) → 系统调用 exit_group()
↓
内核回收 cat 进程资源(mm_struct、files_struct、fdtable 等)
↓
内核向 Shell 父进程发送 SIGCHLD 信号
↓
Shell 的 waitpid() 返回,回收子进程僵尸态
↓
Shell 打印提示符(如 `$`),等待下一条命令
从命令到系统调用的完整链路
用户输入 "cat file.txt"
↓
Shell: fork() → execve("/usr/bin/cat")
↓
cat 进程: fopen() → open() 系统调用
↓
cat 进程: fread() → read() 系统调用(批量读)
↓
内核 VFS → 文件系统 → page cache → DMA → 磁盘
↓
数据到达用户态 buf
↓
cat 进程: fwrite() → write() 系统调用(行缓冲)
↓
内核 VFS → 终端缓冲区 → 显示设备
↓
字符出现在屏幕
↓
cat exit(0) → Shell waitpid() → Shell 打印提示符
一、起点:用户空间的 read() 调用
两个应用程序(App 1 和 App 2)都想读取同一份文件。它们各自在用户空间调用:
c
ssize_t read(int fd, void *buf, size_t count);
这里的 fd(文件描述符)是之前通过 open() 系统调用获得的。同一个文件可以被多个进程打开,但它们拿到的 fd 是否指向同一个内核结构,取决于打开方式:
| 场景 | file_struct 是否共享 |
文件偏移量是否共享 |
|---|---|---|
分别 open() |
否(各自独立) | 否 |
fork() 继承 |
是 | 是 |
dup() / dup2() |
是 | 是 |
file_struct 里维护了:
- 当前读写偏移量(
f_pos) - 文件打开权限(读/写/追加等)
- 文件锁状态(
flock/fcntl锁) - 指向
dentry和inode的指针
二、VFS 层:两种拿到 inode 的路径
Linux 通过 VFS(Virtual File System) 统一处理所有文件系统的访问。VFS 的核心思想是:无论底层是 ext4、XFS、Btrfs 还是 NFS,上层看到的接口都是一样的。
在文件读取的流程中,inode 是核心枢纽。但拿到 inode 有两条完全不同的路径,取决于你处在哪个阶段:
路径 A:通过文件名解析拿到 inode(发生在 open() 时)
当应用程序调用 open("/path/to/file", O_RDONLY) 时,内核需要把字符串形式的路径名转换成内核中的 inode 对象。这个过程叫路径名解析(Pathname Resolution)。
调用链路如下:
open()
↓
do_sys_open()
↓
do_filp_open()
↓
path_openat() ← 路径解析的起点
↓
link_path_walk() ← 核心:逐级遍历路径
↓
walk_component() ← 解析每一个路径分量(如 "path", "to", "file")
↓
lookup_fast() ← 先查 dentry 缓存(dcache)
↓ 命中
└──→ d_hash + d_lookup() → 拿到 dentry → dentry->d_inode → inode
↓ 未命中
└──→ lookup_slow() → 调用具体文件系统的 ->lookup() → 从磁盘读目录项 → 创建 dentry → 拿到 inode
特点:
- 触发条件:只发生在
open()、stat()、access()等需要路径名作为输入的系统调用。 - 时间复杂度:O(n),n 是路径深度。需要逐个分量查找,可能涉及多次磁盘 I/O(如果目录项不在 dcache 中)。
dentry在这里起关键作用:它是路径名到inode的缓存层,避免重复解析。
路径 B:通过 fd 直接拿到 inode(发生在 read()/write() 时)
一旦文件被 open() 成功,内核返回一个 fd 给进程。此后所有的 read()、write()、lseek() 都不需要再解析路径名,而是通过 fd 直接定位到内核数据结构。
调用链路如下:
read(fd, buf, len)
↓
ksys_read()
↓
fdget(fd) ← 从进程的 fdtable 中取出 fd 对应的 file_struct
↓
file_struct->f_inode ← 直接拿到 inode(O(1))
↓
vfs_read() → 具体文件系统实现
特点:
- 触发条件:发生在所有以
fd为参数的系统调用(read、write、pread、mmap、fsync等)。 - 时间复杂度:O(1)。进程的内核态有一个
files_struct结构,其中fdt->fd[fd]数组直接索引到file_struct。 - 完全没有路径解析开销,也不经过
dentry层(虽然file_struct内部仍持有f_path.dentry引用)。
两条路径的对比
| 维度 | 路径 A(文件名 → inode) | 路径 B(fd → inode) |
|---|---|---|
| 触发时机 | open()、stat() 等 |
read()、write() 等 |
| 是否需要路径解析 | 是,逐级遍历目录 | 否,直接数组索引 |
| 是否经过 dentry | 是,dcache 是关键优化 | 否,绕过 dentry |
| 时间复杂度 | O(路径深度),可能磁盘 I/O | O(1),纯内存操作 |
拿到 inode 后的用途 |
创建 file_struct,建立 fd 映射 |
直接进行文件读写 |
一句话总结 :
open()负责"找门"(解析路径拿到 inode 并创建 fd),read()负责"进门办事"(通过 fd 直接访问 inode,不再问路)。
三、文件系统层:inode、address_space 与 page cache
拿到 inode 后,内核要查询 page cache(页缓存)。这是理解 Linux I/O 的核心。
三者的关系
每个 inode 通过 i_mapping 字段关联到一个 address_space 结构:
inode (struct inode)
└── i_mapping ──→ address_space (struct address_space)
└── i_pages ──→ XArray / Radix Tree
│
├──→ page[0] → 文件第 0 页
├──→ page[1] → 文件第 1 页
└──→ page[N] → 文件第 N 页
address_space 是什么?
它不是"地址空间"的字面意思,而是一个页缓存管理器 。一个 address_space 实例管理一个文件 在 page cache 中的所有页面。它的核心字段是 i_pages,在现代内核中是一个 XArray (以前是 Radix Tree),以**文件内的页索引(page index)**为键,值为 struct page *。
查询 page cache 的过程:
vfs_read()
↓
file->f_inode->i_mapping ← 拿到 address_space
↓
find_get_page(address_space, page_index) ← 以文件偏移计算页索引
↓
xa_load(&mapping->i_pages, page_index) ← XArray 查找
↓
命中 → 返回 struct page * → copy_to_user()
未命中 → 发起磁盘 I/O → 分配新 page → 插入 XArray → DMA 读入数据
page 结构体里的物理地址信息
struct page 本身并不直接存储"物理地址"字符串,但它存储了页框号(Page Frame Number, PFN)。在 Linux 中:
物理地址 = PFN << PAGE_SHIFT
其中 PAGE_SHIFT 通常是 12(4KB 页),所以:
物理地址 = PFN × 4096
如果要从文件中某个字节偏移读取数据:
页索引(page_index)= 字节偏移 / PAGE_SIZE
页内偏移(offset) = 字节偏移 % PAGE_SIZE
struct page *p = xa_load(mapping->i_pages, page_index)
PFN = page_to_pfn(p)
物理地址 = (PFN << PAGE_SHIFT) + offset
address_space、page、PFN 的关系总结:
| 概念 | 作用 | 关联方式 |
|---|---|---|
address_space |
管理一个文件在 page cache 中的所有页 | inode->i_mapping |
| XArray / Radix Tree | 以页索引为键快速查找 struct page |
address_space->i_pages |
struct page |
代表一个物理页框,存储元数据和 PFN | XArray 的值 |
| PFN | 页框号,用于计算物理地址 | page_to_pfn() |
| 页内偏移 | 目标数据在页内的字节位置 | offset = pos % PAGE_SIZE |
注意 :page cache 中存储的页面是物理页 (通过
struct page+ PFN 描述),但用户进程看到的永远是虚拟地址 。内核通过copy_to_user()把物理页中的数据拷贝到用户虚拟地址空间;mmap()则是通过页表直接把物理页映射到用户虚拟地址空间,省去拷贝。
四、系统调用与中断:从用户态进入内核态
如果 page cache 未命中,真正的 I/O 开始了。
用户态调用 read() 后,程序通过系统调用指令陷入内核。在 x86_64 架构下,使用的是 syscall 指令:
- 32 位系统使用
int 0x80软中断。 - 64 位系统使用
syscall指令,速度更快,不需要查询中断描述符表(IDT),而是通过 MSR 寄存器直接跳转到内核入口entry_SYSCALL_64。
流程如下:
用户态 read()
↓
syscall 指令
↓
entry_SYSCALL_64(内核入口)
↓
vfs_read() → 具体文件系统的 read 实现
↓
块设备层发起 I/O 请求
五、DMA 层:磁盘到内存的直接搬运
当需要真正从磁盘读取数据时,内核并不让 CPU 亲自搬运数据,而是委托给 DMA(Direct Memory Access,直接内存访问)控制器。
DMA 的工作流程
-
CPU 配置 DMA 寄存器组:
- 源地址(磁盘控制器的数据寄存器地址)
- 目的地址(内存中 page cache 的物理地址)
- 传输数据大小
- 启动位
-
CPU 交出总线控制权:
- CPU 继续执行其他进程,当前进程挂起(进入等待状态)。
-
DMA 获取总线权限:
- DMA 控制器向 CPU 发出 HRQ(Hold Request,总线请求)。
- CPU 响应 HLDA(Hold Acknowledge,总线授权)。
- DMA 获得总线控制权。
-
DMA 传输数据:
- DMA 直接从磁盘读取数据,写入内存。
- 每写入一次,目的地址自增,剩余大小递减。
- 传输完成时,剩余大小归零。
-
DMA 中断通知 CPU:
- DMA 发出中断信号。
- CPU 暂停当前工作,处理 DMA 完成中断。
- 挂起的进程被唤醒,恢复执行。
DMA 与缓存一致性
DMA 写完内存后,CPU 在读取前必须处理 缓存一致性 问题。现代 CPU 通常支持 DDIO(Data Direct I/O) 等技术,使 DMA 直接写入 CPU 的 L3 Cache,而不是主内存,从而避免缓存同步问题。在不支持 DDIO 的系统中,需要通过 dma_sync_sg_for_cpu() 等接口刷新 CPU Cache,否则 CPU 可能读到过时的缓存数据。
六、CPU 与缓存层:进程切换、上下文恢复与数据处理
DMA 完成后,数据已经躺在 page cache 里。接下来进程要被唤醒,数据最终到达用户态。
进程上下文保存在哪里?
进程上下文 = 硬件上下文(寄存器、程序计数器、栈指针、标志位等)+ 内核态执行状态。
在 Linux 中,进程上下文分散保存在以下几个地方:
1. task_struct ------ 进程的"身份证"
每个进程有一个 task_struct 结构体(俗称 PCB,Process Control Block),它是进程存在的唯一标识。其中 thread 字段(struct thread_struct)保存了进程切换时需要恢复的 CPU 寄存器状态。
c
struct task_struct {
struct thread_info *thread_info; // 低版本在内核栈底部,新版本在 task_struct 内
struct thread_struct thread; // x86 架构的寄存器保存区
struct mm_struct *mm; // 用户态内存描述符
struct files_struct *files; // 打开的文件表(fdtable)
struct signal_struct *signal; // 信号处理
// ...
};
2. 内核栈 ------ 中断现场的"快照"
当系统调用或中断发生时,CPU 自动将关键寄存器压入当前进程的内核栈 ,形成一个 struct pt_regs 结构:
用户态发生 syscall
↓
CPU 自动压栈:SS → RSP → RFLAGS → CS → RIP
↓
entry_SYSCALL_64 中继续压栈:RAX, RCX, RDX, ... 所有通用寄存器
↓
形成 pt_regs 结构,保存在当前进程的内核栈顶
struct pt_regs 是系统调用/中断发生瞬间的完整 CPU 状态快照。
3. TSS(Task State Segment)------ 特权级切换的桥梁
x86 架构的 TSS 中存储了每个 CPU 的内核栈指针(rsp0)。当从用户态(ring 3)陷入内核态(ring 0)时,CPU 从 TSS 中加载新的栈指针,切换到进程的内核栈。
进程切换时如何保存与恢复?
当 DMA 完成中断发生,或调度器决定切换进程时,执行 context_switch():
schedule() ← 调度器选择下一个进程
↓
context_switch(prev, next)
↓
├─→ switch_mm(prev->mm, next->mm) ← 切换页表(MMU)
↓
└─→ switch_to(prev, next) ← 切换寄存器上下文
↓
save prev's callee-saved regs to prev->thread
restore next's callee-saved regs from next->thread
↓
返回后,CPU 已经在 next 进程的上下文中运行
详细保存内容:
| 保存位置 | 保存内容 | 何时保存 |
|---|---|---|
pt_regs(内核栈) |
RAX, RCX, RDX, RBX, RSP, RBP, RSI, RDI, R8~R15, RIP, RFLAGS, CS, SS | 每次 syscall / 中断入口自动压栈 |
task_struct->thread |
RBP, RBX, R12~R15, RSP, RIP(callee-saved 寄存器) | switch_to() 宏手动保存 |
mm_struct |
页表基址(CR3) | switch_mm() 切换时保存旧页表、加载新页表 |
files_struct |
fd 表指针 | 不切换,每个进程有自己的 files_struct |
恢复过程(以 read() 返回为例):
DMA 完成中断
↓
IRQ handler → softirq → 唤醒等待队列中的进程
↓
调度器选中该进程
↓
switch_to(prev, next):
- 从 next->thread 恢复 RBX, RBP, R12~R15
- 从 next->thread 恢复 RSP(内核栈指针)
- 从 next->thread 恢复 RIP(返回地址)
↓
sysret / iret:
- 从内核栈的 pt_regs 恢复 RAX, RCX, RDX 等
- 恢复 RIP(用户态程序计数器)
- 恢复 RSP(用户态栈指针)
- 恢复 CS, SS(段寄存器)
- 切换回 ring 3(用户态)
↓
用户态 read() 返回,buf 中已有数据
数据拷贝与缓存读取
进程恢复后:
copy_to_user():将 page cache 中的数据拷贝到用户空间的buf。- CPU 缓存读取 :
- CPU 通过 MMU 将虚拟地址翻译为物理地址。
- 优先从 L1 Cache → L2 Cache → L3 Cache 查找。
- 如果缓存未命中,从主内存加载。每次加载的单位是一个缓存行(Cache Line) ,通常为 64 字节。
- ALU 运算:如果程序需要对数据进行计算,数据进入算术逻辑单元(ALU)进行处理。
七、输出层:从内存到终端
数据被读取并处理后,如果程序需要输出(比如 cat 命令),会调用 write():
c
write(1, buf, len); // fd=1 是标准输出(stdout)
stdout 是 C 标准库中的 FILE* 流(对应 fd = 1),底层实际是 write() 系统调用。
write(1, ...) 的流程与 read() 类似,只是方向相反:
- 用户态
buf→ 内核copy_from_user()→ 终端缓冲区(tty buffer) - 终端驱动将数据发送到显示设备,最终字符出现在屏幕上。
八、完整流程图
为了更直观地理解整个过程,可以参考下面的分层架构图:
(见配套 HTML 流程图文件 linux-file-read-flow.html)
整个流程可以概括为以下分层:
┌─────────────────────────────────────────┐
│ Shell 层 │
│ cat file.txt → fork → execve → cat │
├─────────────────────────────────────────┤
│ 用户空间(cat 进程) │
│ fopen → fread → fwrite → 用户缓冲区 │
├─────────────────────────────────────────┤
│ VFS 虚拟文件系统层 │
│ 文件名 → dentry → fd → file_struct │
├─────────────────────────────────────────┤
│ 文件系统层 │
│ inode → address_space → page cache │
├─────────────────────────────────────────┤
│ 系统调用与中断层 │
│ syscall → entry_SYSCALL_64 → vfs_read│
├─────────────────────────────────────────┤
│ 块设备与 DMA 层 │
│ 磁盘控制器 → DMA → 总线仲裁 → 磁盘 │
├─────────────────────────────────────────┤
│ CPU 与缓存层 │
│ DMA中断 → copy_to_user → L1/L2/L3 → ALU│
├─────────────────────────────────────────┤
│ 输出层 │
│ write(fd=1) → 终端缓冲区 → stdout │
└─────────────────────────────────────────┘
九、核心概念速查表
| 概念 | 说明 |
|---|---|
| page cache | 内核在内存中缓存文件内容的区域 |
| inode | 文件的元数据结构,包含大小、权限、数据块指针等 |
| dentry | 目录项缓存,加速路径名到 inode 的查找 |
| address_space | 管理一个文件在 page cache 中的所有页面 |
| XArray / Radix Tree | address_space 中用于索引页的数据结构 |
| PFN | 页框号,用于计算物理地址 |
| HRQ | Hold Request,DMA 向 CPU 请求总线控制权 |
| syscall | x86_64 系统调用指令 |
| task_struct | 进程控制块,保存进程的所有状态信息 |
| pt_regs | 内核栈上的寄存器快照,记录 syscall/中断现场 |
| switch_to | 进程切换时恢复寄存器上下文的核心宏 |
十、小结
从终端里敲下 cat file.txt 到字符出现在屏幕上,整个流程跨越了 Shell 命令解析、进程创建(fork/exec)、标准库 I/O 缓冲、用户态到内核态的系统调用、VFS 路径解析与 fd 快速访问、inode 与 address_space 的页缓存管理、DMA 数据传输、进程上下文切换与恢复、CPU 缓存层次结构,最终才到达显示设备。
理解这个流程,对于排查 I/O 性能问题、理解文件锁的行为、以及掌握 Linux 系统编程都至关重要。
延伸阅读话题:
mmap()vsread():零拷贝的奥秘sendfile():网络传输中的零拷贝- Linux Writeback:page cache 何时刷回磁盘
- Direct I/O(
O_DIRECT):绕过 page cache 的场景