Linux 文件 I/O:文件描述符与系统调用
本节学习 Linux 系统级文件 I/O,比较标准 C 库 I/O 与系统调用的关系,并理解文件描述符在进程管理文件时的作用。
重点包括 open、close、read、write 和 lseek 的参数与返回值,以及错误处理、命令行参数、创建权限、umask 和文件描述符数量限制。
一、文件操作的基本流程
通过代码操作文件时,主要涉及以下操作:
打开文件 → 读文件 / 写文件 → 移动文件读写位置 → 关闭文件标准 C 库中对应的函数是:
fopen、fread、fwrite、fseek、fclose本节学习 Linux 系统级函数:
open、read、write、lseek、close文件打开后,默认读写位置通常在文件头。读写操作从当前文件位置开始,操作完成后位置会向后移动。
如果要从指定位置操作,需要先使用 lseek 移动文件位置。
自己打开的文件,使用完成后应由自己关闭,及时回收系统资源。
二、标准 I/O 与系统 I/O
1. 两者的调用关系
标准 I/O 库函数是对系统调用的进一步封装。
例如,应用程序调用 fwrite 时,底层最终仍需通过系统提供的写入能力完成操作。
直接使用系统调用减少了一层标准库封装,并且可以更直接地控制文件描述符、打开标志和读写行为。
2. Linux 中一切皆文件
Linux 把普通磁盘文件、终端设备和网络套接字等对象统一抽象为可通过文件描述符访问的资源,因此很多上层功能在系统层都可以归结为读取和写入:
fwrite:最终把数据写入磁盘文件;printf:最终把数据写入标准输出对应的终端;- 网络发送:可以通过套接字相关接口或
write向套接字写入数据。
上层函数名称和用途不同,但系统层处理的核心都是对不同类型的对象进行读写。
3. 标准 I/O 缓冲区
标准 I/O 使用 FILE * 操作文件。
FILE 相关结构会维护文件描述符、文件位置和用户空间缓冲区等信息。缓冲区的具体大小由标准库实现和运行场景决定,常见实现中可能观察到 8192 字节,但不能把它视为所有系统上的固定值。
设置缓冲区的目的,是减少频繁进入内核和访问外部存储设备的次数:
- 先把多次产生的数据保存在内存缓冲区中;
- 缓冲区写满时自动刷新;
- 数据较少时也可以主动刷新。
内存读写通常快于磁盘读写,因此合并多次小规模 I/O 能提高效率。
系统调用本身不经过标准 I/O 的用户空间缓冲区,但内核内部仍可能存在页缓存和设备缓冲机制。
三、Linux 进程空间和文件管理
以典型的 32 位 Linux 地址空间配置为例,每个进程拥有 4 GB 虚拟地址空间,其中可能划分为:
0 GB ───────────── 3 GB ───────────── 4 GB 用户空间 内核空间- 每个进程的用户空间彼此独立;
- 内核空间由各进程共享映射;
- 操作系统通过进程控制块等内核数据结构管理进程。
具体划分可能因体系结构和内核配置而不同,图中展示的是一种典型模型。
四、文件描述符
1. 文件描述符是什么
文件描述符是一个 int 类型的编号,用来标识进程已经打开的文件或其他 I/O 对象。
调用 open 成功后会获得文件描述符,后续的 read、write、lseek 和 close 都通过这个编号指定要操作的对象。
2. 分配原则
文件描述符按照“当前可用的最小非负整数”分配。
例如,一个进程默认已经占用了 0、1、2:
- 第一次打开普通文件,得到 3;
- 再打开一个文件,得到 4;
- 关闭文件描述符 3 后再打开新文件,新文件会重新得到 3。
close 不仅关闭当前引用,也会回收对应的文件描述符编号。
3. 默认打开的三个文件描述符
每个进程启动时通常默认打开三个文件描述符:
| 文件描述符 | 宏 | 含义 |
|---|---|---|
| 0 | STDIN_FILENO | 标准输入 |
| 1 | STDOUT_FILENO | 标准输出 |
| 2 | STDERR_FILENO | 标准错误 |
例如,从终端输入的数据进入标准输入;printf 的输出最终写入标准输出;错误信息可以写入标准错误。
正常情况下,一个文件描述符引用一个已打开的对象。通过复制文件描述符、重定向等操作,也可以让不同文件描述符引用同一个打开文件。
五、open:打开或创建文件
1. 函数形式
int open(const char *pathname, int flags);int open(const char *pathname, int flags, mode_t mode);pathname:文件路径和文件名。不写目录时,默认相对于当前工作目录查找;flags:打开方式及附加选项;mode:创建新文件时设置期望权限,使用O_CREAT时需要提供第三个参数。
返回值:
- 成功:返回新分配的文件描述符;
- 失败:返回
-1,并设置errno。
2. 必选访问方式
下面三项必须选择一项:
| 标志 | 含义 |
|---|---|
O_RDONLY | 只读打开 |
O_WRONLY | 只写打开 |
O_RDWR | 可读可写打开 |
3. 附加标志
附加标志可以不写,也可以使用按位或 | 组合多个:
| 标志 | 含义 |
|---|---|
O_APPEND | 每次从文件尾追加写入 |
O_TRUNC | 文件存在且以可写方式打开时,把原文件长度截断为 0 |
O_CREAT | 文件不存在时创建,需要提供 mode |
O_EXCL | 与 O_CREAT 同时使用,若文件已经存在则报错 |
O_NONBLOCK | 非阻塞方式 |
4. 不同写入标志的结果
假设文件原内容是 ABC:
- 普通写入,从文件头写
XX:结果可能变为XXC,新内容覆盖对应位置,未覆盖部分保留; - 使用
O_APPEND写入XX:结果为ABCXX; - 使用
O_TRUNC后写入XX:原内容先清空,结果为XX。
因此写文件前需要先考虑:
- 文件是否存在;
- 不存在时是否需要创建;
- 文件原内容是否需要保留;
- 新内容要覆盖、清空后重写,还是追加到末尾。
六、close:关闭文件
int close(int fd);fd:要关闭文件的文件描述符;- 成功返回
0; - 失败返回
-1,并设置errno。
如果在函数中途返回,必须先关闭之前已经成功打开的文件,防止资源泄漏。
七、perror:输出错误原因
系统调用失败后会设置 errno:
perror("open aa fail");输出由两部分组成:
open aa fail: 系统给出的错误原因应在调用失败并确认 errno 对应本次错误后及时使用 perror,避免后续函数调用改变错误状态。
普通的参数合法性检查并没有发生系统调用失败,不应直接依赖 perror,可使用 printf 或 fprintf 输出提示。
八、main 函数的命令行参数
完整的 main 函数可以接收命令行参数:
int main(int argc, char *argv[])argc:参数总数;argv:字符串数组,每个元素保存一个参数。
例如执行:
./a.out aa bb cc此时:
argc = 4argv[0] = "./a.out"argv[1] = "aa"argv[2] = "bb"argv[3] = "cc"argv[0] 是调用程序时使用的程序名或路径,真正由用户提供的其他参数从 argv[1] 开始。
九、创建权限与 umask
使用 O_CREAT 创建文件时,第三个参数是期望的权限:
open(argv[i], O_WRONLY | O_CREAT, 0642);但文件的最终权限还会受到权限掩码 umask 影响:
最终权限 = mode & ~umask例如,系统原来的 umask 为 0002,指定 0642 时,对应的权限位会被掩掉。
为了在实验中让创建结果严格使用代码中的权限,可以临时在进程中设置:
umask(0000);umask 会影响该进程之后创建的文件和目录。真实项目中应根据安全要求选择合适的权限,不应无条件长期设置为 0000。
十、系统和进程可打开文件数
1. 整个系统的上限
cat /proc/sys/fs/file-max2. 单个进程的上限
ulimit -n它用于查看当前 Shell 启动的进程可打开文件描述符数量上限。
如果输出为 1024,由于 0、1、2 通常已经被占用,理论上还可以继续分配约 1021 个;实际可用数量还会受到其他已打开描述符和系统限制影响。
也可以在系统允许的范围内修改当前环境的限制:
ulimit -n 4096文件描述符属于有限资源。提高单个进程的上限时,也要考虑系统总上限和其他进程的资源需求。
十一、read:读取文件
ssize_t read(int fd, void *buf, size_t count);参数:
fd:从哪个文件描述符读取;buf:保存读取结果的缓冲区首地址;count:本次最多读取的字节数。
返回值:
| 返回值 | 含义 |
|---|---|
| 大于 0 | 本次实际读取到的字节数 |
| 0 | 对普通文件通常表示已经到达文件尾,本次没有读到数据 |
| -1 | 读取失败,并设置 errno |
count 只是请求读取的最大长度,实际返回值可能小于 count。
例如,文件只剩 30 字节,但请求读取 100 字节,本次返回 30;下一次到达文件尾后返回 0。
文件可能很大,不能假设一次就能全部读完,因此通常应循环调用 read,直到返回 0 或发生错误。
十二、write:写入文件
ssize_t write(int fd, const void *buf, size_t count);参数:
fd:写入哪个文件描述符;buf:待写数据的首地址;count:希望写入的字节数。
返回值:
- 成功:返回本次实际写入的字节数;
- 失败:返回
-1,并设置errno。
实际写入量可能小于 count,因此需要可靠写完全部数据时,应根据返回值调整缓冲区位置并继续写入。
读写文件的函数形式与网络中的接收、发送函数相似,只是第一个参数在这里是普通文件描述符。
echo:把内容写入标准输出,或通过重定向写入文件;cat:从指定文件读取,再写入标准输出;cp:从源文件读取,再写入目标文件;mv:主要改变文件所在目录中的名称或目录项关系,通常不是通过循环复制文件内容完成。
十三、lseek:移动文件位置
off_t lseek(int fd, off_t offset, int whence);参数:
fd:要操作文件的文件描述符;offset:偏移量,可与基准位置配合使用;whence:偏移基准位置。
whence | 含义 |
|---|---|
SEEK_SET | 从文件头开始计算 |
SEEK_CUR | 从当前文件位置开始计算 |
SEEK_END | 从文件尾开始计算 |
成功时,返回移动完成后当前文件位置距离文件头的字节数;失败返回 -1。
对于支持定位的普通文件,把位置移动到文件尾并读取返回值,可以得到文件大小:
off_t size = lseek(fd, 0, SEEK_END);这一步同时会改变当前文件位置。如果后续还要从文件头读写,应再使用 lseek 把位置移回需要的位置。
如果这篇文章对你有帮助,欢迎分享给更多人!





