R0KernelPath
module 017 / 262

Function Pointers & Callbacks

Function pointers are C's superpower for runtime polymorphism — and the entire Linux kernel driver model is just one giant, beautiful ops-struct dispatch table.

~12h focused study12 concepts

Overview

Function pointers are one of C's most powerful and most misunderstood features. At their core, a function pointer is simply a variable that holds the address of a function — and since every function is just machine instructions sitting at some memory address, you can store that address, pass it around, and call it later. This is the mechanism behind callbacks, plugin systems, virtual dispatch, and the kernel's entire device-driver model.

Understanding function pointers requires you to shift your mental model: in C, a function name (without parentheses) is already an address — just like an array name decays to a pointer to its first element. When you write sort(arr, n, compare), you are handing sort the address of your comparison function so it can call back into your code. This "calling back" pattern — where a library calls user-supplied code — is so fundamental that it has its own name: a callback.

The Linux kernel takes this idea much further. The kernel cannot know in advance what hardware you will plug in, what filesystem you will mount, or what network protocol you will use. Its solution is elegant: define a struct full of function pointers (an "ops struct"), and require every driver or module to fill that struct in. When the kernel needs to read from your USB flash drive, it looks up the read pointer in your driver's file_operations struct and calls through it. This is manual polymorphism — the same trick C++ compilers use for virtual functions, but written explicitly in C and fully visible in the source code.

In this module you will learn the syntax from first principles, build up to dispatch tables and vtable-style designs, and finally read real kernel code that uses file_operations, inode_operations, and net_device_ops. By the end, function pointers will feel like a natural tool rather than an arcane incantation.

why this matters for the kernel

The Linux kernel is written entirely in C, yet it achieves the kind of runtime polymorphism that most people associate with object-oriented languages. The mechanism is function pointers in structs — ops structs. Every subsystem in the kernel (VFS, networking, block devices, character devices, memory management) relies on this pattern. If you cannot read and write function pointer code confidently, the kernel source will look like line noise: you will see inode->i_op->lookup(dir, dentry, flags) and have no idea what is happening. Once you understand the pattern, that line becomes obvious: look up the lookup function pointer in the inode's operations struct and call it, dispatching to whichever filesystem is actually mounted.

Beyond the kernel, function pointers are the backbone of every C plugin system, every C-language event loop (libevent, libuv), every C test framework (Unity, CMock), and every C standard library function that accepts a comparator or predicate (qsort, bsearch, lfind). Mastering this topic means you can read and contribute to virtually any serious C codebase. It also gives you a concrete mental model for how virtual dispatch works in C++, Java, and Python — you will understand the machinery those languages hide from you.

VFS read() Dispatch Through file_operations

rendering diagram…

When userspace calls read(), the VFS layer dispatches through file->f_op->read_iter (or the legacy ->read). The VFS does not know which filesystem is mounted — it just calls through the pointer. ext4, procfs, and the null device each provide their own implementation in their file_operations struct.

The concepts

01What Is a Function Pointer? The Memory Model

Every function you write compiles into a sequence of machine instructions stored somewhere in the program's text (code) segment. That sequence has a starting address — the address the CPU jumps to when you call the function. A function pointer is just a variable that holds that address.

Think of RAM as a giant array of bytes. Your main function might live at address 0x401000. Your add function might live at 0x401050. A function pointer variable contains one of those numbers. When you "call through" the pointer, the CPU loads that address and jumps there.

This is why, in C, writing a function name without parentheses gives you its address:

int add(int a, int b) { return a + b; }

int main(void) {
    void *p = (void *)add;  // p holds the address of add's first instruction
    printf("%p\n", p);      // prints something like 0x401050
    return 0;
}

The parentheses are the "call" operator. add(1, 2) means "jump to add's address and call it with arguments 1 and 2". add alone (no parentheses) means "give me add's address". This symmetry is important: it means function pointers and function names are interchangeable in call expressions.

#include <stdio.h>

int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

int main(void) {
    /* Print the addresses of two functions */
    printf("add lives at %p\n", (void *)add);
    printf("mul lives at %p\n", (void *)mul);

    /* They are different — they are distinct blobs of machine code */
    printf("same? %d\n", (void *)add == (void *)mul);  /* 0 */
    return 0;
}

02Function Pointer Syntax: Declaring, Assigning, and Calling

The syntax for function pointers is notoriously confusing because C's declaration syntax follows the "declaration mirrors use" principle: you declare a variable the same way you would use it.

For a plain int, you write: int x;
For a pointer to int, you write: int *p; — meaning *p is an int.
For a pointer to a function that takes two ints and returns an int, you write: int (*fp)(int, int); — meaning (*fp)(a, b) is an int.

The parentheses around *fp are mandatory. Without them, int *fp(int, int) declares a function named fp that returns int * — completely different!

The three-step idiom:

  1. Declare: int (*fp)(int, int);
  2. Assign: fp = add; (or fp = &add; — both are identical)
  3. Call: fp(1, 2) (or (*fp)(1, 2) — both compile identically)

The explicit dereference (*fp)(...) is the historically "correct" form and makes the pointer nature visible. Modern C (and most real code) omits the * at the call site for brevity.

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int main(void) {
    /* Step 1: declare a function pointer */
    int (*op)(int, int);

    /* Step 2: assign — both forms are identical */
    op = add;
    /* op = &add;  <-- also valid, same address */

    /* Step 3: call — both forms compile to the same instruction */
    printf("%d\n", op(3, 4));     /* 7  — shorthand */
    printf("%d\n", (*op)(3, 4)); /* 7  — explicit dereference */

    op = sub;
    printf("%d\n", op(10, 3));   /* 7 */

    return 0;
}

03typedef for Readability: Naming the Signature

Raw function pointer syntax is hard to read and harder to maintain. When a function takes a function pointer as a parameter, the declaration becomes a cascade of stars and parentheses. The solution is typedef.

typedef creates a type alias. For function pointers, it lets you name a signature once and use that name everywhere:

typedef int (*BinaryOp)(int, int);

Now BinaryOp is a type meaning "pointer to a function taking two ints and returning int". You can use it as a variable type, a parameter type, a struct field type, or a return type — just like int or char *.

The Linux kernel uses this pattern pervasively. You will see things like:

typedef irqreturn_t (*irq_handler_t)(int, void *);

in <linux/interrupt.h>. Naming the type makes it self-documenting.

A common alternative (preferred in the kernel for ops-struct fields) is to skip the typedef and just use the raw syntax inside structs — this keeps the struct definition readable as a table of named signatures. But for standalone parameters and return types, typedef wins on clarity.

Note: the Linux kernel style guide (Documentation/process/coding-style.rst) says to avoid typedef for most types but makes an exception for function pointer types where it genuinely aids readability.

#include <stdio.h>

/* Name the signature once */
typedef int (*BinaryOp)(int, int);

int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

/* Now parameter and return types read naturally */
void apply_and_print(BinaryOp op, int x, int y) {
    printf("result = %d\n", op(x, y));
}

BinaryOp choose(char which) {
    return (which == '+') ? add : mul;
}

int main(void) {
    BinaryOp op = choose('+');
    apply_and_print(op, 6, 7);  /* result = 13 */

    op = choose('*');
    apply_and_print(op, 6, 7);  /* result = 42 */
    return 0;
}

04Callbacks: Inverting Control

A callback is a function you pass to another function so that the other function can call it at the right moment. The caller does not know or care what your function does; it just calls it according to a contract (same signature, same expected behavior).

The canonical C example is qsort from the standard library:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

qsort knows how to sort; you know what "less than" means for your data. The callback glues them together. qsort calls back into your comparator with pairs of elements, and you tell it which is smaller. Neither side needs to know the other's implementation.

This is called inversion of control (IoC): instead of your code calling the library, the library calls your code. It is the foundation of event loops ("call me when there's data on this socket"), GUIs ("call me when the button is clicked"), and the kernel's entire driver model ("call this function when userspace does read()").

Key contract rules for callbacks:

  • Signature must match exactly — the compiler enforces this.
  • Lifetime: the callback must remain valid (not freed, not out of scope) for as long as the callee might call it.
  • Context: often you need to pass extra data to the callback. C does this with a void *userdata parameter (or, in the kernel, with container_of on an embedded struct).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* A callback for qsort: sort strings by length, shortest first */
int by_length(const void *a, const void *b) {
    /* qsort passes pointers-to-elements; elements are char* here */
    const char *sa = *(const char **)a;
    const char *sb = *(const char **)b;
    return (int)strlen(sa) - (int)strlen(sb);
}

int main(void) {
    const char *words[] = { "banana", "fig", "kiwi", "a", "cherry" };
    int n = 5;

    qsort(words, n, sizeof(char *), by_length);

    for (int i = 0; i < n; i++)
        printf("%s\n", words[i]);
    /* a, fig, kiwi, banana, cherry */
    return 0;
}

05Dispatch Tables: Arrays of Function Pointers

If you have a set of operations indexed by an integer (an opcode, a command number, a state), a dispatch table (also called a jump table) is the elegant solution. Instead of a long if/else chain or switch statement, you build an array of function pointers and index into it.

Advantages:

  • O(1) dispatch — no chain of comparisons, just one array lookup plus an indirect call.
  • Open/closed principle — adding a new operation means adding an entry to the table, not editing existing logic.
  • Data-driven — the table itself is data; it can be built at runtime, serialized, or swapped.

This is exactly what the kernel's VFS (Virtual File System) does. The VFS has operations like open, read, write, seek, close. Each filesystem (ext4, tmpfs, procfs, btrfs) fills in its own table of these operations. When you call read(fd, buf, n), the VFS looks up file->f_op->read_iter and calls it — dispatching to ext4's reader, or procfs's reader, whatever is appropriate.

Dispatch tables are also how compilers implement switch statements on dense integer ranges: the compiler emits a table of branch targets and uses the switch value as an index.

#include <stdio.h>

/* Simulating a simple calculator with a dispatch table */
typedef int (*ArithOp)(int, int);

int op_add(int a, int b) { return a + b; }
int op_sub(int a, int b) { return a - b; }
int op_mul(int a, int b) { return a * b; }
int op_div(int a, int b) { return b ? a / b : 0; }

/* Opcode → function mapping */
enum Opcode { OP_ADD=0, OP_SUB, OP_MUL, OP_DIV, OP_COUNT };

ArithOp dispatch[OP_COUNT] = {
    [OP_ADD] = op_add,
    [OP_SUB] = op_sub,
    [OP_MUL] = op_mul,
    [OP_DIV] = op_div,
};

int main(void) {
    int a = 12, b = 4;
    const char *names[] = { "add", "sub", "mul", "div" };

    for (int op = 0; op < OP_COUNT; op++)
        printf("%s(%d,%d) = %d\n", names[op], a, b, dispatch[op](a, b));
    /* add(12,4)=16, sub=8, mul=48, div=3 */
    return 0;
}

06Ops Structs: Named Dispatch Tables (Manual vtables)

An array dispatch table works when operations are indexed by number. But real systems have operations with names, not just indices — and the names need to be self-documenting. The solution is a struct of function pointers: an ops struct.

An ops struct is exactly a vtable (virtual method table) from C++ OOP, but written explicitly in C. The struct lists every operation a "class" of objects must support; each concrete "class" (driver, filesystem, protocol) provides its own filled-in version.

The pattern:

  1. Define a struct type with one function pointer field per operation.
  2. Each concrete implementation declares a static const instance of that struct, filling in its own function pointers (and leaving unused ones as NULL).
  3. The "object" (inode, socket, device) holds a pointer to its ops struct.
  4. The framework dispatches through the pointer: obj->ops->do_something(obj, args).

This achieves runtime polymorphism in pure C: the same call site can invoke different implementations depending on which ops struct the object's pointer points to. This is structurally identical to how C++ virtual functions work — the only difference is that C++ hides the vtable and the dispatch syntax, while C exposes them.

The kernel pattern also commonly uses container_of to recover the outer struct from the struct file * or struct inode * pointer, which is the C equivalent of this.

#include <stdio.h>

/* --- Define the "interface" --- */
struct Animal;
typedef struct Animal Animal;

typedef struct {
    void (*speak)(Animal *self);
    void (*move)(Animal *self);
    const char *(*name)(void);
} AnimalOps;

struct Animal {
    const AnimalOps *ops;  /* pointer to this animal's vtable */
    int energy;
};

/* --- Dog implementation --- */
static void dog_speak(Animal *self) { printf("Woof! (energy=%d)\n", self->energy); }
static void dog_move(Animal *self)  { self->energy--; printf("Dog runs.\n"); }
static const char *dog_name(void)   { return "Dog"; }

static const AnimalOps dog_ops = {
    .speak = dog_speak,
    .move  = dog_move,
    .name  = dog_name,
};

/* --- Cat implementation --- */
static void cat_speak(Animal *self) { printf("Meow. (energy=%d)\n", self->energy); }
static void cat_move(Animal *self)  { self->energy -= 2; printf("Cat slinks.\n"); }
static const char *cat_name(void)   { return "Cat"; }

static const AnimalOps cat_ops = {
    .speak = cat_speak,
    .move  = cat_move,
    .name  = cat_name,
};

/* --- Framework code: polymorphic dispatch --- */
void interact(Animal *a) {
    printf("=== %s ===\n", a->ops->name());
    a->ops->speak(a);
    a->ops->move(a);
    a->ops->speak(a);
}

int main(void) {
    Animal dog = { .ops = &dog_ops, .energy = 10 };
    Animal cat = { .ops = &cat_ops, .energy = 8  };

    interact(&dog);
    interact(&cat);
    return 0;
}

07The Kernel's file_operations: Real Vtables in the Wild

The most important ops struct in the Linux kernel is struct file_operations, defined in include/linux/fs.h. It is the interface between the Virtual File System (VFS) layer and every concrete filesystem or character-device driver.

Every time userspace calls open(), read(), write(), ioctl(), mmap(), poll(), or close() on a file descriptor, the VFS:

  1. Looks up the struct file associated with the file descriptor.
  2. Reads file->f_op — a pointer to the file_operations struct for this file.
  3. Checks if the relevant function pointer is non-NULL.
  4. Calls through it.

For a regular ext4 file, f_op points to ext4_file_operations. For /dev/null, it points to null_fops. For a socket, it points to socket_file_ops. Same call path, radically different behavior — pure vtable dispatch.

A minimal character device driver only needs to provide the operations it supports. Unused slots default to NULL or a generic kernel fallback. The C99 designated-initializer syntax (.read = my_read) makes it clear and maintainable.

The kernel also defines struct inode_operations (for filesystem metadata operations like lookup, mkdir, rename), struct super_operations (for filesystem-level operations), struct address_space_operations (for page cache I/O), struct net_device_ops (for network drivers), and many more — all following the same ops-struct pattern.

/* Minimal character device driver — kernel module skeleton.
 * Shows file_operations usage. Compile as a kernel module. */

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>

#define DEVICE_NAME "hello_dev"

static int     hello_open(struct inode *inode, struct file *filp);
static int     hello_release(struct inode *inode, struct file *filp);
static ssize_t hello_read(struct file *filp, char __user *buf,
                          size_t count, loff_t *ppos);

/* The vtable: map VFS operations to our functions */
static const struct file_operations hello_fops = {
    .owner   = THIS_MODULE,
    .open    = hello_open,
    .release = hello_release,
    .read    = hello_read,
    /* .write, .ioctl, .mmap etc. left NULL — kernel returns -EINVAL */
};

static int hello_open(struct inode *inode, struct file *filp)
{
    pr_info("hello_dev: opened\n");
    return 0;
}

static int hello_release(struct inode *inode, struct file *filp)
{
    pr_info("hello_dev: closed\n");
    return 0;
}

static const char msg[] = "Hello from kernel!\n";

static ssize_t hello_read(struct file *filp, char __user *buf,
                          size_t count, loff_t *ppos)
{
    size_t len = sizeof(msg) - 1;
    if (*ppos >= len) return 0;          /* EOF */
    if (count > len - *ppos) count = len - *ppos;

    if (copy_to_user(buf, msg + *ppos, count))
        return -EFAULT;

    *ppos += count;
    return count;
}

/* Module init/exit omitted for brevity */

08NULL Checks and Safety: Calling Through Ops Structs Defensively

Ops structs typically allow any field to be NULL to mean "not supported" or "use the default". This makes the calling code responsible for checking before dispatching. Forgetting this check is a classic kernel crash: if f_op->read is NULL and you call through it, you get a null-pointer dereference in kernel space — a kernel panic.

The VFS handles this elegantly with helpers. For example, vfs_read() does:

if (!file->f_op->read && !file->f_op->read_iter)
    return -EINVAL;

before calling. Your own dispatch code should follow the same pattern.

Another common pattern in the kernel is a two-level default: the ops struct pointer itself can be NULL (use global defaults), or the struct can be present but individual fields NULL (use per-operation defaults). See generic_file_read_iter which is the default read implementation many filesystems fall back to.

For your own ops structs, three approaches:

  1. Always NULL-check before calling: if (ops->foo) ops->foo(...);
  2. Provide a no-op default function and fill it in, so callers need not check.
  3. Use a wrapper function (like the kernel's vfs_read) that encapsulates the check.

Approach 2 is cleanest for frequently-called paths. Approach 1 is used when an unimplemented operation should be silently skipped.

#include <stdio.h>
#include <errno.h>

typedef struct {
    int (*read)(char *buf, int n);
    int (*write)(const char *buf, int n);
    void (*flush)(void);
} IoOps;

/* A read-only device: write and flush are not supported */
static int mydev_read(char *buf, int n) {
    buf[0] = 'A'; buf[1] = '\0';
    return 1;
}

static const IoOps mydev_ops = {
    .read  = mydev_read,
    .write = NULL,    /* not supported */
    .flush = NULL,    /* not supported */
};

int safe_write(const IoOps *ops, const char *buf, int n) {
    if (!ops || !ops->write) {
        fprintf(stderr, "write not supported\n");
        return -EINVAL;
    }
    return ops->write(buf, n);
}

int main(void) {
    char buf[4];
    int n = mydev_ops.read(buf, sizeof(buf));
    printf("read %d byte(s): '%s'\n", n, buf);

    safe_write(&mydev_ops, "hi", 2);  /* prints: write not supported */
    return 0;
}

09Function Pointers vs. Direct Calls: The Cost of Indirection

A direct function call compiles to a single CALL instruction with a known (compile-time) target address. The CPU can predict this branch perfectly.

An indirect call through a function pointer compiles to a CALL instruction with a runtime target — the address stored in the pointer. This is an indirect branch. Modern CPUs have an Indirect Branch Target Buffer (IBTB) and indirect branch predictors, but they are less accurate than the always-correct direct call predictor.

In the kernel, indirect calls through function pointers are also subject to Spectre variant 2 mitigations (retpoline, IBRS). The kernel wraps indirect calls in CALL_NOSPEC / call_indirect_thunk macros on x86 to prevent speculative execution through malicious gadgets. This adds a small overhead.

For ordinary application code, the overhead of an indirect call is usually negligible — a few nanoseconds. The real cost is that the compiler cannot inline through a function pointer (at compile time), which means it cannot constant-fold, dead-code-eliminate, or otherwise optimize across the call boundary.

Bottom line: use function pointers where flexibility matters (drivers, plugins, callbacks). Do not add indirection just because it seems elegant — if the target is always the same, use a direct call or let link-time optimization (LTO) devirtualize it.

The x86-64 assembly for a direct vs indirect call:

; direct call (target known at compile time)
call   0x401050           ; CALL rel32 — predictor is perfect

; indirect call through a register
mov    rax, [rbp-8]       ; load function pointer from stack
call   rax                ; CALL r/m64 — needs indirect predictor
; x86-64: direct call vs indirect call
; Compiled from:
;   direct:   add(3, 4);
;   indirect: fp(3, 4);   where fp = add

section .text

; Direct call — assembler fills in exact offset at link time
    mov  edi, 3
    mov  esi, 4
    call add_fn          ; rel32: fixed displacement, fully predictable

; Indirect call — address loaded from memory at runtime
    mov  rax, [rel fp]   ; load function pointer from data segment
    mov  edi, 3
    mov  esi, 4
    call rax             ; CALL r64: requires indirect branch prediction

10Storing Context with Callbacks: The userdata Pattern

A bare callback has a limitation: it can only work with the arguments it receives. If you need the callback to access additional state (a file handle, a counter, a configuration), you need a way to pass that state through.

The universal C pattern is a void *userdata (also called void *context, void *closure, or void *priv) parameter. The registering code casts its state pointer to void * and passes it; the callback receives void * and casts it back.

The kernel uses a closely related pattern: container_of. Rather than a separate void * pointer, kernel objects embed a "base" struct inside a larger struct. container_of(ptr, type, member) takes a pointer to the embedded member, the outer struct type, and the member name, and computes the address of the outer struct using pointer arithmetic. This is the kernel's version of "this pointer" recovery.

For example, a struct timer_list embedded in your driver's device struct:

struct mydev {
    struct device *dev;
    struct timer_list timer;
    int state;
};

static void timer_cb(struct timer_list *t) {
    struct mydev *d = container_of(t, struct mydev, timer);
    d->state = EXPIRED;
}

The container_of macro computes: (struct mydev *)((char *)t - offsetof(struct mydev, timer)). No extra pointer needed — the state lives adjacent in memory.

#include <stdio.h>
#include <stddef.h>  /* for offsetof */

/* A minimal container_of implementation */
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

/* A generic "event source" that fires a callback */
typedef void (*EventCallback)(void *userdata, int event_id);

typedef struct {
    EventCallback cb;
    void *userdata;
} EventSource;

void fire(EventSource *src, int id) {
    if (src->cb) src->cb(src->userdata, id);
}

/* Application code: wants a counter per-event-source */
typedef struct {
    int count;
    EventSource src;  /* embedded — kernel style */
} MyHandler;

static void my_callback(void *userdata, int event_id) {
    MyHandler *h = (MyHandler *)userdata;  /* userdata style */
    h->count++;
    printf("Event %d received (total=%d)\n", event_id, h->count);
}

/* Alternative: recover outer struct via container_of */
static void my_callback2(void *userdata, int event_id) {
    /* Imagine userdata is &h->src — recover h */
    EventSource *src = (EventSource *)userdata;
    MyHandler *h = container_of(src, MyHandler, src);
    h->count++;
    printf("(container_of) Event %d, total=%d\n", event_id, h->count);
}

int main(void) {
    MyHandler h = { .count = 0 };
    h.src.cb       = my_callback;
    h.src.userdata = &h;

    fire(&h.src, 42);
    fire(&h.src, 43);
    return 0;
}

11Reading Real Kernel Code: file_operations and inode_operations

Let us trace a real read() syscall through the kernel's function pointer dispatch to make everything concrete.

When userspace calls read(fd, buf, n), the syscall entry path calls ksys_read(), which calls vfs_read(), which eventually calls call_read_iter() (or the legacy .read pointer). Here is the simplified flow:

userspace: read(fd, buf, n)
    |
    v  [syscall table dispatch]
ksys_read(fd, buf, count)
    |
    v
vfs_read(file, buf, count, &pos)
    |
    +-- if (file->f_op->read)       --> file->f_op->read(file, buf, count, pos)
    +-- else if (f_op->read_iter)   --> new_sync_read(file, buf, count, pos)
                                         --> call_read_iter(file, &kiocb, &iter)
                                              --> file->f_op->read_iter(&kiocb, &iter)
                                                   --> e.g. ext4_file_read_iter()

Every arrow in that chain is either a direct call (within the VFS layer) or an ops-struct dispatch (the f_op->read_iter call). The VFS does not know about ext4; ext4 does not know about the VFS dispatch logic. The ops struct is the contract between them.

The struct inode_operations is similar but for metadata. When you call openat(AT_FDCWD, "foo", O_RDONLY), the VFS must resolve the filename "foo" to an inode. It calls dir->i_op->lookup(dir_inode, dentry, flags) where i_op is the inode operations struct for the directory's filesystem. For ext4, this calls ext4_lookup; for procfs, proc_lookup; for tmpfs, simple_lookup.

You can read these structs right now in the kernel source:

  • include/linux/fs.h: struct file_operations, struct inode_operations, struct super_operations
  • fs/ext4/file.c: ext4_file_operations
  • drivers/char/mem.c: null_fops (for /dev/null)
  • net/socket.c: socket_file_ops
/* From include/linux/fs.h (simplified, Linux 6.x) */
struct file_operations {
    struct module *owner;
    loff_t (*llseek)(struct file *, loff_t, int);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*read_iter)(struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);
    int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *, unsigned int flags);
    int (*iterate_shared)(struct file *, struct dir_context *);
    __poll_t (*poll)(struct file *, struct poll_table_struct *);
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
    long (*compat_ioctl)(struct file *, unsigned int, unsigned long);
    int (*mmap)(struct file *, struct vm_area_struct *);
    int (*open)(struct inode *, struct file *);
    int (*flush)(struct file *, fl_owner_t id);
    int (*release)(struct inode *, struct file *);
    int (*fsync)(struct file *, loff_t, loff_t, int datasync);
    /* ... many more ... */
};

/* From drivers/char/mem.c — /dev/null's ops (simplified) */
static const struct file_operations null_fops = {
    .owner          = THIS_MODULE,
    .llseek         = null_lseek,
    .read           = read_null,
    .write          = write_null,
    .read_iter      = read_iter_null,
    .write_iter     = write_iter_null,
    .splice_write   = splice_write_null,
};

12Putting It All Together: A Mini Plugin System

The best way to internalize function pointers, ops structs, callbacks, and dispatch tables is to build a small system that uses all of them. Here is a mini plugin architecture: a "formatter" registry that can take a string and format it in different ways (uppercase, reverse, truncate). Plugins register themselves; the dispatcher looks them up by name and calls through the ops struct.

This mirrors exactly how the kernel's filesystem type registry works: filesystems register with register_filesystem(), providing a file_system_type struct; the VFS looks up filesystem types by name when you call mount().

Key ideas demonstrated:

  • Ops struct with a format function pointer and a name string.
  • A static registry (array of pointers to ops structs).
  • A dispatcher that searches by name and calls through the pointer.
  • NULL safety check before dispatch.
  • Typedef for the callback type.

This pattern scales to production: the Linux kernel's net_proto_family, inet_protosw, and proto_ops all follow this exact shape.

#include <stdio.h>
#include <string.h>
#include <ctype.h>

#define MAX_FORMATTERS 8
#define MAX_OUT 128

typedef void (*FormatFn)(const char *in, char *out, int out_sz);

typedef struct {
    const char *name;
    FormatFn    format;
} Formatter;

/* --- Concrete formatters --- */
static void fmt_upper(const char *in, char *out, int sz) {
    int i;
    for (i = 0; in[i] && i < sz-1; i++) out[i] = toupper((unsigned char)in[i]);
    out[i] = '\0';
}

static void fmt_reverse(const char *in, char *out, int sz) {
    int len = (int)strlen(in);
    int i;
    if (len >= sz) len = sz - 1;
    for (i = 0; i < len; i++) out[i] = in[len-1-i];
    out[len] = '\0';
}

static void fmt_shout(const char *in, char *out, int sz) {
    snprintf(out, sz, "%s!!!", in);
}

/* --- Formatter registry --- */
static const Formatter *registry[MAX_FORMATTERS];
static int reg_count = 0;

void register_formatter(const Formatter *f) {
    if (reg_count < MAX_FORMATTERS) registry[reg_count++] = f;
}

const Formatter *find_formatter(const char *name) {
    for (int i = 0; i < reg_count; i++)
        if (strcmp(registry[i]->name, name) == 0) return registry[i];
    return NULL;
}

int apply(const char *fmt_name, const char *input, char *output, int sz) {
    const Formatter *f = find_formatter(fmt_name);
    if (!f || !f->format) return -1;
    f->format(input, output, sz);
    return 0;
}

/* --- Static formatter descriptors --- */
static const Formatter upper_fmt  = { "upper",   fmt_upper   };
static const Formatter rev_fmt    = { "reverse", fmt_reverse };
static const Formatter shout_fmt  = { "shout",   fmt_shout   };

int main(void) {
    register_formatter(&upper_fmt);
    register_formatter(&rev_fmt);
    register_formatter(&shout_fmt);

    char out[MAX_OUT];
    const char *input = "hello world";
    const char *fmts[] = { "upper", "reverse", "shout", "unknown" };

    for (int i = 0; i < 4; i++) {
        if (apply(fmts[i], input, out, sizeof(out)) == 0)
            printf("%-10s -> \"%s\"\n", fmts[i], out);
        else
            printf("%-10s -> NOT FOUND\n", fmts[i]);
    }
    return 0;
}

Key insights

  • ✦A function name without parentheses IS a pointer — the address of the function's first instruction in the text segment. Parentheses are the call operator, not part of the name.
  • ✦The ops-struct pattern (a struct of function pointers) is how C achieves runtime polymorphism. It is structurally identical to a C++ vtable — the kernel just writes it explicitly rather than having the compiler generate it.
  • ✦Every major kernel subsystem (VFS, networking, block I/O) is an interface defined by an ops struct. To write a driver or filesystem, you fill in that struct. You are literally providing a vtable.
  • ✦NULL fields in an ops struct mean 'not supported' — the VFS and other frameworks check before dispatching. Forgetting this check is a classic kernel panic path.
  • ✦The `container_of` macro is the kernel's 'this pointer' — it recovers the outer enclosing struct from a pointer to an embedded member, enabling callbacks to access their full context without a separate void* parameter.
  • ✦typedef for function pointer types trades syntactic precision for readability. The Linux kernel uses this selectively — ops struct fields are written inline (readable as a table), but standalone function-pointer parameters benefit from a named type.
  • ✦Indirect calls through function pointers inhibit compiler inlining and optimization across call boundaries, and carry a small branch-prediction cost. Use them where flexibility is genuinely needed, not as decoration.

Common pitfalls

  • ⚠Forgetting parentheses around *fp in the declaration: `int *fp(int)` declares a function returning int*, not a pointer to a function returning int. Always write `int (*fp)(int)`.
  • ⚠Calling a NULL function pointer. Ops structs allow NULL fields for 'not supported'. Always check `if (ops->method)` before calling, or your program/kernel will crash with a null dereference.
  • ⚠Mismatched signature: assigning a function with the wrong parameter types or return type to a pointer. C will often compile with a warning; calling through the mismatched pointer is undefined behavior and causes crashes or corrupted results.
  • ⚠Dangling function pointer: the function pointed to has been unloaded (e.g., a kernel module unregistered, or a DLL unloaded) but the pointer still exists. Calling it reads freed executable memory.
  • ⚠Confusing `typedef void (*Fn)(void)` (a type alias for a function pointer type) with `typedef void (Fn)(void)` (a type alias for a function type, not a pointer). The latter cannot be directly used as a variable type without adding `*`.
  • ⚠Over-using function pointers where a direct call or a simple if/else is clearer. Indirection has a real (if small) cost and hides the call target from the debugger and profiler.
  • ⚠Forgetting that C99 designated initializers in ops structs (`.read = my_read`) leave unspecified fields as NULL — this is the correct and safe behavior, but you must check those fields before calling.

Practice

  1. 01
    easy

    Build a generic event system. Define an EventHandler struct with a `void (*on_event)(void *ctx, int type, void *data)` callback and a `void *ctx` field. Write a simple event bus (a fixed-size array of EventHandler) with `bus_subscribe(handler)` and `bus_emit(type, data)` functions. Write two subscribers: one that counts events, one that prints them. Fire 10 events and verify the counts.

  2. 02
    easy

    Implement a generic `sort_by` function with the signature `void sort_by(void *arr, int n, int elem_size, int (*key)(const void *))`. The key function extracts an integer sort key from one element. Use insertion sort. Test it on an array of structs containing a name and an age, sorting first by age then by name length.

  3. 03
    medium

    Write a mini state machine engine. Define a StateOps struct with `enter`, `run`, and `exit` function pointer fields (all taking a void *ctx). Build a simple traffic light with three states (RED, YELLOW, GREEN), each with its own StateOps. The engine transitions between states, calling exit on the old state and enter on the new state. Run the light through 6 transitions.

  4. 04
    medium

    Implement a dispatch table for a simple stack-based VM. Define 8 opcodes (PUSH, POP, ADD, SUB, MUL, DUP, SWAP, PRINT). Each opcode handler has signature `void handler(int *stack, int *sp)`. Build a dispatch table (array of function pointers indexed by opcode). Write and execute a short program: push 6, push 7, multiply, print. Extend with a tiny assembler that reads opcode mnemonics from a string.

  5. 05
    hard

    Write a Linux kernel character device module (hello_dev) that: (1) registers a major number dynamically with alloc_chrdev_region, (2) fills in a file_operations struct with open, release, read, and ioctl handlers, (3) read returns the string "KernelPath\n", (4) ioctl with command 0x1234 resets an internal counter. Test with insmod, mknod, cat /dev/hello_dev, and a small C program that calls ioctl. Check dmesg for your log messages.

  6. 06
    hard

    Implement a plugin loader for a userspace math library. Define a MathPlugin struct with a name string and function pointers for init, compute (takes double array and length, returns double), and cleanup. Write three plugins: sum, product, and max. Write a loader that reads plugin names from argv, calls init, compute, and cleanup on each, and prints results. Now make the loader dlopen a shared library (.so) at runtime that provides a fourth plugin (e.g., geometric mean), and call it through the same interface without recompiling the loader.

Resources

📄Linux Kernel source: include/linux/fs.h
docsfree

The canonical definition of file_operations, inode_operations, and super_operations — read it alongside this module.

📕Linux Device Drivers, 3rd Edition (LDD3)
bookfree

Chapter 3 (char drivers) and Chapter 4 (debugging) build a char driver with file_operations step by step. Free online.

📄The Linux Kernel Module Programming Guide
docsfree

Up-to-date (tracks recent kernels), covers file_operations in module context with working examples.

✎Beej's Guide to C Programming — Function Pointers
articlefree

Beej's characteristic clarity on pointer mechanics; function pointer chapter is approachable for beginners.

🔧Compiler Explorer (godbolt.org)
toolfree

Paste a direct call and an indirect call side by side, switch to x86-64 gcc, and see the CALL vs CALL RAX difference instantly.

✎LWN.net: How the VFS works
articlefree

Classic LWN article explaining the VFS layer and how file_operations fits into the kernel's object model.

📄Linux Kernel Documentation: VFS
docsfree

Official kernel doc describing every ops struct field in the VFS — the definitive reference for file_operations semantics.

📕Expert C Programming: Deep C Secrets
bookpaidPeter van der Linden, Prentice Hall. Chapters on declarations, function pointers, and the C type system.

Unusually fun C book with deep coverage of declaration syntax, typedef, and the mental model for reading complex C types.

📕C Programming: A Modern Approach, 2nd Ed.
bookpaidK.N. King, W.W. Norton. Chapter 17 covers advanced uses of pointers including function pointers.

Rigorous and thorough; the best textbook treatment of function pointer syntax and usage.

✎kernelnewbies.org: container_of
articlefree

Explains container_of macro, offsetof, and the embedded-struct idiom used throughout the kernel for OOP-in-C.

Checkpoints — you can now…

  • ✓You can declare a function pointer variable from scratch, assign a function to it, and call through it — without looking up the syntax.
  • ✓You can define a typedef for a function pointer type and use it as a parameter type, struct field type, and return type.
  • ✓You can build a dispatch table (array of function pointers) indexed by an integer opcode and explain why it is O(1) vs a switch statement.
  • ✓You can design an ops struct for a given interface (e.g., a logging backend, a storage driver) and write two concrete implementations that fill it in.
  • ✓You can read a struct file_operations definition in the Linux kernel source and explain what each NULL field means and what happens when the VFS encounters it.
  • ✓You can trace a read() syscall through the VFS dispatch layer to a concrete filesystem's read_iter implementation and identify each function pointer call.
  • ✓You can implement the userdata/context pattern to pass state into a callback without global variables.
  • ✓You can explain the performance difference between a direct call and an indirect call through a function pointer, and name one kernel mitigation for indirect branch speculation.