TRT
Sign in

Foundations · Module 3 · Lesson 8 of 9

Function Pointers and Callback Architecture

Store machine code entry addresses in function pointers, execute indirect calls, build jump tables, and architect decoupled callback APIs.

FreeAbout 16 minutes

By the end you can

  • Decipher complex function pointer declarations using clockwise/spiral parsing rules
  • Trace how the CPU dispatches indirect function calls via registers
  • Construct lookup jump tables to replace repetitive switch statements
  • Pass callback functions and context payloads to design decoupled APIs

Builds on Pointers to Pointers and Indirection.

01 · A question first

Why does int *fp(int, int) declare a function returning an integer pointer, while int (*fp)(int, int) declares a variable that can store a function's memory address? Understanding function pointers unlocks dynamic dispatch and callback architecture in C.

Make a guess, then step through the trace below to check it.

02 · Watch it run

Indirect function call through a function pointerstep 1 of 4
int add(int a, int b) { return a + b; }int (*op)(int, int) = add;int res = op(3, 4);
  1. slot 0.text: add @ 0x401120(looked at in this step)
  2. slot 1op: unset
  3. slot 2args: unset
  4. slot 3res: unset

Function add compiled into executable machine instructions at address 0x401120 in the .text section.

Machine work: loader loads text section at 0x401000; symbol add = 0x401120

03 · The idea

Start with the problem

In C, variables store passive data: integers, floating-point numbers, characters, or structs. Yet there are architectural scenarios where software must manipulate executable behavior dynamically at runtime.

Consider a GUI framework processing keyboard events:

c
void on_key_press(int keycode) {
    if (keycode == KEY_ENTER) {
        submit_form();
    } else if (keycode == KEY_ESCAPE) {
        close_modal();
    } else if (keycode == KEY_TAB) {
        focus_next();
    }
}

This design tightly couples the event loop to concrete functions. If an application needs pluggable plugins, user-configurable hotkeys, or custom sorting orders, hardcoded branching breaks down.

Now consider parsing the syntax:

c
int *calculate(int a, int b);   /* Declaration A */
int (*calculate)(int a, int b); /* Declaration B */

Declaration A defines a normal function that returns a pointer to an integer (int *). Declaration B defines an 8-byte variable on the stack that stores the virtual memory address of an executable function. A single pair of parentheses completely changes the semantics.

How do functions occupy memory, how does the CPU execute code from an address stored in a register, and how do systems programmers build flexible callback architectures in pure C?


How the idea works

Functions are not abstract entities; they are sequences of machine instructions stored in the executable .text segment of virtual memory.

Because a compiled function begins at a concrete virtual memory address, that address can be loaded into a pointer, stored in arrays, passed to other functions, and invoked dynamically.

text
Memory Layout (.text Segment)
Address: 0x401120 ──► [ push %rbp; mov %rsp, %rbp; add %esi, %edi; ... ]
 
Function Pointer Variable (Stack)
Address: 0x7ffd10 ──► [ 0x401120 ] (Holds address of add)

1. Function Pointer Syntax and Type Signatures

A function pointer declaration must specify the complete signature of the target function: its return type, calling convention, and parameter types:

c
return_type (*pointer_name)(param1_type, param2_type);

To parse complex declarations, apply the Right-Left (Spiral) Rule: start at the identifier, move right until a parenthesis stops you, move left, and spiral outward:

text
        ┌──────────────────┐
        │  ┌────────────┐  │
        ▼  ▼            │  │
   int (*fn_ptr)(double, char);
    ▲       ▲          │
    │       └──────────┘
    └──────────────────┘
1. fn_ptr is a...
2. * pointer to a function taking (double, char)...
3. returning int.

Assigning an address and invoking it is straightforward:

c
int multiply(int a, int b) { return a * b; }
 
/* The function name decays to its entry address: */
int (*op)(int, int) = multiply;    /* Or: &multiply */
 
/* Calling the function pointer: */
int res = op(5, 6);                /* Or: (*op)(5, 6) */

2. Simplifying Signatures with typedef

Raw function pointer syntax quickly becomes unwieldy when passed as parameters. Idiomatic C defines a clean type alias using typedef:

c
/* Defines 'BinaryOp' as a type representing a function pointer: */
typedef int (*BinaryOp)(int, int);
 
/* Clean, readable function signatures: */
int compute(int x, int y, BinaryOp operation) {
    return operation(x, y);
}

3. Dynamic Dispatch via Jump Tables

Instead of cascading if-else or large switch blocks, systems programs construct jump tables: arrays of function pointers that execute in constant O(1) time:

c
typedef void (*CommandHandler)(void);
 
void cmd_start(void) { puts("Starting"); }
void cmd_stop(void)  { puts("Stopping"); }
void cmd_pause(void) { puts("Pausing"); }
 
/* Array of function pointers indexed by opcode: */
CommandHandler dispatch_table[3] = {
    [0] = cmd_start,
    [1] = cmd_stop,
    [2] = cmd_pause
};
 
void execute_opcode(unsigned int opcode) {
    if (opcode < 3) {
        dispatch_table[opcode](); /* Direct O(1) branch */
    }
}

4. Callback Architecture with Context Payloads

Unlike high-level languages with closures, standard C functions cannot capture variables from their outer scope.

To build robust, reentrant callbacks, every callback function pointer must be accompanied by an opaque context pointer (void *user_data):

c
typedef void (*DataCallback)(const uint8_t *payload, size_t len, void *user_data);
 
/* Event producer: accepts callback and arbitrary context pointer */
void read_network_stream(DataCallback cb, void *user_data) {
    uint8_t buffer[64] = {0};
    /* ... read packets from network ... */
    cb(buffer, sizeof(buffer), user_data);
}
 
/* Consumer: reconstructs state via user_data */
struct ClientSession {
    uint64_t bytes_received;
    int client_id;
};
 
void on_data(const uint8_t *payload, size_t len, void *user_data) {
    struct ClientSession *session = (struct ClientSession *)user_data;
    session->bytes_received += len;
}

Why it is correct

Understanding CPU instruction execution explains how function pointers operate at the hardware level.

Direct vs. Indirect Branching

  • Direct Call (call label): The compiler encodes a relative offset directly into the machine instruction opcode. The target address is fixed at link time.
  • Indirect Call (call *register): The machine instruction tells the CPU: "Read the 64-bit value currently stored in this register, and jump to whatever memory address it contains."
assembly
# Generated assembly for op(3, 4):
movq    -8(%rbp), %rax    # Load address (0x401120) into RAX
movl    $4, %esi          # Second argument in ESI
movl    $3, %edi          # First argument in EDI
call    *%rax             # Indirect jump to target address

This single instruction mechanism provides complete runtime polymorphism without requiring virtual method tables or object-oriented runtime engines.


What it costs

Using function pointers introduces specific performance trade-offs:

FactorDirect CallIndirect Call (Function Pointer)
Compiler inliningFully eligible for -O2 inliningUsually cannot inline; address determined at runtime
Branch predictionFixed target; predictableRelies on CPU Indirect Branch Predictor
Call latency1–2 CPU clock cycles2–6 cycles (or 15–20 cycles on predictor mispredict)
Memory footprint0 bytes extra (embedded in opcode)8 bytes per function pointer variable

Indirect Branch Predictor Misses

Modern CPUs feature a dedicated Indirect Branch Target Buffer (IBTB) that predicts where an indirect jump will land.

If a function pointer invokes the exact same target function millions of times in a tight loop, the branch predictor achieves high accuracy. However, if a callback target alternates randomly across iterations, the CPU suffers branch mispredictions, stalling the execution pipeline for 15 to 20 clock cycles per call.


Pitfalls and variations

Pitfall 1: Missing Parentheses Around Identifier

Omitting parentheses changes a pointer variable into a function prototype:

c
/* Declares a function prototype returning int *: */
int *worker(void);
 
/* Declares a function pointer variable: */
int (*worker)(void);

Pitfall 2: Signature and Calling Convention Mismatches

Invoking a function pointer through an incompatible signature causes stack corruption or register mismatches:

c
void log_message(const char *msg) {
    puts(msg);
}
 
/* DANGEROUS: Type mismatch cast */
void (*bad_fn)(int, int) = (void (*)(int, int))log_message;
 
bad_fn(10, 20); /* UNDEFINED BEHAVIOR: register/argument mismatch */

Under the System V ABI, log_message expects a pointer in RDI. Calling it as bad_fn(10, 20) places the integer 10 into RDI. The function treats integer 10 as a pointer address (0x0000000A) and dereferences it, crashing immediately with a segmentation fault.

Pitfall 3: Dereferencing Uninitialized Function Pointers

Declaring a function pointer without an initializer leaves it filled with garbage stack bits:

c
void (*callback)(void);
callback(); /* CRASH: jumps to arbitrary unmapped address */

Always initialize function pointers to NULL and defensively verify them before invocation:

c
void (*callback)(void) = NULL;
 
if (callback != NULL) {
    callback();
}

Where it is used

Function pointers form the core architectural foundation of professional systems software:

  • Standard Library Algorithms (qsort, bsearch): <stdlib.h> uses comparison callbacks (int (*compar)(const void *, const void *)) to sort arbitrary memory arrays generically.
  • The Linux Kernel Virtual Filesystem (VFS): The Linux kernel represents files, sockets, and block devices using polymorphic function tables (struct file_operations). When user code calls read(), the kernel dispatches through a function pointer table:
    c
    struct file_operations {
        ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
        ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
        int (*open)(struct inode *, struct file *);
    };
  • Signal Handlers (sigaction): Operating system POSIX signal handling registers custom signal interceptors by passing function pointers to the kernel:
    c
    signal(SIGINT, handle_sigint);

This completes Module 2. With virtual memory addressing, pointer arithmetic, contiguous arrays, string sentinels, double indirection, and function pointers mastered, you possess the complete toolkit to manipulate physical memory and design modular, high-performance systems architectures.

04 · Prove it

Question 1 of 3 · answer from memory

What does the declaration `int (*handler)(const char *, size_t)` declare in C?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save