Before you start#
Read files, processes and permissions, particularly the distinction between a descriptor and an open file. You should recognize a C function and basic variable declaration. This chapter reads upstream v6.12; the small userspace program in practice is synthetic teaching code, not kernel code.
Learning goals#
Read struct members and function pointers; distinguish an address from ownership of its target; follow an embedded list member back to its container; recognize error pointers; and describe a reference's acquisition and release without assuming that a pointer remains valid forever.
Objects and their representations#
A C type tells the compiler how to interpret an object and what operations are valid. An object has storage, a lifetime and a type; its bytes are not necessarily a portable serialization. A structure groups members, but the compiler may insert padding for alignment. Structure size, field offsets and pointer width depend on the target ABI, compiler options and configuration. Kernel internal structures are not a stable userspace binary interface. A cast changes the compiler's interpretation of a value; it does not allocate an object, prove alignment or extend lifetime.
An array holds a fixed sequence of elements, whereas a pointer is a distinct object that can store a pointer value. Except in contexts such as sizeof array and &array, an array expression is converted to a pointer to its first element; this conversion does not carry the length. Thus sizeof array in the defining scope can differ from sizeof pointer (but an array function parameter is adjusted to a pointer parameter). p->field means (*p).field; it requires p to designate a live object of the appropriate type. C permits forming a pointer one past an array's last element for comparison, but not dereferencing it. Null, Linux error-encoded, dangling and uninitialized pointers are not usable object references.
Kernel code also uses annotations such as __user and __iomem to express address-space distinctions to tools and reviewers. A userspace address cannot simply be treated as a trusted kernel object. Likewise, a device register address is not automatically ordinary RAM. The rules for copying userspace data and for I/O access are separate from ordinary C pointer syntax.
Source walkthrough#
- ksys_writefd lookup
fdget_pos(fd)
- fd lookupksys_write
Return. referenced file or invalid descriptor
- ksys_writevfs_write
write while reference is held
- vfs_writefile operation
dispatch through f_op
- file operationvfs_write
Return. count or error
- ksys_writefd lookup
fdput_pos after use
Time runs downward. Dashed arrows show returns.
Read the text version
- ksys_write → fd lookup: fdget_pos(fd)
- fd lookup → ksys_write (return): referenced file or invalid descriptor
- ksys_write → vfs_write: write while reference is held
- vfs_write → file operation: dispatch through f_op
- file operation → vfs_write (return): count or error
- ksys_write → fd lookup: fdput_pos after use
In v6.12 include/linux/fs.h, struct file starts with these members (SPDX GPL-2.0; remaining members omitted):
atomic_long_t f_count;
spinlock_t f_lock;
fmode_t f_mode;
const struct file_operations *f_op;
f_count participates in reference counting; its atomic type is not by itself a complete proof of safe lifetime. f_lock protects state according to its documented uses, not automatically every member. f_mode records mode flags. f_op points to an operation table whose function pointers select behavior for this open file. const qualifies the pointed-to table through this pointer; it does not make the file immutable. Build configuration and architecture can influence the layout. Reading these declarations alone does not tell us every locking rule; find each operation's contract and callers.
The following real call from v6.12 fs/read_write.c shows a use of that table (SPDX GPL-2.0; conditional branches and surrounding code omitted):
ret = file->f_op->write(file, buf, count, pos);
The caller first checks whether write exists in its selected branch. It calls a function through the table, supplying the file, userspace buffer, byte count and position pointer. Other branches can use write_iter or return an error. The function pointer determines an implementation at runtime; text-searching for the call does not automatically identify its one universal target. The caller must keep file valid during the call, and the target's contract determines how the buffer and position are used. In ksys_write, fdget_pos acquires a descriptor reference and fdput_pos releases it after vfs_write; a bad descriptor instead returns -EBADF. This is a concrete acquisition/use/release path, not permission to save the pointer after release.
Embedded lists and errors#
A kernel list embeds a struct list_head in the containing object. Links connect list members rather than separately allocated wrapper nodes. In v6.12 include/linux/list.h, this macro converts a pointer to the embedded member back to its specified container (SPDX GPL-2.0):
#define list_entry(ptr, type, member) \
container_of(ptr, type, member)
For a hypothetical struct job { int id; struct list_head link; }, a valid &job.link can be used with list_entry(p, struct job, link) to recover the containing job. It works only when p actually points to that member in a live matching object. A list head is a sentinel, and interpreting it as a real element without the correct traversal/empty-list rules is wrong. List membership does not itself keep an object alive or serialize concurrent modifications. The subsystem must establish both locking and reclamation rules; removing an entry and freeing it while another reader still holds a pointer can cause use-after-free.
list_entry is a wrapper around container_of. In v6.12 include/linux/container_of.h, the macro is defined as follows (SPDX GPL-2.0; the documentation comment and the container_of_const variant are omitted):
#define container_of(ptr, type, member) ({ \
void *__mptr = (void *)(ptr); \
static_assert(__same_type(*(ptr), ((type *)0)->member) || \
__same_type(*(ptr), void), \
"pointer type mismatch in container_of()"); \
((type *)(__mptr - offsetof(type, member))); })
The member's type is checked at compile time, so a mismatch is a build error rather than silent arithmetic. The check does not confirm that the pointer addresses a live object of the containing type, and it also accepts a void pointer argument. The statement expression is a GNU C extension, so this macro is not strict ISO C. The result is the member address minus the member's offset within the type. The following teaching model follows that arithmetic:
- Member pointer
Address of the embedded member, for example &job.link.
- Type check
static_assert compares the member type; a mismatch fails at compile time.
- Subtract offset
offsetof(type, member) bytes are subtracted from the member address.
- Typed container
The result has the containing type; valid only for a live matching object.
A second convention lets some APIs return either a pointer or an encoded error. v6.12 include/linux/err.h defines ERR_PTR, IS_ERR and PTR_ERR. Check the called function's contract: an error pointer is not NULL and must not be dereferenced. Some APIs use NULL, some negative integers and some error pointers. Checking the wrong convention can turn a normal failure into invalid memory access. Similarly, incrementing a counter on an already freed object is not a safe way to acquire a reference; acquisition must occur while another rule guarantees the object exists. RCU, locks and reference counts solve different parts of this problem and appear later in the course.
A callback and a reference timeline#
Consider a descriptor that resolves successfully. At time A the lookup produces a reference to a live open file. At B, code selects its operation table and calls the relevant callback while that reference is held. At C, the callback returns either a count or an error; the caller performs its documented position handling and releases the reference. At D, another owner might still hold a reference, so this one release need not free the object. Conversely, if it was the last reference, later dereferencing a saved pointer without a separate lifetime guarantee is invalid. A lock can serialize a field update, but acquiring that lock through an already-freed pointer is itself unsafe. A reference can keep an object alive, but it does not automatically protect all mutable fields from races. These two guarantees must be established independently.
The following teaching model shows which function holds the reference and which call only borrows it for the duration of the write:
- ksys_writeDescriptor table
fdget_pos: take reference
- ksys_writevfs_write
vfs_write(file, buf, count, ppos)
- vfs_writef_op->write
write(file, buf, count, pos)
- f_op->writevfs_write
Return. count or error
- vfs_writeksys_write
Return. return to caller
- ksys_writeDescriptor table
fdput_pos: release reference
Time runs downward. Dashed arrows show returns.
Read the text version
- ksys_write → Descriptor table: fdget_pos: take reference
- ksys_write → vfs_write: vfs_write(file, buf, count, ppos)
- vfs_write → f_op->write: write(file, buf, count, pos)
- f_op->write → vfs_write (return): count or error
- vfs_write → ksys_write (return): return to caller
- ksys_write → Descriptor table: fdput_pos: release reference
Numbers, bits, and representation#
A C integer type has a width, range and signedness determined by the implementation and ABI. Unsigned arithmetic wraps modulo one more than its maximum value; signed overflow in ordinary C is not a portable wraparound technique. Kernel code uses compiler options and documented helpers for many low-level operations, but a reader should still ask which type an expression has before assuming how it overflows. An implicit conversion can change a comparison: mixing a negative signed value with an unsigned value can convert the former to a large unsigned number. size_t describes object sizes; a negative error code stored in an unsigned variable can become a large positive-looking value.
Bit masks encode independent flags. For example, in a synthetic unsigned value flags = 0x09, the expression flags & 0x01 tests the low bit and flags | 0x02 produces 0x0b. & is bitwise AND, while && is logical AND; | and || are likewise different. Parentheses make intended precedence clear. A shift count must be valid for the promoted operand type; shifting a signed value into an unrepresentable result is not a portable way to construct a mask. Kernel helpers such as BIT and typed flag definitions express intent, but the value's type and width still matter. A flag being set does not establish that an associated pointer is live.
sizeof measures the representation size in bytes of a type or object, including structure padding. Alignment can place gaps between members or at the end of a structure. A flexible array member, written as a final items[] member, represents variable trailing storage supplied by the allocation; sizeof of the containing structure does not tell you how many elements were allocated. The number of valid elements needs a separately checked size or count. Integer overflow in header + count * element_size can produce an undersized allocation; use the kernel's appropriate checked-size helpers and follow the allocator's failure path rather than trusting arithmetic.
Endianness determines the byte order used to represent multi-byte values. A byte stream 01 02 03 04 can represent different integers under little- and big-endian interpretations. An on-disk or network format must specify the order; casting an unaligned byte buffer to an integer pointer can also violate alignment or access rules. Kernel types and conversion helpers such as __le32 and le32_to_cpu communicate a format boundary. Your workstation's output is one example, not an ABI definition for every architecture.
Reading callbacks and a real error pointer#
A function pointer stores the address of a function with a particular signature. An operation table is a structure containing such pointers. In v6.12 drivers/char/mem.c, the null_fops initializer contains this excerpt (SPDX GPL-2.0; other members omitted):
.read = read_null,
.write = write_null,
The designated initializers assign the read and write members of a struct file_operations table; they do not execute either function. The same source maps minor number 3 to null_fops, and memory_open assigns the selected table to filp->f_op. Later the VFS can dispatch a read through that member. read_null returns zero, representing end-of-file for that operation. This is conditional on opening the relevant Linux character device and reaching this table; a different file, kernel configuration or device namespace can select something else. The containing file object and its operation table must remain valid for the call under the VFS and module lifetime rules.
Macros perform textual preprocessing subject to their definitions, not ordinary function-call evaluation rules. Arguments can be evaluated more than once by some macros; inspect the definition before supplying an expression with side effects. container_of uses a member's offset and type information to recover its containing object under its contract. GNU C extensions used by the kernel, such as statement expressions and typeof, are not necessarily accepted by a strict ISO C compiler. Sparse annotations such as __user and __iomem help distinguish address spaces; __must_check marks a result that callers should not silently discard. These annotations communicate contracts to tools and reviewers; they do not by themselves make an invalid pointer safe.
The error-pointer convention appears in a real open path. In v6.12 fs/open.c, do_sys_openat2 includes this excerpt (SPDX GPL-2.0-only; surrounding allocation and success branches omitted):
if (IS_ERR(f)) {
put_unused_fd(fd);
fd = PTR_ERR(f);
do_filp_open returned f; IS_ERR tests whether it encodes an error rather than a valid file pointer. The path releases the descriptor slot reserved before that failure, then extracts the negative error for the return path. The success branch instead installs the file into the descriptor table. The later putname releases the filename reference. This concrete cleanup shows why checking only f == NULL and then dereferencing it would be wrong, and why an error branch has its own ownership obligations. The excerpt omits the closing brace and must be read with the linked source. The following teaching model lists the steps around that error branch, including the descriptor reserved before do_filp_open runs:
- build_open_flags
Validate the open_how flags; a nonzero result returns at once.
- getname
Copy the pathname into the kernel; an error pointer returns PTR_ERR.
- get_unused_fd_flags
Reserve a descriptor number; a negative value skips the open.
- do_filp_open
Returns either a struct file or an encoded error.
- IS_ERR(f)
On error: put_unused_fd(fd), then fd = PTR_ERR(f).
- fd_install
On success: install the file in the reserved slot.
Borrowing is not owning#
Suppose a callback receives struct file *file while the caller holds a file reference. The callback can use that borrowed pointer during the call under its contract. If it stores the pointer for later asynchronous work, the caller's eventual fdput_pos may end the guarantee; the callback needs an independently acquired reference and a corresponding release, if that API permits such retention. A reference count protects a specific lifetime, not the correctness of every mutable field. The same separation applies to an embedded list: unlinking its list_head removes a relationship, while reclamation must wait for all readers and references under the subsystem's locking or RCU rules.
The practice program below is compiled as C11 userspace code. It is useful for examining your compiler's layout, but its output does not prove a kernel structure's offsets, module compatibility or another architecture's ABI. Kernel configuration, GNU extensions, compiler, target and internal source version all matter. Never copy a guessed structure layout from a userspace example into an interface that expects a kernel UAPI definition.
Practice#
On a Linux host with a C compiler, use a private temporary directory. The following is a synthetic userspace program, not an upstream excerpt. It prints only defined in-bounds accesses; don't dereference a freed or fabricated pointer.
lab_dir=$(mktemp -d) || exit 1
cd "$lab_dir" || exit 1
cat > layout.c <<'C'
#include <stddef.h>
#include <stdio.h>
struct record { char tag; int count; int values[3]; };
int main(void) {
struct record r = { 'A', 7, { 10, 20, 30 } };
struct record *p = &r;
printf("size=%zu count-offset=%zu array=%zu pointer=%zu\n",
sizeof r, offsetof(struct record, count), sizeof r.values, sizeof p);
printf("tag=%c count=%d second=%d\n", p->tag, p->count, p->values[1]);
return 0;
}
C
cc -std=c11 -Wall -Wextra -Werror -o layout layout.c
./layout
Expect tag=A count=7 second=20; the size and offset numbers depend on the platform. The array occupies space for three int objects, whereas p is a pointer to the whole record. A counterexample to the assumption that member sizes simply add is possible padding before count; inspect the printed offset rather than hard-coding it. If compilation fails, record the compiler's diagnostic and version (cc --version); do not turn warnings off blindly. Write down each object's owner, lifetime, valid index range and the values before and after the program exits. Exiting ends the automatic object's lifetime; printing an address would not make it valid afterward.
Remove only the two files you created, leave the directory and remove the now-empty directory:
rm layout layout.c
cd /tmp
rmdir "$lab_dir"
Knowledge check#
-
What does
file->f_op->writeselect, and why is a text search alone insufficient to identify every implementation? -
Why does an embedded list link not guarantee that the containing object remains alive?
-
What must be true before using
list_entryon a member pointer? -
Why is checking only for
NULLinsufficient for an API documented to returnERR_PTR? -
Why does the
do_sys_openat2error branch callput_unused_fdbefore returning the encoded error?
Answers#
-
It calls the function pointer in the open file's operation table. The table is selected at runtime and can point to different implementations, so inspect initialization and configuration as well as the indirect call.
-
The links express membership, not ownership or synchronization. A subsystem must separately prevent concurrent removal and reclamation while a reader uses the object.
-
It must refer to the specified member of a live object of the stated type; the correct list traversal and synchronization rules must hold. A sentinel is not automatically a real object.
-
An encoded error is a non-null pointer-shaped value. Use
IS_ERRandPTR_ERRwhen the API specifies that convention, and never dereference it. -
The path reserved a descriptor slot before
do_filp_openfailed. It must release that slot and extract the error rather than install or dereference an error pointer.
Checkpoint#
Deliver the program output and an annotated reference timeline for fdget_pos → vfs_write → fdput_pos, including the invalid-descriptor branch. Pass when you can explain array versus pointer size, a valid list_entry, and why lifetime is separate from a lock. If uncertain, reread the linked definitions and rerun the program with a fresh directory. This is a learning checkpoint, not certification.
Sources#
- v6.12 include/linux/fs.h:
struct file. - v6.12 fs/read_write.c: descriptor, VFS and operation-table path.
- v6.12 include/linux/list.h: embedded list helpers.
- v6.12 include/linux/err.h: error-pointer helpers.
- v6.12 character-device operations and open error handling.