C++-linux 7.文件IO(二)文件描述符、阻塞与非阻塞
文件 IO 进阶:文件描述符、阻塞与非阻塞
在前文我们介绍了文件 IO 的核心系统调用,本章将深入探讨 Linux 文件 IO 的底层机制,包括文件描述符的本质、阻塞与非阻塞 IO 模型、文件偏移量控制(lseek
)以及系统调用中的参数传递规则,帮助你构建更完整的系统编程知识体系。
一、文件描述符:进程与文件的桥梁
在Linux系统中,当我们打开或创建一个文件(或套接字)时,操作系统会提供一个文件描述符(File Descriptor,FD),这是一个非负整数,我们可以通过它来进行读写等操作。
文件描述符(File Descriptor)是 Unix/Linux 系统中标识打开文件或 IO 设备的核心机制,几乎所有 IO 系统调用都依赖它。理解文件描述符的本质和内核管理方式,是掌握 Linux 系统编程的关键。
1.1 基本概念与本质
文件描述符是一个非负整数(通常是小整数),本质是进程级文件描述符表的索引。它的作用是:
- 作为进程与打开文件/设备的唯一标识;
- 简化系统调用参数(用整数代替复杂的文件信息结构体);
- 隔离不同进程的文件访问(每个进程的文件描述符表独立)。
从内核视角看,当进程调用 open
打开文件时,内核会:
- 在磁盘中查找文件并验证权限;
- 在系统级打开文件表中创建一个表项(记录文件偏移量、状态等);
- 在进程的文件描述符表中分配一个空闲索引(即文件描述符),指向系统级表项;
- 返回该索引给进程,后续通过该索引进行读写操作。
1.2 内核中的文件管理结构(三层表模型)
文件描述符的管理依赖内核中的三层数据结构,确保进程安全且高效地访问文件:
1. 进程级文件描述符表
- 归属:每个进程独立拥有,存储在进程控制块(PCB,本质是
struct task_struct
结构体)中。 - 内容:以数组形式存储,索引即文件描述符,表项包含:
- 指向系统级打开文件表项的指针;
- 文件描述符的状态标志(如
FD_CLOEXEC
,进程执行exec
时自动关闭)。
2. 系统级打开文件表
- 归属:内核全局维护,所有进程共享。
- 内容:记录每个打开文件的动态状态,包括:
- 文件偏移量(下一次读写的位置);
- 打开模式(只读/只写/读写等);
- 引用计数(多少个文件描述符指向该表项);
- 指向 i-node 表的指针。
3. 文件系统 i-node 表
- 归属:内核全局维护,与文件系统关联。
- 内容:记录文件的静态元数据,包括:
- 文件类型(普通文件/目录/设备等);
- 权限、所有者、大小、创建/修改时间;
- 磁盘块位置(文件数据在磁盘上的存储地址)。
三者关系:进程通过文件描述符(索引)找到进程级表项,进而指向系统级打开文件表,最终通过 i-node 表访问实际文件数据。这种分层设计确保了“同一文件被多个进程打开时,各自维护独立偏移量但共享文件数据”的特性。
1.3 预定义文件描述符
每个进程启动时,内核会自动打开 3 个标准文件描述符,无需显式 open
:
文件描述符 | 宏定义(unistd.h) | 对应设备 | C 标准库流(stdio.h) | 用途 |
---|---|---|---|---|
0 | STDIN_FILENO | 标准输入 | stdin | 接收用户输入(如键盘) |
1 | STDOUT_FILENO | 标准输出 | stdout | 输出正常信息(如屏幕) |
2 | STDERR_FILENO | 标准错误输出 | stderr | 输出错误信息(如屏幕) |
示例:Shell 中的重定向(ls > file.txt
)本质是修改进程的文件描述符表,将 fd=1
(标准输出)指向文件 file.txt
的系统级表项,而非默认的终端设备。
1.4 分配与释放机制
- 分配规则:调用
open
、socket
等函数时,内核从进程文件描述符表中分配最小未使用的非负整数作为新描述符。例如:若 0、1、2 已占用,新分配的描述符通常是 3。 - 释放方式:
- 显式释放:调用
close(fd)
关闭描述符,内核会递减系统级表项的引用计数,若计数为 0 则释放该表项。 - 隐式释放:进程退出时,内核自动关闭所有未释放的文件描述符(但仍建议显式关闭以避免资源泄漏)。
- 显式释放:调用
- 数量限制:
- 系统级限制:
/proc/sys/fs/file-max
限制全局打开文件总数。 - 进程级限制:
ulimit -n
查看/修改单个进程的最大文件描述符数(默认通常为 1024 或 65535)。
- 系统级限制:
1.5 编程示例:文件描述符操作与重定向
下面通过示例演示文件描述符的创建、使用及重定向:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main() {// 1. 创建并打开文件,获取文件描述符int fd = open("example.txt", O_RDWR | O_CREAT, 0644);if (fd < 0) {perror("open failed");return 1;}printf("打开文件的描述符:%d\n", fd); // 通常为 3(0/1/2 已被标准流占用)// 2. 向文件写入数据const char *content = "Hello, File Descriptor!";ssize_t bytes_written = write(fd, content, strlen(content));if (bytes_written < 0) {perror("write failed");close(fd);return 1;}// 3. 标准输出重定向到文件(模拟 Shell 的 > 操作)// 保存原标准输出(fd=1)的副本,避免重定向后丢失int stdout_backup = dup(1); // dup 复制描述符,返回最小可用整数if (stdout_backup < 0) {perror("dup failed");close(fd);return 1;}// 将 fd 重定向到标准输出(fd=1 现在指向 example.txt)if (dup2(fd, 1) < 0) { // dup2(oldfd, newfd):让 newfd 指向 oldfd 的文件perror("dup2 failed");close(fd);close(stdout_backup);return 1;}// 此时 printf 输出会写入文件而非屏幕printf("This text is redirected to file!\n"); // 写入 example.txtfflush(stdout); // 刷新缓冲区确保数据写入// 4. 恢复标准输出dup2(stdout_backup, 1); // 恢复 fd=1 指向原终端close(stdout_backup); // 释放备份描述符close(fd); // 关闭文件描述符// 验证恢复:输出到屏幕printf("This text is printed to terminal!\n");return 0;
}
关键函数解析:
dup(oldfd)
:复制oldfd
,返回新的文件描述符(指向同一系统级表项)。dup2(oldfd, newfd)
:关闭newfd
并使其指向oldfd
的文件,常用于重定向。
二、阻塞与非阻塞 IO:进程等待机制的选择
在 Linux 中,IO 操作的阻塞与非阻塞特性决定了进程在等待数据时的行为,这对程序性能和响应性至关重要。需要明确:阻塞是设备文件(如终端、串口)或网络文件(如套接字)的属性,普通磁盘文件的读写通常不会阻塞(数据通常已在磁盘或内核缓冲区中)。
2.1 阻塞 IO(Blocking IO)
定义
当进程执行 IO 操作时,若数据未准备好(如终端无输入、网络无数据),进程会被内核挂起(进入 TASK_INTERRUPTIBLE
状态),暂停占用 CPU 资源,直到数据就绪或操作完成后被内核唤醒。
关键特征
- 进程状态:阻塞时进入等待状态,不消耗 CPU 时间。
- 编程模型:简单直观,无需轮询,调用后等待结果即可。
- 局限性:在多任务场景下可能导致资源浪费(如单线程服务器只能依次处理请求)。
典型场景
- 交互式命令行工具(如
cat
、read
):等待用户输入。 - 单线程同步服务器:处理完一个客户端请求再接收下一个。
2.2 非阻塞 IO(Non-Blocking IO)
定义
进程执行 IO 操作时,无论数据是否就绪,系统调用都会立即返回:
- 若数据就绪:返回实际读写的字节数。
- 若数据未就绪:返回
-1
并设置errno
为EWOULDBLOCK
或EAGAIN
(表示“操作暂时无法完成”)。
关键特征
- 进程状态:始终处于运行状态(或就绪态),不会被挂起。
- 编程模型:需主动轮询或结合事件通知机制(如
select
/epoll
)检查数据状态。 - 优势:适合多任务并发场景,进程可同时处理其他操作。
典型场景
- 高性能服务器(如 Nginx):单线程处理 thousands of 并发连接。
- 实时应用(如 GUI 程序):需同时响应用户操作和网络数据。
2.3 如何设置非阻塞模式?
通过 fcntl
(File Control)系统调用修改文件描述符的属性,设置 O_NONBLOCK
标志:
fcntl
函数原型
#include <fcntl.h>// 获取/设置文件描述符的状态标志
int fcntl(int fd, int cmd, ... /* arg */);
设置非阻塞模式的步骤
- 调用
fcntl(fd, F_GETFL)
获取当前状态标志。 - 用按位或
|
添加O_NONBLOCK
标志。 - 调用
fcntl(fd, F_SETFL, new_flags)
应用新标志。
// 设置 fd 为非阻塞模式
int flags = fcntl(fd, F_GETFL, 0); // 获取当前标志
if (flags == -1) {perror("fcntl F_GETFL failed");return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) { // 添加 O_NONBLOCKperror("fcntl F_SETFL failed");return -1;
}
2.4 阻塞与非阻塞模式下的读写行为对比
以 read
和 write
为例,对比两种模式的核心差异:
操作 | 阻塞模式(默认) | 非阻塞模式 |
---|---|---|
read | 无数据时:进程挂起,直到数据就绪或连接关闭。 有数据时:返回实际读取字节数。 | 无数据时:立即返回 -1 ,errno=EWOULDBLOCK 。有数据时:返回实际读取字节数。 |
write | 缓冲区满时:进程挂起,直到有空间写入。 有空间时:返回实际写入字节数。 | 缓冲区满时:立即返回 -1 ,errno=EWOULDBLOCK 。有空间时:返回实际写入字节数。 |
适用对象 | 终端、管道、套接字等(数据可能延迟到达)。 | 同上,需频繁检查状态的场景。 |
普通文件 | 始终立即返回(数据在磁盘/缓冲区中)。 | 同上,行为与阻塞模式一致(无实际意义)。 |
2.5 代码示例:非阻塞读取终端输入
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>int main() {// 设置标准输入(fd=0)为非阻塞模式int flags = fcntl(0, F_GETFL, 0);if (flags == -1 || fcntl(0, F_SETFL, flags | O_NONBLOCK) == -1) {perror("fcntl failed");return 1;}char buf[1024];ssize_t bytes_read = read(0, buf, sizeof(buf)); // 读取终端输入if (bytes_read > 0) {// 有数据:输出读取内容buf[bytes_read] = '\0'; // 确保字符串结束printf("读取到数据:%s", buf);} else if (bytes_read == 0) {// 文件结束(如管道关闭)printf("输入已结束\n");} else {// 错误处理if (errno == EWOULDBLOCK || errno == EAGAIN) {printf("当前无输入数据,请稍后再试\n");} else {perror("read failed");return 1;}}return 0;
}
运行结果:若程序启动后未立即输入数据,会打印“当前无输入数据”;若输入数据并回车,会打印读取到的内容。
2.6 阻塞 vs 非阻塞:如何选择?
场景 | 推荐模式 | 理由 |
---|---|---|
简单工具/单任务程序 | 阻塞模式 | 编程简单,无需处理轮询逻辑。 |
多并发/高响应需求 | 非阻塞模式 | 结合 epoll 等机制实现高效事件驱动。 |
磁盘文件读写 | 阻塞模式 | 非阻塞无实际收益(磁盘 IO 已被内核优化)。 |
网络/终端交互 | 按需选择 | 低并发用阻塞,高并发用非阻塞+事件通知。 |
三、lseek:文件偏移量与随机访问控制
lseek
是控制文件读写位置的核心系统调用,它允许进程跳转到文件的任意位置进行读写,实现随机访问(区别于顺序访问)。
3.1 函数原型与核心作用
#include <unistd.h>// 设置文件描述符 fd 的当前读写偏移量
off_t lseek(int fd, off_t offset, int whence);
核心作用
修改文件的“当前偏移量”(一个非负整数,记录下一次 read
/write
的起始位置)。对于新打开的文件:
- 普通文件:偏移量初始为
0
(文件开头)。 - 用
O_APPEND
打开的文件:偏移量初始为文件末尾。
3.2 参数详解
参数 | 含义与取值 | 效果说明 |
---|---|---|
fd | 目标文件描述符 | 必须是已打开的有效描述符。 |
offset | 偏移字节数(可正可负) | 结合 whence 计算新偏移量。 |
whence | 参考点(三选一) | - SEEK_SET :从文件开头计算(offset ≥ 0)。- SEEK_CUR :从当前偏移量计算。- SEEK_END :从文件末尾计算(offset 可负)。 |
示例
调用 | 效果 |
---|---|
lseek(fd, 100, SEEK_SET) | 偏移量设为 100(从开头第 100 字节)。 |
lseek(fd, 50, SEEK_CUR) | 当前偏移量 +50。 |
lseek(fd, -10, SEEK_END) | 偏移量设为文件末尾前 10 字节。 |
lseek(fd, 0, SEEK_CUR) | 获取当前偏移量(无修改)。 |
3.3 返回值
- 成功:返回新的文件偏移量(从文件开头算起的字节数)。
- 失败:返回
-1
并设置errno
(如EBADF
表示 fd 无效,ESPIPE
表示管道/套接字不支持lseek
)。
3.4 典型应用场景
场景 1:随机访问文件内容
跳过文件开头部分,直接读取中间数据:
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>int main() {int fd = open("data.txt", O_RDONLY);if (fd < 0) {perror("open failed");return 1;}// 跳转到文件第 100 字节处(假设文件足够大)off_t new_offset = lseek(fd, 100, SEEK_SET);if (new_offset == -1) {perror("lseek failed");close(fd);return 1;}printf("当前偏移量:%ld\n", new_offset);// 从偏移量 100 开始读取 50 字节char buf[51];ssize_t bytes_read = read(fd, buf, 50);if (bytes_read < 0) {perror("read failed");close(fd);return 1;}buf[bytes_read] = '\0';printf("读取内容:%s\n", buf);close(fd);return 0;
}
场景 2:创建空洞文件(Sparse File)
空洞文件是逻辑大小大于实际占用磁盘空间的文件,通过 lseek
跳转到文件末尾后写入数据实现:
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>int main() {int fd = open("sparse.txt", O_WRONLY | O_CREAT, 0644);if (fd < 0) {perror("open failed");return 1;}// 跳转到文件第 1MB(1024*1024)位置off_t offset = lseek(fd, 1024*1024 - 1, SEEK_SET); // -1 是因为后续写入 1 字节if (offset == -1) {perror("lseek failed");close(fd);return 1;}// 写入 1 字节,使文件逻辑大小为 1MBwrite(fd, "A", 1);close(fd);// 查看文件信息:逻辑大小 1MB,但实际占用磁盘空间很小printf("空洞文件创建完成,逻辑大小:1MB\n");return 0;
}
特点:
- 逻辑大小:
1MB
(ls -l
显示)。 - 实际磁盘占用:仅几字节(
du -h
显示),内核用特殊标记表示空洞,不分配磁盘块。 - 用途:数据库、虚拟机镜像等需预分配空间但初期数据稀疏的场景。
场景 3:确保写入追加到文件末尾
即使不用 O_APPEND
打开文件,也可通过 lseek
手动定位到末尾实现追加:
// 等价于 O_APPEND 模式的追加写入
lseek(fd, 0, SEEK_END); // 定位到文件末尾
write(fd, "追加内容", strlen("追加内容"));
场景 4:多线程并发访问的独立性
每个进程/线程的文件偏移量独立存储在系统级打开文件表中,因此多线程可通过 lseek
各自控制读写位置,互不干扰:
// 线程 1:读取文件前半部分
lseek(fd, 0, SEEK_SET);
read(fd, buf1, 100);// 线程 2:读取文件后半部分
lseek(fd, -100, SEEK_END);
read(fd, buf2, 100);
3.5 注意事项
- 不支持的文件类型:管道、套接字、终端等不可 seek,调用
lseek
会返回-1
并设置errno=ESPIPE
。 - 偏移量范围:
offset
可大于文件当前大小(创建空洞),但不可为负(whence=SEEK_SET
时)。 - 原子性:
O_APPEND
模式下,write
会自动将偏移量移到末尾,比手动lseek
更安全(避免多线程竞争)。
四、系统调用中的参数传递:传入、传出与传入传出
系统调用的参数传递规则决定了数据如何在用户态与内核态之间交互,理解传入、传出、传入传出参数的区别对正确使用系统调用至关重要。
4.1 传入参数(Input Parameters)
定义
由调用者提供数据,内核仅读取不修改的参数。
特征
- 通常用
const
修饰指针(表示内核不修改指向的数据)。 - 指针需指向有效内存(内核需读取数据)。
示例
open
的pathname
和flags
:const char *pathname
是传入参数,内核读取路径字符串。write
的buf
:const void *buf
是传入参数,内核读取缓冲区数据并写入文件。
// 传入参数示例:write 的 buf 是传入参数
const char *msg = "Hello"; // 调用者提供数据
write(fd, msg, strlen(msg)); // 内核读取 msg 内容,不修改
4.2 传出参数(Output Parameters)
定义
由内核填充数据,返回给调用者的参数。
特征
- 指针需指向已分配的有效内存(内核需写入数据)。
- 调用前内存内容无意义,调用后存储结果。
示例
read
的buf
:void *buf
是传出参数,内核将读取的数据写入缓冲区。fcntl
的F_GETFL
模式:通过返回值传出文件状态标志。
// 传出参数示例:read 的 buf 是传出参数
char buf[1024]; // 调用者分配内存(内容无意义)
ssize_t n = read(fd, buf, sizeof(buf)); // 内核写入数据到 buf
if (n > 0) {// 调用后 buf 存储有效数据
}
4.3 传入传出参数(Input-Output Parameters)
定义
内核先读取参数数据,再修改并返回新数据的参数。
特征
- 调用前指针指向有效数据(内核读取)。
- 调用后指针指向被内核修改的新数据(返回结果)。
示例
fcntl
的F_SETFL
模式:arg
参数先被内核读取(旧标志),修改后设置新标志。ioctl
(设备控制):许多命令需要先传入参数,再接收返回结果。
// 传入传出参数示例:fcntl 设置非阻塞模式
int flags = fcntl(fd, F_GETFL, 0); // 先传出当前标志(flags 是传出参数)
flags |= O_NONBLOCK; // 调用者修改
fcntl(fd, F_SETFL, flags); // 再传入新标志(flags 是传入参数)
4.4 总结:参数类型与系统调用安全
参数类型 | 核心要求 | 典型系统调用示例 |
---|---|---|
传入参数 | 指针指向有效只读数据(const) | open(pathname, flags) |
传出参数 | 指针指向已分配内存(可写) | read(fd, buf, count) |
传入传出参数 | 指针指向有效数据,调用后被修改 | fcntl(fd, F_SETFL, &flags) |
安全原则:
- 传出参数必须提前分配内存(避免内核写入无效地址导致崩溃)。
- 传入参数需确保数据有效(如路径字符串以
\0
结尾)。 - 指针参数需注意用户态与内核态的内存隔离(内核不会访问用户态未映射的内存)。
总结
本章深入解析了文件描述符的内核管理机制、阻塞与非阻塞 IO 的行为差异、lseek
实现的随机访问,以及系统调用的参数传递规则。这些知识点是 Linux 系统编程的核心:
- 文件描述符是进程与文件的桥梁,依赖三层内核表实现安全访问。
- 阻塞与非阻塞选择需结合场景:简单用阻塞,高并发用非阻塞+事件通知。
lseek
赋能随机访问和空洞文件创建,扩展了文件 IO 的灵活性。- 理解参数类型可避免常见错误(如传出参数未分配内存)。
掌握这些内容后,将能更高效地使用系统调用,编写可靠且高性能的 Linux IO 程序。