VFS File Object
The file object —
struct file, defined ininclude/linux/fs.h(torvalds/linux v6.12) — is the kernel’s in-memory representation of a single open instance of a file. It is created when a process callsopen(2)(and a handful of internal openers), it carries the cursor (f_pos), the access mode (f_mode), the open-time flags (f_flags), a pointer to the file’s method table (f_op), the page-cache anchor (f_mapping), and the(dentry, vfsmount)pair that locates the file in the mounted tree (f_path). It is reference-counted (f_count), shared acrossdup()andfork(), and torn down by the lastfput(). The crucial distinction: an inode is per-file (one per file on the filesystem, shared by every opener), whereas astruct fileis per-open — twoopen()calls on the same path yield two independentstruct files with independent positions.
This note is about the kernel object itself — what fields it holds and how read/write dispatch through it. The POSIX-level semantics of the same object (it is what POSIX calls the open file description, and how dup/fork make several file descriptors share one) are owned by The struct file and Open File Description; the small-integer file descriptors that point at struct files and the per-process table that holds them are owned by File Descriptors and the fd Table. Read those for the userspace-visible behavior; read this for the data structure.
Mental Model
Think of the open-file machinery as a three-level chain, narrowing from per-process to per-system:
flowchart LR subgraph P["Process A"] FDA["fd 3<br/>(int index)"] end subgraph Q["Process B (forked)"] FDB["fd 3<br/>(int index)"] end FDA --> FILE["struct file<br/>f_pos, f_flags, f_mode<br/>f_op, f_mapping, f_path<br/>f_count = 2"] FDB --> FILE FILE --> PATH["f_path<br/>(vfsmount + dentry)"] PATH --> DEN["dentry<br/>(name to inode)"] DEN --> INO["struct inode<br/>(per-file metadata,<br/>shared by all openers)"] FILE -.f_mapping.-> AS["address_space<br/>(page cache)"] AS -.-> INO FILE -.f_op.-> OPS["file_operations<br/>(read_iter, write_iter,<br/>llseek, mmap, ...)"]
The fd → file → inode chain. What it shows: small-integer file descriptors in each process index into a struct file; the struct file holds the per-open cursor and points (via f_path → dentry) at the single shared struct inode; f_op is the function-pointer table the VFS calls through, and f_mapping is the page-cache handle. The insight to take: the offset lives in struct file, not in the inode and not in the descriptor — that is exactly why a forked child shares its parent’s f_pos (both fds point at the same struct file, f_count == 2) but two independent open()s of the same path do not.
Anatomy of struct file (v6.12)
The v6.12 layout was deliberately reorganized for cache efficiency — the kernel-doc comment in fs.h annotates cache-line boundaries, and the structure is __randomize_layout-protected and 4-byte aligned. The verbatim definition (fs.h:1032):
struct file {
atomic_long_t f_count; /* reference count */
spinlock_t f_lock; /* protects f_ep, f_flags */
fmode_t f_mode; /* FMODE_* — hot-path access flags */
const struct file_operations *f_op; /* the method table */
struct address_space *f_mapping; /* page cache for this file's data */
void *private_data; /* fs/driver scratch pointer */
struct inode *f_inode; /* cached inode (== f_path.dentry->d_inode) */
unsigned int f_flags; /* O_* open flags */
unsigned int f_iocb_flags;/* precomputed iocb flags */
const struct cred *f_cred; /* credentials of the opener */
/* --- cacheline 1 boundary (64 bytes) --- */
struct path f_path; /* {vfsmount *mnt; dentry *dentry} */
union {
struct mutex f_pos_lock; /* serializes f_pos for FMODE_ATOMIC_POS + dirs */
u64 f_pipe; /* pipes reuse this space */
};
loff_t f_pos; /* current file position (cursor) */
void *f_security; /* LSM context (CONFIG_SECURITY) */
/* --- cacheline 2 boundary (128 bytes) --- */
struct fown_struct *f_owner; /* SIGIO/async owner (now a pointer)*/
errseq_t f_wb_err; /* per-open writeback error cursor */
errseq_t f_sb_err; /* per-open sb writeback error cursor */
struct hlist_head *f_ep; /* epoll hooks (CONFIG_EPOLL) */
union { /* overlapped: only one is live */
struct callback_head f_task_work;
struct llist_node f_llist;
struct file_ra_state f_ra; /* readahead state */
freeptr_t f_freeptr;/* SLAB_TYPESAFE_BY_RCU free pointer */
};
/* --- cacheline 3 boundary (192 bytes) --- */
} __randomize_layout __attribute__((aligned(4)));The fields that matter conceptually, walked one at a time:
f_count— anatomic_long_treference count. As long as it is non-zero the object is alive; the last decrement to zero triggers teardown.get_file()increments it (with aWARN_ONCEif it was already zero, which would mean a use-after-free), andfput()decrements it.file_count(x)reads it. Note that in v6.12 this is the first field in the struct — it was moved to the front and madeatomic_long_tas part of the lockless-fget(SLAB_TYPESAFE_BY_RCU) work; older kernels placed it differently and usedatomic_t. Pin this to v6.12.f_mode— anfmode_tbitmask ofFMODE_*flags that the kernel checks on every hot-path operation:FMODE_READ,FMODE_WRITE,FMODE_CAN_READ/FMODE_CAN_WRITE(set at open time iff the operation table actually provides a method),FMODE_LSEEK,FMODE_STREAM(no seekable position, e.g. pipes),FMODE_PATH(anO_PATHopen), andFMODE_OPENED. It is derived from the open flags byOPEN_FMODE(flags)and then refined infile_init_path().f_flags— the rawO_*open flags (O_RDONLY/O_WRONLY/O_RDWR,O_APPEND,O_NONBLOCK,O_DIRECT,O_CLOEXECsemantics live on the fd not here, etc.).f_modeis the kernel’s distilled, fast-to-test form;f_flagsis the userspace-supplied original.fcntl(fd, F_SETFL, ...)mutatesf_flagsafter open. See Open Flags and Access Modes.f_op—const struct file_operations *, the per-open method table. This is the heart of VFS dispatch: every operation on the open file (read,write,llseek,mmap,ioctl,poll,fsync,release, …) is a call through this pointer. The concrete filesystem (or device driver) supplies the table; the VFS never reaches into filesystem internals directly. Detailed in VFS Operation Tables.f_mapping—struct address_space *, the page-cache index for this file’s data. Normally it isf_inode->i_mapping, copied in at open time. Aread()that misses the page cache, awrite()that dirties pages, anmmap()of the file — all go throughf_mapping. This is the seam with The Page Cache and address_space.f_inode— a cached pointer to thestruct inode(identical tof_path.dentry->d_inode). It exists so hot paths can reach the inode without dereferencing throughf_path.dentry.f_path— astruct path, which is exactly{ struct vfsmount *mnt; struct dentry *dentry; }(path.h). The dentry binds the file to its name and inode in the dcache; the vfsmount records which mount the file was opened through. Both halves matter: a single inode can be reachable through several bind mounts, andf_path.mntrecords the specific one — which is what makes/proc/<pid>/fd/<n>symlinks resolve to the right mount-qualified path. The kernel takes a reference on both (path_get) at open and drops it (path_put, i.e.dput+mntput) at the finalfput.f_pos—loff_t(a signed 64-bit offset), the read/write cursor. This is the per-open piece of state: it lives here, not in the inode, which is the entire reason independent opens have independent positions. For regular files and directories opened withFMODE_ATOMIC_POS, concurrent updates are serialized by the embeddedf_pos_lockmutex (see below).f_cred— the credentials (struct cred) captured at open time. Later permission checks on the open file use these, not the caller’s current credentials — which is why an already-open fd keeps working even if the process later drops privileges.private_data— an opaquevoid *the filesystem or driver stashes per-open state in. Character drivers, for instance, almost universally allocate a per-open context in their->openmethod and hang it here.f_wb_err/f_sb_err—errseq_t“error cursors” for writeback-error reporting.fsync()/close()compare the file’s snapshot against the mapping’s current error sequence so that a writeback failure is reported exactly once to each open file description, even across multiplefsync()calls. This is the modern fix for the “lost write error after the first fsync” class of bugs (LWN: Improved block-layer error handling). See fsync fdatasync and Durability.- The trailing union (
f_task_work/f_llist/f_ra/f_freeptr) overlaps four mutually-exclusive uses to save space: the deferred-__fputwork item, the delayed-fput list node, the readahead state for files that actually read, and the SLAB free pointer used while the object sits on the type-safe-by-RCU free list.
Field reference: what each field holds, who writes it, and where the state really lives
The bullet walk-through above explains the fields; this table answers the question that actually matters when you are debugging — if I change this, who else sees it? The “Scope” column is the load-bearing one. Per-open means the value is private to this struct file, so it is shared by every file descriptor that aliases this object (dup, fork, SCM_RIGHTS) and by nobody else. Cached from inode means the field is a copy of a pointer that every opener of the file shares, so it is effectively per-file. Per-fd state is not in this structure at all — it lives in the descriptor table and is listed here only because people expect to find it here and do not.
| Field | Type | Written by | Scope | Note |
|---|---|---|---|---|
f_count | atomic_long_t | init_file() (set to 1, deliberately last); get_file()/fput() | per-open | Counts references to this object, not openers of the file. dup() and fork() raise it; a second open() does not. |
f_lock | spinlock_t | init_file() | per-open | Protects f_ep and f_flags mutation. |
f_mode | fmode_t | OPEN_FMODE(flags) in init_file(), refined in file_init_path() | per-open | The kernel’s distilled access mask; the hot-path checks read this, never f_flags. |
f_op | const struct file_operations * | file_init_path() from inode->i_fop; a driver ->open may overwrite it | per-open pointer, shared table | Two opens of one device can end up on different tables (this is how ->open specializes behaviour per open). |
f_mapping | struct address_space * | file_init_path(): = dentry->d_inode->i_mapping | cached from inode | The page cache is per-file, never per-open. See The Page Cache and address_space. |
private_data | void * | the filesystem/driver ->open method | per-open | Exists because the object is per-open: it is where a driver hangs the state belonging to this particular opening. |
f_inode | struct inode * | file_init_path(): = path->dentry->d_inode | cached from inode | A shortcut so hot paths skip f_path.dentry->d_inode. |
f_flags | unsigned int | init_file() from the open(2) argument; later fcntl(F_SETFL) | per-open | Only the status flags survive here; see the flag table below. |
f_iocb_flags | unsigned int | file_init_path(): = iocb_flags(file) | per-open, derived | Precomputed IOCB_* bits so init_sync_kiocb() does not re-derive them per I/O. |
f_cred | const struct cred * | init_file(): get_cred(current_cred()) | per-open, frozen at open | Why an fd keeps working after the process drops privileges. |
f_path | struct path = {mnt, dentry} | file_init_path(), with path_get() taking both references | per-open | Records which mount the file was opened through — demonstrated below. |
f_pos_lock / f_pipe | union: struct mutex / u64 | init_file() initialises the mutex; pipes re-init the u64 | per-open | Serialises f_pos for FMODE_ATOMIC_POS files and directories. |
f_pos | loff_t | read/write/llseek write-back in ksys_read()/ksys_write() | per-open — the canonical case | The single field that makes “one open” a meaningful unit. |
f_security | void * | security_file_alloc() in init_file() | per-open | Present only under CONFIG_SECURITY. |
f_owner | struct fown_struct * | lazily allocated on fcntl(F_SETOWN) | per-open | A pointer in v6.12; it used to be embedded. SIGIO/SIGURG delivery target. |
f_wb_err / f_sb_err | errseq_t | sampled in file_init_path(); advanced by fsync/close | per-open snapshot of shared state | The counter is on the mapping/superblock; each open carries its own cursor into it, which is how one writeback error is reported once per open file description. |
f_ep | struct hlist_head * | epoll registration | per-open | Present only under CONFIG_EPOLL. See epoll and Scalable Readiness Notification. |
f_task_work / f_llist / f_ra / f_freeptr | union | fput (task-work), readahead, the slab allocator | per-open | Four mutually exclusive lifetimes packed into one slot. |
(FD_CLOEXEC) | bit in fdtable->close_on_exec | open(O_CLOEXEC), fcntl(F_SETFD) | per-fd, NOT in struct file | The most common misplacement. Proven below: /proc/<pid>/fdinfo has to synthesise the O_CLOEXEC bit back into its flags: line because it is not stored here (fs/proc/fd.c:42-44). |
Field reference for struct file in v6.12. What it shows: every field, the code that writes it, and whether the value belongs to this one open, to the underlying file, or to the descriptor. The insight to take: exactly one field — f_pos — is the reason this object exists as a separate layer between the descriptor and the inode. Everything else is either a cached pointer into per-file state (f_inode, f_mapping), a per-open policy decision frozen at open time (f_mode, f_cred, f_flags), or bookkeeping for the object’s own lifetime (f_count, the trailing union).
Two initialisation details in file_init_path() (fs/file_table.c:291-312) are worth calling out because they are invisible from userspace but change behaviour. First, FMODE_LSEEK is set only if fop->llseek is non-NULL, and FMODE_CAN_READ/FMODE_CAN_WRITE only if the table actually supplies a read/write method — so the “can I do this?” question is answered once at open time by inspecting the vtable, not on every syscall. Second, a read-only open calls i_readcount_inc() on the inode, bumping the inode’s reader count; that count is what makes ETXTBSY (writing to a running executable) and the deny-write machinery work, and it is incremented only when (f_mode & (FMODE_READ|FMODE_WRITE)) == FMODE_READ — a read-write open does not participate.
f_flags: which O_* flags actually end up here
open(2) takes one flags word, but its bits have three different fates. Only the third group is what f_flags holds for the life of the open.
| Group | Examples | Where the bit ends up | Observable in fdinfo flags:? |
|---|---|---|---|
| Access mode (2 bits) | O_RDONLY, O_WRONLY, O_RDWR | converted to FMODE_READ/FMODE_WRITE in f_mode by OPEN_FMODE(), and kept verbatim in f_flags | yes (low two bits) |
| Creation / act-during-open flags | O_CREAT, O_EXCL, O_TRUNC, O_DIRECTORY, O_NOFOLLOW, O_TMPFILE | consumed by path resolution and do_dentry_open; they alter what the open does and then have no further meaning | stripped by do_dentry_open — not stored |
| Status flags | O_APPEND, O_NONBLOCK, O_DIRECT, O_NOATIME, O_SYNC/O_DSYNC, O_LARGEFILE, O_PATH | stored in f_flags; mutable afterwards with fcntl(F_SETFL) | yes |
| Descriptor flag | O_CLOEXEC | the close_on_exec bitmap in the fd table — never in struct file | yes, but synthesised: fs/proc/fd.c ORs it back in from the fd table |
Where the O_* bits go. What it shows: the single flags argument is demultiplexed into three destinations with three different lifetimes. The insight to take: “is this flag per-open or per-fd?” is decided entirely by which row it is in — which is why fcntl(F_SETFL, O_NONBLOCK) on one dup()ed fd affects its twin (same struct file, same f_flags) while fcntl(F_SETFD, FD_CLOEXEC) does not (different fd, different bitmap entry). The per-flag detail is owned by Open Flags and Access Modes.
How read/write dispatch through f_op
The single most important behavior of struct file is that it is the dispatch vehicle: the VFS turns a syscall into a method call through f_op. Tracing read(2) end to end in v6.12 (fs/read_write.c):
SYSCALL_DEFINE3(read, …)callsksys_read(fd, buf, count).ksys_readcallsfdget_pos(fd). This looks the fd up in the per-process descriptor table to get thestruct file *, takes a reference (or borrows one), and — for files with a real position — takesf_pos_lockso concurrentread/writeon the samestruct fileserialize on the cursor. This is the mechanism behind POSIX’s “concurrent reads/writes on a shared open file description are atomic w.r.t. the offset.”- It reads the cursor:
pos = *file_ppos(file).file_ppos()returns&file->f_posfor seekable files, orNULLforFMODE_STREAMfiles (pipes, sockets), in which case there is no position to advance. - It calls
vfs_read(file, buf, count, ppos). vfs_readenforces access:-EBADFif!(f_mode & FMODE_READ),-EINVALif!(f_mode & FMODE_CAN_READ)— note these are exactly thef_modebits, andFMODE_CAN_READwas set at open time only iff_op->read || f_op->read_iterexisted. It validates the user buffer and clampscounttoMAX_RW_COUNT.- The actual dispatch:
if (file->f_op->read) ret = file->f_op->read(file, buf, count, pos); else if (file->f_op->read_iter) ret = new_sync_read(...); else ret = -EINVAL;. Modern filesystems implement onlyread_iter(the iterator-based form), so the common path isnew_sync_read, which wraps the buffer in aniov_iter, builds astruct kiocbinitialized from the file, and callsfilp->f_op->read_iter(&kiocb, &iter). - On success,
ksys_readwrites the advanced position back:file->f_pos = pos, thenfdput_pos(f)dropsf_pos_lockand the reference.
write(2) is the mirror image through vfs_write → f_op->write or new_sync_write → f_op->write_iter. The takeaway: the VFS supplies the generic scaffolding (lookup, locking, access checks, position management) and the concrete filesystem supplies only the f_op methods. ext4, XFS, a character device, a pipe, and /proc all present the same struct file shape to the upper layers; they differ only in which file_operations table f_op points at.
Drawn as an exchange, read(2) looks like this — note that steps 2 and 7 bracket the whole call with the position lock, which is what makes the offset update atomic with respect to a concurrent read on a dup()ed descriptor:
sequenceDiagram autonumber participant U as User process participant SC as ksys_read (syscall) participant FDT as fd table participant F as struct file participant VFS as vfs_read participant FS as f_op (ext4, tmpfs, driver, ...) U->>SC: read(fd, buf, count) SC->>FDT: fdget_pos(fd) FDT-->>SC: struct file * (ref taken) SC->>F: mutex_lock(f_pos_lock) if FMODE_ATOMIC_POS SC->>F: pos = *file_ppos(file) Note over F: file_ppos returns NULL for<br/>FMODE_STREAM (pipes, sockets) SC->>VFS: vfs_read(file, buf, count, &pos) VFS->>F: check f_mode alt no FMODE_READ VFS-->>SC: -EBADF else no FMODE_CAN_READ VFS-->>SC: -EINVAL else permitted VFS->>FS: f_op->read, else new_sync_read -> f_op->read_iter FS-->>VFS: bytes read, pos advanced end VFS-->>SC: ret SC->>F: file->f_pos = pos SC->>F: fdput_pos - unlock f_pos_lock, drop ref SC-->>U: ret
read(2) from syscall entry to the filesystem method. What it shows: the fixed scaffolding the VFS runs around every read — descriptor lookup, position lock, f_mode gate, dispatch through f_op, position write-back — and the two distinct error codes that the two different f_mode bits produce. The insight to take: the only filesystem-specific step is the single arrow into f_op; everything before and after it is generic code operating on struct file fields, which is precisely why a pipe, an ext4 file and a character device can all be read through the same syscall.
The f_op contract: what the VFS does when a method is NULL
A file_operations table has no required members. Every slot is optional, and “optional” has a specific meaning per method: the VFS either substitutes a generic implementation, silently skips the step, or returns a particular errno. Knowing which is which turns “why did this return ESPIPE?” into a one-line answer. The v6.12 table has 30-odd members (fs.h:2062-2105); these are the ones reachable from a syscall on an ordinary open file.
| Method | Reached from | Behaviour when NULL | Verified at |
|---|---|---|---|
read / read_iter | read, readv, pread | FMODE_CAN_READ is never set at open, so vfs_read returns -EINVAL (if FMODE_READ itself is clear it is -EBADF instead) | fs/read_write.c, fs/file_table.c:301 |
write / write_iter | write, writev, pwrite | mirror image: -EINVAL, or -EBADF without FMODE_WRITE | fs/read_write.c |
llseek | lseek | FMODE_LSEEK is never set, so vfs_llseek returns -ESPIPE (“Illegal seek”) | fs/read_write.c:378-383 |
iterate_shared | getdents, getdents64 | iterate_dir returns -ENOTDIR — this is how “you cannot readdir a regular file” is enforced | fs/readdir.c:85-91 |
poll | poll, select, epoll | vfs_poll returns DEFAULT_POLLMASK — i.e. the file is reported always ready for read and write. This is why an ordinary file is never “not ready” in select() | include/linux/poll.h:80-85 |
unlocked_ioctl | ioctl | -ENOTTY (“Inappropriate ioctl for device”) | fs/ioctl.c:46-53 |
mmap | mmap | -ENODEV | mm/mmap.c:421-422 |
fsync | fsync, fdatasync | -EINVAL | fs/sync.c:184-185 |
open | open, openat | skipped — the open simply succeeds with the defaults file_init_path() installed; a NULL ->open is the common case for ordinary files | fs/open.c:956 |
flush | every close, and fput of a shared file | skipped; close() returns 0 | fs/open.c:1526-1527 |
release | the last fput only | skipped | fs/file_table.c:430-431 |
file_operations members reachable from a syscall, and the VFS’s behaviour when the filesystem leaves one NULL. What it shows: three distinct NULL policies — substitute a default (poll), skip silently (open, flush, release), or fail with a method-specific errno (everything else). The insight to take: several familiar error codes are not the filesystem’s decision at all but the VFS reporting an absent vtable slot, and two of them (-ESPIPE, -EINVAL on read) are decided at open time by caching the answer into f_mode rather than re-checking per call. The five operation tables as a family are covered in VFS Operation Tables; this table is specifically the per-method fallback contract for the one table f_op points at.
Note also the asymmetry between flush and release, visible in the last two rows and a routine source of confusion: ->flush runs on every close(2) of any descriptor referring to the file, while ->release runs exactly once, when f_count finally hits zero. A process that dup()s an fd and closes one copy triggers ->flush but not ->release.
Lifecycle: birth at open(), death at the last fput()
A struct file is allocated from a dedicated slab cache, filp_cachep, created in files_init() with the SLAB_TYPESAFE_BY_RCU flag — meaning a freed struct file may be immediately reused for another struct file, which is what lets fget-by-RCU readers probe a descriptor locklessly and re-validate (fs/file_table.c).
Allocation. alloc_empty_file(flags, cred) first enforces the system-wide limit: if get_nr_files() >= files_stat.max_files and the caller lacks CAP_SYS_ADMIN, it does an expensive precise percpu_counter_sum_positive() check and returns -ENFILE (“file-max limit reached”) if truly over. Otherwise it kmem_cache_zallocs a zeroed object and calls init_file(), which stashes the credentials, runs security_file_alloc() (the LSM hook), initializes f_lock and f_pos_lock, sets f_flags = flags and f_mode = OPEN_FMODE(flags), and — deliberately last, because of SLAB_TYPESAFE_BY_RCU — does atomic_long_set(&f->f_count, 1). The comment is explicit: “initialize f_count last … fget-rcu pattern users need to be able to handle spurious refcount bumps.”
Binding to a path. Once the path has been resolved, file_init_path(file, path, fop) wires the object to its file: it copies f_path = *path, sets f_inode and f_mapping from the dentry’s inode, samples the writeback-error cursors, sets FMODE_CAN_READ/FMODE_CAN_WRITE only if the operation table provides the corresponding method, sets f_iocb_flags, marks FMODE_OPENED, and finally assigns file->f_op = fop. The driver/filesystem ->open method runs around here and may overwrite f_op or stash private_data.
Reference sharing. get_file() (or dup/fork installing the same struct file under a second fd) bumps f_count. Crucially, sharing the struct file means sharing f_pos — see fork dup and Shared File Offsets.
Teardown. fput(file) does atomic_long_dec_and_test(&file->f_count); if that was the last reference, the real cleanup must run, but not necessarily immediately. For a normal task, fput queues __fput as task work (task_work_add(..., TWA_RESUME)) so it runs when the task returns to userspace — this avoids doing the potentially-sleeping teardown in awkward contexts. For kernel threads / interrupt context it falls back to a per-CPU delayed_fput_list flushed by a workqueue. The actual guts, __fput(), run the cleanup chain in a fixed order: fsnotify_close(), eventpoll_release(), locks_remove_file(), security_file_release(), then f_op->release(inode, file) (the filesystem/driver’s chance to free private_data), fops_put(), dropping the f_owner, dput(dentry) and mntput(mnt) (releasing the f_path references), and finally file_free() back to the slab. The order is load-bearing — the comment in __fput insists eventpoll_release() must be first in the chain.
The whole lifetime, drawn as the state machine the reference count implements:
stateDiagram-v2 [*] --> Allocated: alloc_empty_file()<br/>kmem_cache_zalloc(filp_cachep)<br/>ENFILE checked here Allocated --> Initialised: init_file()<br/>f_cred, f_lock, f_pos_lock,<br/>f_flags, f_mode, then f_count = 1 Initialised --> Opened: file_init_path()<br/>f_path, f_inode, f_mapping,<br/>FMODE_CAN_READ/WRITE, f_op<br/>then optional f_op->open() Opened --> Installed: fd_install(fd, file)<br/>visible to userspace Installed --> Installed: get_file() / dup() / fork()<br/>f_count++ Installed --> Installed: fput() with f_count > 1<br/>f_op->flush() runs, object survives Installed --> LastPut: final fput()<br/>f_count reaches 0 LastPut --> DeferredTaskWork: normal task<br/>task_work_add(TWA_RESUME) LastPut --> DeferredWorkqueue: kernel thread / atomic ctx<br/>delayed_fput_list LastPut --> Teardown: __fput_sync()<br/>(narrow callers only) DeferredTaskWork --> Teardown: on return to userspace DeferredWorkqueue --> Teardown: flushed by workqueue Teardown --> Freed: __fput()<br/>eventpoll_release, locks_remove_file,<br/>f_op->release, dput, mntput Freed --> Allocated: SLAB_TYPESAFE_BY_RCU<br/>slot may be reused immediately Freed --> [*]
The struct file lifetime as a state machine. What it shows: the two-step birth (allocate, then bind to a path), the reference-counted plateau where dup/fork and non-final closes keep the object alive, and the three routes from “last reference dropped” to actual teardown. The insight to take: two edges here are the source of most surprises. Freed --> Allocated is SLAB_TYPESAFE_BY_RCU: the memory can become a different struct file before an RCU grace period elapses, which is why init_file() sets f_count last and why lockless readers must use get_file_rcu/get_file_active and re-validate. And LastPut --> DeferredTaskWork means close(2) can return before f_op->release has run — the cleanup is queued, not synchronous.
Uncertain
Verify: the exact field set and ordering shown above is from v6.12
include/linux/fs.h. Thestruct filelayout changed materially across recent releases (e.g.f_countmoved to the front and becameatomic_long_t;f_ownerbecame a pointer;f_versionwas removed; the readahead/task-work union was reworked) as part of the lockless-fgetand cache-line work. Reason: layout is volatile release-to-release. To resolve: diffinclude/linux/fs.hbetween the specific tag in question and v6.12 before quoting field offsets for any other kernel. uncertain
file vs inode: per-open vs per-file (the core distinction)
This is the single most common point of confusion, so it is worth stating precisely. A struct inode (VFS Inode Object) is the canonical in-memory representation of a file — its mode, owner, size, timestamps, link count, and the pointers into on-disk data. There is exactly one live inode per file on a given filesystem, no matter how many processes have it open. A struct file is the representation of one act of opening that file. Consequences:
- Position is per-open.
f_poslives instruct file. Two unrelatedopen("/x")calls produce twostruct files with independent cursors — reading from one does not move the other. Conversely, adup()’d orfork()’d descriptor shares the samestruct file, so the cursors move together. The offset is therefore neither in the descriptor (too narrow) nor in the inode (too broad). - Access mode is per-open. Opening read-only vs read-write is a property of the
struct file(f_mode), not the inode. The same inode can be open read-only in one process and read-write in another. - Flags are per-open.
O_APPEND,O_NONBLOCK,O_DIRECTlive inf_flags. Changing them withfcntl(F_SETFL)affects only that open file description. - Metadata is per-file. File size, timestamps, permissions, link count are in the inode and are shared by all openers —
ftruncate()on one fd is visible through every other open of the same file because they all reach the same inode.
A useful mnemonic: the inode answers “what is this file?”; the struct file answers “how is this process currently looking at it?”
Observing struct file From Userspace
Everything above is kernel-internal, but four of the fields are exported verbatim, and that makes the abstraction directly testable. All output below was measured on 2026-09-12 on Fedora 44, kernel 7.1.13-200.fc44.x86_64 (uname -r) — a stable-series kernel, not the v6.12 tag the source citations point at; the fields concerned have not changed shape between them, but treat the numbers as this host’s, not as v6.12’s.
/proc/<pid>/fdinfo/<fd> is a literal dump of four struct file fields
fs/proc/fd.c builds the file with a single seq_printf (fd.c:57-60):
seq_printf(m, "pos:\t%lli\nflags:\t0%o\nmnt_id:\t%i\nino:\t%lu\n",
(long long)file->f_pos, f_flags,
real_mount(file->f_path.mnt)->mnt_id,
file_inode(file)->i_ino);fdinfo line | struct file expression | What it proves |
|---|---|---|
pos: | file->f_pos | the cursor is in this object |
flags: | file->f_flags, with O_CLOEXEC OR-ed in from the fd table | the status flags are per-open; FD_CLOEXEC is not |
mnt_id: | real_mount(file->f_path.mnt)->mnt_id | f_path carries a mount, not just a name |
ino: | file_inode(file)->i_ino | the inode is the shared, per-file half |
Mapping from fdinfo output to kernel fields. What it shows: the file is not a summary, it is four field reads. The insight to take: because pos, mnt_id and ino come from three different layers of the chain, a single fdinfo dump tells you whether two descriptors alias the same struct file (same pos moving together), the same mount, and/or the same inode — no debugger required.
Per-open cursors, observed
A small program opens /tmp/w2demo (16 bytes), opens it a second time, and dup()s the first descriptor, then reads three bytes through the first:
== start ==
open A fd=3 fdinfo: pos: 0 flags: 0100002 mnt_id: 61 ino: 1400308
open B (2nd open) fd=4 fdinfo: pos: 0 flags: 0100000 mnt_id: 61 ino: 1400308
dup(A) fd=5 fdinfo: pos: 0 flags: 0100002 mnt_id: 61 ino: 1400308
== after 3 reads on A ==
A fd=3 fdinfo: pos: 3 flags: 0100002 mnt_id: 61 ino: 1400308
B fd=4 fdinfo: pos: 0 flags: 0100000 mnt_id: 61 ino: 1400308
dup(A) fd=5 fdinfo: pos: 3 flags: 0100002 mnt_id: 61 ino: 1400308
== after child read 4 bytes ==
A in parent fd=3 fdinfo: pos: 7 flags: 0100002 mnt_id: 61 ino: 1400308
B in parent fd=4 fdinfo: pos: 0 flags: 0100000 mnt_id: 61 ino: 1400308
Read it field by field. ino is 1400308 on all three lines throughout: one inode, three descriptors. mnt_id is 61 on all three: one mount. flags differs — 0100002 is O_RDWR|O_LARGEFILE for A and its dup, 0100000 is O_RDONLY|O_LARGEFILE for B — so the access mode is per-struct file, as the field table says. And pos is the whole argument: it advances on fd 3 and fd 5 together (one struct file, f_count == 2) while fd 4 stays at 0 (a second struct file), and a fork()ed child reading four more bytes leaves the parent’s pos at 7 — because fork() copied the descriptor table, not the objects it points at. The POSIX-level rules this demonstrates belong to fork dup and Shared File Offsets and The struct file and Open File Description; what it shows here is that f_pos is a field of this structure and nothing else.
f_path is a (mount, dentry) pair — proved with bind mounts
This is the field most often mis-described as “a pointer to the file”. It is a struct path, and the vfsmount half carries real information. unshare -rm creates a private mount namespace in which the invoking uid-1000 user holds CAP_SYS_ADMIN, which is enough to build the demonstration without root:
$ unshare -rm sh f2.sh
--- mountinfo for the three views (same sb, three mounts) ---
1987 1506 0:105 / /tmp/w2f/data rw,relatime - tmpfs none rw,seclabel,uid=1000,gid=1000,inode64
2127 1506 0:105 / /tmp/w2f/view1 rw,relatime - tmpfs none rw,seclabel,uid=1000,gid=1000,inode64
2128 1506 0:105 / /tmp/w2f/view2 rw,relatime - tmpfs none rw,seclabel,uid=1000,gid=1000,inode64
--- open the SAME inode through each mount, then look at /proc/self/fd ---
/proc/self/fd/7 -> /tmp/w2f/data/file
/proc/self/fd/8 -> /tmp/w2f/view1/file
/proc/self/fd/9 -> /tmp/w2f/view2/file
fd 7: mnt_id: 1987 ino: 2
fd 8: mnt_id: 2127 ino: 2
fd 9: mnt_id: 2128 ino: 2The script mounts a tmpfs at /tmp/w2f/data, creates one file in it, then mount --binds that directory at two more paths, and opens the same file through each of the three. The 0:105 column of /proc/self/mountinfo is the superblock’s device number: identical on all three lines, so there is one superblock. ino: 2 on all three descriptors: one inode, and therefore one dentry chain rooted at one d_inode. But mnt_id is 1987, 2127, 2128 — three different vfsmounts, because mount --bind creates a new mount over the same superblock. The three struct files differ in f_path.mnt and in nothing else that identifies the file.
The consequence is the third block: /proc/self/fd/<n> resolves to three different pathnames. d_path() walks up from f_path.dentry to the root of f_path.mnt and then continues up the mount tree — so without the mount half it would be impossible to say which of the three names this descriptor was opened under. That is what f_path.mnt is for, and it is why path_get()/path_put() take and drop two references rather than one.
The same reference is what pins a mount. Continuing in the same namespace:
--- an open fd pins f_path.mnt: umount the bind while fd 8 is open ---
umount: /tmp/w2f/view1: target is busy.
umount view1 (fd 8 open) rc=32
umount view1 (fd 8 closed) rc=0
--- lazy umount still works with fd 9 open ---
umount -l view2 rc=0umount fails with EBUSY purely because one struct file holds an mntget() reference on that vfsmount via f_path.mnt; closing the descriptor drops it at the final fput and the unmount succeeds. umount -l (MNT_DETACH) succeeds regardless because it detaches the mount from the tree and lets the outstanding reference expire later — the classic answer to “umount says busy and I cannot find the process”. The mount-side view of this is owned by The Mount Tree and vfsmount; the file-side cause is one field in this structure.
file-nr counts struct file allocations — and why you will never hit ENFILE
fs/file_table.c keeps a per-CPU counter nr_files, incremented in alloc_empty_file() and decremented in file_free(), exported through /proc/sys/fs/file-nr as allocated / free / max. A program that opens 4,096 descriptors on one file and then closes them:
RLIMIT_NOFILE soft=1048576 hard=1048576
baseline file-nr: 19621 0 9223372036854775807
opened 4096 fds
after opening them file-nr: 23722 0 9223372036854775807
after closing them file-nr: 19626 0 9223372036854775807
with RLIMIT_NOFILE=64, opened 61 more then errno=24 (Too many open files)
19,621 → 23,722 is +4,101 for 4,096 opens: the counter is a direct count of live struct files (the extra few are unrelated churn elsewhere on the system during the run). It falls back on close, confirming that each open allocated a distinct object — 4,096 struct files over one inode.
The third column is the interesting one. files_maxfiles_init() derives a memory-proportional default (file_table.c:539-548), but this host reports 9223372036854775807 — LONG_MAX. That is not the kernel default: systemd’s PID 1 overwrites it at boot. bump_file_max_and_nr_open() in systemd v259 src/core/main.c:1263-1281 writes LONG_MAX to fs/file-max, with the comment: “On current kernels large numbers of file descriptors are no longer a performance problem and their memory is properly tracked by memcg … This function hence turns off 2 of the 4 levels of limits on file descriptors, and makes RLIMIT_NOFILE (soft + hard) the only ones that really matter.”
So on any systemd host, the ENFILE path in alloc_empty_file() is effectively dead and the limit you will actually hit is the per-process one — measured above as errno 24, EMFILE, after 61 more opens under RLIMIT_NOFILE=64. The two errnos come from two different layers: ENFILE from the struct file allocator in fs/file_table.c, EMFILE from the descriptor-table allocator (see File Descriptors and the fd Table). If you are diagnosing “too many open files”, the message text tells you which: “Too many open files” is EMFILE, “Too many open files in system” is ENFILE.
Uncertain
Verify: the
file-max = LONG_MAXobservation is specific to systemd-managed hosts, and the exact systemd version here was read from thev259tag while the running system reportssystemd-udev-259.8-1.fc44. Reason: the running binary is a point release of that tag, not the tag itself, and distributions can disable the bump at build time (BUMP_PROC_SYS_FS_FILE_MAXis a compile-time option in the code quoted above). To resolve: checkfs.file-maxon the specific host rather than assuming either the kernel default orLONG_MAX. uncertain
Failure Modes and Diagnostics
ENFILE(“Too many open files in system”). The system-widestruct filecount hitfiles_stat.max_files(thefs.file-maxsysctl). This is distinct fromEMFILE(“Too many open files”), which is the per-process descriptor limit (RLIMIT_NOFILE) and is enforced in the fd-table layer, not here — see File Descriptors and the fd Table. Diagnose withcat /proc/sys/fs/file-nr(allocated / free / max) andsysctl fs.file-max.- Leaked
struct files. A descriptor that is never closed keepsf_countnon-zero, pinning the inode, dentry, and the mount. The classic symptom is “device or resource busy” onumount: an open file holds anmntgetreference onf_path.mnt, so the mount cannot be torn down until the finalfput.lsofand/proc/<pid>/fd/enumerate open files. - Use-after-free / spurious refcount bumps. Because
filp_cachepisSLAB_TYPESAFE_BY_RCU, a freedstruct filecan be instantly recycled. Locklessfgetreaders must therefore re-validate after taking a reference; theget_file()WARN_ONCE("incremented from zero")fires if code bumps a count that already reached zero, signalling a genuine bug. - Deferred-close surprises. Because
__fputruns as task-work on return to userspace (not synchronously insideclose()/the lastfput), a filesystem’s->releaseand anyfsync-on-close error can be observed slightly later than naively expected. The synchronous variant__fput_syncexists for the narrow cases (e.g. unmount paths in kernel threads) that must not defer.
Alternatives and Boundaries
There is no “alternative” to struct file inside Linux — it is the open-file abstraction, and every openable thing (regular files, directories, pipes, sockets, devices, eventfd/timerfd/signalfd, epoll instances, io_uring rings) is represented by one, differing only in its f_op. The interesting boundaries are conceptual:
- vs the file descriptor. The fd is a per-process
int; thestruct fileis the shared object it indexes.dupcreates a new fd pointing at the samestruct file; opening the same path twice creates twostruct files. Owned by File Descriptors and the fd Table and The struct file and Open File Description. - vs the inode. Per-open vs per-file, as above. Owned by VFS Inode Object.
- vs
address_space.f_mappingpoints at the page-cache index; the file is the handle, the address_space is where the data actually lives. Owned by The Page Cache and address_space.
Production Notes
Reasoning about struct file lifetime is routine in real kernel work. Two recurring themes: (1) the lockless fget path — the v6.12 SLAB_TYPESAFE_BY_RCU filp_cachep plus the “init f_count last” rule exist precisely so high-rate fd lookups (think a busy server doing millions of recv/read per second) do not bounce a cache line on a shared lock; the cost is that callers must tolerate seeing a recycled object and re-check, which is why the lockless fget helpers (get_file_rcu, get_file_active) exist alongside plain get_file. (2) The task-work-deferred __fput is why tools that strace a close() sometimes see the heavy work (writeback flush, ->release) attributed to a later point — it is the kernel choosing not to do sleeping cleanup at an inconvenient moment. The iterator-based read_iter/write_iter methods are the modern data path; the older read/write callbacks survive only for filesystems and drivers that never converted, which is exactly why vfs_read tries f_op->read first and falls back to new_sync_read → f_op->read_iter (fs/read_write.c).
See Also
- The struct file and Open File Description — the POSIX-level view of this same object;
dup/forksharing semantics - File Descriptors and the fd Table — the per-process
int→struct fileindirection - VFS Inode Object — the per-file counterpart (metadata, shared by all opens)
- VFS Operation Tables —
file_operationsand the other method tablesf_opis one of - VFS Superblock Object — the mounted-instance object that ultimately owns the inode this file points at
- The Page Cache and address_space — what
f_mappingindexes - Open Flags and Access Modes — how
f_flags/f_modeare derived and changed - fork dup and Shared File Offsets — the consequence of sharing one
struct file - epoll and Scalable Readiness Notification — what hangs off
f_ep, and whyeventpoll_release()runs first in__fput - The Mount Tree and vfsmount — the
vfsmounthalf off_path, and why an open fd makesumountreturnEBUSY - The Virtual File System Layer — the layer this object lives in
- MOC: Linux Filesystems and VFS MOC