
上一篇我们掌握了 Linux 系统 IO 接口(open/read/write),但始终围绕一个神秘的 "小整数"------文件描述符(fd) 。比如open成功后返回3,stdin对应0,stdout对应1------ 这个 "小整数" 到底是什么?它如何关联进程和文件?为什么关闭1后新打开的文件 fd 会变成1?这篇文章会带你从内核数据结构出发,彻底揭开 fd 的面纱:从 "内核数组下标" 的本质,到默认 fd 的由来,再到 fd 与FILE结构体的关系,最后通过实战验证 fd 的分配规则,让你明白 "为什么 fd 是 Linux 文件操作的万能钥匙"。
文章目录
-
- [一、先搞懂:fd 的本质 ------ 内核数组的 "下标"](#一、先搞懂:fd 的本质 —— 内核数组的 “下标”)
- 二、内核视角:进程与文件的关联机制
-
- [2.1 核心结构体 1:task_struct------ 进程的 "身份证"](#2.1 核心结构体 1:task_struct—— 进程的 “身份证”)
- [2.2 核心结构体 2:files_struct------ 进程的 "打开文件表"](#2.2 核心结构体 2:files_struct—— 进程的 “打开文件表”)
- [2.3 核心结构体 3:struct file------ 打开文件的 "详细档案"](#2.3 核心结构体 3:struct file—— 打开文件的 “详细档案”)
- [2.4 三者关系图(直观理解)](#2.4 三者关系图(直观理解))
- [三、默认 fd:为什么 0、1、2 被 "预留" 了?](#三、默认 fd:为什么 0、1、2 被 “预留” 了?)
-
- [3.1 三个默认 fd 的对应关系](#3.1 三个默认 fd 的对应关系)
- [3.2 验证默认 fd:查看进程的 fd 列表](#3.2 验证默认 fd:查看进程的 fd 列表)
- [3.3 实战:关闭默认 fd 后的 "异常" 现象](#3.3 实战:关闭默认 fd 后的 “异常” 现象)
-
- [案例 1:关闭 stdout(fd=1)后,新打开的文件 fd 变成 1](#案例 1:关闭 stdout(fd=1)后,新打开的文件 fd 变成 1)
- [案例 2:关闭 stdin(fd=0)后,无法从键盘读入](#案例 2:关闭 stdin(fd=0)后,无法从键盘读入)
- [四、fd 的分配规则:"最小未使用" 原则](#四、fd 的分配规则:“最小未使用” 原则)
-
- [场景 1:默认情况下,新打开文件的 fd 是 3](#场景 1:默认情况下,新打开文件的 fd 是 3)
- [场景 2:关闭 fd=2 后,新打开文件的 fd 是 2](#场景 2:关闭 fd=2 后,新打开文件的 fd 是 2)
- [场景 3:关闭 fd=1 和 fd=3 后,新打开文件的 fd 是 1](#场景 3:关闭 fd=1 和 fd=3 后,新打开文件的 fd 是 1)
- [五、fd 与 FILE 结构体的关系:封装与被封装](#五、fd 与 FILE 结构体的关系:封装与被封装)
-
- [5.1 FILE 结构体的核心成员(简化版)](#5.1 FILE 结构体的核心成员(简化版))
- [5.2 验证:FILE * 内部的_fd 就是 fd](#5.2 验证:FILE * 内部的_fd 就是 fd)
- [5.3 C 库 IO 与系统 IO 的调用关系(再次梳理)](#5.3 C 库 IO 与系统 IO 的调用关系(再次梳理))
- [六、fd 的常见问题与排查方法](#六、fd 的常见问题与排查方法)
-
- [6.1 问题 1:fd 泄漏(最常见)](#6.1 问题 1:fd 泄漏(最常见))
- [6.2 问题 2:使用已关闭的 fd](#6.2 问题 2:使用已关闭的 fd)
- [6.3 问题 3:混淆 fd 的 "进程独立性"](#6.3 问题 3:混淆 fd 的 “进程独立性”)
- 七、总结与下一篇预告
一、先搞懂:fd 的本质 ------ 内核数组的 "下标"
很多人把 fd 理解为 "文件的编号",但更准确的定义是:fd 是 "进程打开文件表(fd_array)的下标" ------ 这个表存储在进程的files_struct结构体中,每个元素都是指向 "打开文件描述(struct file)" 的指针。
我们可以用一个通俗的比喻理解:
- 进程就像一家 "公司";
files_struct是公司的 "钥匙管理室";fd_array是管理室里的 "钥匙串"(数组);- fd 是 "钥匙串上的位置编号"(如下标
0、1、2); - 每个位置挂着的 "钥匙"(
struct file*),对应一个 "打开的文件 / 设备"(如键盘、显示器、磁盘文件)。
当进程调用open打开文件时,内核会:
- 创建一个
struct file结构体(记录文件属性、读写位置等); - 在进程的
fd_array中找一个 "最小未使用的下标"(比如3); - 把
struct file*指针存到fd_array[3]中; - 返回这个下标(
3)给进程 ------ 这就是 fd。
后续进程调用read(fd, buf, count)时,内核会:
- 根据 fd 找到
fd_array[fd]; - 通过
fd_array[fd]拿到struct file*; - 调用
struct file中的read函数指针(比如磁盘文件的read逻辑),完成数据读取。
二、内核视角:进程与文件的关联机制
要理解 fd,必须先搞懂 "进程如何通过 fd 找到文件"------ 这涉及三个核心内核结构体:task_struct(进程控制块)、files_struct(进程打开文件表)、struct file(打开文件描述)。它们的关系就像 "链条",把进程和文件牢牢绑定。
2.1 核心结构体 1:task_struct------ 进程的 "身份证"
每个进程在 kernel 中都有一个task_struct结构体,记录进程的所有信息(PID、内存、打开的文件等)。其中有一个关键指针:
c
struct task_struct {
// ... 其他字段 ...
struct files_struct *files; // 指向进程的"打开文件表"
// ... 其他字段 ...
};
这个files指针,就是进程通往 "文件世界" 的入口 ------ 通过它能找到所有该进程打开的文件。
2.2 核心结构体 2:files_struct------ 进程的 "打开文件表"
files_struct是进程专属的 "文件管理中心",核心是一个指针数组fd_array,存储所有打开文件的struct file*:
c
struct files_struct {
// ... 其他字段(如fd数量限制、锁等) ...
struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 默认大小1024,可扩展
// ... 其他字段 ...
};
NR_OPEN_DEFAULT:fd_array的默认大小(通常是 1024),表示一个进程默认最多能打开 1024 个文件;fd_array[i]:下标i就是 fd,值是指向struct file的指针(若为NULL,表示该 fd 未使用)。
2.3 核心结构体 3:struct file------ 打开文件的 "详细档案"
struct file是 "打开文件的详细档案",记录了文件的所有关键信息,无论这个 "文件" 是磁盘文件、键盘还是显示器:
c
struct file {
// 1. 文件属性相关
struct inode *f_inode; // 指向文件的inode(存储权限、大小、创建时间等)
const struct file_operations *f_op; // 指向文件的操作函数集(read/write/close等)
// 2. 读写状态相关
loff_t f_pos; // 当前读写位置(比如读了100字节,f_pos=100)
unsigned int f_flags; // 打开文件时的flags(如O_RDONLY、O_APPEND)
// 3. 引用计数(避免文件被重复关闭)
atomic_long_t f_count; // 引用次数,close时减1,减到0才真正释放文件
};
f_inode:关联文件的 "元数据"(属性),比如stat命令查看的信息都来自inode;f_op:关联文件的 "操作逻辑"(比如磁盘文件的read是 "读磁盘扇区",键盘的read是 "读键盘缓冲区");f_pos:记录当前读写位置,保证read/write的 "顺序性"(除非用lseek修改)。
2.4 三者关系图(直观理解)
我们用一张图总结 "进程→fd→文件" 的关联链条:
plaintext
进程(task_struct)
↓ 包含指针
files_struct(打开文件表)
↓ 包含数组fd_array[1024]
fd_array[0] → struct file(对应键盘,stdin)
fd_array[1] → struct file(对应显示器,stdout)
fd_array[2] → struct file(对应显示器,stderr)
fd_array[3] → struct file(对应磁盘文件test.txt)
...
fd_array[1023] → struct file(对应其他打开的文件/设备)

简单说:fd 是 "fd_array 的下标",通过 fd 能找到 struct file,再通过 struct file 找到文件的属性和操作逻辑------ 这就是 fd 能 "打开一切文件" 的底层原因。
三、默认 fd:为什么 0、1、2 被 "预留" 了?
你可能注意到:自己用open打开的第一个文件,fd 总是3,而不是0------ 因为 Linux 进程启动时,会默认打开 3 个文件 ,对应的 fd 固定为0、1、2,分别对应 "标准输入(stdin)""标准输出(stdout)""标准错误输出(stderr)"。
3.1 三个默认 fd 的对应关系
| fd 值 | 流名称 | 对应设备 | 作用 | 系统 IO 接口使用场景 |
|---|---|---|---|---|
0 |
stdin |
键盘 | 接收用户输入 | read(0, buf, count)(读键盘) |
1 |
stdout |
显示器 | 输出正常信息 | write(1, buf, count)(写显示器) |
2 |
stderr |
显示器 | 输出错误信息 | write(2, "error", 5)(写错误) |
为什么要默认打开这三个?因为程序的核心是 "数据处理",而数据处理需要 "输入源" 和 "输出目标"------0是默认输入源(键盘),1/2是默认输出目标(显示器),这样程序启动就能直接交互,不用手动打开。
3.2 验证默认 fd:查看进程的 fd 列表
Linux 提供了一个 "虚拟文件系统"/proc,可以查看任意进程的 fd 信息。比如查看当前终端进程(bash)的 fd:
bash
# 1. 查看当前bash的PID
echo $$ # 输出当前shell的PID,比如3447138
# 2. 查看该进程的fd列表(/proc/[PID]/fd目录下的文件就是fd)
ls -l /proc/3447138/fd
输出结果类似:

0/1/2都是符号链接,指向/dev/pts/0(当前终端设备文件);/dev/pts/0是 "伪终端设备",同时对应键盘(输入)和显示器(输出)------ 这就是为什么read(0)能读键盘,write(1)能写显示器。
3.3 实战:关闭默认 fd 后的 "异常" 现象
如果我们手动关闭0(stdin)或1(stdout),会发生什么?通过代码验证:
案例 1:关闭 stdout(fd=1)后,新打开的文件 fd 变成 1
c
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// 1. 关闭stdout(fd=1)
close(1);
printf("这段文字不会显示(stdout已关闭)\n"); // printf默认写fd=1,关闭后失效
// 2. 打开新文件,查看fd
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1) { perror("open"); return 1; }
printf("fd=%d\n", fd); // 此时printf写的是新文件(fd=1),不会显示在终端
close(fd);
return 0;
}
运行后查看test.txt:
bash
cat test.txt
# 输出:fd=1
现象解释:关闭1后,fd_array[1]变成NULL(未使用),新打开文件时,内核会分配 "最小未使用的 fd"------ 也就是1,所以open返回1。此时printf(默认写 fd=1)会把内容写入test.txt,而不是显示器。
案例 2:关闭 stdin(fd=0)后,无法从键盘读入
c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
// 1. 关闭stdin(fd=0)
close(0);
// 2. 尝试从键盘读入(read(fd=0))
char buf[1024] = {0};
ssize_t read_cnt = read(0, buf, sizeof(buf)-1);
if (read_cnt == -1) {
perror("read failed"); // 会报错:Bad file descriptor(fd=0已关闭)
return 1;
}
printf("你输入了:%s\n", buf);
return 0;
}
运行结果:
bash
./close_stdin
read failed: Bad file descriptor
现象解释:read(0)试图从 fd=0 读数据,但 fd=0 已被关闭(fd_array[0]是NULL),内核无法找到对应的文件,所以返回错误。
四、fd 的分配规则:"最小未使用" 原则
通过上面的案例,我们能总结出 fd 的核心分配规则:内核会从fd_array的下标0开始,找第一个未使用的(fd_array[fd] == NULL)最小下标,作为新的 fd。
这个规则非常重要,是 "重定向" 的底层基础(下一篇会讲)。我们用三个场景验证这个规则:
场景 1:默认情况下,新打开文件的 fd 是 3
c
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// 打开3个文件,查看fd
int fd1 = open("a.txt", O_WRONLY | O_CREAT, 0666);
int fd2 = open("b.txt", O_WRONLY | O_CREAT, 0666);
int fd3 = open("c.txt", O_WRONLY | O_CREAT, 0666);
printf("fd1=%d, fd2=%d, fd3=%d\n", fd1, fd2, fd3); // 输出:3,4,5
close(fd1);
close(fd2);
close(fd3);
return 0;
}
原因:0/1/2已被默认 fd 占用,最小未使用的下标是3,之后依次是4、5。
场景 2:关闭 fd=2 后,新打开文件的 fd 是 2
c
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
close(2); // 关闭stderr(fd=2)
int fd = open("test.txt", O_WRONLY | O_CREAT, 0666);
printf("fd=%d\n", fd); // 输出:2
close(fd);
return 0;
}
原因:关闭2后,fd_array[2]变为NULL,最小未使用的下标是2,所以新 fd 是2。
场景 3:关闭 fd=1 和 fd=3 后,新打开文件的 fd 是 1
c
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// 先打开一个文件,fd=3
int fd3 = open("temp.txt", O_WRONLY | O_CREAT, 0666);
printf("fd3=%d\n", fd3); // 输出:3
// 关闭fd=1和fd=3
close(1);
close(fd3);
// 新打开文件,查看fd
int new_fd = open("new.txt", O_WRONLY | O_CREAT, 0666);
printf("new_fd=%d\n", new_fd); // 输出:1(最小未使用是1)
close(new_fd);
return 0;
}
原因:关闭1和3后,未使用的 fd 是1、3,最小的是1,所以新 fd 是1。
五、fd 与 FILE 结构体的关系:封装与被封装
在第二篇中,我们学过 C 库 IO 的FILE*指针(如fopen返回的FILE* fp)------ 它和 fd 是什么关系?答案是:FILE 结构体是 fd 的 "封装体",内部包含 fd 和用户态缓冲区。
5.1 FILE 结构体的核心成员(简化版)
C 标准库的FILE结构体定义在/usr/include/libio.h中,核心成员如下:
c
typedef struct _IO_FILE FILE;
struct _IO_FILE {
// 1. 用户态缓冲区相关(C库IO的缓冲机制)
char* _IO_read_ptr; // 当前读指针(缓冲区中已读到的位置)
char* _IO_read_end; // 读缓冲区的末尾
char* _IO_write_ptr; // 当前写指针(缓冲区中已写到的位置)
char* _IO_write_end; // 写缓冲区的末尾
char* _IO_buf_base; // 缓冲区的起始地址
char* _IO_buf_end; // 缓冲区的末尾地址
// 2. 封装的文件描述符(关键!关联系统IO)
int _fileno; // 对应的文件描述符(如0、1、2、3...)
// 3. 其他字段(如缓冲类型、错误标志等)
int _flags; // 缓冲类型(行缓冲/全缓冲/无缓冲)
// ... 其他字段 ...
};
_fileno:是FILE结构体的 "核心",存储的就是系统 IO 的 fd------C 库 IO 的所有操作(fread/fwrite/fclose),最终都会通过_fileno调用系统 IO 接口;- 用户态缓冲区:
_IO_buf_base等字段构成 C 库的 "用户态缓冲"------fread/fwrite会先操作缓冲区,减少系统调用次数(比如printf会先把数据写到缓冲区,满了再调用write)。
5.2 验证:FILE * 内部的_fd 就是 fd
我们可以通过fileno函数(C 库提供)获取FILE*对应的 fd,验证两者的关联:
c
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// 1. C库IO打开文件,获取FILE*
FILE *fp = fopen("test.txt", "w");
if (fp == NULL) { perror("fopen"); return 1; }
// 2. 获取FILE*对应的fd(通过fileno函数)
int fd = fileno(fp);
printf("FILE*对应的fd=%d\n", fd); // 输出:3(默认情况下)
// 3. 用系统IO操作这个fd(验证关联)
const char *msg = "通过fd写入的数据\n";
write(fd, msg, strlen(msg)); // 数据会写入test.txt
fclose(fp);
return 0;
}
运行后查看test.txt:
bash
cat test.txt
# 输出:通过fd写入的数据
现象解释:fileno(fp)获取的 fd=3,和系统 IO 的 fd 完全一致 ------ 用write(3, ...)写入的数据,会和fwrite(fp, ...)写入的数据到同一个文件,证明FILE*确实封装了 fd。
5.3 C 库 IO 与系统 IO 的调用关系(再次梳理)
有了 fd 和FILE的关系,我们可以更清晰地梳理 C 库 IO 与系统 IO 的调用链条:
plaintext
用户代码(C库IO) → FILE结构体 → fd → 系统IO接口 → 内核
比如fwrite(msg, 1, len, fp)的流程:
- 检查
fp的用户态缓冲区是否有空间; - 若有空间,将
msg拷贝到缓冲区(不调用系统 IO); - 若缓冲区满,调用
write(fp->_fileno, 缓冲区数据, 缓冲区大小); - 内核通过
fp->_fileno(fd)找到fd_array[fd],再找到struct file,完成写入。
而write(fd, msg, len)(系统 IO)的流程:
- 直接调用内核的
write系统调用; - 内核通过 fd 找到
fd_array[fd]和struct file,完成写入; - 无用户态缓冲,数据直接从用户空间拷贝到内核空间。
六、fd 的常见问题与排查方法
fd 虽然是 "小整数",但使用不当会导致严重问题(如 fd 泄漏、程序崩溃)。这里总结常见问题及排查方法。
6.1 问题 1:fd 泄漏(最常见)
现象
进程打开的 fd 越来越多,最终达到NR_OPEN_DEFAULT(默认 1024),新open返回-1,错误信息为Too many open files。
原因
- 打开文件 / 设备后未调用
close(如open成功但忘记close,函数中途返回未清理); - 循环中重复
open而不close(如每次循环都open同一个文件,不关闭旧 fd)。
排查方法
-
用
lsof查看进程打开的 fd :lsof -p <PID>(PID是目标进程的 ID),输出中FD列就是 fd,TYPE列是文件类型(如REG表示普通文件,CHR表示字符设备)。 -
用
/proc/[PID]/fd查看 fd 列表 :ls -l /proc/[PID]/fd | wc -l(统计 fd 总数),ls -l /proc/[PID]/fd查看具体 fd 对应的文件。
示例:fd 泄漏代码
c
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
int count = 0;
while (1) {
// 循环open但不close,导致fd泄漏
int fd = open("/dev/null", O_WRONLY);
if (fd == -1) {
perror("open failed");
printf("总共打开了%d个fd\n", count);
break;
}
count++;
printf("当前fd=%d\n", fd);
// 忘记close(fd);
}
return 0;
}
运行后会输出:
plaintext
plaintext
当前fd=3
当前fd=4
...
当前fd=1023
open failed: Too many open files
总共打开了1021个fd
原因:默认 fd=0/1/2 已占用,最多能打开 1024 个 fd,所以循环 1021 次后(fd=3~1023),新open失败。
6.2 问题 2:使用已关闭的 fd
现象
调用read/write时返回-1,错误信息为Bad file descriptor(无效的文件描述符)。
原因
- fd 已被
close,但后续代码仍使用该 fd; - fd 被重复
close(第一次close后 fd 失效,第二次close会报错)。
避免方法
- 关闭 fd 后,将 fd 变量设为
-1(如close(fd); fd = -1;); - 使用 fd 前先判断是否为
-1(如if (fd != -1) { write(fd, ...); })。
6.3 问题 3:混淆 fd 的 "进程独立性"
现象
进程 A 打开文件得到 fd=3,进程 B 打开同一个文件得到 fd=4,但两者写入的数据都能到同一个文件。
原因
fd 是 "进程私有" 的 ------ 每个进程的fd_array独立,不同进程的 fd 即使值相同,也可能对应不同文件;反之,不同进程的 fd 值不同,也可能对应同一个文件(通过struct file的引用计数实现)。
示例:两个进程打开同一个文件
c
// 进程A(a.c)
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1) { perror("open"); return 1; }
const char *msg = "进程A写入的数据\n";
write(fd, msg, strlen(msg));
printf("进程A的fd=%d\n", fd); // 输出:3
sleep(10); // 等待进程B写入
close(fd);
return 0;
}
// 进程B(b.c)
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("test.txt", O_WRONLY | O_APPEND, 0666);
if (fd == -1) { perror("open"); return 1; }
const char *msg = "进程B写入的数据\n";
write(fd, msg, strlen(msg));
printf("进程B的fd=%d\n", fd); // 输出:3(默认情况下)
close(fd);
return 0;
}
分别运行两个进程(进程 A 先运行),查看test.txt:
bash
cat test.txt
# 输出:
# 进程A写入的数据
# 进程B写入的数据
现象解释:进程 A 和 B 的 fd 都是 3,但它们的fd_array[3]都指向同一个文件的struct file(内核通过inode关联同一个文件),所以写入的数据都会到test.txt。
七、总结与下一篇预告
这篇我们彻底揭开了文件描述符 fd 的面纱:
- 本质 :fd 是进程
fd_array数组的下标,通过 fd 能找到struct file,进而关联文件; - 默认 fd:进程启动时默认打开 0(stdin)、1(stdout)、2(stderr),对应终端设备;
- 分配规则 :最小未使用原则 ------ 内核分配
fd_array中第一个未使用的最小下标; - 与 FILE 的关系 :
FILE是 fd 的封装,内部通过_fileno存储 fd,同时包含用户态缓冲区。
理解 fd 是掌握 "重定向" 的关键 ------ 因为重定向的本质就是 "修改 fd 对应的struct file指针"(比如把 fd=1 的指针从 "显示器文件" 改成 "磁盘文件")。
下一篇我们将聚焦重定向 :讲解重定向的底层原理(基于 fd 分配规则)、dup2系统调用的使用(实现高效重定向),以及如何在自定义 Shell 中添加重定向功能(衔接你之前的微型 Shell 实战)------ 让你明白 "ls -l > log.txt到底是怎么实现的"。