底层文件I/O(一):open()、close()、read()、write() 系统调用的使用
说到文件操作,很多同学第一反应就是 fopen、fread 这些标准C库函数。嗯,没错,它们确实好用。但今天我想跟你聊聊更底层的东西——系统调用。
说白了,标准C库函数是站在巨人的肩膀上,而系统调用才是那个「巨人」本身。我刚开始做嵌入式开发时,总觉得用库函数就够了,直到有一次在RTOS上调试一个文件系统驱动……嗯,那次经历让我彻底明白了底层接口的重要性。
核心概念:系统调用是操作系统内核提供给用户程序的接口。open()、close()、read()、write() 这四位,就是文件操作中最基础的系统调用。
1. 为什么需要底层文件I/O?
你想想看,标准C库函数带缓冲区,读写效率高,用起来也方便。那为什么还要学底层接口?
我在项目中遇到过这样一个场景:一个数据采集系统需要实时写入传感器数据,每次写入量很小但频率极高。用 fwrite 的话,数据会先积在缓冲区里,如果这时候系统突然掉电……嗯,缓冲区里的数据就全丢了。
底层系统调用没有用户态缓冲区,每次调用直接跟内核打交道。虽然单次调用开销大一点,但胜在实时性和可控性。说白了,就是「我要写,现在就要写进磁盘」,不跟你玩缓存那一套。
2. 四个核心系统调用
咱们一个一个来看。我习惯把这四个函数称为「文件操作的四大金刚」。
2.1 open() —— 打开或创建文件
#include <fcntl.h>
#include <unistd.h>
int fd = open(const char *pathname, int flags, mode_t mode);
pathname 是文件路径,flags 是打开方式,mode 是创建文件时的权限。
flags 参数常用的有:
O_RDONLY—— 只读打开O_WRONLY—— 只写打开O_RDWR—— 读写打开O_CREAT—— 文件不存在则创建O_TRUNC—— 打开时清空文件内容O_APPEND—— 追加模式写入
个人经验:我习惯在 flags 里加上 O_CLOEXEC,防止子进程继承文件描述符。这个坑我踩过——有一次 fork 子进程后,父进程的文件描述符被子进程继承了,导致文件锁混乱,查了两天才找到原因。
2.2 close() —— 关闭文件
#include <unistd.h>
int close(int fd);
关闭文件描述符,释放内核资源。成功返回0,失败返回-1。
注意:忘记 close() 会导致文件描述符泄漏。嵌入式系统资源有限,泄漏多了就再也打不开新文件了。我曾经在一个长期运行的服务器程序里,就是因为少写了一个 close(),跑了三天后程序突然报「Too many open files」……嗯,那次教训很深刻。
2.3 read() —— 从文件读取数据
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
从文件描述符 fd 中读取最多 count 字节到 buf 中。返回值是实际读取的字节数,返回0表示读到文件末尾,返回-1表示出错。
这里有个细节:read() 不一定能一次读满你要求的 count 字节。比如你要求读1024字节,但文件只剩500字节了,那 read() 就只返回500。所以,写代码时一定要检查返回值。
2.4 write() —— 向文件写入数据
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
向文件描述符 fd 写入 buf 中的 count 字节。返回值是实际写入的字节数。同样,write() 也可能没有写完你要求的全部数据。
避坑指南:我曾经在写日志系统时,直接用 write() 写入一个结构体。结果结构体里有指针,写入文件的是指针地址,而不是指针指向的数据。重启程序后,读出来的指针地址早就失效了。记住:write() 写入的是内存中的二进制数据,不会帮你做序列化。
3. 完整示例:拷贝文件
光说不练假把式。咱们写一个用底层接口拷贝文件的例子:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#define BUFFER_SIZE 4096
int main(int argc, char *argv[]) {
if (argc != 3) {
write(STDERR_FILENO, "Usage: ./copy src dst\n", 22);
exit(EXIT_FAILURE);
}
int src_fd = open(argv[1], O_RDONLY);
if (src_fd == -1) {
perror("open source file");
exit(EXIT_FAILURE);
}
int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (dst_fd == -1) {
perror("open destination file");
close(src_fd);
exit(EXIT_FAILURE);
}
char buf[BUFFER_SIZE];
ssize_t bytes_read, bytes_written;
while ((bytes_read = read(src_fd, buf, BUFFER_SIZE)) > 0) {
char *p = buf;
ssize_t remaining = bytes_read;
while (remaining > 0) {
bytes_written = write(dst_fd, p, remaining);
if (bytes_written == -1) {
perror("write");
close(src_fd);
close(dst_fd);
exit(EXIT_FAILURE);
}
p += bytes_written;
remaining -= bytes_written;
}
}
if (bytes_read == -1) {
perror("read");
}
close(src_fd);
close(dst_fd);
return 0;
}
这个例子有几个要点:
- 用
O_CREAT | O_TRUNC确保目标文件被创建或清空 - read() 和 write() 的返回值一定要检查
- write() 可能没写完,所以用了一个内层循环确保全部写入
- 出错时记得关闭已经打开的文件描述符
4. 知识体系总览
下面这张图帮你理清这四个系统调用的关系:
5. 文件描述符的本质
open() 返回的 int 叫文件描述符(file descriptor)。它本质上是一个数组的索引,这个数组在内核里维护,记录了当前进程打开的所有文件。
标准输入、标准输出、标准错误分别对应文件描述符 0、1、2。所以你在例子里看到我用 STDERR_FILENO(就是2)来输出错误信息。
| 文件描述符 | 名称 | 符号常量 |
|---|---|---|
| 0 | 标准输入 | STDIN_FILENO |
| 1 | 标准输出 | STDOUT_FILENO |
| 2 | 标准错误 | STDERR_FILENO |
新打开的文件,描述符从3开始分配。close() 之后,这个描述符可以被重用。
6. 常见错误与处理
系统调用出错时返回 -1,并设置全局变量 errno。用 perror() 可以打印出可读的错误信息。
常见的 errno 值:
EACCES—— 权限不足EINTR—— 调用被信号中断EAGAIN—— 非阻塞模式下暂时无数据可读ENOSPC—— 磁盘空间不足
我的习惯:每次调用系统调用后,我都会检查返回值。如果返回 -1,立刻用 perror() 打印错误信息。这在调试阶段能省下大量时间。别问我怎么知道的——我曾经花了一整个下午,才发现是磁盘写满了,而我的代码里根本没有检查 write() 的返回值。
7. 什么时候用底层接口?
说了这么多,到底什么时候该用底层接口?我总结了几条:
- 需要实时写入,不能容忍数据在缓冲区里积压
- 操作特殊文件(如设备文件、管道、socket)
- 需要精细控制文件描述符(如 dup2、fcntl)
- 在信号处理函数中(标准库函数大多不可重入)
- 内存极度受限的嵌入式环境
反过来,如果只是读写普通文件,对实时性没要求,用标准C库函数更省心。毕竟人家帮你做了缓冲、做了格式化,用起来确实方便。
嗯,底层文件I/O就聊到这里。这四个系统调用是基础中的基础,理解了它们,后面的文件锁、多路复用、异步I/O学起来就轻松多了。