C 进阶(2) - 文件I/O

在 Linux 系统下进行 C/C++ 编程时,文件 I/O(输入/输出)是绝对的核心基础。Linux 完美践行了**"一切皆文件"**的哲学,无论是普通文档、目录,还是键盘、显示器、网络套接字,在底层都可以通过统一的文件 I/O 接口来操作。

在 Linux 中,文件 I/O 主要分为两大阵营:系统调用 I/O标准 I/O 库

⚙️ 系统调用 I/O(无缓冲 I/O)

这是 Linux 内核直接提供给用户程序的底层接口(API),定义在 POSIX 标准中。它们不带用户态缓冲区,直接与内核打交道。

核心函数速查:

  • open() :打开或创建一个文件,成功时返回一个非负整数(即文件描述符 fd),失败返回 -1。
  • read():从文件描述符 fd 中读取指定长度的数据到缓冲区。
  • write():将缓冲区的数据写入文件描述符 fd。
  • lseek():移动文件读写的指针位置(实现随机读写)。
  • close():关闭文件,释放内核资源。

实战示例(使用系统调用复制文件):

复制代码
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

#define BUFFER_SIZE 1024

int main() {
    // 1. 打开源文件(只读)和目标文件(写入,不存在则创建)
    int fd_in = open("source.txt", O_RDONLY);
    int fd_out = open("target.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    
    if (fd_in < 0 || fd_out < 0) {
        perror("open failed");
        return 1;
    }

    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    // 2. 循环读取并写入
    while ((bytes_read = read(fd_in, buffer, BUFFER_SIZE)) > 0) {
        write(fd_out, buffer, bytes_read);
    }

    // 3. 关闭文件
    close(fd_in);
    close(fd_out);
    return 0;
}

适用场景:需要精确控制文件读写(如数据库底层)、操作硬件设备(如串口、I2C)、或者追求极致性能避免用户态缓冲拷贝时。

📚 标准 I/O 库(带缓冲 I/O)

标准 I/O 是 C 语言标准库(glibc)在系统调用的基础上封装的一层高级接口。它在用户空间维护了一块缓冲区(Buffer)

核心函数速查:

  • fopen() :打开文件,返回一个 FILE* 指针(流)。
  • fread() / fwrite():以数据块为单位读写文件。
  • fprintf() / fscanf():格式化读写(比如直接写字符串、整数)。
  • fseek() / ftell():移动和获取文件流的位置。
  • fclose():关闭文件流。

实战示例(使用标准 I/O 写入日志):

复制代码
#include <stdio.h>

int main() {
    // 1. 打开文件流
    FILE *fp = fopen("log.txt", "w");
    if (!fp) {
        perror("fopen failed");
        return 1;
    }

    // 2. 格式化写入(数据会先存入用户态缓冲区)
    for (int i = 0; i < 100; i++) {
        fprintf(fp, "Log entry %d\n", i);
    }

    // 3. 关闭文件(fclose 会自动将缓冲区剩余数据刷新到内核)
    fclose(fp);
    return 0;
}

适用场景 :处理普通磁盘文件,尤其是频繁读写小数据块时。因为缓冲区的存在,它极大地减少了昂贵的系统调用次数,整体效率往往比直接调用 write() 更高。

🆚 核心区别与总结

特性 系统调用 I/O (open/read/write) 标准 I/O 库 (fopen/fread/fwrite)
缓冲机制 无用户态缓冲(直接触发内核态切换) 自带用户态缓冲区(减少系统调用次数)
操作对象 文件描述符 (int fd) **文件流 (FILE *fp)**
可移植性 依赖 Linux/Unix 系统 跨平台(遵循 C 标准)
适用对象 所有文件(包括字符设备、套接字等) 主要针对普通磁盘文件

💡 进阶概念:文件描述符与 VFS

  1. 文件描述符 (File Descriptor, fd)
    在 Linux 中,fd 是一个非负整数,它是进程与内核打开文件表之间的"索引号"。每个进程启动时,默认会打开三个标准的 fd:
  • 0 :标准输入 (STDIN_FILENO)
  • 1 :标准输出 (STDOUT_FILENO)
  • 2 :标准错误 (STDERR_FILENO)
  1. 虚拟文件系统 (VFS)
    当你调用 open()write() 时,Linux 内核并不是直接去操作硬盘。它会通过 VFS 这一层抽象,将你的请求转发给具体的文件系统驱动(如 ext4, xfs)。VFS 让上层程序不需要关心底层到底是硬盘、SSD 还是内存盘,统一了所有设备的操作接口。

sync, fsync, fdatasync 函数

在 Linux 系统编程中,syncfsyncfdatasync 都是为了保证数据从内存安全写入磁盘而设计的。要理解它们,首先得明白 Linux 的**"延迟写(Delayed Write)"**机制:

平时我们调用 write() 写入文件时,数据其实只是被写进了内存的**页缓存(Page Cache)**中,并没有真正落到物理磁盘上。内核会在后台定期(默认约 5~30 秒)将这些"脏页"批量刷入磁盘。这样做极大地提升了 I/O 效率,但如果系统在数据落盘前突然断电或崩溃,缓存中的数据就会永久丢失。

这三个函数的作用,就是让应用程序能主动、强制地把缓存中的数据刷到磁盘上,但它们的"强制力度"和性能开销各不相同。

⚖️ 三者的核心区别

为了让你一目了然,先通过一个表格来对比它们的特性:

接口 同步范围 是否阻塞等待 性能开销 典型场景
sync 全局所有文件系统 否(仅触发排队) 极低 系统关机、重启前预处理
fsync 单个指定文件(数据+全量元数据) 是(等待磁盘确认) 数据库事务提交、关键配置文件保存
fdatasync 单个指定文件(数据+必要元数据) 是(等待磁盘确认) 高频日志写入、流媒体数据持久化

📝 详细原理解析

1. sync:全局"广撒网"

sync 的作用范围是整个系统。当你调用它时,内核会把所有 文件系统中修改过的块缓冲区全部排入写队列,然后立刻返回

  • 特点:它只管"触发"刷盘动作,根本不等待磁盘实际写完。
  • 适用场景 :通常用于系统关机或重启前的预处理,或者在 Shell 中手动执行 sync 命令来降低意外断电的数据丢失风险。
2. fsync:单文件"强一致性"

fsync(int fd) 只针对传入的单个文件描述符(fd) 起作用。它会强制将该文件的所有修改(包括文件数据元数据 )同步到物理磁盘,并且会一直阻塞等待,直到磁盘硬件报告 I/O 操作彻底完成才返回。

  • 什么是元数据(Metadata)? 比如文件的修改时间(mtime)、访问时间(atime)、权限、文件大小等存储在 inode 中的信息。
  • 为什么开销大? 因为文件数据和元数据在物理磁盘上通常存储在不同的位置。调用 fsync 意味着磁盘磁头至少要进行两次寻道写操作(一次写数据,一次写 inode 元数据),这在机械硬盘上会带来约 10ms 甚至更高的延迟。
  • 适用场景:对数据安全性要求极高的场景,比如数据库在提交事务时,必须确保事务日志完全落盘。
3. fdatasync:单文件"性能优化版"

fdatasync(int fd)fsync 的优化版本。它同样针对单个文件并阻塞等待,但它只同步文件数据和"必要的元数据"

  • 什么是"必要的元数据"? 只有那些如果不更新、会导致后续数据读取错误的元数据(例如文件大小 st_size 、数据块映射指针)。如果文件大小没变,仅仅修改了文件内容,fdatasync 就可以跳过更新 mtime 等属性,从而减少一次磁盘 I/O 写操作。
  • 适用场景:数据必须持久化,但对文件修改时间等属性不敏感的高频写入场景,比如 Web 服务器的访问日志、数据库的预写日志(WAL)。

💻 实战代码示例

在 C 语言中,你可以这样使用它们:

复制代码
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>

int main() {
    // 打开或创建日志文件
    int fd = open("app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd < 0) {
        perror("open failed");
        return 1;
    }

    const char* log = "2026-05-01: 用户执行了关键操作\n";
    write(fd, log, strlen(log));

    // 【方案一】强一致性:确保数据和修改时间都落盘(最安全,但最慢)
    if (fsync(fd) == -1) {
        perror("fsync failed");
    }

    // 【方案二】高性能:仅确保日志内容落盘,忽略修改时间(推荐日志场景)
    // if (fdatasync(fd) == -1) {
    //     perror("fdatasync failed");
    // }

    close(fd);
    return 0;
}

💡 避坑指南

  1. 标准 I/O 库的坑(fflush vs fsync)
    如果你使用的是标准 C 库的 fprintffwrite 等函数,数据会先缓存在用户态的缓冲区 中。此时直接调用 fsync 是没用的,因为数据还在用户态没交给内核。

正确做法 :必须先调用 fflush(fp) 把数据从用户态缓冲区推送到内核态的页缓存,然后再调用 fsync(fileno(fp)) 把数据从页缓存刷到磁盘。

  1. 硬件写缓存(Write Cache)的坑
    即使 fsync 返回成功,如果硬盘控制器或 SSD 自身开启了硬件写缓存(Write Cache),数据可能只是落到了硬盘自带的易失性缓存里,还没真正写入闪存颗粒。如果此时突然断电,数据依然可能丢失。在极端严苛的场景下,需要通过 hdparm 禁用硬盘缓存,或使用支持断电保护的企业级硬盘。

fcntl函数

如果说 openwrite 是操作文件的基础,那么 fcntl 就是文件描述符(fd)的**"万能遥控器"**,它可以对已经打开的文件描述符进行各种精细化的控制。

🛠️ fcntl 函数基础

fcntl 的函数原型如下:

复制代码
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */);
  • fd:你要操作的目标文件描述符。
  • cmd :控制命令,决定了 fcntl 要执行什么功能。
  • arg :可选参数,是否需要以及具体传什么,完全由 cmd 决定。

fcntl 的功能非常繁杂,但在实际开发中,你最常接触的核心用途主要有以下三类:

1️⃣ 设置文件状态标志(最常用的场景)

这是 fcntl 在 Linux 网络编程和高性能 I/O 中最经典的应用------将文件描述符(或 Socket)设置为非阻塞模式(Non-blocking)

比如,当你希望 readwrite 在没有数据可读写时立刻返回,而不是让程序傻等,就可以这样写:

复制代码
#include <fcntl.h>
#include <unistd.h>

// 将 fd 设置为非阻塞模式
int set_nonblocking(int fd) {
    // 1. 先获取 fd 原有的状态标志
    int old_flags = fcntl(fd, F_GETFL);
    if (old_flags == -1) return -1;

    // 2. 在原有标志的基础上,按位或上 O_NONBLOCK
    int new_flags = old_flags | O_NONBLOCK;

    // 3. 设置新的状态标志
    if (fcntl(fd, F_SETFL, new_flags) == -1) return -1;

    return 0;
}

注:F_GETFL 用于获取文件状态标志,F_SETFL 用于设置。

2️⃣ 复制文件描述符

fcntl 可以替代 dupdup2 来复制文件描述符。这在需要将标准输出重定向到 Socket(例如 CGI 服务器)或者多进程共享资源时非常有用。

  • F_DUPFD:复制一个现有的文件描述符,返回的新 fd 是当前可用的、大于等于指定参数的最小整数。

  • F_DUPFD_CLOEXEC :在复制的同时,自动为新 fd 设置 FD_CLOEXEC 标志(在执行 exec 加载新程序时自动关闭,防止资源泄漏)。

    // 复制 fd,返回的新描述符 >= 0(通常会拿到当前最小的空闲描述符)
    int new_fd = fcntl(old_fd, F_DUPFD, 0);

3️⃣ 文件记录锁(File Locking)

在多进程并发读写同一个文件时,为了防止数据错乱,可以使用 fcntl 给文件的特定区域加锁。它通过一个 struct flock 结构体来精细控制锁的范围、类型(读锁/写锁)和起始位置。

常用的锁命令包括:

  • F_SETLK:尝试设置锁,如果拿不到锁直接返回错误(非阻塞)。
  • F_SETLKW :尝试设置锁,如果拿不到锁则阻塞等待,直到拿到锁为止。
  • F_GETLK:检查文件某段区域是否已经被别人加了锁。

📌 核心命令速查表

为了方便你记忆,这里整理了 fcntl 最常用的几组命令:

命令类别 常用 cmd 功能描述
文件状态标志 F_GETFL / F_SETFL 获取/设置文件状态(如 O_NONBLOCK, O_APPEND
文件描述符标志 F_GETFD / F_SETFD 获取/设置描述符标志(如 FD_CLOEXEC
复制描述符 F_DUPFD / F_DUPFD_CLOEXEC 复制文件描述符,返回新的 fd
文件记录锁 F_SETLK / F_SETLKW / F_GETLK 设置或获取文件的区域锁(并发控制)
异步 I/O F_GETOWN / F_SETOWN 获取/设置接收 SIGIO 信号的进程 ID

💡 避坑指南

在使用 F_SETFLF_SETFD 修改标志时,千万不要直接覆盖设置 。一定要先用 F_GETFLF_GETFD 把原来的标志取出来,通过按位或(|)加上新标志后再设置回去。否则,你不仅设置了新标志,还会意外地抹除系统或程序之前设置的其他重要标志(比如把阻塞模式改成非阻塞的同时,意外关掉了 O_APPEND 追加写模式)。

ioctl 函数

ioctl(Input/Output Control)可以说是 Linux 文件 I/O 体系中的"终极武器"。如果说 fcntl 是文件描述符的"万能遥控器",那么 ioctl 就是设备驱动的"万能工具箱"

它填补了标准文件操作(如 readwriteopenclose)的空白,专门用于处理那些无法归类到标准 I/O 的设备特定控制操作

⚙️ ioctl 函数基础

ioctl 的函数原型如下:

复制代码
#include <sys/ioctl.h>

int ioctl(int fd, unsigned long request, ...);
  • fd:打开的设备文件描述符。
  • request :控制命令(也叫命令码),决定了 ioctl 要执行什么具体功能。
  • ... :可选参数,通常是一个指针或整数,具体含义由 request 决定。

ioctl 的核心价值在于,它允许用户空间的程序向设备驱动发送各种"元操作"指令,比如配置设备参数(如串口波特率)、获取设备状态(如网卡统计信息)、执行特权操作(如格式化磁盘、弹出光驱)等。

🛠️ 常用场景与实战示例

ioctl 的功能极其繁杂,但在日常 Linux 系统编程中,你接触到的主要有两大类场景:

1. 通用文件 I/O 控制(替代 fcntl)

在某些特定的 I/O 操作上,ioctl 提供了与 fcntl 类似甚至更直接的功能:

  • 设置非阻塞 I/O(FIONBIO) :效果等同于 fcntl 设置 O_NONBLOCK
  • 查询可读字节数(FIONREAD):获取当前可以立即读取的字节数,这在网络编程或管道通信中非常有用。
  • 设置 exec 时关闭(FIOCLEX):设置文件描述符的 close-on-exec 标志。

实战示例(查询可读字节数与设置非阻塞):

复制代码
#include <sys/ioctl.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    int fd = open("/dev/ttyUSB0", O_RDWR); // 假设打开一个串口设备
    
    // 1. 使用 ioctl 设置非阻塞模式
    int flag = 1;
    if (ioctl(fd, FIONBIO, &flag) < 0) {
        perror("ioctl FIONBIO failed");
    }

    // 2. 使用 ioctl 查询当前缓冲区有多少字节可读
    int bytes_available = 0;
    if (ioctl(fd, FIONREAD, &bytes_available) >= 0) {
        printf("当前可读取的字节数: %d\n", bytes_available);
    }

    close(fd);
    return 0;
}
2. 特定硬件设备的专属控制

这是 ioctl 最强大的地方。不同的设备驱动会定义自己专属的命令码。例如:

  • 终端/串口控制 :设置波特率、数据位、奇偶校验等(通常使用 termios 结构体配合 ioctl)。
  • 网络设备控制 :获取/设置 IP 地址、MAC 地址、网卡状态等(如 SIOCGIFADDR 获取接口地址)。
  • 块设备控制:获取磁盘几何信息、刷新缓存等。

📌 ioctl 与 fcntl 的核心区别

虽然两者都是对文件描述符进行控制,但定位截然不同:

特性 fcntl ioctl
核心定位 通用的文件描述符属性管理 设备特定的底层控制与状态查询
适用范围 适用于几乎所有类型的文件描述符 主要用于字符设备、块设备、套接字等
命令标准化 命令高度标准化(如 F_GETFL, F_DUPFD) 命令高度依赖具体设备驱动,千差万别
典型场景 设置非阻塞、追加写、文件锁、复制fd 串口配置、网卡参数、磁盘控制、终端设置

💡 避坑指南

  1. ENOTTY 错误 :在使用 ioctl 时,最常见的报错是 ENOTTY(Error Not a TTY)。这通常意味着你传入的 fd 并不是一个字符设备,或者该设备根本不支持你传入的 request 命令。
  2. 命令码冲突 :在编写驱动或高级应用时,ioctl 的命令码(request)不是随便定义的。Linux 内核有一套标准的命令编码规范(包含方向位、幻数、序号、参数大小),以防止不同设备间的命令冲突。
  3. 可移植性差 :由于 ioctl 的命令高度依赖底层驱动和硬件,使用 ioctl 的代码通常很难在不同的 Unix/Linux 系统甚至不同内核版本之间直接移植。

总结一下 :在日常的 Linux 应用开发中,除非你需要直接操作底层硬件(如串口通信、网络配置)或使用 FIONREAD 等特定功能,否则优先使用标准的 read/write 和通用的 fcntl。当这些接口无法满足需求时,ioctl 就是你通往设备底层控制的大门。

相关推荐
A小辣椒1 天前
TShark:Wireshark CLI 功能
linux
A小辣椒1 天前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩3 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言