struct file 不是磁盘文件:一次打开实例和共享偏移
上一篇已经完成两件事:
- 沿着
current->files -> fdtable->fd[fd]找到struct file。 - 通过完整实验确认
open()、dup()、共享偏移、两类 flags 和unlink()的可见行为。
上一篇已经得到的关系不在本文重新证明:
| 操作 | fdtable | struct file |
f_pos / f_flags |
|---|---|---|---|
再次 open() 同一路径 |
增加新槽位 | 创建新 file | 独立 |
dup() |
增加新槽位 | 指向原 file | 共享 |
fork() |
子进程复制 fdtable | 对应槽位指向原 file | 共享 |
线程 / CLONE_FILES |
直接共享 fdtable | 自然看到相同表项 | 共享 |
本文从这些现象继续向内核实现追问:
struct file的完整字段分别负责什么?- 为什么
f_pos可以共享,又怎样避免并发更新相互覆盖? FD_CLOEXEC和O_APPEND为什么存放在不同对象中?dup()和fork()在源码中复制的究竟是哪一层引用?close()为什么先让 fd 失效,最后一个引用消失时才调用release?unlink()删除名字后,打开实例和 inode 为什么还能继续存在?
核心结论仍然只有一句:
struct file描述一次打开实例,也就是 POSIX 所说的 open file description;它保存打开后的共享状态和操作入口,不是路径名,也不是 inode,更不是磁盘上的文件格式。
一、Linux v6.12.65 的完整 struct file
下面是 Linux v6.12.65 中 struct file 的完整结构声明。它依赖内核头文件中的类型、配置宏和属性,不是可以脱离内核单独编译的用户态程序。
c
struct file {
atomic_long_t f_count;
spinlock_t f_lock;
fmode_t f_mode;
const struct file_operations *f_op;
struct address_space *f_mapping;
void *private_data;
struct inode *f_inode;
unsigned int f_flags;
unsigned int f_iocb_flags;
const struct cred *f_cred;
/* --- cacheline 1 boundary (64 bytes) --- */
struct path f_path;
union {
/* regular files (with FMODE_ATOMIC_POS) and directories */
struct mutex f_pos_lock;
/* pipes */
u64 f_pipe;
};
loff_t f_pos;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* --- cacheline 2 boundary (128 bytes) --- */
struct fown_struct *f_owner;
errseq_t f_wb_err;
errseq_t f_sb_err;
#ifdef CONFIG_EPOLL
struct hlist_head *f_ep;
#endif
union {
struct callback_head f_task_work;
struct llist_node f_llist;
struct file_ra_state f_ra;
freeptr_t f_freeptr;
};
/* --- cacheline 3 boundary (192 bytes) --- */
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
__randomize_layout 和条件编译意味着实际布局不是面向用户态的稳定 ABI。理解字段的语义和引用关系是必要的,但不能让用户程序依赖某个字段偏移。
1.1 按职责分组

这五组分别回答:
text
目标身份:这次打开最终关联了谁?
操作能力:这个已打开对象允许执行什么、调用哪组实现?
打开状态:这一次打开当前进行到哪里、使用什么状态?
身份与错误:以什么凭据打开、怎样记录异步通知和错误?
生命周期:还有谁持有它、最后怎样释放?
1.2 每个字段的角色
| 字段 | 作用 | 需要避免的误解 |
|---|---|---|
f_count |
struct file 的内核引用计数 |
不是用户态 fd 数量;临时内核引用也会计入 |
f_lock |
保护 f_flags、epoll hooks 等内部状态 |
不负责保护共享位置,位置使用 f_pos_lock |
f_mode |
内核计算出的 FMODE_* 能力与内部模式 |
不等同于用户传给 open() 的 O_* flags |
f_op |
本次打开最终使用的 file_operations |
后续 read/write/poll/ioctl 从这里分派 |
f_mapping |
可缓存对象对应的 address_space |
不是进程虚拟地址空间 |
private_data |
文件系统、驱动或伪文件的每次打开私有状态 | 同一个 inode 的不同 file 可以拥有不同值 |
f_inode |
缓存的目标 inode 指针 | inode 描述对象,不保存这次打开独有的偏移 |
f_flags |
file status flags | O_APPEND、O_NONBLOCK 等共享状态位于这里 |
f_iocb_flags |
构造 I/O control block 时使用的内部 flags | 不是 fd flags |
f_cred |
创建或打开该 file 时保存的凭据 | 后续操作不一定只看调用线程此刻的凭据 |
f_path |
mount + dentry 组成的路径引用 |
unlink 名字不会直接清除已打开 file 的这份引用 |
f_pos_lock |
必要时串行化共享文件位置操作 | 内核按条件使用,不是所有 I/O 都无条件加锁 |
f_pipe |
pipe 对同一个 union 的专用解释 | pipe 没有普通文件那样的 seek 位置 |
f_pos |
当前文件位置 | 属于打开实例,不属于 fd 槽位 |
f_security |
LSM 保存的 file 安全上下文 | 只有相应配置启用时存在 |
f_owner |
异步通知等机制使用的 owner 状态 | 不是 inode 的 uid/gid 所有者 |
f_wb_err |
该 file 对 mapping writeback 错误的观察状态 | 服务于异步回写错误报告 |
f_sb_err |
superblock 级错误的观察状态 | 同样不是普通系统调用返回值缓存 |
f_ep |
与该 file 关联的 epoll hooks | 只有启用 epoll 配置时存在 |
| 最后一个 union | 在 readahead、task work、延迟 fput 和 slab 释放阶段复用存储 | 不同成员在不同生命周期阶段解释 |
1.3 f_mode 与 f_flags 解决不同问题
f_mode 表示内核已经建立的能力和内部状态:
text
FMODE_READ 允许通过这个 file 读
FMODE_WRITE 允许通过这个 file 写
FMODE_LSEEK 支持定位
FMODE_ATOMIC_POS 位置操作需要满足原子性要求
FMODE_STREAM 按没有普通文件位置的数据流处理
FMODE_PATH 这是 O_PATH 得到的路径句柄
f_flags 保存本次打开使用的 file status flags:
text
O_APPEND 追加写语义
O_NONBLOCK 非阻塞语义
O_DIRECT direct I/O 请求
O_SYNC 同步写语义
用户传给 open() 的 flags 会参与构造两者,但两者不是同一份原始参数。F_SETFL 也只能修改规定的那部分 file status flags,不能在打开后随意重写访问模式或所有打开选项。
1.4 与用户态 FILE * 的边界
text
用户态 FILE * -> libc 缓冲、EOF、错误状态
│ fileno()
▼
整数 fd -> fdtable 槽位
│ 系统调用
▼
struct file -> 内核打开实例
fwrite() 可能只修改 libc 缓冲;内核 struct file 不知道这个缓冲中还有多少数据。三个名字相似,但处于三个不同层次。
二、共享 f_pos 的实现与串行化
上一篇已经用 AB/CD/AB 的读取结果证明:dup() 得到的两个 fd 共享位置,第二次 open() 得到的 file 拥有独立位置。
从内核对象看,原因不是"两个偏移被同步",而是根本只有一个值:
text
fd[original] ─┐
├─> struct file A -> f_pos
fd[duplicate] ─┘
fd[independent] ──> struct file B -> 另一个 f_pos
2.1 隐式位置与显式位置

read()、write() 和 lseek() 使用打开实例的当前位置,因此共享同一 file 的调用者会互相观察到位置变化。
pread()、pwrite() 由调用者显式传入 offset,不以 f_pos 作为本次位置,也不在成功后推进它。它们仍然经过 VFS、权限检查、具体文件操作和其他 flags 语义,不能理解为"绕过 struct file"。
2.2 为什么共享位置需要锁
如果两个执行流同时对同一个 f_pos 做普通读取,真正需要保护的不是最后那次赋值,而是整个读改写过程:
text
读取旧 f_pos
↓
以旧位置执行 I/O
↓
根据实际传输量计算新位置
↓
写回 f_pos
没有串行化时,两个执行流可能读取相同旧值,对同一范围执行 I/O,再互相覆盖更新结果。
Linux v6.12.65 中,决定是否锁位置以及取得位置锁的两个完整函数如下:
c
static inline bool file_needs_f_pos_lock(struct file *file)
{
return (file->f_mode & FMODE_ATOMIC_POS) &&
(file_count(file) > 1 || file->f_op->iterate_shared);
}
struct fd fdget_pos(unsigned int fd)
{
struct fd f = fdget(fd);
struct file *file = fd_file(f);
if (file && file_needs_f_pos_lock(file)) {
f.word |= FDPUT_POS_UNLOCK;
mutex_lock(&file->f_pos_lock);
}
return f;
}
判断条件分成两部分:
- file 带有
FMODE_ATOMIC_POS,位置操作需要满足相应的原子性语义。 - file 可能经由多个引用访问,或者操作表提供共享目录迭代。
需要加锁时,fdget_pos() 锁住 f_pos_lock,并在临时 struct fd 中记录 FDPUT_POS_UNLOCK。配对的 fdput_pos() 才能知道退出时还要解锁。
file_count(file) > 1 不能被反推成"用户一定拥有两个 fd"。f_count 是内核引用计数,系统调用的临时引用、fork 复制和其他内核持有者都可能影响它。
三、fd flags 与 file status flags 的内部边界
3.1 存储位置决定共享规则

| 用户操作 | 内核状态位置 | dup() 后是否共享 |
|---|---|---|
F_GETFD / F_SETFD 操作的 FD_CLOEXEC |
fdtable 的 close_on_exec 位图 |
否 |
F_GETFL / F_SETFL 操作的可变状态 flags |
struct file->f_flags |
是 |
read/write/lseek 使用的当前位置 |
struct file->f_pos |
是 |
因此,判断某个状态是否共享时,不要从系统调用名字猜,要看它最终存在哪个对象中。
3.2 O_CLOEXEC 为什么不成为共享的 f_flags
O_CLOEXEC 是创建 fd 时原子设置该槽位 FD_CLOEXEC 的请求。open 参数整理阶段会把它从真正进入 file->f_flags 的 flags 中剥离,fd 分配路径则用它设置 close_on_exec 位。
它解决多线程程序中的竞态:
text
有窗口的两步:
open() 得到 fd
fcntl(F_SETFD, FD_CLOEXEC)
原子的一步:
open(..., O_CLOEXEC)
如果另一个线程在两步之间执行 fork()+execve(),新程序可能意外继承这个 fd。O_CLOEXEC 让"创建槽位"和"标记 exec 时关闭"成为同一次 fd 创建操作。
| 操作 | 新槽位的 FD_CLOEXEC |
|---|---|
open() 不带 O_CLOEXEC |
默认关闭 |
open() 带 O_CLOEXEC |
开启 |
dup(oldfd) |
默认关闭,即使 oldfd 开启 |
dup3(oldfd, newfd, O_CLOEXEC) |
开启 |
fork() |
子 fdtable 复制父表当时的各个位 |
execve() |
关闭标记了 FD_CLOEXEC 的槽位 |
四、dup()、fork() 和线程在哪一层建立共享
4.1 dup() 移交的是同一个 file 引用
Linux v6.12.65 中 dup() 系统调用的完整函数如下:
c
SYSCALL_DEFINE1(dup, unsigned int, fildes)
{
int ret = -EBADF;
struct file *file = fget_raw(fildes);
if (file) {
ret = get_unused_fd_flags(0);
if (ret >= 0)
fd_install(ret, file);
else
fput(file);
}
return ret;
}

引用所有权是理解这段代码的关键:
fget_raw()找到原 file 并取得一份引用,没有 pathname walk,也没有分配新 file。get_unused_fd_flags(0)分配新槽位;参数为 0,所以新槽位不设置FD_CLOEXEC。fd_install()把同一个指针和刚取得的引用移交给新槽位。- 如果槽位分配失败,
fput()归还刚才取得的引用。
dup2() / dup3() 还要处理指定目标、原子替换、oldfd == newfd、O_CLOEXEC 和并发预留槽位,但最终安装的仍然是源 file 的引用。
4.2 fork 复制表,CLONE_FILES 共享表

这两种共享不能混为一谈:
| 场景 | files_struct / fdtable |
file 引用 |
|---|---|---|
dup() |
同一张表增加槽位 | 新旧槽位各持有同一个 file |
fork() |
子进程复制出另一张表 | 复制的槽位增加并持有原 file 引用 |
CLONE_FILES |
task 共享同一个 files_struct |
共同使用同一批槽位 |
再次 open() |
分配新槽位 | 槽位持有新 file |
fork 后,子进程 close(3) 只清除子 fdtable 的第 3 项;父槽位仍然存在。但两边槽位都存在时,它们指向同一个 file,所以共享 f_pos 和 f_flags。
线程共享 fdtable 时则只有一个 fd[3]。线程 T1 清除这个槽位后,T2 之后重新使用整数 3 发起的新调用也看不到旧 file。
这里必须区分"已经取得 file 引用的 I/O"和"之后重新用 fd 查表":
text
T2 已经 fdget() 并开始 I/O
T1 随后 close(fd)
T2 正在进行的 I/O:
依靠临时 file 引用继续
T2 之后的新系统调用:
重新查 fdtable,可能得到 EBADF
如果槽位已复用,甚至可能命中另一个对象
五、close():先结束槽位,最后才结束 file
5.1 完整的 close 系统调用入口
Linux v6.12.65 中 close 的完整系统调用函数如下:
c
SYSCALL_DEFINE1(close, unsigned int, fd)
{
int retval;
struct file *file;
file = file_close_fd(fd);
if (!file)
return -EBADF;
retval = filp_flush(file, current->files);
/*
* We're returning to user space. Don't bother
* with any delayed fput() cases.
*/
__fput_sync(file);
/* can't restart close syscall because file table entry was cleared */
if (unlikely(retval == -ERESTARTSYS ||
retval == -ERESTARTNOINTR ||
retval == -ERESTARTNOHAND ||
retval == -ERESTART_RESTARTBLOCK))
retval = -EINTR;
return retval;
}
入口按顺序处理三个不同层次:
file_close_fd(fd)从 fdtable 取出 file,并先清空这个 fd 槽位。filp_flush()执行这一次 close 所需的 flush、锁清理等工作。__fput_sync()归还槽位持有的 file 引用;如果这是最后一份引用,就进入最终释放。
fd 槽位在 flush 和最终释放之前已经清空,所以即使后续工作报告错误,也不能简单重试 close(fd)。另一个线程可能已经把相同的 fd 数字分配给新对象。
5.2 引用释放的完整主线

假设两个槽位来自 dup():
text
初始:
fd[3] ─┐
├─> struct file A
fd[7] ─┘
close(3):
fd[3] -> 空
fd[7] -> struct file A
close(7) 且没有其他引用:
struct file A 才进入最终 __fput
5.3 flush 与 release 不是同一个时机
| 回调 | 生命周期位置 | file 是否可能仍有其他引用 |
|---|---|---|
flush(file, id) |
一个 fd 的 close 路径 | 可能 |
release(inode, file) |
file 最后一份引用释放时 | 不再有普通持有者继续使用该打开实例 |
因此,同一个 file 被 dup() 后,关闭其中一个 fd 可以进入一次 close/flush 处理,但不能因此调用最终 release 并销毁 file。
具体文件系统或驱动可以不实现其中一个或两个回调。VFS 定义生命周期位置,具体实现决定是否需要动作。
f_count 也不能被理解成"每个用户 fd 固定贡献 1,除此之外没有别的引用"。fork 复制、正在执行的系统调用、内核子系统之间传递 file 等都可能持有引用。可靠边界只有一个:
最后一份内核引用消失之前,
struct file不能被释放。
六、unlink() 与 close() 解除不同层次的引用
上一篇已经用 /proc/self/fd/N 中的 (deleted) 和仍可读取的内容验证了 unlink 后继续访问。本节只解释为什么。
6.1 名字引用与打开引用

两类操作改变的对象不同:
text
unlink:
解除"父目录中的名字 -> inode"链接
减少 inode 的 i_nlink
close/fput:
解除"fd 或内核持有者 -> struct file"引用
最后再释放 file 持有的 path、inode 相关引用
最后一个硬链接消失,只说明对象不再能通过原目录名字找到;已打开 file 仍持有访问所需的路径和 inode 相关引用。
6.2 什么时候才具备回收条件
对典型本地文件系统,可以先使用下面的普通心智模型:
text
i_nlink > 0:
至少还有目录名字引用 inode
i_nlink == 0,但仍有打开 file:
没有名字,旧 fd 仍可使用
i_nlink == 0,打开引用也结束:
文件系统具备最终回收 inode 和数据的条件
最后一句是"具备条件",不是对物理回收时刻的绝对承诺。mmap、page cache、日志、延迟删除和具体文件系统内部状态都可能让实际回收晚于最后一次用户态 close()。
dentry、inode、mount 和 superblock 的详细对象关系由下一篇继续展开;本文只需要确认名字生命周期与打开实例生命周期相互独立。
七、完整 fork 实验:表独立,file 共享
上一篇的完整实验已经验证独立 open()、dup()、两类 flags 和 unlink。本篇换成一个不重复的 fork 专项实验。
实验同时验证:
- 子进程先读取两个字节,父进程随后从共享位置继续读取。
- 子进程修改
O_APPEND,父进程从共享 file 观察到变化。 - 子进程关闭自己的 fd 后,父 fdtable 中的槽位仍然有效。
完整源码位于 assets/fork-file-sharing-demo.c,正文贴出全部头文件、错误处理、同步、断言和资源释放:
c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *what)
{
perror(what);
exit(EXIT_FAILURE);
}
static void write_all(int fd, const void *buffer, size_t length)
{
const char *p = buffer;
while (length > 0) {
ssize_t n = write(fd, p, length);
if (n < 0) {
if (errno == EINTR)
continue;
die("write");
}
if (n == 0) {
fprintf(stderr, "write returned zero\n");
exit(EXIT_FAILURE);
}
p += n;
length -= (size_t)n;
}
}
static void read_exact(int fd, char *buffer, size_t length)
{
size_t done = 0;
while (done < length) {
ssize_t n = read(fd, buffer + done, length - done);
if (n < 0) {
if (errno == EINTR)
continue;
die("read");
}
if (n == 0) {
fprintf(stderr, "unexpected EOF\n");
exit(EXIT_FAILURE);
}
done += (size_t)n;
}
buffer[length] = '\0';
}
static void wait_for_success(pid_t child)
{
int status;
pid_t result;
do {
result = waitpid(child, &status, 0);
} while (result < 0 && errno == EINTR);
if (result < 0)
die("waitpid");
if (!WIFEXITED(status) || WEXITSTATUS(status) != EXIT_SUCCESS) {
fprintf(stderr, "child failed: status=0x%x\n", status);
exit(EXIT_FAILURE);
}
}
int main(void)
{
char path[] = "/tmp/fork-file-sharing-XXXXXX";
char child_bytes[3];
char parent_bytes[3];
int report_pipe[2];
int shared_fd;
int status_flags;
off_t position;
pid_t child;
shared_fd = mkstemp(path);
if (shared_fd < 0)
die("mkstemp");
write_all(shared_fd, "ABCDEFGHIJ", 10);
if (lseek(shared_fd, 0, SEEK_SET) < 0)
die("lseek start");
if (pipe(report_pipe) < 0)
die("pipe");
child = fork();
if (child < 0)
die("fork");
if (child == 0) {
if (close(report_pipe[0]) < 0)
die("child close read end");
read_exact(shared_fd, child_bytes, 2);
write_all(report_pipe[1], child_bytes, 2);
status_flags = fcntl(shared_fd, F_GETFL);
if (status_flags < 0)
die("child F_GETFL");
if (fcntl(shared_fd, F_SETFL, status_flags | O_APPEND) < 0)
die("child F_SETFL");
if (close(shared_fd) < 0)
die("child close shared fd");
if (close(report_pipe[1]) < 0)
die("child close write end");
_exit(EXIT_SUCCESS);
}
if (close(report_pipe[1]) < 0)
die("parent close write end");
wait_for_success(child);
read_exact(report_pipe[0], child_bytes, 2);
if (close(report_pipe[0]) < 0)
die("parent close read end");
read_exact(shared_fd, parent_bytes, 2);
position = lseek(shared_fd, 0, SEEK_CUR);
if (position < 0)
die("lseek current");
status_flags = fcntl(shared_fd, F_GETFL);
if (status_flags < 0)
die("parent F_GETFL");
printf("child read before close = %s\n", child_bytes);
printf("parent read after child close = %s\n", parent_bytes);
printf("shared file position = %lld\n",
(long long)position);
printf("parent observes O_APPEND = %d\n",
!!(status_flags & O_APPEND));
printf("parent fd remained valid = yes\n");
if (strcmp(child_bytes, "AB") != 0 ||
strcmp(parent_bytes, "CD") != 0 ||
position != 4 ||
!(status_flags & O_APPEND)) {
fprintf(stderr, "unexpected sharing semantics\n");
exit(EXIT_FAILURE);
}
if (close(shared_fd) < 0)
die("parent close shared fd");
if (unlink(path) < 0)
die("unlink");
return EXIT_SUCCESS;
}
7.1 编译与运行
在仓库根目录执行:
sh
docker run --rm --platform linux/amd64 \
-v "$PWD/os/filesystem/assets:/src:ro" \
gcc:13 \
sh -c 'gcc -O2 -Wall -Wextra -Werror -std=c11 \
/src/fork-file-sharing-demo.c -o /tmp/fork-demo &&
/tmp/fork-demo'
实际输出:
text
child read before close = AB
parent read after child close = CD
shared file position = 4
parent observes O_APPEND = 1
parent fd remained valid = yes
7.2 为什么使用 pipe 和 waitpid()
实验要证明共享位置,必须确定子进程先读、父进程后读。仅仅在 fork 后让父子各自调用 read(),调度顺序不确定,父进程也可能先读到 AB。
程序用两种机制建立确定顺序:
- 子进程把自己读到的两个字节写入
report_pipe,父进程因此可以取得子进程的真实结果。 - 父进程用
waitpid()等到子进程完成读取、设置 flags、关闭 fd 并退出,之后才读取共享 file。
pipe 在这里只负责报告和同步,不是被测试的文件内容。
7.3 输出怎样对应两层共享关系
fork 完成后的初始状态:
text
父 fdtable:shared_fd -> struct file A
子 fdtable:shared_fd -> struct file A
两张 fdtable 相互独立;
两个槽位持有同一个 file A。
子进程先读取 AB,使 file A 的 f_pos 从 0 变成 2;它再设置 file A 的 O_APPEND,最后只清除子 fdtable 中的槽位。
父进程等待子进程退出后:
- 仍能使用父 fdtable 中的
shared_fd,说明子 close 没有清除父槽位。 - 读取到
CD而不是AB,说明父子共享 file A 的f_pos。 - 查询到
O_APPEND=1,说明父子共享 file A 的f_flags。 - 最终位置为 4,符合两次顺序读取各推进 2 字节。
因此,这个实验同时展示:
text
fdtable 的修改边界:父子独立
struct file 的状态边界:父子共享
八、把打开实例的生命周期压成一张图

这张图把本文的边界固定下来:
- fdtable 槽位决定"当前 task 用哪个整数找到 file"。
struct file决定"这一次打开共享哪些状态、调用哪些操作"。- inode 和路径对象决定"打开实例最终关联谁",由下一篇继续展开。
- close 解除 fd/file 引用,unlink 解除名字/inode 链接,两者不是同一种生命周期操作。
参考资料与源码
- Linux v6.12.65:
include/linux/fs.h:struct file、get_file()与file_count()。 - Linux v6.12.65:
fs/file.c:fdget_pos()、dup()、fork 复制 fdtable 与 fd install。 - Linux v6.12.65:
fs/open.c:close()、filp_flush()与filp_close()。 - Linux v6.12.65:
fs/file_table.c:fput()、__fput()与最终release路径。 - Linux 内核文档:Overview of the Linux Virtual File System:file object 与
file_operations。 - Linux 内核文档:File management in the Linux kernel:fdtable 的 RCU、锁和引用计数。
- open(2)、dup(2)、fork(2):open file description 的用户态语义。
- 下一篇:VFS 对象地图。