文件IO深度解析
# 02.文件IO深度解析
嵌入式系统中,文件 IO 的正确与否直接决定数据是否丢失、启动是否够快。本节从最基础的
open/read/write出发,深挖到零拷贝与 Direct I/O——理解每一步都可以绕过哪些 OS 开销。
# 目录介绍
# 2.1 案例引入
# 2.1.1 设备断电后配置文件消失了
某智能网关设备在实验室运行一个月无问题,到电厂现场部署后,每次异常断电(电厂检修拉闸),配置文件 gateway.conf 就变成 0 字节。
原因:开发用 open(conf, O_WRONLY | O_TRUNC) 直接覆盖 + 没有 fsync。断电时 Page Cache 还没刷回闪存——OS 返回 write 成功 ≠ 数据已落盘。
典型的错误代码:
// 错误示例:看似"写成功了"但实际上数据还在内存里
void save_config(const char *path, const char *data) {
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); return; }
ssize_t written = write(fd, data, strlen(data));
if (written < 0) {
perror("write");
} else {
printf("写入成功: %zd 字节\n", written); // ← 只代表进 Page Cache
}
close(fd);
// ⚠ 此时断电 —— 数据全部丢失,文件 0 字节
}
修复后的代码:
void save_config_safe(const char *path, const char *data) {
// 步骤 1:先写临时文件
char tmp[256];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open tmp"); return; }
size_t len = strlen(data);
if (write(fd, data, len) != (ssize_t)len) {
perror("write"); close(fd); return;
}
// 步骤 2:强制刷盘
if (fsync(fd) < 0) {
perror("fsync"); close(fd); return;
}
close(fd);
// 步骤 3:原子替换(顺序:先刷盘,再 rename)
if (rename(tmp, path) < 0) {
perror("rename");
unlink(tmp); // 清理临时文件
}
}
三步关键设计:① 写 .tmp 不破坏原文件 → ② fsync 确保写入物理介质 → ③ rename 是原子的。即使在步骤③前后断电,最坏情况是留一个完整的 .tmp 文件或保留旧版本配置——绝不会出现 0 字节文件。
# 2.1.2 写成功的假象剖析
write() 返回成功,数据经历了这些层:
用户程序
│ write(fd, buf, 4096)
▼
用户空间 ──────────────────────────── buf 是用户态地址
│ 系统调用 → 内核态
▼
VFS 层 ───────────────────────────── 检查 fd 合法性、权限
│
▼
文件系统层 (ext4) ───────────────── 分配磁盘块、更新 inode
│
▼
Page Cache ───────────────────────── 数据复制到内核页面缓存
│ ↑ write() 在这里返回成功
│ │ 数据还在内存里!
▼
块设备层 ─────────────────────────── 排入 I/O 请求队列
│
▼
磁盘驱动 ─────────────────────────── 通过 DMA 写入存储介质
│ ← 只有到这里,数据才真正"落盘"
关键认知:write() 返回只代表数据进了 Page Cache。如果此时断电,Page Cache(RAM)的内容全部丢失,磁盘上只有旧数据或空白。
验证实验(在开发板上测试):
# 写入一个大文件,立即拔电源
dd if=/dev/urandom of=/data/test.bin bs=1M count=100 &
sleep 0.5
# 此时拔电源
重启后 test.bin 大小可能是 0、或者 20 MB,但绝不是 100 MB——前 20 MB 可能已被内核的 pdflush 线程刷到磁盘,但剩下的还在 Page Cache 里。
# 2.1.3 核心问题
write()返回后数据到底在哪?事实:在 Page Cache 中,尚未落盘- 一个文件描述符在内核中对应哪些数据结构?
O_DIRECT、O_SYNC、fsync、fdatasync应该怎么选?- 零拷贝(
sendfile / splice / mmap)各自省掉了哪些数据拷贝? - 嵌入式闪存比 SSD 更脆弱,本文的核心篇幅聚焦于 "每次都写成功" 与 "不烧毁闪存" 的平衡
# 2.2 文件描述符体系
# 2.2.1 进程级文件描述符表
每个进程在内核中有一个 files_struct,包含一个 fdtable 指针数组——这就是文件描述符表。
三层结构:
进程 task_struct
└─ files_struct
└─ fdtable (fd[] 数组)
├─ fd[0] → struct file (stdin)
├─ fd[1] → struct file (stdout)
├─ fd[2] → struct file (stderr)
├─ fd[3] → struct file (app.log)
└─ ...
文件描述符的本质:一个整数索引,指向内核中 struct file 对象的指针。fd=3 的意思是 current->files->fdt->fd[3] 指向某个 struct file。
关键参数:
| 限制 | 含义 | 查看方式 | 典型值 |
|---|---|---|---|
RLIMIT_NOFILE | 单个进程最大打开 fd 数 | ulimit -n | 1024(默认) |
/proc/sys/fs/file-max | 系统级最大打开文件数 | cat /proc/sys/fs/file-max | ~100000 |
在嵌入式系统上尤其注意:默认 1024 的限制对网关/服务器型嵌入式应用可能不够——一个 TCP 连接就是一个 fd,100 个连接 + 50 个日志文件 + 50 个配置文件的读操作 = 随时可超过默认值。
突破限制:
#include <sys/resource.h>
struct rlimit rl;
rl.rlim_cur = 65536;
rl.rlim_max = 65536;
if (setrlimit(RLIMIT_NOFILE, &rl) < 0) {
perror("setrlimit");
}
或者用 prlimit 命令行工具:
prlimit --nofile=65536:65536 -p $(pidof my_app)
# 2.2.2 内核级打开文件表
文件描述符是"进程级"的视角,内核还有一个系统级的打开文件表(file 结构体)。
一个 struct file 对应一次 open() 调用,存储的是:
struct file {
struct path f_path; // 指向 dentry(目录项)+ vfsmount(挂载点)
struct inode *f_inode; // 指向 inode(包含文件大小、权限、块映射)
const struct file_operations *f_op; // 操作函数表(read/write/seek/ioctl 的具体实现)
loff_t f_pos; // 当前读写位置
fmode_t f_mode; // 打开模式(O_RDONLY / O_WRONLY / O_RDWR)
atomic_long_t f_count; // 引用计数(多个 fd 可以指向同一个 file)
// ...
};
f_op 函数表的作用——多态:
用户代码调 open("/dev/tty", ...) → 内核创建 struct file,f_op = tty_fops (终端驱动)
用户代码调 open("/data/app.log", ...) → f_op = ext4_file_operations (ext4 文件系统)
用户代码调 open("/dev/null", ...) → f_op = 特殊的 null 操作
同一个文件可以被多次打开——每次 open() 创建一个新的 struct file(有自己的 f_pos),但都指向同一个 inode。
进程 A: 进程 B:
fd[3] → file_A ────┐ fd[5] → file_B ────┐
│ │
▼ ▼
同一个 inode (app.log)
这就是为什么两个进程可以同时写同一个文件而文件偏移互不影响——每个 struct file 有自己的 f_pos。
# 2.2.3 文件描述符与 fork/dup
fork() 后父子进程共享 struct file(共享文件偏移):
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
pid_t pid = fork();
if (pid == 0) {
// 子进程
write(fd, "child\n", 6); // 写 6 字节
} else {
// 父进程
write(fd, "parent\n", 7); // 写 7 字节
}
输出文件内容:因为 O_APPEND,两次写入都是原子的,文件内容为 child\nparent\n 或 parent\nchild\n(顺序不定,但不会交叉)。
如果没有 O_APPEND:两次写入可能交叉——f_pos 在父子间共享,内核调度导致 write 不是原子的。
dup() 和 dup2():
int fd = open("log.txt", O_WRONLY | O_CREAT, 0644);
int fd2 = dup(fd); // fd2 指向同一个 struct file
write(fd, "A", 1);
write(fd2, "B", 1); // 因为共享 f_pos,B 写在 A 之后
// 文件内容:"AB"
// dup2 指定目标 fd 号:
dup2(fd, STDOUT_FILENO); // 把 stdout 重定向到 log.txt
printf("this goes to log.txt\n"); // 输出到文件而不是终端
fcntl 的 F_DUPFD 和 F_DUPFD_CLOEXEC:
// 复制 fd,并设置 close-on-exec 标志
int new_fd = fcntl(fd, F_DUPFD_CLOEXEC, 0);
这个标志很重要——fork + exec 后自动关闭 fd,避免子程序继承不该用的文件描述符。
# 2.3 open 旗标全景
# 2.3.1 O_RDONLY/O_WRONLY/O_RDWR
三个访问模式必须三选一:
int fd = open("file", O_RDONLY); // 只读
int fd = open("file", O_WRONLY); // 只写
int fd = open("file", O_RDWR); // 读写
在 <fcntl.h> 中,访问模式用低 2 位掩码:
#define O_ACCMODE 0003 // 访问模式掩码
#define O_RDONLY 00 // 只读 = 0
#define O_WRONLY 01 // 只写 = 1
#define O_RDWR 02 // 读写 = 2
比特位提取惯例:
int flags = fcntl(fd, F_GETFL);
int access_mode = flags & O_ACCMODE; // 提取低 2 位
if (access_mode == O_RDONLY) {
// 只读
} else if (access_mode == O_WRONLY) {
// 只写
} else {
// O_RDWR
}
注意:不能直接用 == 比较 flags & O_RDONLY——因为 O_RDONLY = 0,与任何东西 AND 都是 0。
# 2.3.2 O_CREAT/O_EXCL 原子创建
O_CREAT | O_EXCL 的组合保证原子性——"文件不存在就创建,文件已存在就失败"。这个操作在内核中是原子的,不会被其他进程插队:
int fd = open("lock.pid", O_WRONLY | O_CREAT | O_EXCL, 0644);
if (fd < 0) {
if (errno == EEXIST) {
fprintf(stderr, "另一个实例已运行\n");
exit(1);
}
perror("open"); exit(1);
}
// 成功拿到锁——写入 PID 作为进程锁
char pid_str[16];
snprintf(pid_str, sizeof(pid_str), "%d\n", getpid());
write(fd, pid_str, strlen(pid_str));
close(fd);
// 退出时:
unlink("lock.pid");
**为什么需要原子性?**如果有两次 open() + 一次 O_CREAT,中间窗口期内可能另一个进程也创建了同文件——TOCTOU(Time-of-Check-Time-of-Use)竞态。而 O_CREAT | O_EXCL 在内核的 do_filp_open() → lookup_open() 中一次性检查 + 创建,全程持锁。
TOCTOU 对比:
// ❌ 不安全:两次操作之间有窗口
if (access("lock.pid", F_OK) != 0) { // 第 1 步:检查
// ← 另一个进程可能在这里抢先创建
int fd = open("lock.pid", O_CREAT); // 第 2 步:创建 ── 可能不报错!
}
// ✅ 安全:一次原子操作
int fd = open("lock.pid", O_CREAT | O_EXCL, 0644);
if (fd >= 0) {
// 成功——只有我能创建它
}
扩展用法——openat + 相对路径:
int dir_fd = open("/var/run/myapp", O_RDONLY | O_DIRECTORY);
int fd = openat(dir_fd, "lock.pid", O_CREAT | O_EXCL, 0644);
// 即使在多线程环境下,dir_fd 也是稳定的——不会因为 chdir 而受影响
# 2.3.3 O_TRUNC/O_APPEND 写的语义
O_TRUNC:打开时把文件大小截断为 0。配合 O_WRONLY | O_CREAT 就是"覆盖写入":
int fd = open("config.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, "new content\n", 12); // 之前的文件内容全部消失
O_TRUNC 的三大坑:
// 坑 1:截断后再断电——旧数据被抹,新数据未写
// 这就是 2.1.1 案例的根因
int fd = open("config.txt", O_WRONLY | O_TRUNC); // ← 文件已经是 0 字节了
// ... 还没写入就断电 → 文件永久丢失
// 坑 2:截断不会增加权限检查
// 只要能 open 成功,文件就被截断——即使后续 write 被 EACCES 拒绝
// 坑 3:与 O_APPEND 冲突
// open("f", O_WRONLY | O_TRUNC | O_APPEND) 不报错但语义矛盾
// 实际行为:O_TRUNC 生效,O_APPEND 生效——但每次写都在末尾,无异于普通 O_WRONLY
O_APPEND:每次 write 前自动把 f_pos 设到文件末尾,然后写入——整个"定位 + 写"是原子的:
int fd = open("log.txt", O_WRONLY | O_APPEND | O_CREAT, 0644);
// 两个进程同时写:
// 进程 A: write(fd, "AAA\n", 4);
// 进程 B: write(fd, "BBB\n", 4);
// 输出:"AAA\nBBB\n" 或 "BBB\nAAA\n",但不会出现 "AABBAB\n" 的交叉
O_APPEND 的原子性取决于文件系统:
- 本地文件系统(ext4/xfs/btrfs):✅ 原子
- NFS(部分实现):⚠️ 不保证
- 管道(PIPE_BUF 以内):✅ 原子
open 旗标速查:
| 旗标 | 含义 | 典型场景 |
|---|---|---|
O_RDONLY (0) | 只读 | 读配置文件 |
O_WRONLY (1) | 只写 | 输出日志 |
O_RDWR (2) | 读写 | 数据库文件 |
O_CREAT | 不存在则创建 | 创建新文件 |
O_EXCL | 与 O_CREAT 组合,文件已存在则失败 | 进程锁 |
O_TRUNC | 打开时截断为 0 | 覆盖写入 |
O_APPEND | 每次写定位到末尾 | 日志、多进程写 |
O_NONBLOCK | 非阻塞打开(只对 FIFO/设备有意义) | 命名管道 |
O_CLOEXEC | exec 时自动关闭 fd | 安全实践 |
O_DIRECT | 绕过 Page Cache | 数据库 |
O_SYNC | 每次写同步刷盘(数据 + 元数据) | 关键数据 |
O_DSYNC | 每次写同步刷盘(只数据) | 稍优于 O_SYNC |
O_NOFOLLOW | 不跟随符号链接 | 安全 |
O_TMPFILE | 创建匿名临时文件(Linux 3.11+) | 零碎 I/O 中间结果 |
# 2.4 缓冲与非缓冲 IO
# 2.4.1 Page Cache 机制
Page Cache 是 Linux 的读写缓存——位于 VFS 层和文件系统层之间,以**页(4 KB)**为单位缓存文件数据。
读取流程:
read(fd, buf, 1000)
│
▼
VFS: 检查 Page Cache 中是否有文件偏移 [0, 999] 对应的页
│
├─ 命中 → 直接从 Page Cache 拷贝到 buf (无磁盘 I/O)
│
└─ 未命中 → 分配新页 → 发起读 I/O → 读完成后拷贝到 buf
写入流程:
write(fd, buf, 1000)
│
▼
VFS: 检查 Page Cache 中对应页是否已缓存
│
├─ 已缓存 → 直接写入 Page Cache (标记页为 "脏")
│
└─ 未缓存 → 分配新页 → 写入 Page Cache → 标记为脏
│ write() 此时返回成功(数据还在内存中)
▼
稍后:内核 pdflush 线程把脏页刷到磁盘
Page Cache 带来的两个核心好处:
- 延迟写——多次小写合并成一次大 I/O,降低磁头寻道
- 预读——read 100 bytes,内核自动读 4 KB 进 Page Cache,下次读相邻数据命中率为 100%
Page Cache 回收策略(内核回收空闲页):
# 查看系统脏页配置
sysctl -a | grep dirty_background # 后台刷盘阈值(默认 10%)
sysctl -a | grep dirty_ratio # 阻塞写阈值(默认 20%)
当脏页比例超过 dirty_background_ratio(总内存的 10%),pdflush 开始刷盘;超过 dirty_ratio(20%),write 调用会被阻塞直到脏页下降。
嵌入式场景下要注意:内存总量小(128 MB),20% 只有 25 MB——一个 50 MB 的文件写入就可能触发阻塞,表现为整个应用卡住(ANR 式冻结)。
# 嵌入式调优建议
echo 5 > /proc/sys/vm/dirty_background_ratio # 降低阈值,尽早刷
echo 10 > /proc/sys/vm/dirty_ratio # 降低阻塞点
# 2.4.2 O_DIRECT 直接 IO
O_DIRECT 绕过 Page Cache,数据直接从用户态缓冲区到存储介质——适合自带缓存的场景(如数据库):
// 数据库通常这样打开文件
int fd = open("data.db", O_RDWR | O_DIRECT | O_DSYNC);
O_DIRECT 的三项严格限制:
| 限制 | 含义 | 原因 |
|---|---|---|
| 内存地址对齐 | buf 必须是 512 字节对齐 | DMA 要求 |
| I/O 大小对齐 | count 必须是 512 的倍数 | 块设备最小单位 |
| I/O 偏移对齐 | offset 必须是 512 的倍数 | 块设备最小单位 |
获取对齐要求:
#include <stdio.h>
// man 2 posix_memalign 或直接用 mmap 拿页对齐内存
void *buf;
posix_memalign(&buf, 4096, 1024 * 1024); // 4096 对齐,1 MB
// ... 使用 buf ...
free(buf);
O_DIRECT 的优缺点:
| 视角 | 效果 |
|---|---|
| ✅ 优点 | 写入路径极短,延迟极低 |
| ✅ 优点 | 不污染 Page Cache(不挤掉热数据) |
| ✅ 优点 | 用户态直接与硬件对话,无数据拷贝 |
| ❌ 缺点 | 没有读预取(冷数据每次都要读 I/O) |
| ❌ 缺点 | 对齐要求给应用增加复杂度 |
| ❌ 缺点 | 不能与 mmap 混用(语义冲突) |
选择原则:数据库、自建缓存的存储引擎 → 用 O_DIRECT;普通应用、日志写 → 用 Page Cache(让内核做聚合 + 预读)。
# 2.4.3 O_SYNC/O_DSYNC 同步写
O_SYNC:每次 write() 在数据及文件元数据都落盘后才返回:
int fd = open("critical.dat", O_WRONLY | O_SYNC);
write(fd, data, len); // 返回时数据 + inode(mtime, 文件大小)都已落盘
O_DSYNC:每次 write() 只等数据落盘,不等元数据(除非元数据影响数据读取——如文件大小变化):
int fd = open("critical.dat", O_WRONLY | O_DSYNC);
write(fd, data, len); // 返回时数据落盘,但 mtime 可能还没更新
O_SYNC vs O_DSYNC 差异:
场景:追加 100 字节到文件末尾
O_SYNC: write → 等数据刷盘 → 等 inode 刷盘(更新文件大小 + mtime)→ 返回
O_DSYNC: write → 等数据刷盘 → 返回(mtime 稍后由 pdflush 刷)
O_DSYNC 少等一次 inode I/O——在文件大小已知变化时也等(因为文件末尾现在是新位置)。
性能对比(写入 100 次,每次 1 KB):
| 模式 | 耗时 | 说明 |
|---|---|---|
| 普通 buffered write | ~2 ms | 只写 Page Cache |
fsync 在循环外 | ~20 ms | 100 次写 → 1 次 fsync → 刷盘 |
O_DSYNC | ~200 ms | 100 次 write,每次等一次 I/O |
O_SYNC | ~350 ms | 100 次 write,每次等两次 I/O |
嵌入式场景建议:性能敏感的路径不直接用 O_SYNC/O_DSYNC——而是积累写入后做一次 fsync/fdatasync。
# 2.4.4 fsync/fdatasync 的差异
fsync——把 fd 对应的文件数据和元数据都刷到磁盘:
write(fd, buf, 4096);
fsync(fd); // 确保写入持久化
fdatasync——只刷数据,不刷元数据(除非必要):
write(fd, buf, 4096);
fdatasync(fd); // 只确保数据落盘,mtime 可以等
fdatasync 省什么:
| 元数据项 | fsync | fdatasync |
|---|---|---|
| 文件数据 | ✅ 刷盘 | ✅ 刷盘 |
| mtime(修改时间) | ✅ 刷盘 | ❌ 不刷 |
| 文件大小(如果没变) | ✅ 刷盘 | ❌ 不刷 |
| 文件大小(如果变了) | ✅ 刷盘 | ✅ 必须刷 |
关键选择规则:
// 写入不改变文件大小 → 用 fdatasync(省一次 inode 写 I/O)
pwrite(fd, buf, 100, 500); // 覆盖写入,文件大小不变
fdatasync(fd); // 只需刷数据
// 追加写入 → 文件大小变 → fdatasync 也会刷 inode(与 fsync 差距不大)
write(fd, buf, 100); // 追加写入
fdatasync(fd); // 仍需刷 inode(因为大小变了)
// 修改时间不重要 → 用 fdatasync(少一次 I/O)
// 修改时间重要(备份软件依赖 mtime)→ 用 fsync
三类持久化保证:
// 1. 什么也不做 —— 依赖内核 pdflush(默认 30 秒刷一次脏页)
write(fd, buf, n);
// 2. 只保证数据(忽略 atime/mtime) —— 开销最小
fdatasync(fd);
// 3. 保证数据和元数据 —— 开销最大
fsync(fd);
// 4. 同步写 —— 每次 write 即落盘
open("f", O_DSYNC); // 数据同步
open("f", O_SYNC); // 数据 + 元数据同步
# 2.5 零拷贝技术
# 2.5.1 sendfile 原理
传统文件发送需要 4 次拷贝 + 4 次上下文切换:
// 传统方式:读文件 → 写 socket
char buf[4096];
read(fd, buf, 4096); // 内核 → 用户态 buf(拷贝 1)
write(sockfd, buf, 4096); // 用户态 buf → 内核 socket 缓冲区(拷贝 2)
sendfile 在内核内部完成数据传输,省掉用户态中转:
// 2 次拷贝 + 2 次上下文切换
off_t offset = 0;
ssize_t sent = sendfile(sockfd, fd, &offset, 4096);
sendfile 的 DMA 路径(支持 scatter-gather DMA 时):
传统路径:
磁盘 → DMA → 内核 Page Cache → CPU copy → 用户态 buf → CPU copy → socket 缓冲区 → DMA → 网卡
(拷贝1) (拷贝2) (拷贝3) (拷贝4)
sendfile 路径(无 SG-DMA):
磁盘 → DMA → 内核 Page Cache → CPU copy → socket 缓冲区 → DMA → 网卡
(拷贝1) (拷贝2) (拷贝3)
sendfile 路径(有 SG-DMA):
磁盘 → DMA → 内核 Page Cache ───┐
(拷贝1) ├→ SG-DMA → 网卡
│ 只传描述符(页地址 + 长度),
│ 网卡直接从 Page Cache 拉到数据
└→ 0 次 CPU copy!
sendfile 的两大限制:
- 源必须是文件 fd(可 mmap 的)
- 目标必须是socket fd
# 2.5.2 splice 管道接力
splice 比 sendfile 更灵活——在两个 fd 之间"接管道",源和目标都可以是任意 fd:
// 文件 → socket(与 sendfile 等价)
int pipefd[2];
pipe(pipefd);
splice(fd, NULL, pipefd[1], NULL, 4096, SPLICE_F_MOVE); // fd → pipe
splice(pipefd[0], NULL, sockfd, NULL, 4096, SPLICE_F_MOVE); // pipe → sockfd
splice 的精髓:"管道接力":
文件 fd ──→ pipe[1] ──→ pipe[0] ──→ socket fd
splice() splice()
(内核中两端的页引用连接,无数据拷贝)
管道在这中间充当内核页引用传递的桥梁——两端都不是真的复制数据,而是传递页引用(page reference)。
splice vs sendfile 对比:
| 特性 | sendfile | splice |
|---|---|---|
| 源端类型 | 只限文件 | 任意 |
| 目标端类型 | 只限 socket | 任意 |
| 需要管道 | 不需要 | 需要 |
| 系统调用次数 | 1 次 | 2 次(各 splice 一次) |
| 适用场景 | 静态文件服务 | 任意 I/O 间转发 |
tee 是 splice 的"T 型管"变体——把一个管道的内容同时写到两个管道中:
// 既把数据发到 socket,又存到日志文件
splice(input_fd, NULL, pipe1[1], NULL, len, 0);
tee(pipe1[0], pipe2[1], len, 0);
splice(pipe1[0], NULL, sockfd, NULL, len, 0); // 分支 1
splice(pipe2[0], NULL, log_fd, NULL, len, 0); // 分支 2
# 2.5.3 mmap + write 方案
内存映射 I/O——用 mmap 把文件映射到进程的虚拟地址空间,省去 read 时的拷贝:
struct stat st;
fstat(fd, &st);
char *map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
// 直接访问 map[0], map[1], ...——页面按需由 Page Fault 加载
write(sockfd, map, st.st_size); // 数据直接从 Page Cache 到 socket
munmap(map, st.st_size);
mmap 路径:
磁盘 → DMA → Page Cache ←─ 用户空间虚拟地址 (mmap 映射,无拷贝)
│
└→ write(sockfd) → socket 缓冲区 → DMA → 网卡
(仍需一次 CPU copy 到 socket 缓冲区)
mmap 开销:
- mmap 调用本身:建立 VMA(Virtual Memory Area)结构 + 页表项,约 5-10μs
- Page Fault:第一次访问某页时触发,一次约 2-5μs
- munmap:拆除 VMA + 刷脏页回磁盘(如果用了 MAP_SHARED)
mmap 的适用条件:
- 文件大小确定、不变化
- 需要随机访问
- 可以接受一次 mmap 的初始开销(适合中大型文件)
mmap 的坑:SIGBUS——mmap 后文件被另一个进程截断,访问末尾页会收到总线错误信号。
# 2.5.4 性能对比表
| 方案 | 用户态拷贝 | CPU 拷贝 | 上下文切换 | 适用场景 |
|---|---|---|---|---|
read + write | 1 次(buf) | 4 次 | 4 次 | 小文件、有处理逻辑 |
mmap + write | 0 次 | 3 次 | 4 次 | 随机访问、反复读 |
sendfile | 0 次 | 1~2 次 | 2 次 | 静态文件服务 |
sendfile + SG-DMA | 0 次 | 0 次 | 2 次 | 高性能静态文件服务 |
splice | 0 次 | 0 次 | 2 次 | 中继、日志转发 |
copy_file_range | 0 次 | 0 次 | 2 次 | 文件复制(Linux 4.5+) |
选择建议(按数据流方向):
文件 → 文件 : copy_file_range > splice > read+write
文件 → socket : sendfile > mmap+write > read+write
socket → socket: splice > read+write
socket → 文件 : splice > read+write
需要中间处理 : read+write(无法零拷贝)
# 2.6 嵌入式 IO 优化指南
# 2.6.1 闪存写入寿命与写放大
嵌入式闪存(eMMC / NAND Flash)的根本约束:
| 闪存类型 | 擦除块大小 | 编程页大小 | P/E 周期(寿命) |
|---|---|---|---|
| SLC NAND | 128 KB | 2 KB | ~100,000 次 |
| MLC NAND | 256 KB | 4 KB | ~3,000-10,000 次 |
| TLC NAND | 512 KB | 4 KB | ~1,000 次 |
| eMMC (MLC) | 由控制器管理 | 512 B - 4 KB | ~3,000 次 |
关键规则:闪存写入以页为单位(2-4 KB),擦除以块为单位(128-512 KB),且必须先擦除才能写入。
写放大(Write Amplification):
场景:修改文件的 1 个字节
期望:1 字节写 I/O
实际:
1. 读目标页所在的整个块(128 KB)到控制器缓存
2. 修改 1 个字节
3. 找空闲块
4. 把修改后的整个块写入新块
5. 擦除旧块
总物理写入:128 KB(写放大 = 131,072 倍!)
减少写放大的五大策略:
| 策略 | 原理 | 效果 |
|---|---|---|
| 聚合写入 | 攒够一页(4 KB)再写 | 减少小写 |
| 块对齐写入 | 从块边界开始写,避免跨页 | 减少读-改-写 |
| 避免小文件频繁更新 | 小文件的元数据更新触发块擦除 | 减少 I/O |
使用 fstrim / discard | 告知控制器哪些块已不再使用 | 提高垃圾回收效率 |
最小化 fsync 范围 | 用 fdatasync 省 inode 写 I/O | 减少块擦除 |
嵌入式文件系统选择:
| 文件系统 | 特点 | 推荐场景 |
|---|---|---|
| ext4 | 通用,支持 discard | 通用嵌入式 |
| F2FS | 专为闪存设计,延迟更低 | Android / 闪存优化 |
| UBIFS | 直接管理裸 NAND,无需 FTL | 裸 NAND 设备 |
| SquashFS | 压缩只读,极致空间 | 只读系统镜像 |
| tmpfs | 内存文件系统 | 临时数据,不磨损闪存 |
| overlayfs | 只读底层 + 可写上层 | 固件 + 用户配置分离 |
# 2.6.2 日志文件的设计模式
嵌入式日志文件的两难:太多 fsync 磨损闪存;太少 fsync 断电丢失数据。
模式 1:固定大小循环日志(最省闪存)
#define LOG_BLOCK_SIZE 512
#define LOG_BLOCK_COUNT 200 // 100 KB 总大小
void log_write(const char *msg) {
static int block_index = 0;
// 每次写一个固定块——块对齐写入,闪存友好
char block[LOG_BLOCK_SIZE] = {0};
snprintf(block, LOG_BLOCK_SIZE, "[%d] %s\n", block_index, msg);
pwrite(fd, block, LOG_BLOCK_SIZE,
(block_index % LOG_BLOCK_COUNT) * LOG_BLOCK_SIZE);
block_index++;
}
优点:无写放大(块对齐),无需 fsync(只丢失最后一块),闪存寿命最大。
缺点:日志总量有上限,重启后找不到最近日志的开始位置。
模式 2:双缓冲 + 定时刷盘
static char flush_buf[4096];
static size_t flush_pos = 0;
void log_write_buffered(const char *msg) {
size_t len = strlen(msg);
if (flush_pos + len > sizeof(flush_buf)) {
// 满了,write + fsync
write(log_fd, flush_buf, flush_pos);
fdatasync(log_fd); // 数据刷盘
flush_pos = 0;
}
memcpy(flush_buf + flush_pos, msg, len);
flush_pos += len;
}
// 定时器回调(每秒一次)
void log_timer_callback() {
if (flush_pos > 0) {
write(log_fd, flush_buf, flush_pos);
fdatasync(log_fd);
flush_pos = 0;
}
}
优点:每秒最多一次 fdatasync,闪存抖动可控。
缺点:最多丢失 1 秒的日志(可接受范围)。
模式 3:日志落盘优先级分层
Priority 0 (CRITICAL):故障信息 - 立即 write + fsync
Priority 1 (WARNING) :异常事件 - 缓冲到 100ms 定时器
Priority 2 (INFO) :正常信息 - 缓冲到 1s 定时器
Priority 3 (DEBUG) :开发调试 - 只写 Page Cache(不 fsync)
# 2.6.3 断电安全的写入顺序
原子文件替换的标准流程(POSIX 语义保证):
1. 写入临时文件 write(tmp_fd, data, len)
2. 强制刷临时文件 fsync(tmp_fd)
3. 关闭临时文件 close(tmp_fd)
4. 原子替换 rename("file.tmp", "file")
5. 刷目录 fsync(dir_fd) ← 关键!很多开发者漏了这一步
为什么步骤 5 必须做?
rename 操作修改的是目录的 inode(把 file.tmp 的名字改成 file,覆盖旧名字)。目录 inode 也在 Page Cache 中——如果不 fsync(dir_fd),断电后目录内容是旧的,此时:
文件系统里可能有:
- file(旧内容或空)
- file.tmp(新内容)
或
- file(旧内容)
- file.tmp(不存在——因为 rename 日志已提交但目录 inode 没刷盘)
完整的断电安全写模板:
int atomic_write(const char *path, const void *data, size_t len) {
// 1. 构造临时文件名
char tmp[PATH_MAX];
snprintf(tmp, sizeof(tmp), "%s.tmp.%d", path, getpid());
// 2. 写入临时文件
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return -1;
if (write(fd, data, len) != (ssize_t)len) { close(fd); unlink(tmp); return -1; }
if (fsync(fd) < 0) { close(fd); unlink(tmp); return -1; }
close(fd);
// 3. 原子替换
if (rename(tmp, path) < 0) { unlink(tmp); return -1; }
// 4. 刷目录(关键!)
char *dir_path = strdup(path);
char *slash = strrchr(dir_path, '/');
if (slash) {
*slash = '\0';
int dir_fd = open(dir_path, O_RDONLY | O_DIRECTORY);
if (dir_fd >= 0) {
fsync(dir_fd);
close(dir_fd);
}
}
free(dir_path);
return 0;
}
数据库的 WAL(Write-Ahead Logging)原理(嵌入式 SQLite 的标准做法):
用户事务数据
│
▼
先写 WAL 日志(append-only,顺序写) + fsync(WAL) ← 快速!
│
▼ (稍后异步)
刷主数据文件(随机写,慢) ← 不阻塞
│
▼
checkpoint:WAL 已提交的修改合并回主数据文件
嵌入式 SQLite 的实用配置:
// SQLite 开启 WAL 模式 + 调整同步策略
PRAGMA journal_mode = WAL; // 用 WAL 替代传统日志模式
PRAGMA synchronous = NORMAL; // WAL 模式下 NORMAL 是安全的
PRAGMA cache_size = -4000; // 4 MB 页缓存
PRAGMA wal_autocheckpoint = 1000; // 每 1000 页 WAL 自动做 checkpoint
# 2.7 关键结论与速查表
核心结论五条:
write()返回成功 ≠ 数据落盘——数据还在 Page Cache;断电时所有脏页全部丢失- 文件描述符是三表索引——fd →
struct file(共享 f_pos)→inode(共享磁盘块映射) - 同步 = 开销——
fsync>fdatasync> 缓冲写;选择时机比选择方法更关键 - 零拷贝省的是 CPU copy——DMA copy 仍在;只有 SG-DMA 能把 CPU copy 降为 0
- 嵌入式闪存有寿命——每次
fsync都在消耗 P/E 周期;聚合写入 + 块对齐 + 最少 fsync 是黄金三角
I/O 路径速查表:
| 操作 | 数据位置 | 断电后 | 调用返回时 |
|---|---|---|---|
write() | Page Cache | 丢失 | 立即 |
write() + fsync() | 磁盘 | 保留 | 等待 I/O |
write() + fdatasync() | 磁盘(数据) | 保留 | 等待 I/O |
O_SYNC write() | 磁盘(数据 + 元数据) | 保留 | 等待 I/O |
O_DSYNC write() | 磁盘(数据) | 保留 | 等待 I/O |
O_DIRECT write() | 磁盘 | 保留 | 等待 I/O |
同步旗标选择速查:
| 场景 | 最佳方案 | 原因 |
|---|---|---|
| 应用日志(可容忍丢失) | 缓冲写,无 fsync | 性能最重要 |
| 用户配置(不可丢失) | write + fsync + rename + 刷目录 | 完整原子替换 |
| 数据库存储引擎 | O_DIRECT | 自备缓存 |
| 高频写入传感器 | O_DSYNC 或批量 write + 定时 fsync | 平衡安全与闪存寿命 |
| 多进程写日志 | O_APPEND + 无 fsync | 原子追加 |
| 跨重启持久化状态 | write + fdatasync | 省 inode I/O |
| 只读文件 | mmap (MAP_PRIVATE) | 零拷贝,按需加载 |
下一步:在你的嵌入式开发板上运行 strace -e trace=open,write,fsync -c ./your_app,查看实际 I/O 次数和耗时分布——亲手验证"每次 fsync 到底多贵"。
延伸阅读:
man 2 open—— open 旗标完整列表man 2 fcntl—— 文件描述符操作man 2 sendfile/man 2 splice—— 零拷贝接口man 2 fsync/man 2 fdatasync—— 同步操作- 《The Linux Programming Interface》(Kerrisk) —— 第 4、5、13 章
- Linux kernel / Direct I/O (opens new window)