编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
        • 目录
        • 1. 案例引入
          • 1.1 日志拖垮服务
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 文件IO的两层API
          • 2.2 用户态到磁盘
        • 3. 文件描述符本质
          • 3.1 内核三结构
          • 3.2 fork 后的文件描述符共享
          • 3.3 fd限制泄漏
          • 3.4 dup2 与重定向原理
        • 4. 系统调用的切换代价
          • 4.1 syscall 指令到底做了什么
          • 4.2 量化一次 read 的开销
          • 4.3 大小读写拐点
          • 4.4 用户态缓冲
        • 5. 标准IO与缓冲机制
          • 5.1 FILE* 结构体剖解
          • 5.2 三种缓冲策略
          • 5.3 fwrite 的内部分步流程
          • 5.4 fflush 与数据落盘的关系
        • 6. mmap 零拷贝原理
          • 6.1 read+write 两次拷贝的浪费
          • 6.2 mmap 怎么减少一次拷贝
          • 6.3 mmap缺页联动
          • 6.4 msync 与数据持久化保证
        • 7. VFS 抽象层
          • 7.1 一切皆文件
          • 7.2 VFS 四大对象模型
          • 7.3 从 open 看 VFS 如何派发到 ext4
          • 7.4 proc/sys原理
        • 8. 综合案例串讲
          • 8.1 案例真相揭晓
          • 8.2 write旅程
          • 8.3 面试高频问题清单
          • 8.4 文件IO速查卡
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

文件IO与系统调用

# 17.文件IO与系统调用

文件描述符本质是内核打开文件表索引、open/read/write/lseek/close 系统调用的用户态↔内核态切换代价、标准 IO FILE* 的 4096 字节用户态缓冲区、setvbuf 控制缓冲策略(全缓冲/行缓冲/无缓冲)、mmap 文件映射零拷贝原理、msync 刷盘、VFS 虚拟文件系统抽象层

# 目录

  • 1. 案例引入
    • 1.1 日志拖垮服务
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 文件IO的两层API
    • 2.2 用户态到磁盘
  • 3. 文件描述符本质
    • 3.1 内核三结构
    • 3.2 fork 后的文件描述符共享
    • 3.3 fd限制泄漏
    • 3.4 dup2 与重定向原理
  • 4. 系统调用的切换代价
    • 4.1 syscall 指令到底做了什么
    • 4.2 量化一次 read 的开销
    • 4.3 大小读写拐点
    • 4.4 用户态缓冲
  • 5. 标准IO与缓冲机制
    • 5.1 FILE* 结构体剖解
    • 5.2 三种缓冲策略
    • 5.3 fwrite 的内部分步流程
    • 5.4 fflush 与数据落盘的关系
  • 6. mmap 零拷贝原理
    • 6.1 read+write 两次拷贝的浪费
    • 6.2 mmap 怎么减少一次拷贝
    • 6.3 mmap缺页联动
    • 6.4 msync 与数据持久化保证
  • 7. VFS 抽象层
    • 7.1 一切皆文件
    • 7.2 VFS 四大对象模型
    • 7.3 从 open 看 VFS 如何派发到 ext4
    • 7.4 proc/sys原理
  • 8. 综合案例串讲
    • 8.1 案例真相揭晓
    • 8.2 write旅程
    • 8.3 面试高频问题清单
    • 8.4 文件IO速查卡

# 1. 案例引入

# 1.1 日志拖垮服务

某电商后台服务的 QPS 在促销期间从 2000 掉到了 200。运维检查发现 CPU 使用率 95%——但不是业务逻辑在吃 CPU,而是 内核态 CPU 占比高达 80%。

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  0      0  12345  89012  45678    0    0     0   500 12000 34000  5 80  5 10  0
                                                                        ↑ sy=80%
1
2
3
4
5

用 strace 抓了一下主进程正在做什么:

$ strace -c -p $PID
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 99.50   12.345678          35    352000           write
  0.30    0.037212          12      3090           read
  0.10    0.012405          10      1234           openat
  ... (其他)
1
2
3
4
5
6
7

现象:write 系统调用被调用了 35 万次/秒,每次平均 35 微秒——总耗时 12 秒(在采样周期内)。问题代码很简单:

// logger.c —— 日志模块
void log_message(const char* msg) {
    int fd = open("/var/log/app.log", O_WRONLY | O_APPEND | O_CREAT, 0644);
    if (fd >= 0) {
        char buf[256];
        int len = snprintf(buf, sizeof(buf), "[%ld] %s\n", time(NULL), msg);
        write(fd, buf, len);         // ← 每行日志调一次 write
        close(fd);                   // ← 写完马上关
    }
}
1
2
3
4
5
6
7
8
9
10

四个问题叠加:

  1. 每条日志 open + write + close——每次 open 都要走完整的文件系统路径查找
  2. write 是系统调用——每次从用户态陷入内核态再返回
  3. 写的是几十字节的小数据——系统调用开销远大于实际数据传输
  4. close 后下次 open 又要重新分配 fd、重新定位—文件描述符被反复创建销毁

# 1.2 顺藤摸到根因

追查这条日志代码的完整根因链:

  • 假设 1:是不是磁盘慢?—— iostat 看磁盘 IO 延迟正常(~2ms),瓶颈不在磁盘。
  • 假设 2:是不是 write 调用太多?—— 对,每秒 35 万次 write。但为什么每行日志都 open/close?因为代码里就是这么写的。
  • 假设 3:能不能先把日志攒一批再写?—— 可以用 fprintf + FILE* 缓冲(4096 字节攒满了才调一次 write)。
  • 假设 4:用 fopen/fprintf 替代 open/write 后,write 调用次数从 35 万降到 800——CPU sy 从 80% 降到 3%。
  • 假设 5:为什么差距这么大?—— 用户态缓冲区(FILE 的 buffer)* 把成千上万次小 write 合并成少数几次大 write。系统调用的开销是固定成本(~1µs 级别),不受数据量影响。

这个 bug 的根因是 不了解系统调用(write)和库函数(fwrite)的区别——前者每次调用陷入内核,后者在用户态缓存到一定量才调用一次前者。

这个事故里藏着至少 8 个原理点:

① 文件描述符 fd 到底是个什么?为什么是整数?              → 第 3 章
② write 系统调用经过了多少步才到磁盘?                   → 第 4 章
③ 为什么系统调用开销这么大?                             → 第 4.2 节
④ FILE* 的缓冲区在哪?fwrite 怎么减少系统调用次数?       → 第 5 章
⑤ 全缓冲/行缓冲/无缓冲有什么区别?什么时候用哪种?         → 第 5.2 节
⑥ mmap 为什么比 read+write 快?零拷贝是什么意思?        → 第 6 章
⑦ Linux 怎么做到"一切皆文件"?socket 也有 fd?           → 第 7 章
⑧ 文件数据什么时候真正写到磁盘?fflush 够吗?             → 第 5.4/6.4 节
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这起事故是文件 IO 性能问题的典型缩影——频繁的小 write 系统调用比逻辑计算更吃 CPU。它折射出 C 语言文件 IO 的两层 API 设计哲学:底层 open/write(系统调用,精确控制但代价高),上层 fopen/fwrite(库函数,带缓冲以减少系统调用)。

本篇路线:

架构总图 (第 2 章)
   ↓
文件描述符本质 (第 3 章) ─→ 解开"fd 只是一个 int,背后有什么"
   ↓
系统调用切换代价 (第 4 章) ─→ 解开"一次 write 到底花了多少 CPU"
   ↓
标准IO缓冲 (第 5 章) ─→ 解开"fwrite 怎么把 100 次 write 变成 1 次"
   ↓
mmap 零拷贝 (第 6 章) ─→ 解开"怎么跳过用户态缓冲区直接操作文件"
   ↓
VFS 抽象层 (第 7 章) ─→ 解开"为什么 socket/pipe/文件都是 fd"
   ↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
1
2
3
4
5
6
7
8
9
10
11
12
13

📌 本篇定位:前 16 篇讲了代码怎么写、怎么编译、怎么构建——本篇开始进入运行时:代码执行时怎么和内核交互。文件 IO 是 C 程序与"外部世界"交互的最基本通道,系统调用是这条通道的收费站——理解它的代价是写出高性能 C 程序的前提。

# 2. 架构概览

# 2.1 文件IO的两层API

C 语言有两套文件 IO API,它们在设计哲学上有根本差异:

┌─────────────────────────────────────────────────────────────────┐
│                    标准 IO (Standard I/O)                        │
│                                                                 │
│  fopen / fclose / fread / fwrite / fprintf / fscanf            │
│  FILE* 指针                                                     │
│  带用户态缓冲区 (默认 4096 字节)                                  │
│  可移植: 跨平台统一接口                                          │
│  性能: 减少系统调用次数 (批量操作)                                │
│                                                                 │
│  ───────────────────── 底层调用 ──────────────────────►         │
│                                                                 │
│                    系统 IO (POSIX I/O)                           │
│                                                                 │
│  open / close / read / write / lseek / fsync                   │
│  int fd (文件描述符)                                             │
│  无用户态缓冲区                                                  │
│  平台相关: POSIX 标准 (Linux/Unix/macOS)                        │
│  性能: 每次调用进入内核 (无批量)                                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

快速对照:

维度 标准 IO (FILE*) 系统 IO (fd)
头文件 <stdio.h> <fcntl.h>, <unistd.h>
句柄类型 FILE* 指针 int 文件描述符
缓冲区 用户态,可配置 无(直接传给内核)
系统调用频率 低(攒满缓存才调) 每次调用都系统调用
适用场景 普通文本/日志 网络/管道/精确控制
格式化 原生支持 (fprintf) 需要 snprintf + write
线程安全 有锁 (flockfile) 无(内核层面处理)

# 2.2 用户态到磁盘

一次 write(fd, buf, 100) 经历的完整旅程:

用户程序: write(fd, buf, 100)
  │
  ├─ 1. glibc 的 write() 包装函数
  │      └─ 把参数放入寄存器 (rdi=fd, rsi=buf, rdx=100, rax=1)
  │         syscall 指令 → 陷入内核
  │
  ├─ 2. 内核态: sys_write() 入口
  │      └─ 从寄存器取出参数
  │         通过 fd → 找到 struct file
  │         → 找到 struct inode
  │         → 找到 address_space
  │
  ├─ 3. VFS 层: vfs_write()
  │      └─ 检查权限 (文件是否可写?)
  │         调用文件系统特定的 write 实现 (ext4_file_write)
  │
  ├─ 4. 文件系统层: ext4_file_write()
  │      └─ 计算写入位置 (文件偏移 + 写入长度)
  │         调用 generic_file_write_iter()
  │
  ├─ 5. 页缓存层 (Page Cache):
  │      └─ 把 buf 的数据拷贝到内核页缓存 (kernel page cache)
  │         标记该页为 dirty
  │         ← 这里 write() 系统调用就返回了!
  │
  ├─ 6. (异步) 回写线程: pdflush / kworker
  │      └─ 定期扫描 dirty 页
  │         调用块设备驱动 → 写入磁盘
  │
  └─ 7. 磁盘: 磁头移动到对应扇区 → 写入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

关键认知:write 返回 >0 不代表数据已落盘——数据可能只写到了内核页缓存(Page Cache)。必须 fsync(fd) 才能保证数据持久化到磁盘。

# 3. 文件描述符本质

# 3.1 内核三结构

疑惑:int fd = open("file.txt", O_RDONLY); —— 这个 fd 只是一个整数(通常是 3、4、5...),它怎么就能代表"一个打开的文件"?

论证——内核用三个数据结构串联成一个完整的关系图:

进程 A                      内核全局                     文件系统
────────                    ────────                     ────────

files_struct                file 表 (全局)               inode
┌─────────────┐            ┌──────────────┐            ┌──────────────┐
│ fd_array[]  │            │  struct file  │            │  struct inode │
│             │            │  ────────────  │            │  ─────────────│
│ [0] ────────┼──────────► │  f_count: 2   │            │  i_ino: 42    │
│ [1] ────────┼──► stdout  │  f_pos: 0     │──────┐    │  i_size: 1024 │
│ [2] ────────┼──► stderr  │  f_flags: O_RD│      │    │  i_mode: 0644 │
│ [3] ────────┼──►         │  f_inode ─────┼───┐  │    │  ...          │
│ [4] ────────┼──► pipe    │  f_op ────────┼─┐ │  │    └──────────────┘
│   ...       │            └──────────────┘ │ │  │
└─────────────┘                              │ │  │
    ↑                                        │ │  │
    fd = 3 是一个索引                             │ │  │     ┌──────────────┐
    (files_struct.fd_array[3])                   │ │  │     │ address_space│
                                                 │ │  │     │ ─────────────│
                                                 │ │  └────►│ page tree    │
                                                 │ │        │ (管理页缓存)  │
                                                 │ │        └──────────────┘
                                                 │ │
                                                 │ └────────► dentry
                                                 │           ┌──────────────┐
                                                 │           │ struct dentry│
                                                 │           │ d_name:"txt" │
                                                 │           │ d_inode ─────┼──► inode
                                                 │           └──────────────┘
                                                 │
                                                 └──────────► file_operations
                                                             ┌──────────────┐
                                                             │ .read  = ... │
                                                             │ .write = ... │
                                                             │ .llseek= ... │
                                                             └──────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

三层结构:

层级 数据结构 作用域 关键字段
进程级 files_struct 每进程一份 fd_array[](fd→file 映射表)
内核全局 struct file 每次 open 创建一个 f_pos(文件偏移)、f_inode、f_op
文件系统级 struct inode 每个文件一个 i_ino(inode号)、i_size、address_space

关键洞察:

  • 同一个文件被两次 open → 两个 struct file,但同一个 struct inode
  • fork 后父子进程的 struct file 是共享的(引用计数 f_count 递增)——所以父子进程共享文件偏移
  • fd 只是一个数字索引——它本身没有"文件"的含义,含义全在内核的数据结构中

# 3.2 fork 后的文件描述符共享

int fd = open("log.txt", O_WRONLY | O_APPEND);

pid_t pid = fork();
if (pid == 0) {
    // 子进程
    write(fd, "child\n", 6);      // 父进程打开的 fd,子进程也能用!
    close(fd);
} else {
    // 父进程
    write(fd, "parent\n", 7);
    close(fd);
}
// 输出: (取决于调度顺序)
// parent
// child
// 或者: child\nparent
// 两者共享 struct file → 共享文件偏移 → 不会互相覆盖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

关键:fork 后,子进程的 fd_array 拷贝自父进程,但指向的是同一个 struct file(f_count 从 1 变成 2)。只有当父子都 close 后,struct file 才被释放。

# 3.3 fd限制泄漏

# 每个进程的文件描述符上限
$ ulimit -n
1024       # 默认 1024

# 查看当前进程打开了多少 fd
$ ls /proc/self/fd | wc -l
$ lsof -p $PID | wc -l
1
2
3
4
5
6
7

fd 泄漏是最常见的内存泄漏兄弟:

// ❌ fd 泄漏——每次打开后忘记关闭
void process_file(const char* name) {
    int fd = open(name, O_RDONLY);
    if (fd < 0) return;
    char buf[4096];
    read(fd, buf, sizeof(buf));
    // 忘记 close(fd)!→ fd 永远不回收
    // 调用 1024 次后 → "Too many open files"
}
1
2
3
4
5
6
7
8
9

Linux 的 fd 分配策略——总是用最小的可用整数:

close(0);  // 关闭 stdin
int fd = open("file.txt", O_RDONLY);  // fd = 0 (最小的可用整数!)
// 现在 fd 0 指向 file.txt,而不是 stdin——这被利用在重定向中
1
2
3

# 3.4 dup2 与重定向原理

Shell 重定向 command > output.txt 的本质就是 dup2:

// Shell 执行 "ls > output.txt" 的等价代码
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1);   // 让 fd 1 (stdout) 指向 fd 所指的 struct file
close(fd);     // fd 这个"临时"描述符可以关了
execlp("ls", "ls", NULL);  // ls 的 stdout 现在写到了 output.txt
1
2
3
4
5

dup2 的内部操作:

dup2(fd, 1):
  1. 关闭 fd 1 原来指向的 struct file (f_count--)
  2. fd_array[1] 指向 fd_array[fd] 指向的 struct file (f_count++)
  3. 现在 fd 和 1 都指向同一个 struct file → 共享文件偏移
1
2
3
4

# 4. 系统调用的切换代价

# 4.1 syscall 指令到底做了什么

疑惑:为什么 write(fd, buf, 100) 这个"函数调用"比 memcpy(dst, src, 100) 慢几百倍?

论证——memcpy 是纯用户态操作(几条 mov 指令),write 是系统调用,需要用户态↔内核态切换:

用户态                        内核态
──────                        ──────

write(fd, buf, 100):
  │
  ├─ 1. glibc 包装:
  │      把参数写入寄存器:
  │        rax = 1 (write 的系统调用号)
  │        rdi = fd
  │        rsi = buf
  │        rdx = 100
  │
  ├─ 2. syscall 指令:
  │      ┌─ CPU 从 Ring 3 (用户态) 切换到 Ring 0 (内核态)    ← 权限切换
  │      │─ 保存用户态寄存器上下文 (RCX←RIP, R11←RFLAGS)      ← 现场保存
  │      │─ 跳转到 IA32_LSTAR MSR 指定的内核入口               ← 地址空间切换
  │      │─ 切换到内核栈                                       ← 栈切换
  │      │
  │      ├─ 内核: sys_write() 入口
  │      │    ├─ 从寄存器恢复参数
  │      │    ├─ 通过 fd → struct file (内核数据结构)
  │      │    ├─ 安全检查 (权限、地址合法性)
  │      │    ├─ vfs_write → ext4_file_write
  │      │    ├─ 拷贝数据到 page cache
  │      │    └─ 更新文件偏移 f_pos
  │      │
  │      ├─ 内核准备返回:
  │      │    ├─ 返回值放入 rax
  │      │    └─ sysretq 指令
  │      │
  │      └─ 返回到用户态:
  │           ├─ CPU 从 Ring 0 回到 Ring 3
  │           ├─ 恢复用户态寄存器 (RIP←RCX, RFLAGS←R11)
  │           └─ 继续执行 write 之后的指令
  │
  └─ 3. 返回值在 rax 中,返回给调用者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 4.2 量化一次 read 的开销

用 perf 精确测量:

// bench_syscall.c
#include <unistd.h>
#include <fcntl.h>

int main() {
    int fd = open("/dev/zero", O_RDONLY);  // /dev/zero 不涉及磁盘
    char buf[1];
    for (int i = 0; i < 10000000; i++) {
        read(fd, buf, 1);  // 每次只读 1 字节,十万次系统调用
    }
    close(fd);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
$ gcc -O2 bench_syscall.c -o bench
$ perf stat ./bench

Performance counter stats for './bench':
        1,234.56 msec task-clock         # 1.000 CPUs utilized
    10,000,000      syscalls:sys_enter_read  # 一千万次系统调用
         2,345      page-faults
    45,678,901      cycles
    12,345,678      instructions

# 平均每次 read: 1234ms / 10M = 123ns
# 纯用户态 memcpy: 1 字节 ≈ 1ns
# → 系统调用比用户态操作慢 ~100 倍
1
2
3
4
5
6
7
8
9
10
11
12
13

系统调用的开销分解:

开销来源 大约耗时 说明
寄存器保存/恢复 ~20 ns push/pop 现场
Ring 3→0 切换 ~30 ns 权限级别切换
内核栈切换 ~10 ns 加载内核栈指针
内核函数调用 ~30 ns sys_write 的 C 函数开销
安全检查 + 数据处理 可变 取决于具体操作
返回用户态 ~30 ns 恢复现场 + 切换

总结:纯系统调用框架开销约 100-150 ns,再加上具体操作的耗时。

# 4.3 大小读写拐点

// 测试: 读 1MB 数据, 不同块大小的吞吐量
for (int blk = 1; blk <= 65536; blk *= 2) {
    char* buf = malloc(blk);
    int fd = open("testfile", O_RDONLY);
    double start = now();
    for (int i = 0; i < 1024*1024/blk; i++)
        read(fd, buf, blk);
    double elapsed = now() - start;
    printf("blk=%6d: %.2f MB/s\n", blk, 1.0/elapsed);
}
1
2
3
4
5
6
7
8
9
10
实验结果:
blk=     1:  12 MB/s    ← 一百多万次系统调用!
blk=     8:  85 MB/s
blk=    64: 380 MB/s
blk=   512: 890 MB/s
blk=  4096: 2400 MB/s   ← page size 对齐
blk= 16384: 2500 MB/s
blk= 65536: 2550 MB/s   ← 渐近线
1
2
3
4
5
6
7
8

结论:读写块大小 < 4KB(一个 page)时,系统调用开销主导总时间;> 4KB 后,数据传输时间主导。推荐最小读写 4096 字节(对齐 page size)。

# 4.4 用户态缓冲

标准 IO 的 FILE* 就是为了解决"小读写频繁系统调用"的问题而设计的:

没有缓冲 (裸 write):
  100 次 write(fd, buf, 1)
  → 100 次系统调用 × 150ns = 15,000ns 固定开销 + 100字节数据传输

有缓冲 (fwrite):
  100 次 fwrite(buf, 1, 1, fp) → 数据攒在 FILE* 的 4096 字节缓冲区
  → 缓冲区满 → 1 次 write(fd, buf, 4096)
  → 1 次系统调用 × 150ns = 150ns 固定开销 + 4096字节数据传输
  → 快了 ~100 倍
1
2
3
4
5
6
7
8
9

这就是第 1 章案例的性能根因——每秒 35 万次 write 中的 80% 时间都花在系统调用的框架开销上,而不是实际写数据。

# 5. 标准IO与缓冲机制

# 5.1 FILE* 结构体剖解

glibc 中 FILE (即 _IO_FILE) 的核心字段(简化):

// glibc: libio/bits/types/struct_FILE.h (简化)
struct _IO_FILE {
    int    _flags;         // 读写模式标志 (_IO_UNBUFFERED, _IO_LINE_BUF...)
    char*  _IO_read_ptr;   // 读缓冲区当前指针
    char*  _IO_read_end;   // 读缓冲区末尾
    char*  _IO_read_base;  // 读缓冲区起始
    char*  _IO_write_base; // 写缓冲区起始
    char*  _IO_write_ptr;  // 写缓冲区当前指针
    char*  _IO_write_end;  // 写缓冲区末尾
    char*  _IO_buf_base;   // 缓冲区的起始地址 (malloc 分配的)
    char*  _IO_buf_end;    // 缓冲区的末尾地址
    int    _fileno;        // 底层文件描述符 fd
    // ... 锁、宽字符支持等 ...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

FILE 缓冲区的内存布局*:

                    _IO_buf_base                     _IO_buf_end
                    │                                │
                    ▼                                ▼
                    ┌────────────────────────────────┐
                    │  已用                          │ 空 │
                    └────────────────────────────────┘
                    ▲               ▲
                    │               │
                _IO_write_base  _IO_write_ptr

缓冲区大小 = _IO_buf_end - _IO_buf_base (默认 4096 字节)
已用数据 = _IO_write_ptr - _IO_write_base
可用空间 = _IO_buf_end - _IO_write_ptr
1
2
3
4
5
6
7
8
9
10
11
12
13

当 _IO_write_ptr == _IO_buf_end 时 → 缓冲区满 → 触发 write 系统调用 → 清空缓冲区。

# 5.2 三种缓冲策略

setvbuf 可以控制标准 IO 的缓冲行为:

int setvbuf(FILE* stream, char* buf, int mode, size_t size);
1

三种模式:

// 1. _IOFBF (全缓冲) —— 默认对文件
//    缓冲区满时才 write
FILE* fp = fopen("file.txt", "w");
setvbuf(fp, NULL, _IOFBF, 8192);  // 8KB 全缓冲
fprintf(fp, "hello");             // ← 只写入缓冲区,不调用 write
fprintf(fp, "world");             // ← 同上
// ... 直到缓冲区满 → 一次 write(fp->fd, buf, 8192)

// 2. _IOLBF (行缓冲) —— 默认对终端 (stdout)
//    遇到 \n 就 flush
fprintf(stdout, "hello");         // ← 只写入缓冲区
fprintf(stdout, "\n");            // ← \n 触发 flush → write(1, ...)

// 3. _IONBF (无缓冲) —— 默认对 stderr
//    每次 fprintf 都立即 write
fprintf(stderr, "error!");        // ← 立即 write(2, ...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

默认策略:

流 fopen 文件 stdin (终端) stdout (终端) stderr
默认缓冲 全缓冲 (4096) 行缓冲 行缓冲 无缓冲
重定向到文件后 全缓冲 (4096) 全缓冲 (4096) 全缓冲 (4096) 无缓冲

关键陷阱:stdout 重定向到文件后,行缓冲变成全缓冲——输出可能不会立即出现:

$ ./app > output.txt   # stdout 变成全缓冲!
# 程序中的 printf("progress...\n") 不会立即出现
# 只有缓冲区满或程序 exit 时才写入
1
2
3

# 5.3 fwrite 的内部分步流程

// fwrite("hello", 1, 5, fp) 的内部流程 (简化)
size_t fwrite(const void* ptr, size_t size, size_t nmemb, FILE* fp) {
    size_t total = size * nmemb;

    // 1. 如果数据量 ≤ 缓冲区剩余空间 → 直接拷贝到缓冲区
    if (total <= fp->_IO_write_end - fp->_IO_write_ptr) {
        memcpy(fp->_IO_write_ptr, ptr, total);
        fp->_IO_write_ptr += total;
        return nmemb;
    }

    // 2. 如果缓冲区有旧数据 → 先 flush 旧的
    if (fp->_IO_write_ptr > fp->_IO_write_base) {
        _IO_do_flush(fp);  // 内部调 write(fd, buf, len)
    }

    // 3. 如果数据量 > 缓冲区大小 → 直接 write,不经过缓冲区
    if (total >= fp->_IO_buf_end - fp->_IO_buf_base) {
        write(fp->_fileno, ptr, total);  // 直接系统调用
        return nmemb;
    }

    // 4. 拷贝到空缓冲区
    memcpy(fp->_IO_write_base, ptr, total);
    fp->_IO_write_ptr = fp->_IO_write_base + total;
    return nmemb;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

关键:大块 fwrite(> 缓冲区大小)会绕过缓冲区直接走 write——所以 fwrite 不会比 write 慢。

# 5.4 fflush 与数据落盘的关系

疑惑:fflush(fp) 后数据在磁盘上了吗?

论证——不保证:

// write 系统调用 → 数据在 page cache,不在磁盘
// fsync(fd) → page cache 刷到磁盘
// fflush(fp) → FILE* 缓冲区刷到 page cache (调用了 write)
// fclose(fp) → fflush + close

// 完整落盘的正确姿势:
fprintf(fp, "hello\n");
fflush(fp);                      // 1. 用户态→内核 page cache
fsync(fileno(fp));               // 2. page cache→磁盘
1
2
3
4
5
6
7
8
9

数据在不同层的状态:

用户程序                FILE* 缓冲区          内核 Page Cache          磁盘
───────                ────────────          ──────────────          ────
fprintf(fp, ...)  →   [hello\n        ]     (空)                     (旧)
fwrite(buf,...)   →   [hello\n world! ]     (空)                     (旧)
fflush(fp)        →   [              ]  →   [hello\n world!     ]    (旧)
fsync(fd)         →   [              ]      [hello\n world!     ] → [hello\n world!]
1
2
3
4
5
6

fflush 只是用户态→内核的桥梁,fsync 才是内核→磁盘的保证。

# 6. mmap 零拷贝原理

# 6.1 read+write 两次拷贝的浪费

经典的文件复制代码:

// cp 的核心逻辑
char buf[4096];
ssize_t n;
while ((n = read(src_fd, buf, sizeof(buf))) > 0) {
    write(dst_fd, buf, n);
}
1
2
3
4
5
6

数据经历的 4 次拷贝:

磁盘 ──DMA──► 内核 page cache (缓冲区)       ← 拷贝1: DMA
                  │
                  ├─ read() ──► 用户态 buf    ← 拷贝2: CPU (内核→用户)
                  │
                  ├─ write() ◄── 用户态 buf   ← 拷贝3: CPU (用户→内核)
                  │
                  ▼
             内核 page cache (写缓存)          ← 拷贝4: DMA
磁盘 ◄──DMA──
1
2
3
4
5
6
7
8
9

**4 次拷贝中,2 次在用户态和内核态之间来回搬数据——纯浪费。**因为用户态 buf 只做了个中转站,没有任何实质性的数据处理。

# 6.2 mmap 怎么减少一次拷贝

// mmap 版文件复制
struct stat st;
fstat(src_fd, &st);

void* src_map = mmap(NULL, st.st_size, PROT_READ,
                     MAP_PRIVATE, src_fd, 0);
// 文件内容直接映射到用户态虚拟地址!
// 没有 read() → 没有"内核→用户"的拷贝

int dst_fd = open("output.dat", O_RDWR | O_CREAT | O_TRUNC, 0644);
ftruncate(dst_fd, st.st_size);

void* dst_map = mmap(NULL, st.st_size, PROT_WRITE,
                     MAP_SHARED, dst_fd, 0);

memcpy(dst_map, src_map, st.st_size);   // 只有一次 CPU 拷贝!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

mmap 的数据流:

磁盘 ──DMA──► 内核 page cache (物理页)       ← 拷贝1: DMA
                  │
                  ├── 页表映射 ──► 用户态虚拟地址 (同一物理页!)
                  │      ▲
                  │      │ 没有拷贝! 只是页表条目指向同一个物理页
                  │      │
                  ├─ memcpy() ──► 另一个 page cache 页       ← 拷贝2: CPU (内核→内核)
                  │                    │
                  │                    ├── 页表映射 ──► dst_map 用户态地址
                  │                    │
磁盘 ◄──DMA──────                      ▼               ← 拷贝3: DMA
1
2
3
4
5
6
7
8
9
10
11

从 4 次变 3 次——省掉了"内核→用户→内核"的来回搬运。

# 6.3 mmap缺页联动

void* map = mmap(NULL, 1_GB, PROT_READ, MAP_PRIVATE, fd, 0);
// 1GB 映射 — 不会立刻占用 1GB 物理内存!
1
2

mmap 的工作方式:

  1. mmap 调用:只分配虚拟地址空间,不分配物理内存(类似于 malloc)
  2. 第一次访问某页:缺页中断(page fault)→ 内核分配物理页,从文件读数据
  3. 后续访问:直接通过 MMU 访问,零开销

缺页的顺序访问模式:

char* map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
for (off_t i = 0; i < size; i += 4096) {
    char c = map[i];   // 第一次访问 → 缺页 → 读取文件 → 映射物理页
                        // 后续同页访问 → 零开销
}
1
2
3
4
5

mmap + madvise 预读优化:

madvise(map, size, MADV_SEQUENTIAL);  // 告诉内核"我会顺序读"
// → 内核会自动预读后续页,减少缺页中断
1
2

# 6.4 msync 与数据持久化保证

mmap 写入后数据在 page cache 中,需要 msync 才刷到磁盘:

void* map = mmap(NULL, size, PROT_WRITE, MAP_SHARED, fd, 0);
memset(map, 0xFF, size);           // 写入 → 只改 page cache (dirty)

msync(map, size, MS_SYNC);         // 同步刷盘 (阻塞直到完成)
// 或
msync(map, size, MS_ASYNC);        // 异步刷盘 (发起请求后立即返回)
// 或
munmap(map, size);                 // 解除映射,自动刷盘 (但不保证!建议显式 msync)
1
2
3
4
5
6
7
8

mmap vs read/write 性能拐点:

场景 推荐 原因
顺序读大文件 mmap 减少一次拷贝 + 内核自动预读
随机读小文件 read mmap 的页表开销 > 拷贝开销
写后即读 mmap 数据已在 page cache,避免 read 系统调用
多进程共享 mmap + MAP_SHARED 天然共享物理页

# 7. VFS 抽象层

# 7.1 一切皆文件

Unix 的设计哲学之一:一切皆文件。这意味着——

// 这些操作都返回 int fd
int fd1 = open("data.txt",   O_RDONLY);      // 普通文件
int fd2 = socket(AF_INET, SOCK_STREAM, 0);    // 网络 socket
int fd3 = open("/dev/sda",   O_RDONLY);       // 块设备
int fd4 = eventfd(0, 0);                      // 事件通知
int fd5 = timerfd_create(CLOCK_MONOTONIC, 0); // 定时器

// 这些函数对所有 fd 都适用
read(fd1, buf, n);   // ✅
read(fd2, buf, n);   // ✅ socket 也能 read
read(fd3, buf, n);   // ✅ 磁盘也能 read
write(fd4, &val, 8); // ✅ eventfd 也能 write
1
2
3
4
5
6
7
8
9
10
11
12

这一设计的基础就是 VFS(Virtual File System,虚拟文件系统)——一个内核抽象层,把"文件"、"socket"、"设备"、"管道"都统一成同一套接口。

# 7.2 VFS 四大对象模型

VFS 定义了四个核心对象类型:

┌─────────────────────────────────────────────────────────────────────────┐
│                           VFS 四大对象                                   │
│                                                                         │
│  1. super_block (超级块)                                                │
│     ─ 每个挂载的文件系统一个                                              │
│     ─ 存储: 文件系统类型、块大小、设备信息                                 │
│     ─ 方法: s_op→alloc_inode, s_op→write_super                          │
│                                                                         │
│  2. inode (索引节点)                                                      │
│     ─ 每个文件一个 (不管文件开了多少次)                                    │
│     ─ 存储: 文件大小、权限、时间戳、数据块位置                             │
│     ─ 方法: i_op→create, i_op→lookup, i_fop (指向 file_operations)       │
│                                                                         │
│  3. dentry (目录项)                                                       │
│     ─ 每个路径分量一个 (如 /home/user/file → /, home, user, file)        │
│     ─ 存储: 名字、父目录、关联的 inode                                    │
│     ─ 作用: 加速路径查找 (dentry cache / dcache)                         │
│                                                                         │
│  4. file (打开文件)                                                       │
│     ─ 每次 open 创建一个                                                   │
│     ─ 存储: 文件偏移 (f_pos)、打开标志 (f_flags)                           │
│     ─ 方法: f_op→read, f_op→write, f_op→llseek                          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

VFS 的多态实现——file_operations:

// 内核中 struct file_operations 的定义 (简化)
struct file_operations {
    ssize_t (*read)   (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)  (struct file *, const char __user *, size_t, loff_t *);
    int     (*open)   (struct inode *, struct file *);
    int     (*flush)  (struct file *, fl_owner_t id);
    // ...
};
1
2
3
4
5
6
7
8

不同文件系统实现各自的 f_op:

用户程序: read(fd, buf, 100)
                │
                ▼
           VFS: vfs_read()
                │
                ├─ 普通文件 (ext4) → ext4_file_read()    → 读磁盘
                ├─ socket         → sock_read()         → 读网络数据
                ├─ 管道 (pipe)    → pipe_read()          → 读环形缓冲区
                ├─ /proc         → proc_read()          → 动态生成文本
                └─ 设备          → device_read()        → 读硬件
1
2
3
4
5
6
7
8
9
10

# 7.3 从 open 看 VFS 如何派发到 ext4

以 open("/home/user/data.txt", O_RDONLY) 为例,完整路径:

1. 用户态: open("/home/user/data.txt", O_RDONLY)
   → syscall → 内核 sys_openat()

2. VFS: do_sys_openat2()
   → getname() : 把用户态路径字符串拷贝到内核空间
   → get_unused_fd_flags() : 分配一个未使用的 fd (如 3)

3. VFS: do_filp_open()
   → path_openat()
   路径分量遍历: "/" → "home" → "user" → "data.txt"
   ├─ "/":  根目录 dentry (进程的 fs->root)
   ├─ "home": walk_component() → 查 dcache 缓存 → 没命中 → ext4_lookup()
   │           → 读磁盘 inode → 新建 dentry
   ├─ "user": 同上
   └─ "data.txt": 同上

4. VFS: do_dentry_open()
   → 创建 struct file
   → file->f_op = inode->i_fop  (指向 ext4_file_operations)
   → 调用 f_op->open() = ext4_file_open()
   → 把 fd 3 绑定到 struct file

5. 返回用户态: fd = 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 7.4 proc/sys原理

$ ls /proc/self/fd
0  1  2     # stdin, stdout, stderr

$ cat /proc/cpuinfo     # 看起来是"读文件",其实内核动态生成的!
$ cat /sys/class/net/eth0/address   # 读网卡MAC地址,也是内核动态生成
1
2
3
4
5

procfs 和 sysfs 都不是真正的磁盘文件系统——它们实现了一套 file_operations,当用户 read 时,内核动态生成内容而不是从磁盘读取:

// 内核中 /proc/cpuinfo 的 file_operations (简化)
const struct file_operations proc_cpuinfo_operations = {
    .open    = cpuinfo_open,
    .read    = seq_read,    // ← seq_read 动态生成多行文本
    .llseek  = seq_lseek,
    .release = seq_release,
};

// 当 cat /proc/cpuinfo 时:
// read → seq_read → cpuinfo 的 show 函数
//   → 打印 CPU model name / cores / cache size ...
//   → 这些信息来自内核数据结构,不是磁盘文件
1
2
3
4
5
6
7
8
9
10
11
12

VFS 的伟大之处:用户态程序不用关心文件系统类型——cat /proc/cpuinfo 和 cat /etc/hostname 用完全相同的 open+read+close API,底层却走了完全不同的代码路径。

# 8. 综合案例串讲

# 8.1 案例真相揭晓

回到第 1 章日志系统拖垮服务的八个疑问,逐条作答:

疑问 答案
① 文件描述符 fd 是什么? 第 3.1:进程 fd_array 的索引 → struct file → struct inode 的链条
② write 到磁盘经过多少步? 第 4.1:syscall 切换→VFS→文件系统→page cache→异步写回
③ 系统调用开销为什么大? 第 4.2:Ring3→Ring0 切换+寄存器保存+栈切换 ~150ns
④ FILE* 缓冲区怎么减少调用? 第 5.3:攒到 4096 字节才调一次 write
⑤ 三种缓冲策略区别? 第 5.2:全缓冲(文件)、行缓冲(终端)、无缓冲(stderr)
⑥ mmap 为什么快? 第 6.2:省去内核↔用户态的一次数据拷贝
⑦ 一切皆文件怎么做到的? 第 7 章:VFS 通过 file_operations 多态统一接口
⑧ fflush 后数据在磁盘了吗? 第 5.4:不保证——只到 page cache,要磁盘需 fsync

第 1 章案例的完整根因链条:

每条日志: open(路径查找) + write(系统调用) + close(释放 fd)
  × 35万次/秒
  ↓
系统调用框架开销 ~150ns × 35万 ≈ 52ms/秒
但实际上还有路径查找、fd分配、权限检查... → 总 CPU sy = 80%
  ↓
修复: fopen 一次 + fprintf 缓冲 + fclose 一次
  → 35万次 write → 800次 write (缓冲合并)
  → CPU sy 80% → 3%
1
2
3
4
5
6
7
8
9

修复方案(按代价从小到大):

方案 A:保持文件打开,减少 open/close(最简)

static int log_fd = -1;
void log_message(const char* msg) {
    if (log_fd < 0)
        log_fd = open("/var/log/app.log", O_WRONLY | O_APPEND | O_CREAT, 0644);
    char buf[256];
    int len = snprintf(buf, sizeof(buf), "[%ld] %s\n", time(NULL), msg);
    write(log_fd, buf, len);
    // 不 close!让 fd 保持打开
}
1
2
3
4
5
6
7
8
9

方案 B:用标准 IO 缓冲(治本)

static FILE* log_fp = NULL;
void log_init(void) {
    log_fp = fopen("/var/log/app.log", "a");
    setvbuf(log_fp, NULL, _IOFBF, 8192);  // 8KB 全缓冲
}
void log_message(const char* msg) {
    fprintf(log_fp, "[%ld] %s\n", time(NULL), msg);
    // flush 时机: 缓冲区满 或 定时 fflush 或 程序退出
}
1
2
3
4
5
6
7
8
9

方案 C:异步日志(高性能)

用专门的日志线程 + 无锁队列 + 批量 write——日志调用线程只往队列丢一条消息(零系统调用),后台线程批量 write。

# 8.2 write旅程

把 write(fd, "hello\n", 6) 从用户代码到磁盘的完整路径串起来:

用户态
  ├─ 应用: write(fd, "hello\n", 6)
  ├─ glibc: 参数写入寄存器 (rax=1, rdi=fd, rsi=buf, rdx=6)
  └─ syscall 指令
      │
内核态    ▼
  ├─ sys_write() 入口
  ├─ 通过 fd → current->files->fd_array[fd] → struct file
  ├─ 安全检查: 文件是否以写模式打开? buf 地址是否合法?
  ├─ VFS: vfs_write(file, buf, 6, &file->f_pos)
  │     └─ file->f_op->write() = ext4_file_write()
  │         └─ generic_file_write_iter()
  │             └─ 数据拷贝: copy_from_user(page, buf, 6)
  │                  → 把用户态 buf 数据拷贝到 page cache 的物理页
  │                  → 标记 page 为 dirty
  │                  → 更新 file->f_pos = 旧位置 + 6
  ├─ 返回值: 6 (写入成功的字节数) 放入 rax
  └─ sysretq → 返回用户态

延迟写回 (异步)
  ├─ 内核线程 kworker/flush 定时扫描 dirty 页
  ├─ 条件触发: dirty_ratio (脏页占比 > 10%) 或 30 秒超时
  └─ 调用块设备驱动 → DMA → 磁盘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 8.3 面试高频问题清单

1. 文件描述符 fd 是什么?为什么 fork 后父子共享文件偏移?

fd 是进程 files_struct.fd_array[] 的索引,指向内核的 struct file。fork 后子进程拷贝了 fd_array,但指向同一个 struct file(f_count++),所以共享 f_pos(文件偏移)。A 进程 write 后,B 进程的 write 会从新的偏移开始。

2. write 系统调用的完整流程?返回成功是否等于落盘?

syscall → 权限检查 → VFS → ext4 → page cache 拷贝 → 标记 dirty → 返回。不等于落盘——数据只在 page cache 中。fsync(fd) 才保证落盘。详见第 4.1 和 8.2 节。

3. 标准 IO 的三种缓冲策略区别?stdout 重定向到文件后有什么变化?

全缓冲(_IOFBF):缓冲区满才 write。行缓冲(_IOLBF):遇到 \n 就 write。无缓冲(_IONBF):每次立即 write。stdout 默认行缓冲,重定向到文件后变成全缓冲——输出不会实时出现。

4. fflush(fp) 和 fsync(fd) 的区别?

fflush:把 FILE* 的用户态缓冲区刷到内核 page cache(调用 write)。fsync:把内核 page cache 刷到磁盘。fflush 不保证落盘,fsync 保证。详见第 5.4 节。

5. mmap 为什么是"零拷贝"?和 read+write 比省了什么?

read+write:磁盘→内核→用户→内核→磁盘(4次拷贝)。mmap+memcpy:磁盘→内核→内核→磁盘(3次拷贝)。省去了"内核→用户→内核"的一次来回搬运。详见第 6.1/6.2 节。真正的"零拷贝"是 sendfile,数据完全在内核态流转。

6. mmap 的 MAP_PRIVATE 和 MAP_SHARED 区别?

MAP_PRIVATE:修改只对当前进程可见(COW,写时复制),不写回文件。MAP_SHARED:修改对所有映射同一文件的进程可见,且会写回文件(需 msync 或 munmap 触发)。

7. Linux 的"一切皆文件"怎么实现的?

VFS 抽象层:统一 file_operations 接口(read/write/open...)。每种"文件"(ext4/socket/pipe/procfs/设备)各自实现这套接口。用户态只看到 fd + read/write,内核通过 file→f_op 多态派发到具体实现。详见第 7 章。

8. open 一个文件,内核做了哪些事?

路径分量遍历(查 dcache → 缺缓存则 ext4_lookup 读磁盘)→ 找到 inode → 创建 struct file → 分配未使用的 fd → 绑定 fd 到 file → 返回 fd。详见第 7.3 节。

9. 为什么单字节 read 比大块 read 慢那么多?

系统调用的固定开销 ~150ns 与数据量无关。读 1 字节也是 150ns 系统调用框架开销 + ~10ns 数据拷贝;读 4096 字节也是 150ns 框架开销 + ~1µs 数据拷贝。单字节 read 的框架开销占比超 90%。详见第 4.3 节。

10. dup2 和重定向的关系?Shell 的 > 是怎么工作的?

command > file 等价于:open(file) → dup2(fd, 1) → close(fd) → exec(command)。dup2(a, b) 让 fd b 指向 fd a 的 struct file,实现"stdout 指向文件"。详见第 3.4 节。

# 8.4 文件IO速查卡

系统 IO API 参考:

// 打开/关闭
int fd = open("file", O_RDONLY);                  // 只读
int fd = open("file", O_WRONLY | O_CREAT | O_TRUNC, 0644); // 写/创建/截断
int fd = open("file", O_RDWR | O_APPEND);          // 读写/追加
close(fd);

// 读写
ssize_t n = read(fd, buf, sizeof(buf));            // 读, n>0 成功, n=0 EOF, n<0 错误
ssize_t n = write(fd, buf, len);                   // 写
off_t pos = lseek(fd, 0, SEEK_END);                // 移到文件末尾 (获取大小)

// 同步
fsync(fd);                                          // 数据 + 元数据刷盘
fdatasync(fd);                                      // 只刷数据 (不刷元数据,更快)

// 重定向
dup2(fd, 1);                                        // 把 stdout 指向 fd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

标准 IO API 参考:

// 打开/关闭
FILE* fp = fopen("file", "r");                     // 读
FILE* fp = fopen("file", "w");                     // 写 (截断)
FILE* fp = fopen("file", "a");                     // 追加
fclose(fp);

// 格式化读写
fprintf(fp, "val=%d\n", x);
fscanf(fp, "%d", &x);
fgets(buf, sizeof(buf), fp);

// 缓冲控制
setvbuf(fp, NULL, _IOFBF, 8192);                   // 8KB 全缓冲
setvbuf(fp, NULL, _IOLBF, 0);                      // 行缓冲
setvbuf(fp, NULL, _IONBF, 0);                      // 无缓冲
fflush(fp);                                         // 刷新用户态缓冲区
fsync(fileno(fp));                                  // 获取底层 fd 并刷盘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

mmap 参考:

// 文件映射
void* map = mmap(NULL, size, PROT_READ | PROT_WRITE,
                 MAP_SHARED, fd, 0);
// ... 像操作内存一样操作文件 ...
munmap(map, size);

// 匿名映射 (不关联文件, 类似 malloc)
void* anon = mmap(NULL, size, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

// 同步
msync(map, size, MS_SYNC);     // 同步刷盘
msync(map, size, MS_ASYNC);    // 异步

// 预读优化
madvise(map, size, MADV_SEQUENTIAL);  // 顺序访问
madvise(map, size, MADV_RANDOM);      // 随机访问
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

诊断命令:

# 查看进程打开了哪些文件
lsof -p $PID
ls -l /proc/$PID/fd

# 查看 fd 限制
ulimit -n
cat /proc/sys/fs/file-max         # 系统级限制
cat /proc/sys/fs/nr_open           # 进程级硬限制

# 追踪系统调用
strace -c -p $PID                  # 统计总览
strace -e trace=open,read,write -p $PID  # 只看文件IO
strace -e write=fd -p $PID         # 看写入的数据

# 查看 page cache
free -h                            # buff/cache 列
cat /proc/meminfo | grep -E 'Dirty|Writeback|Cached'

# 手动触发刷盘
sync                               # 所有 dirty 页刷盘
echo 3 > /proc/sys/vm/drop_caches  # 释放 page cache (仅测试用!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

性能基准速查:

操作 大致耗时 说明
memcpy 1 byte ~1 ns 纯用户态
memcpy 4096 bytes ~50 ns 纯用户态
系统调用框架 (syscall + ret) ~150 ns 不含内核处理
write 1 byte (到 page cache) ~200 ns syscall + 拷贝
write 4096 bytes (到 page cache) ~1 µs syscall + 拷贝
fsync (SSD, 4KB) ~100 µs 同步落盘
fsync (HDD, 4KB) ~5 ms 机械硬盘寻道
mmap 缺页 (首次访问) ~10 µs 读磁盘
mmap 命中 (再次访问) ~1 ns 仅 MMU 翻译

下一篇:18.进程与线程模型 —— 我们已经知道"文件 IO 是用户态和内核态的桥梁",下一步进入并发:fork 的 COW 机制怎么省内存?pthread 的栈是怎么分配的?互斥锁 futex 在内核里是怎么工作的?

上次更新: 2026/06/11, 09:01:44
Make与CMake构建
动态内存管理揭秘

← Make与CMake构建 动态内存管理揭秘→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式