CFoundations

C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures

TT
TopicTrick Team
C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures

C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures


Table of Contents


Functions Are Memory Addresses

In C, when you define a function like int add(int a, int b), the compiled code for that function is placed in a specific location in your program's text (code) segment. The function's name is a convenient label for that location's address.

Just as int x = 5 has an address (&x), a function int add(int a, int b) has an address (add or equivalently &add). A function pointer is a variable that stores this address:

c
#include <stdio.h>

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

int main(void) {
    printf("Address of add: %p\n", (void*)add);
    printf("Address of mul: %p\n", (void*)mul);
    // These will print different hex addresses — two actual code locations
    return 0;
}

Declaring Function Pointer Variables

The syntax for a function pointer follows a specific pattern: ReturnType (*name)(ParameterTypes):

c
// Pointer to a function that:
// - returns int
// - takes (int, int) as arguments
int (*math_op)(int, int);

// Pointer to a function that:
// - returns void
// - takes no arguments
void (*on_event)(void);

// Pointer to a function that:
// - returns const char*
// - takes (int, const char*) as arguments
const char* (*formatter)(int, const char*);

// Pointer to a function that:
// - returns void*
// - takes a size_t argument
void* (*allocator)(size_t);

The (*name) with parentheses is essential. Without them, int *name(int, int) declares a function that returns int* — not a pointer to a function.


Assigning and Calling Through Pointers

c
#include <stdio.h>

int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }

int main(void) {
    int (*op)(int, int); // Declare the function pointer
    
    op = add;            // Assign: op now points to add()
    printf("5 + 3 = %d\n", op(5, 3)); // Call: same as add(5, 3)
    
    op = subtract;       // Reassign to a different function
    printf("5 - 3 = %d\n", op(5, 3)); // Now calls subtract()
    
    op = &multiply;      // &function is also valid (equivalent to 'multiply')
    printf("5 * 3 = %d\n", (*op)(5, 3)); // (*op)() is also valid call syntax
    
    return 0;
}

Both op(5, 3) and (*op)(5, 3) are valid — the C standard permits both. The modern style omits the * for cleaner code.


typedef: Cleaning Up the Syntax

Function pointer syntax is notoriously verbose. Professional code almost always uses typedef to create a clean type alias:

c
#include <stdio.h>
#include <stdint.h>

// Define 'MathOperation' as the type for any function: int f(int, int)
typedef int (*MathOperation)(int, int);

// Define 'EventHandler' as the type for any function: void f(int event_code)
typedef void (*EventHandler)(int event_code);

// Define 'Comparator' as the type for any function: int f(const void*, const void*)
typedef int (*Comparator)(const void*, const void*);

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

void display_result(MathOperation op, int x, int y) {
    printf("Result: %d\n", op(x, y));
}

int main(void) {
    MathOperation my_op = add;
    display_result(my_op, 10, 7); // Result: 17
    return 0;
}

With typedef, MathOperation my_op = add reads almost like a regular variable declaration — a massive improvement in readability.


Functions as Parameters: Callbacks

The canonical use of function pointers is as callback parameters — passing behavior to a function rather than hardcoding it:

c
#include <stdio.h>
#include <stdint.h>

typedef void (*Transformer)(int32_t *value);

void apply_to_array(int32_t *arr, size_t count, Transformer callback) {
    for (size_t i = 0; i < count; i++) {
        callback(&arr[i]);
    }
}

// Three different behaviors to plug in:
void double_it(int32_t *v)  { *v *= 2; }
void square_it(int32_t *v)  { *v = (*v) * (*v); }
void negate_it(int32_t *v)  { *v = -(*v); }

int main(void) {
    int32_t data[] = {1, 2, 3, 4, 5};
    
    apply_to_array(data, 5, double_it);
    // data: {2, 4, 6, 8, 10}
    
    apply_to_array(data, 5, square_it);
    // data: {4, 16, 36, 64, 100}
    
    for (size_t i = 0; i < 5; i++) printf("%d ", data[i]);
    printf("\n");
    
    return 0;
}

This is the Strategy Pattern in C — the algorithm (the transformer) is swapped out at runtime without changing the container logic (apply_to_array). This exact pattern appears in the C standard library's qsort.


Using qsort with a Comparator Callback

qsort from <stdlib.h> sorts any array using a user-provided comparator:

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// Comparator: returns negative, zero, or positive
int compare_ints(const void *a, const void *b) {
    int ia = *(const int*)a;
    int ib = *(const int*)b;
    return (ia > ib) - (ia < ib); // Branchless comparison
}

int compare_strings(const void *a, const void *b) {
    return strcmp(*(const char**)a, *(const char**)b);
}

int main(void) {
    int numbers[] = {64, 34, 25, 12, 22, 11, 90};
    size_t n = sizeof(numbers) / sizeof(numbers[0]);
    
    qsort(numbers, n, sizeof(int), compare_ints);
    
    for (size_t i = 0; i < n; i++) printf("%d ", numbers[i]);
    // 11 12 22 25 34 64 90
    printf("\n");
    
    // Sort array of strings
    const char *words[] = {"banana", "apple", "cherry", "date"};
    qsort(words, 4, sizeof(char*), compare_strings);
    for (int i = 0; i < 4; i++) printf("%s ", words[i]);
    // apple banana cherry date
    
    return 0;
}

Dispatch Tables: O(1) Event Handling

Instead of a long switch statement mapping event codes to handlers, a dispatch table (an array of function pointers) achieves O(1) dispatch:

c
#include <stdio.h>

typedef void (*Handler)(void);

// Handlers for each event type
void on_mouse_click(void) { printf("Mouse clicked\n"); }
void on_key_press(void)   { printf("Key pressed\n"); }
void on_resize(void)      { printf("Window resized\n"); }
void on_close(void)       { printf("Window closed\n"); }

// Dispatch table indexed by event code
Handler event_table[] = {
    on_mouse_click,  // Event code 0
    on_key_press,    // Event code 1
    on_resize,       // Event code 2
    on_close,        // Event code 3
};

#define EVENT_TABLE_SIZE (sizeof(event_table) / sizeof(event_table[0]))

void handle_event(int event_code) {
    if ((size_t)event_code >= EVENT_TABLE_SIZE || !event_table[event_code]) return;
    event_table[event_code](); // O(1) dispatch — no switch needed
}

int main(void) {
    handle_event(0); // Mouse clicked
    handle_event(2); // Window resized
    handle_event(1); // Key pressed
    return 0;
}

This pattern is used in operating system interrupt descriptor tables (IDT), protocol handlers in network stacks, and GUI event systems.


The vtable Pattern: Manual Polymorphism

C++ virtual methods are implemented as function pointer tables (vtables). In C, you can replicate this explicitly. This is exactly how the Linux Virtual File System (VFS) works — struct file_operations contains function pointers for read, write, ioctl, etc.:

c
#include <stdio.h>
#include <stddef.h>

// "Interface" definition — every Animal implements these operations
typedef struct {
    void (*speak)(void *self);
    void (*move)(void *self);
    const char* (*get_name)(const void *self);
} AnimalVTable;

// Concrete type: Dog
typedef struct {
    const AnimalVTable *vtable; // Pointer to the vtable — FIRST member
    char name[32];
} Dog;

void dog_speak(void *self)  { printf("Woof!\n"); }
void dog_move(void *self)   { printf("Dog runs\n"); }
const char* dog_name(const void *self) { return ((Dog*)self)->name; }

static const AnimalVTable dog_vtable = {
    .speak    = dog_speak,
    .move     = dog_move,
    .get_name = dog_name,
};

// Generic function that works with ANY animal through the vtable
void polymorphic_act(void *animal) {
    const AnimalVTable *vt = *(const AnimalVTable**)animal;
    printf("Name: %s\n", vt->get_name(animal));
    vt->speak(animal);
    vt->move(animal);
}

int main(void) {
    Dog rex = { .vtable = &dog_vtable, .name = "Rex" };
    polymorphic_act(&rex);
    return 0;
}

This is the foundation of how GObject (used in GTK, GNOME), GLib, and the Linux kernel implement OOP in C.


Plugin Architecture in C

Function pointers via dlopen/dlsym (POSIX) enable dynamic plugin loading:

c
#include <dlfcn.h>
#include <stdio.h>

int main(void) {
    // Load a shared library at runtime
    void *handle = dlopen("./my_plugin.so", RTLD_LAZY);
    if (!handle) { fprintf(stderr, "%s\n", dlerror()); return 1; }
    
    // Look up a function by name — returns void* (cast to function pointer)
    typedef int (*PluginInit)(const char*);
    PluginInit init = (PluginInit)dlsym(handle, "plugin_init");
    
    if (init) {
        int result = init("config.json");
        printf("Plugin initialized: %d\n", result);
    }
    
    dlclose(handle);
    return 0;
}

This is how Apache modules, Nginx modules, and countless plugin-based systems load behavior at runtime without recompiling the host application.


Performance Characteristics

A function pointer call ((*fp)(args)) is an indirect call:

  • Compiles to CALL [register] on x86-64.
  • The CPU's branch predictor cannot predict the destination as easily as a direct call.
  • Typical overhead: 1-5 nanoseconds per call (negligible for most use cases).
  • In tight inner loops called millions of times per second, direct function calls or inlining are preferred.

Modern CPUs have improved indirect branch prediction (especially post-Spectre mitigations), so the overhead is rarely significant outside of the most performance-critical microarchitecture-sensitive code.


Frequently Asked Questions

Can a function pointer to a function with the wrong signature cause crashes? Yes — this is undefined behavior. If you cast a function of type void f(int) to void (*)(double) and call it, the argument passing convention will be wrong and the result is unpredictable. Always ensure the function pointer type precisely matches the function's actual signature.

Can I store NULL in a function pointer? Yes — NULL (or in C23, nullptr) is a valid null function pointer. You should check for NULL before calling: if (callback != NULL) callback(args);. This is the standard pattern in callback-based APIs where a callback is optional.

Are there any tools for finding function pointer bugs? Clang's -fsanitize=function flag enables Function Sanitizer, which detects calls through function pointers with mismatched types. AddressSanitizer also detects NULL function pointer dereferences.

What is the signal() function signature — a famous example? signal() from <signal.h> has one of the most complex function pointer signatures in the standard library: void (*signal(int sig, void (*func)(int)))(int). It takes a signal number and a handler pointer, and returns a pointer to the previous handler. Most code uses typedef void (*SigHandler)(int) to make this readable.


Key Takeaway

Function pointers are the secret weapon of Extensible C Systems. They transform logic from a compile-time fixed thing into a runtime-configurable variable. By mastering callbacks, dispatch tables, and vtable patterns, you gain the ability to build systems that are as architecturally sophisticated as any C++ or Java framework — but with zero language overhead.

Read next: Void Pointers & Generic C: Type-Erased Programming →


Part of the C Mastery Course — 30 modules from C basics to expert systems engineering.