从 `cat file.txt` 到屏幕:一次 Linux 文件读取的完整旅程

当你在终端敲下 cat file.txt 并按下回车,从字符出现在屏幕上的那一刻起,中间发生了什么?本文从最外层的 Shell 命令开始,一路深入到内核态的 DMA 传输,完整梳理一次文件读取的全流程。


零、从 cat file.txtread()

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 锁)
  • 指向 dentryinode 的指针

二、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 为参数的系统调用(readwritepreadmmapfsync 等)。
  • 时间复杂度: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,不再问路)。


三、文件系统层:inodeaddress_spacepage 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_spacepage、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 的工作流程

  1. CPU 配置 DMA 寄存器组

    • 源地址(磁盘控制器的数据寄存器地址)
    • 目的地址(内存中 page cache 的物理地址)
    • 传输数据大小
    • 启动位
  2. CPU 交出总线控制权

    • CPU 继续执行其他进程,当前进程挂起(进入等待状态)。
  3. DMA 获取总线权限

    • DMA 控制器向 CPU 发出 HRQ(Hold Request,总线请求)
    • CPU 响应 HLDA(Hold Acknowledge,总线授权)
    • DMA 获得总线控制权。
  4. DMA 传输数据

    • DMA 直接从磁盘读取数据,写入内存。
    • 每写入一次,目的地址自增,剩余大小递减。
    • 传输完成时,剩余大小归零。
  5. 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 中已有数据

数据拷贝与缓存读取

进程恢复后:

  1. copy_to_user() :将 page cache 中的数据拷贝到用户空间的 buf
  2. CPU 缓存读取
    • CPU 通过 MMU 将虚拟地址翻译为物理地址。
    • 优先从 L1 Cache → L2 Cache → L3 Cache 查找。
    • 如果缓存未命中,从主内存加载。每次加载的单位是一个缓存行(Cache Line) ,通常为 64 字节
  3. 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() vs read():零拷贝的奥秘
  • sendfile():网络传输中的零拷贝
  • Linux Writeback:page cache 何时刷回磁盘
  • Direct I/O(O_DIRECT):绕过 page cache 的场景
相关推荐
j_xxx404_1 小时前
Linux信号机制:从键盘到内核、进阶实战硬核剖析
linux·运维·服务器·c++·人工智能·ai
李日灐1 小时前
< 12 > Linux进程:进程虚拟地址空间机制 —— 内存管理的美学
linux·运维·算法
码完就睡1 小时前
Linux——进程间通信
linux·运维·服务器
AOwhisky1 小时前
Docker 学习笔记:Docker Compose 多容器编排
linux·运维·笔记·学习·docker·容器
j_xxx404_1 小时前
Linux进程信号:内核数据结构与捕捉递达全流程
linux·运维·服务器·人工智能·ai
STARFALL0011 小时前
MySQL 运维
运维·数据库·mysql
浪客灿心1 小时前
Linux网络NAT
linux·网络
Black蜡笔小新1 小时前
企业私有化AI训练推理一体工作站/自动化AI算法训练服务器DLTM让企业AI自主可控
服务器·人工智能·自动化
怀旧,1 小时前
【Linux网络编程】10. NAT技术、代理服务、内网穿透
linux·网络·智能路由器