文件I/O基础:从文件描述符说起
大家好,我是你们这门课的老朋友。今天咱们来聊聊文件I/O——说白了,就是怎么让程序和文件打交道。你可能觉得这有什么好讲的?不就是打开文件、读写数据嘛。嗯,我当年也是这么想的,直到有一次线上服务莫名其妙地报"too many open files",排查了一整天才发现是文件描述符泄露了。从那以后,我对文件I/O的每一个细节都不敢马虎。
这一章,咱们就扎扎实实地把文件描述符、open/close/read/write这些系统调用,还有文件权限和umask这些东西讲透。你想想看,这些是Linux系统编程的基石,搞不懂它们,后面讲进程通信、网络编程都是空中楼阁。
核心要点:文件I/O是操作系统为用户态程序提供的"门",文件描述符就是那把钥匙。理解这把钥匙怎么用、怎么管,是系统编程的第一步。
1. 文件描述符:一个整数背后的故事
文件描述符(File Descriptor,简称FD)是什么?说白了,就是一个非负整数。内核用它来标识一个进程打开的文件。每次你调用open()打开一个文件,内核就给你返回一个整数——这就是文件描述符。
我习惯把它想象成"饭票"。你去食堂打饭,食堂阿姨不会记住你长什么样,她只看你手里的饭票。饭票上的编号就是文件描述符,食堂后厨就是内核,饭菜就是文件数据。你拿着饭票去取餐(read),或者加菜(write),阿姨只认票不认人。
这里有个关键点:文件描述符是进程级别的。每个进程都有自己的文件描述符表,进程A的FD 3和进程B的FD 3,指向的可能是完全不同的文件。这一点在讲进程通信时特别重要,我后面会反复提到。
我的小习惯:写代码时,我总会在打开文件后立即检查FD的值。如果返回-1,说明出错了。很多新手会忽略这个检查,结果后面读写操作莫名其妙地失败,排查起来特别痛苦。
2. 标准文件描述符:系统给你的三张"饭票"
每个进程启动时,系统已经帮你准备好了三个文件描述符:
| 文件描述符 | 名称 | 默认关联 | 用途 |
|---|---|---|---|
| 0 | stdin | 键盘输入 | 标准输入 |
| 1 | stdout | 终端屏幕 | 标准输出 |
| 2 | stderr | 终端屏幕 | 标准错误输出 |
你想想看,为什么printf()能直接往屏幕上打印?因为它内部就是往FD 1写数据。为什么perror()能打印错误信息?因为它用的是FD 2。这三个标准描述符,是每个程序都有的"默认饭票"。
我在项目中遇到过一个问题:一个后台服务把日志写到了stdout,结果用nohup启动后,日志全丢了。为什么?因为nohup把stdout重定向到了/dev/null。这就是标准文件描述符被重定向的典型场景。
3. open()系统调用:打开文件的艺术
open()是文件I/O的起点。它的原型长这样:
#include <fcntl.h>
#include <unistd.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
flags参数是核心,它告诉内核你想怎么操作这个文件。常用的有:
- O_RDONLY:只读打开
- O_WRONLY:只写打开
- O_RDWR:读写打开
- O_CREAT:如果文件不存在,就创建它
- O_TRUNC:如果文件存在,把长度截断为0
- O_APPEND:每次写入都追加到文件末尾
这些标志可以用按位或(|)组合。比如:
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
这行代码的意思是:以只写方式打开test.txt,如果不存在就创建,如果存在就清空。第三个参数0644是文件权限,只有创建文件时才需要。
我曾经踩过的坑:有一次我忘了加O_CREAT,结果文件不存在时open()返回-1,程序直接崩溃了。还有一次我用了O_WRONLY却想读数据,read()返回-1,errno是EBADF。所以,flags一定要想清楚再写。
4. close()系统调用:用完记得关门
close()很简单,就是把文件描述符还给内核:
int close(int fd);
成功返回0,失败返回-1。但就这么简单的函数,我见过太多人栽跟头了。
进程能打开的文件描述符数量是有限的。用ulimit -n可以查看,默认通常是1024。如果你打开文件后不关闭,FD就会泄露。等FD用完了,open()就会返回-1,errno是EMFILE。
我建议你养成一个习惯:打开文件后,立即在脑海中规划好什么时候关闭。最好用RAII的思想,在函数出口统一关闭。如果函数里有多个return路径,每个路径都要记得close()。
5. read()和write():真正的数据传输
read()和write()是文件I/O的核心操作:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
read()从fd指向的文件中读取最多count字节,存入buf。返回值是实际读取的字节数。如果返回0,说明到了文件末尾。如果返回-1,说明出错了。
write()向fd指向的文件写入buf中的count字节。返回值是实际写入的字节数。如果返回值小于count,说明没有写完——这种情况在网络编程中特别常见。
这里有个重要的点:read()和write()不保证一次就能读完或写完。尤其是read(),它可能只返回一部分数据。所以正确的做法是用循环:
ssize_t n;
char buf[1024];
while ((n = read(fd, buf, sizeof(buf))) > 0) {
// 处理buf中的n个字节
}
if (n == -1) {
// 处理错误
}
我的经验:很多新手以为read()一次就能把文件读完,结果只读到一部分数据。记住,read()返回的是"这次实际读到的字节数",不是"你想要的字节数"。写代码时一定要处理返回值。
6. 文件权限与umask:谁可以碰你的文件
Linux下每个文件都有三组权限:所有者(owner)、所属组(group)、其他人(others)。每组权限又分为读(r=4)、写(w=2)、执行(x=1)。
当你用open()的O_CREAT标志创建文件时,第三个参数mode_t mode就是你想设置的权限。比如0644表示:所有者可读写(6),组和其他人只读(4+4)。
但这里有个陷阱:实际创建的文件权限,是 mode & (~umask) 的结果。umask是进程级别的掩码,用来"扣掉"你不想要的权限。
举个例子,如果你的umask是0022,那么:
mode = 0644 // 你想要的权限
umask = 0022 // 系统掩码
实际权限 = 0644 & (~0022) = 0644 & 0755 = 0644
umask的默认值通常是0022,意思是"去掉组和其他人的写权限"。你可以用umask命令查看当前值,也可以在程序里用umask()函数修改。
我曾经犯过的错:有一次我写了一个日志程序,open()时传了0666权限,心想"这样谁都能写"。结果因为umask是0027,实际权限变成了0640,其他用户根本写不了。排查了半天才发现是umask搞的鬼。所以,如果你需要精确控制权限,记得在open()之前设置umask。
7. 知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以看到,文件描述符是中心,open/close/read/write是四个基本操作,标准文件描述符和权限控制是辅助机制。
从这张图你能看到,文件描述符是连接用户程序和内核文件的桥梁。open()创建这座桥,close()拆除它,read()和write()在桥上运输数据。标准文件描述符是系统预装的三座桥,而文件权限和umask决定了谁能走这座桥。
8. 实战:一个完整的文件读写示例
光说不练假把式。咱们写一个完整的程序,把今天讲的东西串起来:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
int main() {
const char *filename = "hello.txt";
const char *message = "Hello, File I/O!\n";
char buf[128];
ssize_t n;
// 1. 打开文件(如果不存在则创建,存在则清空)
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
fprintf(stderr, "open() failed: %s\n", strerror(errno));
exit(EXIT_FAILURE);
}
// 2. 写入数据
n = write(fd, message, strlen(message));
if (n == -1) {
fprintf(stderr, "write() failed: %s\n", strerror(errno));
close(fd);
exit(EXIT_FAILURE);
}
printf("成功写入 %zd 字节\n", n);
// 3. 关闭文件
close(fd);
// 4. 以只读方式重新打开
fd = open(filename, O_RDONLY);
if (fd == -1) {
fprintf(stderr, "open() for reading failed: %s\n", strerror(errno));
exit(EXIT_FAILURE);
}
// 5. 读取数据
memset(buf, 0, sizeof(buf));
n = read(fd, buf, sizeof(buf) - 1);
if (n == -1) {
fprintf(stderr, "read() failed: %s\n", strerror(errno));
close(fd);
exit(EXIT_FAILURE);
}
printf("读取到 %zd 字节: %s", n, buf);
// 6. 关闭文件
close(fd);
return 0;
}
这个例子虽然简单,但包含了文件I/O的完整流程:打开、写入、关闭、再打开、读取、再关闭。每一步我都检查了返回值,这是系统编程的好习惯。
我的建议:你可以把这个程序编译运行一下,看看输出。然后试试修改flags,比如去掉O_TRUNC,看看多次运行会有什么不同。实践出真知,光看代码是不够的。
9. 常见错误与避坑指南
最后,我总结几个我当年踩过的坑,希望能帮你少走弯路:
- 忘记检查返回值:open()、read()、write()、close()都可能失败。不检查返回值,程序出错了你都不知道。
- 文件描述符泄露:打开文件后忘记close(),或者函数提前返回时没close()。用久了FD会耗尽。
- 忽略read()的短读:read()可能只返回部分数据,需要用循环读完。
- umask的干扰:创建文件时以为设置了0644,结果被umask"扣掉"了权限。
- 混淆O_RDONLY和O_WRONLY:用只读方式打开却想写,或者反过来,都会导致操作失败。
嗯,这一章的内容就到这儿。文件I/O是系统编程的基石,看似简单,但细节很多。我希望你能把每个系统调用的行为都搞清楚,最好能自己写几个小实验验证一下。下一章咱们会深入讲文件描述符的更多细节,包括dup、fcntl这些高级操作。不过那是后话了,先把今天的内容消化好吧。
公众号:蓝海资料掘金营,微信deep3321