C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures
Master C function pointers from syntax to production patterns. Learn callbacks, typedef aliases, dispatch tables, qsort comparators, vtable polymorphism, plugin architectures, and how the Linux kernel uses function pointers for virtual file system abstraction.

C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures
Table of Contents
- Functions Are Memory Addresses
- Declaring Function Pointer Variables
- Assigning and Calling Through Pointers
- typedef: Cleaning Up the Syntax
- Functions as Parameters: Callbacks
- Using qsort with a Comparator Callback
- Dispatch Tables: O(1) Event Handling
- The vtable Pattern: Manual Polymorphism
- Plugin Architecture in C
- Performance Characteristics
- Frequently Asked Questions
- Key Takeaway
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:
#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):
// 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
#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:
#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:
#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:
#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:
#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.:
#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:
#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.
