linux系统复习:全景串联

零、任务设定

复制代码
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 做了三件事:

  1. fflush :把缓冲区里的 5 字节通过 write(3, buf, 5) 送进内核;
  2. close(3):释放 fd 3(files_struct[3] 变空);
  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 并不会立刻去磁盘,而是:

  1. 按 f_pos(当前偏移)算出"这是文件的第几页";
  2. 在页缓存里找到(或新分配)这一页;
  3. 把 5 个字节 memcpy 进这一页;
  4. 把这一页标记为脏页(dirty);
  5. 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 节):

  1. 从目录里删掉"bigfile → inode 号"这一行;
  2. 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"把文件串成树;用"挂载"把多棵小树接到一棵大树下。

这六句话,就是这两份课件的全部。


十一、自测:如果你能答上来,就真懂了

  1. fopen 返回 FILE*,open 返回 int,这两个是什么关系?
  2. 为什么 open 一个文件返回 3,而不是 0?
  3. 关掉 fd 1 再 open 一个文件,为什么 printf 的内容进了文件?
  4. dup2(fd, 1) 到底改了什么?
  5. exec 之后,为什么重定向还在生效?
  6. printf 和 write 在 fork 后的行为为什么不同?
  7. fclose 和 close 差在哪?
  8. 0KB 的空文件占不占磁盘空间?为什么?
  9. inode 里为什么不存文件名?
  10. 目录的内容是什么?
  11. 访问 /a/b/c 时,内核要读几次磁盘(不考虑缓存)?
  12. 为什么要 dentry 缓存?
  13. 硬链接删掉一个名字,文件还在吗?为什么?
  14. rm 之后磁盘空间没释放,可能是什么原因?
  15. write 返回成功了,数据一定在磁盘上吗?

答案都在前两篇里,找不到再回看对应小节。

相关推荐
m0_547486661 小时前
《Ubuntu Linux操作系统与实验教程》全套PPT课件2026
linux·ubuntu·操作系统
Elastic 中国社区官方博客1 小时前
5 分钟内将 56 页扫描文档转化为带引用的答案:面向搜索无法读取的文档的检索增强生成流水线
大数据·运维·人工智能·elasticsearch·搜索引擎·信息可视化·全文检索
John_Hui1 小时前
第 1 篇:VMware 安装 Ubuntu 24.04.3 Server
linux·运维·ubuntu
kyle~1 小时前
C++ 原子操作、内存模型与无锁并发编程
linux·开发语言·c++·操作系统
2401_869769592 小时前
gdb_进程概念
linux
小猪佩奇TONY2 小时前
Linux 内核学习(19) --- linux swap 机制
linux·学习·swap·kswapd
长不大的杨瑞杰2 小时前
OpenCode v2 团队治理实战:多仓库 Policy、灰度发布与故障回滚
运维·人工智能·安全·软件工程·devops
开开心心就好3 小时前
本地文件搜索工具,双击Ctrl就能呼出搜索
java·服务器·前端·支持向量机·ffmpeg·音视频·模拟退火算法
第404号工位3 小时前
浏览器的“无痕模式”是当代最大安慰剂?我用 30 行 JS 提取硬件指纹,跨网络换代理照样精准锁定你
linux·人工智能·python