文件描述符(File Descriptor)超详解:从内核结构到 fork/exec/重定向
一、再认识文件描述符
文件描述符是进程打开文件时内核返回的非负小整数,它是 Linux "一切皆文件"的统一入口。
但要真正理解它,必须深入到内核的多级数据结构,以及它在
fork、exec、线程、重定向中的不同行为。一句话定位:文件描述符是进程文件描述符表的下标,表项指向一个打开的文件对象。
二、内核数据结构(源码级)
一个文件描述符在内核里要经过四层结构:
task_struct(进程) └─ files → files_struct(进程的文件管理) └─ fdtable(文件描述符表) └─ fd[N] → struct file(打开的文件对象) └─ f_path → dentry → inode(真正的文件)2.1 files_struct 与 fdtable
每个进程的
task_struct里有一个files指针,指向struct files_struct:struct files_struct { atomic_t count; // 共享该结构的任务数(线程共享) struct fdtable __rcu *fdt; // 指向当前 fdtable struct fdtable fdtab; // 内嵌的 fdtable struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 内嵌 32 个 file 指针 ... }; struct fdtable { unsigned int max_fds; // 表的最大容量 struct file __rcu **fd; // file 指针数组(fd 就是它的下标) unsigned long *close_on_exec; // 位图:哪些 fd 设了 O_CLOEXEC unsigned long *open_fds; // 位图:哪些 fd 正被占用 ... };关键细节:
- 进程默认内嵌 32 个 file 指针(
NR_OPEN_DEFAULT = 32),当打开的 fd 超过 32 个,内核会重新分配一个更大的 fdtable 并让fdt指向它;open_fds位图记录 fd 的占用情况,分配新 fd 时就在这张位图上找第一个 0;close_on_exec位图记录哪些 fd 在exec时要关闭(后面重点讲)。2.2 struct file 详解
struct file表示一个"打开的文件实例",是理解 fd 共享行为的核心:struct file { struct path f_path; // 含 dentry 和挂载点 vfsmount const struct file_operations *f_op; // 操作函数表(read/write/...) atomic_long_t f_count; // 引用计数(被几个 fd 指向) unsigned int f_flags; // 文件状态标志(O_APPEND、O_NONBLOCK...) fmode_t f_mode; // 访问模式(只读/只写/读写) loff_t f_pos; // 文件偏移(当前读写位置) void *private_data; // 驱动/文件系统私有数据 ... };注意:
f_pos在 struct file 里,而不是在 fd 表里。这决定了"共享同一个 struct file 的 fd 共享文件偏移,而各自独立的 struct file 拥有独立偏移"。
2.3 struct inode
struct inode代表文件系统中真正的文件,与是否打开无关:
- 文件大小、权限(mode)、属主(uid/gid);
- 时间戳(atime/mtime/ctime);
- 数据块位置指针、链接计数;
- 每个文件只有一个 inode,但可以被 open 多次、对应多个 struct file。
2.4 完整结构图
三、文件描述符的分配规则
内核分配 fd 时遵循**"最小可用整数"原则**:
在
open_fds位图上找第一个为 0 的位置返回。int fd1 = open("a.txt", O_RDONLY); // 3(0/1/2 已占用) close(1); // 释放 1 int fd2 = open("b.txt", O_RDONLY); // 1(最小可用)
四、文件描述符标志 vs 文件状态标志
这是一个高频混淆点,必须分清两类标志:
类别 存放位置 操作方式 包含 文件描述符标志 fdtable
(每个 fd 独立)
fcntl F_GETFD/F_SETFD只有 FD_CLOEXEC文件状态标志 struct file
(共享该 file 的 fd 都生效)
fcntl F_GETFL/F_SETFLO_APPEND、O_NONBLOCK、O_ASYNC、O_DIRECT等int flags = fcntl(fd, F_GETFL); // 取文件状态标志 flags |= O_NONBLOCK; fcntl(fd, F_SETFL, flags); // 改状态标志 fcntl(fd, F_SETFD, FD_CLOEXEC); // 设 fd 标志(exec 时关闭)
五、fork、exec、线程中的 fd 行为(重点)
5.1 fork:复制 fd 表,共享 file
fork()时,子进程复制父进程的 files_struct(fd 表),但表项指向的struct file不复制,只把f_count加 1。因此:
- 父子共享 f_pos:父读了 10 字节,子接着从 10 读;
- 父子写同一个 fd 不会互相覆盖(共享偏移),但写各自 open 的 fd 会覆盖。
5.2 exec:默认保留 fd,CLOEXEC 的关闭
execve()执行新程序时,fd 表默认保留,但close_on_exec位图中标记的 fd 会被关闭。
- 默认情况下 fd 跨 exec 保留;
- 打开时用
O_CLOEXEC,或事后fcntl F_SETFD FD_CLOEXEC,可让该 fd 在 exec 时自动关闭;- 为什么需要 CLOEXEC:避免 fd 意外泄漏给 exec 后的新程序(尤其是子进程 exec 后,父进程的管道写端没关,会导致读端收不到 EOF)。
5.3 线程:完全共享 fd 表
pthread_create创建的线程通过CLONE_FILES共享同一个files_struct:
- 一个线程 open 的 fd,其他线程立即可用;
- 一个线程 close 一个 fd,其他线程也立刻失效;
- 多线程 close 同一个 fd 要小心重复关闭。
5.4 fork/exec 对比图
六、dup 系列深入
fd 符号常量 名字 默认指向 0 STDIN_FILENO标准输入 终端键盘 1 STDOUT_FILENO标准输出 终端屏幕 2 STDERR_FILENO标准错误 终端屏幕 int dup(int oldfd); // 新 fd 指向同一 file,返回最小可用 int dup2(int oldfd, int newfd); // 指定新 fd,已占用先关闭 int dup3(int oldfd, int newfd, int flags); // 可加 O_CLOEXECdup 后两个 fd 共享同一个 struct file,所以共享 f_pos 和文件状态标志:
int fd = open("a.txt", O_RDONLY); int d = dup(fd); lseek(fd, 100, SEEK_SET); // 改 fd 的偏移 // d 的偏移也变成 100,因为它们共享同一个 file
七、文件锁
锁 接口 关联对象 特点 flock 锁
flock
(fd, LOCK_EX/SH/UN)struct file 建议锁,dup/fork 共享,新 open 不共享 fcntl 记录锁 fcntl F_SETLK/F_SETLKW进程 + inode 可锁字节范围,进程退出自动释放
八、重定向与管道(shell 原理)
fd 符号常量 名字 默认指向 0 STDIN_FILENO标准输入 终端键盘 1 STDOUT_FILENO标准输出 终端屏幕 2 STDERR_FILENO标准错误 终端屏幕 8.1 重定向就是 dup2
cmd > file # fd 1 → file cmd < file # fd 0 ← file cmd >> file # 追加 cmd 2> err # fd 2 → err cmd > file 2>&1 # fd 2 指向 fd 1 当前的 file(都进 file) cmd 2>&1 > file# fd 2 先指向终端,fd 1 再指 file(顺序不同结果不同)
注意
2>&1的位置顺序:它是"复制当前 fd 1 的指向",所以
> file 2>&1两个都进文件,2>&1 > file只有 stdout 进文件。# 写法1:两条都进 file,终端无输出 { echo "stdout-msg"; echo "stderr-msg" >&2; } > out.txt 2>&1 cat out.txt # stdout-msg # stderr-msg # 写法2:stdout 进 file,stderr 仍显示在终端 { echo "stdout-msg"; echo "stderr-msg" >&2; } 2>&1 > out.txt # 终端显示:stderr-msg cat out.txt # stdout-msg
写法 含义 cmd &> filebash 简写,stdout 和 stderr 都进 file(等价于 > file 2>&1)cmd >& file同上(bash 扩展) cmd 2> file只把 stderr 重定向到 file cmd 1>&2把 stdout 重定向到 stderr(脚本里常用) cmd > /dev/null 2>&1所有输出都丢弃 8.2 管道
cmd1 | cmd2底层是pipe()创建两个 fd,cmd1 写、cmd2 读,配合 fork 和 dup2 连接。
九、一切皆文件:特殊设备
设备 行为 /dev/null写丢弃,读返回 EOF /dev/zero读无限返回 0 /dev/full读返回 0,写返回 ENOSPC(测试磁盘满) /dev/random、/dev/urandom随机数 /dev/tty进程的控制终端 /dev/stdin、/dev/stdout、/dev/stderr、/dev/fd/N对应 fd
十、零拷贝与 fd
sendfile(out_fd, in_fd, ...):两个 fd 间直接在内核搬运,不经过用户态;splice(fd_in, ..., fd_out, ...):fd 与管道间搬运;tee():在两个管道 fd 间复制,不消耗数据。
十一、fd 上限与泄漏排查
# 进程级上限(默认 1024) ulimit -n # 系统级 file 对象上限 cat /proc/sys/fs/file-max cat /proc/sys/fs/file-nr # 已分配、空闲、上限 # 查看进程打开的 fd ls -l /proc/1234/fd lsof -p 1234 lsof -i :8080 # 查占用端口的进程fd 泄漏的典型表现:
fd 数持续增长,最终
open/socket/accept返回 -1,错误码EMFILE。
十二、常见错误码
错误码 含义 EBADFfd 无效或未打开 EMFILE进程 fd 数达上限 ENFILE系统 fd 数达上限 EPIPE管道破裂(读端已关) EAGAIN/EWOULDBLOCK非阻塞且暂无数据 EINTR被信号中断 EACCES权限不足 ENOENT文件不存在 ENOSPC磁盘已满
十三、面试题(含答案)
文件描述符的本质?
进程 fdtable 中 file 指针数组的下标,表项指向 struct file。
两次 open 同一文件,fd 共享偏移吗?
不共享。各自创建 struct file,独立 f_pos。
dup 后两个 fd 共享偏移吗?
共享。它们指向同一个 struct file。
fork 后父子共享偏移吗?
共享。子进程复制 fd 表但共享 struct file,f_count 加 1。
exec 后 fd 还在吗?
默认保留;设了 FD_CLOEXEC / O_CLOEXEC 的会被关闭。
线程间 fd 共享吗?
完全共享同一个 files_struct(CLONE_FILES)。
fd 标志和文件状态标志的区别?
fd 标志(只有 FD_CLOEXEC)存在 fdtable,用 F_GET/SETFD;
状态标志(O_APPEND 等)存在 struct file ,用 F_GET/SETFL。
为什么需要 O_CLOEXEC?
防止 fd 泄漏给 exec 后的程序,典型是管道写端不关导致读端收不到 EOF。
> file 2>&1和2>&1 > file的区别?前者两个都进文件(2 复制 1 已指向 file 的状态);
后者只有 stdout 进文件。
怎么排查 fd 泄漏?
ls /proc/pid/fd 或 lsof -p pid 看数量是否持续增长。
EMFILE 和 ENFILE 的区别?
EMFILE 是进程级 fd 达上限,ENFILE 是系统级 fd 达上限。
f_pos 存在哪个结构体里?为什么重要?
存在 struct file,决定了共享该 file 的 fd 共享偏移,而独立 file 偏移独立。
总结
理解文件描述符的核心是抓住四层结构 + 三种共享行为:
- 结构上:
task_struct → files_struct/fdtable → struct file → inode,fd 是数组下标,f_pos 在 struct file 里;- 行为上:fork 共享 file、exec 默认保留 fd(CLOEXEC 关闭)、线程完全共享 fd 表;
- 应用上:重定向是 dup2、管道是 pipe+fork+dup2、一切设备都通过 fd 统一访问。
动手实现 C 标准库:myStdio 项目学习指南
一、项目目标与整体架构
glibc 的 stdio 本质就做一件事:
在用户态开一块缓冲区,先把数据攒起来,条件满足时再通过
write()一次性交给内核。我们要复刻的就是这个过程:myfputs("...") → 数据拷贝到 myFILE.buffer(用户态缓冲区) → 判断是否满足刷新条件(换行 / 缓冲区满 / 强制) → 满足则 write(fd, buffer, pos) 交给内核 Page Cache → myfclose 时强制刷完并关闭 fd
二、前置知识对照
代码中的东西 对应知识点 open()返回的fd文件描述符(内核 fd 表的下标) myFILE.buffer用户态 stdio 缓冲区(glibc 里在 FILE 结构体内部) flush_mode = LINE_BUFFER行缓冲 / 全缓冲 / 无缓冲三种类型 myfflushcore缓冲区刷新逻辑(缓冲区满、遇 \n、fflush)write()系统调用,把数据从用户态送入内核 Page Cache fsync()强制内核落盘(标准 fclose 一般不做)
三、第一步:设计 myFILE 数据结构(头文件)
myFILE对应 glibc 的struct _IO_FILE,核心字段就是 fd、缓冲区、当前位置、容量、缓冲类型:// mystdio.h #ifndef _MY_STDIO_H_ #define _MY_STDIO_H_ #include <stddef.h> #define MY_BUF_SIZE 1024 // 缓冲区容量 #define MY_FILE_MODE 0644 // 新建文件权限 typedef enum { MY_NBF = 0, // 无缓冲 _IONBF MY_LBF = 1, // 行缓冲 _IOLBF MY_FBF = 2 // 全缓冲 _IOFBF } my_buf_mode_t; typedef struct myFILE { int fd; // 文件描述符 int flags; // open 标志 int pos; // 缓冲区当前位置 int cap; // 缓冲区容量 my_buf_mode_t flush_mode; // 缓冲类型 char buffer[MY_BUF_SIZE]; // 输出缓冲区 } myFILE; myFILE *myfopen(const char *path, const char *mode); int myfclose(myFILE *fp); int myfputs(const char *s, myFILE *fp); int myfflush(myFILE *fp); #endif
四、第二步:myfopen —— 打开文件
myfopen做两件事:解析 mode 字符串 → 调 open 拿 fd → 分配 myFILE。
重点是 mode 到 open 标志的映射:
mode open 标志 含义 rO_RDONLY只读,文件必须存在 wO_WRONLY|O_CREAT|O_TRUNC只写,不存在则建,存在则清空 aO_WRONLY|O_CREAT|O_APPEND追加写,每次写到末尾 r+O_RDWR读写,文件必须存在 w+O_RDWR|O_CREAT|O_TRUNC读写,清空 a+O_RDWR|O_CREAT|O_APPEND读写,追加
五、第三步:myfflushcore —— 缓冲区刷新核心
这是整个项目最核心、也最容易写错的部分。
三种缓冲类型的刷新判断:
- 无缓冲:任何数据都立即
write;- 行缓冲:缓冲区里出现
\n就刷,或缓冲区满也刷;- 全缓冲:只有缓冲区满才刷;
- 强制刷新(MUST_FLUSH):无论哪种类型都刷。
六、第四步:myfputs —— 写入数据
正确逻辑是一个**"分片拷贝 + 边写边刷"**的循环
(因为一次写入的字符串可能比缓冲区大):
七、myfclose —— 关闭文件
顺序:
强制刷新残留数据 → close(fd) → free(myFILE)。
注意
fsync不是标准库的做法(标准fclose只保证数据到内核,不保证落盘),按需保留即可。八、完整可运行代码
// mystdio.h #ifndef _MY_STDIO_H_ #define _MY_STDIO_H_ #include <stddef.h> #define MY_BUF_SIZE 1024 // 缓冲区容量 #define MY_FILE_MODE 0644 // 新建文件权限 typedef enum { MY_NBF = 0, // 无缓冲 _IONBF MY_LBF = 1, // 行缓冲 _IOLBF MY_FBF = 2 // 全缓冲 _IOFBF } my_buf_mode_t; typedef struct myFILE { int fd; // 文件描述符 int flags; // open 标志 int pos; // 缓冲区当前位置 int cap; // 缓冲区容量 my_buf_mode_t flush_mode; // 缓冲类型 char buffer[MY_BUF_SIZE]; // 输出缓冲区 } myFILE; myFILE *myfopen(const char *path, const char *mode); int myfclose(myFILE *fp); int myfputs(const char *s, myFILE *fp); int myfflush(myFILE *fp); #endif// mystdio.c #include "mystdio.h" #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #define TRY_FLUSH 0 #define MUST_FLUSH 1 myFILE *myfopen(const char *path, const char *mode) { int flags = 0; if (strcmp(mode, "r") == 0) flags = O_RDONLY; else if (strcmp(mode, "w") == 0) flags = O_WRONLY | O_CREAT | O_TRUNC; else if (strcmp(mode, "a") == 0) flags = O_WRONLY | O_CREAT | O_APPEND; else if (strcmp(mode, "r+") == 0) flags = O_RDWR; else if (strcmp(mode, "w+") == 0) flags = O_RDWR | O_CREAT | O_TRUNC; else if (strcmp(mode, "a+") == 0) flags = O_RDWR | O_CREAT | O_APPEND; else return NULL; int fd = (flags & O_CREAT) ? open(path, flags, MY_FILE_MODE) : open(path, flags); if (fd < 0) return NULL; myFILE *fp = calloc(1, sizeof(myFILE)); if (fp == NULL) { close(fd); return NULL; } fp->fd = fd; fp->flags = flags; fp->cap = MY_BUF_SIZE; fp->pos = 0; fp->flush_mode = MY_LBF; return fp; } static int myflush_core(myFILE *fp, int force) { if (fp->pos == 0) return 0; int need = 0; if (force) need = 1; else if (fp->flush_mode == MY_NBF) need = 1; else if (fp->flush_mode == MY_FBF) { if (fp->pos >= fp->cap) need = 1; } else { // 行缓冲 if (fp->pos >= fp->cap || memchr(fp->buffer, '\n', fp->pos)) need = 1; } if (!need) return 0; int sent = 0; while (sent < fp->pos) { ssize_t n = write(fp->fd, fp->buffer + sent, fp->pos - sent); if (n <= 0) return -1; sent += n; } fp->pos = 0; return 0; } int myfflush(myFILE *fp) { if (fp == NULL) return -1; return myflush_core(fp, MUST_FLUSH); } int myfputs(const char *s, myFILE *fp) { if (s == NULL || fp == NULL) return -1; int len = strlen(s); int off = 0; while (off < len) { int n = len - off; if (n > fp->cap - fp->pos) n = fp->cap - fp->pos; memcpy(fp->buffer + fp->pos, s + off, n); fp->pos += n; off += n; if (fp->pos >= fp->cap) { if (myflush_core(fp, MUST_FLUSH) < 0) return -1; } else { if (myflush_core(fp, TRY_FLUSH) < 0) return -1; } } return len; } int myfclose(myFILE *fp) { if (fp == NULL) return -1; myflush_core(fp, MUST_FLUSH); close(fp->fd); free(fp); return 0; }
九、测试用例
// test.c #include "mystdio.h" int main() { myFILE *fp = myfopen("test.txt", "w"); if (fp == NULL) return 1; myfputs("first line\n", fp); // 遇换行,立即刷 myfputs("second line\n", fp); myfputs("no newline yet", fp); // 无换行,关闭时才刷 myfclose(fp); return 0; }编译运行:
gcc mystdio.c test.c -o test ./test cat test.txt可以用
strace -e trace=write ./test观察实际触发了几次write,验证缓冲合并效果。
十、面试题
- 为什么库函数要在用户态再开一层缓冲区?(减少系统调用次数)
- 行缓冲、全缓冲、无缓冲的刷新条件分别是什么?
- 一次写入的字符串比缓冲区大,该怎么处理?(分片拷贝、边写边刷)
write会一次写完所有数据吗?(不一定,要处理部分写)fflush和fsync的区别?(前者到内核,后者落盘)- 为什么
myfputs里必须先算cap-pos?(防止缓冲区越界)fclose一般需要fsync吗?(不需要,标准库只到内核)
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2602_95649725/article/details/169166998










