编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

  • Web开发和进阶

  • Linux应用开发

    • Linux应用开发
    • QML基础入门

    • QT核心库实践

    • Linux系统编程

      • Linux系统编程基础
      • 系统调用原理精讲
      • 文件IO深度解析
        • 进程管理深度解析
        • 线程并发深度解读
        • 信号处理深度解读
        • 进程间通信IPC
        • 网络IO多路复用
        • 内存管理深度解读
        • 动态链接与共享库
        • Linux与定时器
      • 综合项目实战

    • IoT智能硬件开发

    • Apps
    • Linux应用开发
    • Linux系统编程
    杨充
    2025-07-02
    目录

    文件IO深度解析

    # 02.文件IO深度解析

    嵌入式系统中,文件 IO 的正确与否直接决定数据是否丢失、启动是否够快。本节从最基础的 open/read/write 出发,深挖到零拷贝与 Direct I/O——理解每一步都可以绕过哪些 OS 开销。

    # 目录介绍

    • 2.1 案例引入
      • 2.1.1 设备断电后配置文件消失了
      • 2.1.2 写成功的假象剖析
      • 2.1.3 核心问题
    • 2.2 文件描述符体系
      • 2.2.1 进程级文件描述符表
      • 2.2.2 内核级打开文件表
      • 2.2.3 文件描述符与 fork/dup
    • 2.3 open 旗标全景
      • 2.3.1 O_RDONLY/O_WRONLY/O_RDWR
      • 2.3.2 O_CREAT/O_EXCL 原子创建
      • 2.3.3 O_TRUNC/O_APPEND 写的语义
    • 2.4 缓冲与非缓冲 IO
      • 2.4.1 Page Cache 机制
      • 2.4.2 O_DIRECT 直接 IO
      • 2.4.3 O_SYNC/O_DSYNC 同步写
      • 2.4.4 fsync/fdatasync 的差异
    • 2.5 零拷贝技术
      • 2.5.1 sendfile 原理
      • 2.5.2 splice 管道接力
      • 2.5.3 mmap + write 方案
      • 2.5.4 性能对比表
    • 2.6 嵌入式 IO 优化指南
      • 2.6.1 闪存写入寿命与写放大
      • 2.6.2 日志文件的设计模式
      • 2.6.3 断电安全的写入顺序
    • 2.7 关键结论与速查表

    # 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 核心问题

    1. write() 返回后数据到底在哪?事实:在 Page Cache 中,尚未落盘
    2. 一个文件描述符在内核中对应哪些数据结构?
    3. O_DIRECT、O_SYNC、fsync、fdatasync 应该怎么选?
    4. 零拷贝(sendfile / splice / mmap)各自省掉了哪些数据拷贝?
    5. 嵌入式闪存比 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 带来的两个核心好处:

    1. 延迟写——多次小写合并成一次大 I/O,降低磁头寻道
    2. 预读——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 关键结论与速查表

    核心结论五条:

    1. write() 返回成功 ≠ 数据落盘——数据还在 Page Cache;断电时所有脏页全部丢失
    2. 文件描述符是三表索引——fd → struct file(共享 f_pos)→ inode(共享磁盘块映射)
    3. 同步 = 开销——fsync > fdatasync > 缓冲写;选择时机比选择方法更关键
    4. 零拷贝省的是 CPU copy——DMA copy 仍在;只有 SG-DMA 能把 CPU copy 降为 0
    5. 嵌入式闪存有寿命——每次 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)
    上次更新: 2026/07/12, 19:39:51
    系统调用原理精讲
    进程管理深度解析

    ← 系统调用原理精讲 进程管理深度解析→

    最近更新
    01
    audit
    07-27
    02
    C++入门教程全章思考题汇编
    07-24
    03
    12.技术团队建设能力
    07-21
    更多文章>
    Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式