Linux文件IO
UNIX系统中的大多数文件I/O只需用到五个函数:open、read、write、lseek 以及 close。这些函数通常被称为不带缓冲的I/O,它们直接通过系统调用与内核交互,是文件操作最底层的接口。
文件描述符
文件描述符是一个非负整数,它代表进程与已打开文件之间的连接。每个进程启动时,系统会自动分配三个标准描述符:
- 0 ------ 标准输入
- 1 ------ 标准输出
- 2 ------ 标准错误
当进程调用 open 或 creat 打开新文件时,内核会返回当前未被使用的最小描述符编号。每个进程能够打开的文件描述符数量有限,可通过 ulimit -n 查看,并可以通过 setrlimit 调整上限。
在内核中,文件描述符是进程文件描述符表(files_struct)的索引,该表项指向内核中的文件对象(struct file)。文件对象中保存了当前文件偏移量、文件状态标志(如只读/只写)、指向inode的指针以及一组文件操作函数。多个描述符可以指向同一个文件对象(例如通过 dup 复制),此时它们共享相同的文件偏移量。

open 与 openat
open 函数
c
#include <fcntl.h>
int open(const char *pathname, int flags, mode_t mode);
flags 参数必须包含以下三种之一:O_RDONLY、O_WRONLY 或 O_RDWR。其他常用标志包括:
O_CREAT------ 文件不存在则创建O_EXCL------ 与O_CREAT配合,若文件已存在则报错O_TRUNC------ 打开时清空文件内容O_APPEND------ 每次写入追加到文件末尾O_NONBLOCK------ 非阻塞模式
mode 参数仅在创建文件时生效,用于指定文件权限(如 0644),实际权限会受到进程 umask 的影响(最终权限 = mode & ~umask)。
openat 函数
c
int openat(int dirfd, const char *pathname, int flags, mode_t mode);
openat 与 open 的区别在于多了一个 dirfd 参数。当 pathname 为相对路径时,系统会根据 dirfd 指向的目录来解析路径;若 pathname 为绝对路径,则 dirfd 被忽略。如果希望使用当前工作目录,可以将 dirfd 设为 AT_FDCWD。
openat 主要带来两个好处:
- 线程安全 :在多线程程序中,若一个线程调用
chdir改变工作目录,其他线程的open可能受到影响。openat通过目录描述符固定父目录,避免这种竞态。 - 避免 TOCTTOU 攻击 :在检查文件属性后再打开文件时,路径可能被恶意篡改(如符号链接攻击)。
openat基于已打开的目录句柄操作,降低了此类风险。
creat
c
#include <fcntl.h>
int creat(const char *pathname, mode_t mode);
creat 等价于 open(pathname, O_WRONLY | O_CREAT | O_TRUNC, mode)。它只能以只写方式创建文件,并且如果文件已存在,会将其内容截断为空。
由于 open 可以组合更多标志(如同时指定读权限、使用 O_EXCL 等),creat 在现代编程中已较少使用,但出于历史兼容性,POSIX 仍然保留该函数。
read
c
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
read 从当前文件偏移量开始读取数据,读取后偏移量向后移动实际读到的字节数。返回值表示实际读取的字节数:
- 返回
0表示到达文件末尾(EOF) - 返回正数表示读取的字节数,可能小于
count(例如剩余数据不足) - 返回
-1表示出错,可通过errno获取具体错误
注意事项:
- 如果调用被信号中断,
read可能返回部分已读数据,此时errno为EINTR,需要自行处理重试。 - 不能假设一次
read能读满count个字节,应当循环读取直到读完所需数据或遇到 EOF/错误。
性能建议 :单次 read 的 count 值不宜过小(否则系统调用频繁),也不宜过大(浪费内存)。通常选择 4KB 或 8KB,与文件系统块大小对齐效果更佳。
write
c
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
write 从当前文件偏移量开始写入数据,写入后偏移量相应增加。返回值表示实际写入的字节数,正常情况下等于 count,但可能因为磁盘空间不足、信号中断等原因导致部分写入。
重要特性:
- 对于普通文件,若写入的字节数小于
PIPE_BUF,write是原子操作,不会与其他进程或线程的写入交错。 - 数据通常先被拷贝到内核的页缓存(page cache)中,随后由内核异步刷入磁盘,因此
write返回并不代表数据已安全落盘。
处理部分写入:虽然普通文件很少出现部分写入,但在套接字或管道上可能发生。可靠的代码应使用循环写入:
c
size_t total = 0;
while (total < count) {
ssize_t ret = write(fd, buf + total, count - total);
if (ret == -1) {
if (errno == EINTR) continue;
else break; // 真正的错误
}
total += ret;
}
lseek
c
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
lseek 用于显式移动文件偏移量,它本身不产生任何 I/O 操作。whence 参数指定偏移的基准位置:
SEEK_SET------ 从文件开头偏移offset字节SEEK_CUR------ 从当前偏移量增加offset(可正可负)SEEK_END------ 从文件末尾偏移offset(通常为正数表示向后扩展)
常见用法:
- 获取当前偏移量:
off_t cur = lseek(fd, 0, SEEK_CUR); - 获取文件大小:
off_t size = lseek(fd, 0, SEEK_END);(更推荐使用fstat) - 创建空洞文件:将偏移量移到超出文件末尾的位置,然后写入少量数据,文件会变大但实际占用的磁盘块可能很少(稀疏文件)。
需要注意的是,管道、套接字等特殊文件不支持随机访问,调用 lseek 会返回 -1 并设置 errno 为 ESPIPE。
缓存机制
页缓存
Linux 内核通过页缓存(Page Cache)来提升文件 I/O 性能。当进程调用 read 时,内核会先检查所需数据是否已在缓存中,若在则直接拷贝到用户空间,否则从磁盘读取并缓存。write 操作先将数据写入页缓存,并标记该页为"脏页",之后由内核线程(如 flush)异步将脏页写回磁盘。

这种机制大幅减少了磁盘访问次数,但也带来了数据丢失的风险------如果系统崩溃,尚未刷盘的缓存数据会永久丢失。
同步缓存的方法
fsync(int fd)------ 同步文件数据和元数据(如修改时间、文件大小)到磁盘,阻塞直到完成。fdatasync(int fd)------ 仅同步数据,不同步元数据(除非元数据影响后续数据的读取),性能略优于fsync。sync(void)------ 将所有脏缓存同步到磁盘,属于全局操作,不针对特定文件。
打开标志对缓存的影响
O_SYNC------ 每次write都会阻塞直到数据真正写入磁盘(相当于自动调用fsync),性能较差,但保证了持久性。O_DSYNC------ 类似O_SYNC,但只同步数据(类似fdatasync)。O_DIRECT------ 绕过页缓存,直接进行磁盘 I/O(要求数据对齐),常用于数据库等自行管理缓存的应用,但使用门槛较高。
用户态缓冲 vs 内核缓冲
需要区分两类缓冲:
- 不带缓冲的 I/O(如本文所述)------ 系统调用直接操作内核页缓存,用户态没有额外缓冲。
- 标准 I/O 库 (如
fopen、fread)------ 在用户态维护缓冲区,攒够一定数据后再调用write,从而减少系统调用次数,提高效率。
因此,write 并不意味着"立即写磁盘",而是"写入内核缓存";而 fwrite 则是"写入用户缓存" → 再通过 write 送入内核缓存 → 最后异步刷盘。