零、任务设定
FILE *fp = fopen("data.txt", "w"); // 1. 打开
fwrite("hello", 1, 5, fp); // 2. 写 5 个字节
fclose(fp); // 3. 关闭
我们要追问:"hello" 这 5 个字节,经历了哪几步,最后落在磁盘的哪个位置?
先给全景图(后面逐段展开):
①你的代码
│ fwrite("hello", 1, 5, fp)
▼
②libc:FILE 结构体
│ ├─ 用户态缓冲区(5 字节先放这儿)
│ └─ 满 / 遇 \n / fflush / fclose 时才继续往下
│ write(fd, buf, 5)
▼
③内核:fd 表
│ files_struct[fd] → struct file
▼
④struct file
│ f_op->write(...) ← 函数指针分派:磁盘文件 / 键盘 / socket 走不同实现
▼
⑤页缓存(page cache)
│ 先写进内存里的"文件的影子"
│ ★ 到这一步 write 就返回了,数据还没到磁盘!
▼
⑥回写(writeback)
│ 内核按策略把脏页刷出去(或者你 fsync 强制刷)
▼
⑦文件系统层
│ inode.i_block[] → 找到具体的数据块号
▼
⑧块层 + 驱动
│ 块号 → 转换成 LBA
▼
⑨磁盘固件
│ LBA → CHS(柱面/磁头/扇区),驱动磁头写盘
▼
⑩磁介质:数据落盘
关键认知 :write 返回 ≠ 数据落盘。中间隔着一整个 ⑤⑥。这个认知能解释后面所有"诡异现象"。
一、旅程第 0 站:进程刚启动,就已经有三个 fd 了
在 main 第一行执行之前,你已经有:
files_struct
[0] → 键盘
[1] → 显示器
[2] → 显示器
所以 printf 能直接工作 ------它写的是 stdout,stdout 内部装着 fd=1,而 1 号柜子里已经放着显示器了。
这一步对应第 01 篇的 4.1 节。
二、第 1 站:fopen 做了什么
fopen("data.txt", "w") 这个调用,内核里发生的事比你想的多。
2.1 路径解析:从 / 开始一层层走
data.txt 没有路径,于是用进程的 cwd (当前工作目录)补全,假设是 /home/hyb/io/data.txt。
内核要做路径解析(第 02 篇 4.11 节):
/ 的 inode(开机就知道,无需查找)
↓ 读 "/" 这个目录文件的内容,找 "home" → 拿到 home 的 inode
↓ 读 home 目录内容,找 "hyb" → 拿到 hyb 的 inode
↓ 读 hyb 目录内容,找 "io" → 拿到 io 的 inode
↓ 读 io 目录内容,找 "data.txt"
├─ 找到 → 拿到它的 inode
└─ 没找到 → 因为模式是 "w",所以要"创建"(走第 02 篇 4.9 节的 4 步)
每一层都要"打开一个目录文件、读它的内容"------这就是为什么"访问文件必须知道当前目录"。
2.2 dentry 缓存:如果之前走过这条路
如果 data.txt 或者它所在的目录最近被访问过 ,路径信息已经在内存的 dentry 树里了:
- 直接在 Hash 里按名字查 → O(1) 命中 → 不用真的去磁盘读目录;
- 找不到才走上面那套递归。
这就是第 02 篇 4.12 节的"树 + Hash + LRU"。
2.3 拿到 inode 之后
在 data.txt 的 inode 里,内核知道:
i_mode:这是个普通文件、权限 0644;i_size:现在是 0(或者被O_TRUNC清零了);i_block[]:暂时还没有分配数据块(空文件);i_links_count:1。
2.4 分配 fd
内核在 files_struct 里找最小的空闲下标(第 01 篇 4.3 节):
[0] 键盘 [1] 显示器 [2] 显示器 [3] 空 ← 用它
于是 open 返回 3。
2.5 顺便创建 struct file
这个"已打开文件对象"(第 01 篇 9.2 节)里记着:
f_inode→ 指向data.txt的 inode(这就是两篇接上的地方);f_op→ 指向ext4_file_operations(因为这是磁盘文件);f_pos→ 0(当前读写位置:开头);f_flags→O_WRONLY | O_CREAT | O_TRUNC。
2.6 最后包成 FILE*
libc 分配一个 _IO_FILE(第 01 篇第 8 节):
_fileno = 3;- 分配一块缓冲区(默认 4KB);
- 刷新策略:因为目标可能是终端也可能是文件,先按行缓冲初始化,等确定是文件后切换成全缓冲。
fopen 返回这个 FILE*。
总结第 1 站:
fopen
├─ 补全路径(用 cwd)
├─ 路径解析(dentry 命中 or 从 / 递归)→ 拿到 inode
├─ 找空 fd → 3
├─ 建 struct file(f_inode / f_op / f_pos / f_flags)
└─ 建 FILE(_fileno=3 + 缓冲区 + 刷新策略)
三、第 2 站:fwrite 把 5 个字节放进缓冲区
fwrite("hello", 1, 5, fp);
这一步没有发生任何系统调用!
fwrite 只是做了:
memcpy(fp->_IO_buf_base + 已用长度, "hello", 5);
已用长度 += 5;
if (刷新条件满足) 真正写出去;
刷新条件(第 01 篇 7.3 节):
| 类型 | 条件 |
|---|---|
| 行缓冲 | 遇到 \n,或者缓冲区满(1024) |
| 全缓冲 | 缓冲区满(通常 4KB) |
| 无缓冲 | 立刻 |
| 强制 | fflush / fclose / 进程退出 |
我们的例子 (写文件,全缓冲,只写了 5 字节):条件不满足,5 个字节就待在用户态缓冲区里。
这就是第 01 篇 7.4 节那个 bug 的根源 ------如果你这时候直接 close(fd) 而不是 fclose(fp),缓冲区不会被刷,数据就丢了。
四、第 3 站:fclose 触发真正的写
fclose(fp);
fclose 做了三件事:
fflush:把缓冲区里的 5 字节通过write(3, buf, 5)送进内核;close(3):释放 fd 3(files_struct[3]变空);free(FILE*):释放那块用户态缓冲区。
从 write(3, buf, 5) 开始,才真正进入内核。 接下来是内核的表演。
4.1 内核第一步:查 fd 表
write(3, buf, 5)
↓
files_struct[3] → struct file
如果 fd 3 不存在(你 close 过了),直接返回 -EBADF。
4.2 第二步:函数指针分派
struct file->f_op->write(...)
f_op 指向谁,取决于这是"什么文件"(第 01 篇 9.3 节):
| 文件类型 | f_op->write 实际做的事 |
|---|---|
| 普通磁盘文件 | 写进页缓存(我们这条路径) |
| 终端 | 调用 tty 驱动,往屏幕输出 |
| socket | 塞进网络协议栈 |
| 管道 | 塞进环形缓冲区 |
"一切皆文件"在这个位置兑现 :你的 write 调用是同一个,内核在这里拐弯。
4.3 第三步:写进页缓存(page cache)
对于普通文件,write 并不会立刻去磁盘,而是:
- 按
f_pos(当前偏移)算出"这是文件的第几页"; - 在页缓存里找到(或新分配)这一页;
- 把 5 个字节
memcpy进这一页; - 把这一页标记为脏页(dirty);
write返回 5。
此时,数据在内存里,磁盘上什么都没有。
这就是"内核缓冲区"------第 01 篇提到的"我们只讨论用户级缓冲区,内核也有缓冲区",说的就是它。
打个比方:
- 用户态缓冲区 = 你桌上的草稿纸(libc 管);
- 页缓存 = 公司的公共文件柜(内核管,所有进程共享);
- 磁盘 = 仓库(真正落盘)。
你往草稿纸上写,然后交到公共文件柜;仓库什么时候补货,由公司(内核)决定。
4.4 页缓存的意义(为什么必须有)
第 02 篇讲过:磁盘是慢 的(毫秒级),内存是快的(纳秒级)。差几百万倍。
如果每次 write 都同步写盘:
- 你的程序要等几毫秒;
- 更糟的是,所有进程都在等同一块盘。
页缓存把"写盘"变成了异步:
- 进程只管写内存,立刻返回;
- 内核在后台攒一批一起写(减少寻道,提升吞吐)。
这个"攒"的行为叫 writeback(回写)。
五、第 4 站:回写------数据终于到磁盘
5.1 什么时候刷
内核有几个策略(Linux 的典型默认):
| 触发条件 | 说明 |
|---|---|
| 脏页超过一定比例 | 内存里脏数据太多,必须刷 |
| 定期回写 | 比如每 5 秒扫一次,超过 30 秒的脏页强制刷 |
| 内存压力 | 缺内存了,先回收干净的,再刷脏的 |
显式 fsync / fdatasync |
你要求"必须落盘" |
sync 命令 |
刷所有 |
这就是为什么"断电可能丢数据"------最后几秒写过的东西,可能还在页缓存里没刷出去。
5.2 回写时的具体动作
内核要把"页缓存里第 N 页"写到"磁盘上对应的块":
① 找到 inode(struct file->f_inode)
② 算出这个偏移对应第几个数据块
├─ 如果 i_block[] 里已经有块号 → 直接用
└─ 如果没有(第一次写)→ 走分配:
a. 在 Block Bitmap 里找一个空闲块(第 02 篇 4.5 节)
b. 把这个块号记进 inode 的 i_block[](第 02 篇 4.8 节)
c. 更新 inode 的 i_size / i_blocks
③ 把块号交给块层(第 02 篇 2.4 节)
④ 块号 → LBA(线性地址)
⑤ 块层把请求合并、排序(提升磁盘利用率)
⑥ 驱动把 LBA 交给磁盘
⑦ 磁盘固件把 LBA 转成 CHS(磁头/柱面/扇区),移动磁头,写盘
注意第 ② 步的 a/b/c------这正是第 02 篇 4.9 节"新建文件 4 步"里的前 3 步,只是这次是"第一次写数据时惰性分配"。
注意第 ⑦ 步 ------CHS 从来没出现在 OS 代码里,全是磁盘固件算的(第 02 篇 2.5 节)。所以 OS 换任何磁盘都不用改代码。
5.3 落盘之后
磁盘写完,可能:
- 返回"写成功";
- 更新磁盘上的 inode 表 (
i_size、i_block、时间戳); - 更新 Super Block 里的"空闲块数量"(第 02 篇 4.3 节)。
数据 + 属性,两边都更新完,才是一个完整的"写文件"。
六、第 5 站:读回来------read 的旅程
FILE *fp = fopen("data.txt", "r");
char buf[1024];
fread(buf, 1, 5, fp);
6.1 fopen 重复第 1 站的路径解析
先看 dentry 缓存有没有 → 有就 O(1) 拿到 inode → 分配 fd(这次可能是 3,因为上次关了)。
6.2 fread 先看用户态缓冲区
fread 先检查 FILE 的缓冲区里还有没有剩余数据:
- 有 → 直接
memcpy给你,零系统调用; - 没有 → 调
read(fd, ...)一次性读一大块(比如 4KB)进缓冲区,再给你。
这就解释了第 01 篇 2.3 节那个"坑" :为什么改成 sizeof(buf) 读得更快------一次系统调用读 4KB,比一次读 9 字节快得多。
6.3 内核:页缓存命中吗
read(fd, buf, 5)
↓
files_struct[fd] → struct file → f_op->read
↓
查页缓存(第几页?)
├─ 命中 → memcpy 给你,光速返回(数据可能从没进过磁盘)
└─ 未命中 → 发生缺页 → 从磁盘读一整页进来 → 挂到页缓存 → 再给你
"命中"这件事非常重要 :如果你刚写过这个文件,数据就在页缓存里,read 根本不会碰磁盘。
这也是为什么"读文件第二次比第一次快"------第一次要走磁盘,第二次命中缓存。
6.4 未命中时,怎么知道去磁盘哪读
① inode.i_block[] → 得到数据块号(第 02 篇 4.8 节)
② 块号 → LBA(第 02 篇 2.4 节)
③ LBA 交给驱动
④ 磁盘固件 LBA → CHS,移动磁头,读出扇区
⑤ 数据进页缓存,同时拷贝给你
注意第 ① 步 :如果文件很大,用到了间接块 (一级/二级/三级),这里就要多读几次------先读索引块,再读数据块。
这就是"大文件随机读为什么会慢"的原因:间接层级越多,寻址次数越多。
七、把两篇的所有概念,挂到这条链上
一张表,左边是概念,右边是它在这条链的哪一段:
| 概念 | 出自 | 在这条链的哪一步 |
|---|---|---|
| 文件 = 属性 + 内容 | 01 | 全篇的总纲 |
fopen / fwrite / fread |
01 | 第 1、2、6 站 |
| 用户态缓冲区 | 01 | 第 2 站(fwrite 只写这里) |
| 全缓冲 / 行缓冲 / 无缓冲 | 01 | 第 2 站(决定什么时候往下走) |
write / read 系统调用 |
01 | 第 3、6 站 |
| fd(数组下标) | 01 | 第 1 站分配、第 3 站查询 |
files_struct |
01 | 第 3 站(files_struct[3]) |
struct file |
01 | 第 3 站(f_op / f_pos / f_inode) |
file_operations 函数指针 |
01 | 第 3 站(分派到不同设备实现) |
| 页缓存 / 内核缓冲区 | 01 | 第 3 站写入、第 6 站命中 |
| 扇区 / 块 / LBA | 02 | 第 4 站(写盘) |
| 块组 / Super Block / Bitmap | 02 | 第 4 站(分配新块) |
inode / i_block[15] |
02 | 第 4 站写、第 6 站读 |
| 目录 = "名字→inode" | 02 | 第 1 站(路径解析每一层都在读目录) |
| dentry 缓存 | 02 | 第 1 站(命中就跳过解析) |
| 挂载 | 02 | 第 1 站(路径前缀决定去哪个分区找 inode) |
硬链接 links_count |
02 | 删除文件时(第 8 节) |
| 三个时间 atime/mtime/ctime | 02 | 落盘时更新 inode |
八、用这条链解释三个"诡异现象"
现在你有了完整链条,这些曾经想不通的现象都能推出来了。
8.1 现象一:写完文件,cat 却看不到内容
现象(第 01 篇 7.4 节):
close(1);
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
printf("hello world: %d\n", fd);
close(fd); // 注意:没有 fflush,也没有 fclose
return 0;
结果 log.txt 是空的。
用链条解释:
第 2 站:printf 把内容放进了 libc 的用户态缓冲区
第 3 站:没有触发!因为 close(fd) 只是关内核的 fd,不认识 FILE 的缓冲区
用户态缓冲区的内容,只有 fflush / fclose / 进程正常退出时才会刷 。你直接 close(fd),缓冲区就这么悬着了。
修正 :用 fflush(stdout) 或者用 fclose(fp)。
📌 更惨的情况:
_exit不刷缓冲区(这是进程那篇讲过的),进程一退,数据直接蒸发。
8.2 现象二:删了文件,磁盘空间没释放
现象 :rm bigfile 之后,df -h 显示空间没回来。
用链条解释:
rm 只做了两件事(第 02 篇 5.1 节):
- 从目录里删掉"bigfile → inode 号"这一行;
- inode 的
i_links_count减 1。
但:
- 如果还有硬链接 指着这个 inode →
links_count > 0→ 不释放; - 如果还有进程打开着 这个文件(
struct file还在,f_count > 0)→ 也不释放。
这就是"删了但没删干净"的真相:文件不在目录里了,但 inode 还被引用着。
验证 :lsof | grep deleted 能看到"已删除但仍被打开"的文件。
8.3 现象三:fork 之后输出变成两份
现象 (第 01 篇第 6 节):printf + fwrite + fork,重定向到文件后,库函数的输出各出现两次。
用链条解释:
第 2 站:printf/fwrite 的内容在用户态缓冲区(因为全缓冲,没刷)
↓
此时 fork()
↓
进程内存被"写时拷贝"复制 → 缓冲区也被复制了一份
↓
父子进程各自退出 → 各自 fclose/退出刷新 → 各写一次 → 两份
而 write 没有缓冲区,早就进内核了,fork 复制不到它,所以只有一份。
同一个链条,解释了两件事 :为什么库函数有缓冲、为什么 fork 会放大它。
九、一张终极全景图
┌──────────────────────────── 用户态 ────────────────────────────┐
│ 你的代码 │
│ printf("hello") │
│ ↓ │
│ libc(FILE 结构体) │
│ _fileno = 1 │
│ _IO_buf_base ... _IO_buf_end ← 用户态缓冲区(全/行/无缓冲) │
│ ↓ 缓冲区满 / 遇 \n / fflush / fclose │
│ write(1, buf, n) │
└──────────────────────────────┬──────────────────────────────────┘
│ 系统调用(陷入内核,上下文切换)
┌──────────────────────────────▼──────────────────────────────────┐
│ 内核态 │
│ files_struct[1] ──→ struct file │
│ ├─ f_op ──→ file_operations(函数指针) │
│ │ ├─ 磁盘文件 → ext4_file_ops │
│ │ ├─ 终端 → tty_fops │
│ │ └─ socket → sock_fops │
│ ├─ f_inode ──→ struct inode │
│ │ ├─ i_mode / i_size │
│ │ ├─ i_atime/i_mtime/i_ctime│
│ │ ├─ i_links_count │
│ │ └─ i_block[15] │
│ │ ├ 12 直接块 │
│ │ ├ 1 一级间接 │
│ │ ├ 1 二级间接 │
│ │ └ 1 三级间接 │
│ └─ f_pos(当前读写偏移) │
│ │
│ 页缓存(page cache)← 数据先到这,标记脏页 │
│ ↓ 回写(writeback / fsync) │
│ 块层:块号 → LBA → 合并排序 │
└──────────────────────────────┬──────────────────────────────────┘
│ 驱动下发 IO
┌──────────────────────────────▼──────────────────────────────────┐
│ 硬件 │
│ 磁盘固件:LBA → CHS(柱面/磁头/扇区) │
│ 盘片旋转 + 磁头移动 → 写入扇区(512B) │
└─────────────────────────────────────────────────────────────────┘
磁盘侧再展开一层(第 02 篇):
分区
├── 启动块(1KB)
└── 块组 0..N
├── Super Block ← 总量、空闲量、块大小(多份备份)
├── GDT ← 各块组的位置指针
├── Block Bitmap ← 哪些块被占
├── Inode Bitmap ← 哪些 inode 被占
├── Inode Table ← 全部 inode 的数组
└── Data Blocks ← 文件的真实内容
十、最后:用一句话概括这套东西
如果你只能记一句话,记这个:
Linux 用"文件"统一了所有 IO;用"fd 数组下标"统一了进程和文件的关系;用"
f_op函数指针"统一了各种设备的读写;用"inode + 数据块"把文件拆成属性与内容;用"目录文件存名字→inode"把文件串成树;用"挂载"把多棵小树接到一棵大树下。
这六句话,就是这两份课件的全部。
十一、自测:如果你能答上来,就真懂了
fopen返回FILE*,open返回int,这两个是什么关系?- 为什么
open一个文件返回 3,而不是 0? - 关掉 fd 1 再
open一个文件,为什么printf的内容进了文件? dup2(fd, 1)到底改了什么?exec之后,为什么重定向还在生效?printf和write在fork后的行为为什么不同?fclose和close差在哪?- 0KB 的空文件占不占磁盘空间?为什么?
- inode 里为什么不存文件名?
- 目录的内容是什么?
- 访问
/a/b/c时,内核要读几次磁盘(不考虑缓存)? - 为什么要 dentry 缓存?
- 硬链接删掉一个名字,文件还在吗?为什么?
rm之后磁盘空间没释放,可能是什么原因?write返回成功了,数据一定在磁盘上吗?
答案都在前两篇里,找不到再回看对应小节。