When I first started poking around inside the Linux kernel source tree, the thing that confused me most wasn’t how to write a “Hello World” module — that part is almost embarrassingly simple. What confused me was how a tiny chunk of code, loaded on the fly into a running kernel, could talk to a system that was never recompiled to know it existed. How does a module call kernel functions? How does it get called back by the kernel? And how do two unrelated modules manage to cooperate without stepping on each other’s toes?
This article walks through all of that, from the beginner mental model to the deeper mechanics that show up in production drivers.
What a Kernel Module Actually Is, in Communication Terms
A kernel module is a piece of code that gets linked into the running kernel’s address space after boot. Once loaded, it isn’t a separate process — it doesn’t have its own address space, its own scheduler entry, or a “connection” to the kernel in the network sense. It becomes part of the kernel. That single fact answers half the mystery right away: a module talks to the kernel the same way any other piece of kernel code talks to any other piece of kernel code — through direct function calls, shared data structures, and a handful of well-defined registration mechanisms.
Think of the kernel as a large, already-compiled program with thousands of exported “hooks.” A module is a plugin that, at load time, gets its address space merged into that program, resolves its unresolved symbols against exported kernel symbols, and then either calls into the kernel directly or registers itself so the kernel can call back into it later.
Communication With the Core Kernel
There are, broadly, four ways a module talks to the kernel:
1. Direct function calls into exported kernel APIs
The kernel exposes a large set of internal functions to modules using EXPORT_SYMBOL() and EXPORT_SYMBOL_GPL(). When a module is compiled, its calls to functions like kmalloc(), printk(), register_chrdev(), or pci_register_driver() are left as unresolved symbols. When insmod or modprobe loads the module, the kernel’s module loader resolves those symbols against its exported symbol table (visible at runtime in /proc/kallsyms).
#include <linux/kernel.h>
#include <linux/module.h>
static int __init my_init(void)
{
printk(KERN_INFO "my_module: loaded, calling into kernel API\n");
return 0;
}
printk() here is a plain function call into kernel code — no IPC, no syscall, nothing fancy. That’s the baseline communication model.
2. Registration and callback structures
A huge amount of kernel-module interaction is not “call a function and get a result” but “register a structure full of function pointers, and let the kernel call you back later.” Device drivers are the classic example. A character device driver fills in a struct file_operations with pointers to its own open, read, write, and release functions, then hands that structure to the kernel via register_chrdev(). From that point on, when a user-space process calls read() on the device file, the kernel’s VFS layer calls the module’s registered function pointer.
static struct file_operations fops = {
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
This callback pattern is everywhere: network drivers register net_device_ops, filesystems register super_operations, block drivers register block_device_operations. It inverts the usual caller/callee relationship — the kernel becomes the caller, and the module supplies the implementation.
3. Kernel notifier chains and hooks
For looser, event-style communication, the kernel offers notifier chains — lists of callback functions that get invoked when a particular kernel event occurs (network interface state changes, CPU hotplug, reboot, memory pressure, and so on). A module can call register_netdevice_notifier() or similar functions to subscribe. This is conceptually close to a publish-subscribe system, except everything still runs in kernel context with no message passing overhead — it’s a linked list of function pointers walked synchronously.
4. Sysfs, procfs, and ioctl as user-facing communication surfaces
Modules don’t only talk to the kernel core; they also expose interfaces so user space and other tools can talk to them, indirectly going through the kernel. /proc entries, /sys attributes, and ioctl() handlers are all mechanisms a module can register with the kernel so that user-space communication gets routed to the module’s own code. This isn’t module-to-kernel communication in the strict sense, but it’s built entirely out of the registration mechanism described above.
How Modules Communicate With Each Other
This is where a lot of the interesting complexity lives, because modules don’t have a built-in “call another module” primitive — they use the kernel as the intermediary in every case.
1. Symbol export and import (the vermagic dependency graph)
If Module A wants to call a function defined in Module B, Module B has to export that function with EXPORT_SYMBOL(), and Module A has to be loaded after Module B (so its symbol dependency is resolvable). This is exactly how the kernel’s own subsystems are broken into modules — for example, a specific Wi-Fi chipset driver depends on the generic cfg80211 or mac80211 modules, which export the symbols the chipset driver needs.
You can see this dependency graph yourself:
$ modinfo mac80211 | grep depends
depends: cfg80211
modprobe uses this metadata (built by depmod) to load dependencies in the right order automatically, so you rarely have to think about it — but under the hood, it’s still just “load B first so its symbols exist, then load A.”
2. Shared kernel data structures
Two modules can also communicate by both registering into the same kernel subsystem structure. Two network filter modules, for instance, might both hook into Netfilter’s chain of packet-processing callbacks. They don’t call each other directly; they cooperate through a shared kernel-owned data structure that both were registered against.
3. Kernel APIs designed explicitly for module-to-module interfaces
Some subsystems are built specifically to let one module provide services that another module consumes, with the kernel acting purely as a broker of exported functions and registration tables. The device model (struct device, struct bus_type, struct driver) is the best example — a bus driver module and a device driver module communicate entirely through kernel-defined interfaces, never through any custom protocol of their own.
4. Notifier chains and workqueues, again
Just as with kernel-to-module callbacks, modules can subscribe to notifier chains that other modules (or the kernel itself) fire. This gives a loosely-coupled communication style where the “sender” module doesn’t need to know who is listening.
A Simple Mental Diagram
User Space
---------------------
| ^
syscall return
v |
---------------------
Kernel Core
(VFS, scheduler, mm)
---------------------
| | |
calls registers notifier
v ^ chain
---------------------------
| Module A | Module B |
| (driver) | (subsystem)|
---------------------------
exported symbols
(Module B -> A)
Everything funnels through the kernel’s symbol table, registration APIs, and callback structures. There’s no direct “socket” between two modules — the kernel itself is always the switchboard.
Real-World Example: A Filesystem Module and a Block Driver
Consider ext4 (a filesystem module) sitting on top of a block device driver like nvme. The filesystem module never calls NVMe-specific functions directly. Instead:
- The block driver registers itself with the block layer via
blk_mq_alloc_disk()and friends. - The filesystem issues generic I/O requests through the block layer’s API (
submit_bio()). - The block layer routes the request to whichever driver owns that block device, using registered callback structures.
This layered indirection is deliberate — it means ext4 works identically whether the underlying device is an NVMe SSD, a SATA disk, or a RAM disk, because it never talks to the specific driver module directly.
Windows and Other Systems, for Comparison
Windows drivers (kernel-mode drivers, or KMDF/WDM drivers) follow a similar pattern conceptually, though the terminology differs. A Windows driver registers an IRP (I/O Request Packet) handler with the I/O Manager, and other drivers or the kernel dispatch IRPs down a “driver stack.” It’s the same registration-and-callback philosophy as Linux, just with different names and a different object model (DRIVER_OBJECT, DEVICE_OBJECT).
On Android, which runs a modified Linux kernel, the same module communication mechanisms apply, with the addition of Binder as a higher-level IPC mechanism used mostly between user-space processes and specific kernel-exposed services — Binder itself is implemented partly as a kernel driver that other kernel code and user space both talk to through the standard registration model.
iOS and other XNU-based systems use kernel extensions (kexts, now largely replaced by DriverKit and system extensions running in user space for security reasons) with an Input/Output Kit (IOKit) object-oriented C++ framework — communication there happens through IOKit’s class hierarchy and matching system rather than raw function pointers, but the underlying idea of “register, then get called back” is the same.
Troubleshooting Communication Issues Between Modules
A few problems come up constantly when I’ve debugged module interaction issues:
- Unresolved symbol errors at load time. This means a module you’re loading depends on symbols from another module that either isn’t loaded yet or was built against a different kernel version. Check with
modinfo <module>anddmesgfor the exact missing symbol. - Version magic mismatches. The kernel embeds a “vermagic” string in each module; if it doesn’t match the running kernel’s build, loading fails outright with
disagrees about version of symbol. - Race conditions in notifier callbacks. Because notifier chains run synchronously in the caller’s context, a slow or blocking callback in one module can stall whatever kernel path fired the notification. Always keep these handlers fast and non-blocking.
- Reference counting bugs. When Module A depends on Module B, the kernel increments B’s usage count. Forgetting to properly hold or release a reference (via
try_module_get()/module_put()) can either prevent a needed module from unloading or, worse, allow it to unload while still in use, leading to a crash.
Best Practices
- Always export the minimal symbol set you need to —
EXPORT_SYMBOL_GPL()for internal-use-only APIs, plainEXPORT_SYMBOL()only when third-party code legitimately needs it. - Prefer existing kernel subsystem interfaces (block layer, network stack, device model) over building a custom communication path between two of your own modules.
- Use notifier chains for loosely coupled, event-driven communication rather than polling.
- Document module dependencies clearly in your build system so
depmodandmodprobecan resolve load order automatically. - Keep any inter-module callback function short and non-blocking, since it often executes in a context where sleeping isn’t allowed (interrupt context, for example).
Going Deeper: IPC-Style Mechanisms Modules Use With User Space
So far this has focused on module-to-kernel and module-to-module communication, but it’s worth spending real time on how a module talks outward to user space, since in practice that’s often the whole point of writing a driver — the kernel-internal wiring exists to serve requests coming from applications.
Netlink sockets are the modern, preferred way for a kernel module to have a rich, bidirectional conversation with user-space daemons — used heavily by networking subsystems (routing, iproute2, wireless configuration via nl80211). Unlike a simple /proc file, netlink supports structured messages, multicast groups (so a module can broadcast events to many interested listeners at once), and asynchronous notifications, without user space needing to poll.
ioctl() remains the workhorse for device-specific control operations that don’t fit neatly into read()/write() semantics — think configuring a graphics card’s display mode, or querying a storage controller’s RAID status. A module registers an .unlocked_ioctl handler in its file_operations structure, and user space issues numbered commands with an optional data payload. It’s blunt and somewhat unstructured compared to netlink, but simple and still extremely common in driver code.
Shared memory via mmap() lets a module expose a region of kernel-controlled memory directly into a user process’s address space, avoiding the overhead of copying data through read()/write() syscalls entirely. Frame buffer drivers and some high-throughput networking drivers (like those built around AF_XDP) use this to move large volumes of data with minimal per-operation overhead.
Sysfs attributes provide a simpler, more structured alternative to raw /proc files for exposing single values (a fan speed, a firmware version, an on/off toggle) that user-space tools and udev rules can read or write directly as ordinary files under /sys/class/... or /sys/devices/....
Synchronization: An Unavoidable Part of “Communication”
Any discussion of how modules communicate with the kernel and each other is incomplete without touching on synchronization, because concurrent access to shared kernel data structures is exactly where a huge share of the hardest kernel bugs live. When a module registers a callback that the kernel might invoke from multiple CPUs simultaneously (an interrupt handler, a network receive callback), it has to protect any shared state with appropriate primitives:
- Spinlocks (
spin_lock()/spin_unlock()) for short critical sections where sleeping isn’t an option, often used in interrupt context. - Mutexes (
mutex_lock()/mutex_unlock()) for longer critical sections in process context, where blocking is acceptable. - RCU (Read-Copy-Update) for read-heavy shared data structures, allowing many concurrent readers without blocking, at the cost of more complex update logic.
- Atomic operations for simple counters and flags shared across CPUs without needing a full lock.
A module that registers itself into a kernel subsystem is implicitly agreeing to play by that subsystem’s locking rules — get this wrong, and the “communication” you’ve set up becomes the exact mechanism through which a race condition or deadlock enters the system.
Example: Tracing a Real Communication Path End to End
To make all of this concrete, consider what happens when a user runs ethtool -s eth0 speed 1000 to change a network interface’s link speed:
ethtool(user space) issues anioctl()call on a socket associated witheth0.- The kernel’s networking core receives this and, because it’s a driver-specific operation, calls the registered
.ndo_set_settings-style callback in the network driver module’snet_device_opsstructure — a direct example of the registration/callback communication pattern. - The driver module talks to the actual network hardware through low-level register writes (memory-mapped I/O), configuring the physical link speed.
- If the hardware supports it, the driver module may fire a notifier chain event (like
NETDEV_CHANGE) so that other interested kernel code — and, indirectly, user-space listeners via netlink — learn about the change. udev, listening on a netlink socket, might then trigger a rule reacting to the interface state change.
Every single hop in that chain uses one of the mechanisms described above: direct calls, registered callbacks, notifier chains, and netlink — never anything ad hoc.
Summary
A kernel module doesn’t communicate with the kernel through some special protocol — it communicates by becoming part of the same address space and using ordinary function calls, exported symbols, and registration-based callback structures. Communication with other modules works exactly the same way, mediated entirely through the kernel: symbol exports create dependency chains, shared subsystem structures let modules cooperate without knowing about each other directly, and notifier chains provide an event-driven communication style. Once you see that “kernel module talking to kernel” and “kernel module talking to another module” both reduce to the same handful of primitives — function calls, symbol tables, and registered callbacks — the whole architecture stops feeling mysterious.
FAQs
Does a kernel module use system calls to talk to the kernel? No. System calls are how user-space processes cross into the kernel. A module is already running in kernel space, so it just calls kernel functions directly.
Can two modules communicate without one exporting symbols to the other? Yes, through shared kernel subsystems (like a notifier chain or a common registration table) rather than a direct function call. But some form of kernel-mediated interface is always involved.
What happens if a module tries to call a function that isn’t exported? The module fails to load, with an “unknown symbol” error reported in dmesg and by insmod/modprobe.
Is inter-module communication the same on Windows and Linux? Conceptually yes — both use registration and callback-based models — but the specific APIs, object types, and terminology differ significantly.
Why do modules need to be loaded in a specific order? Because of symbol dependencies: a module that calls functions exported by another module can only be loaded after that module is already resolvable in the kernel’s symbol table.
References
- Linux Kernel Documentation, “Linux Kernel Module Programming Guide” — https://tldp.org/LDP/lkmpg/2.6/html/
- Linux Kernel Documentation, “Driver Model” — https://www.kernel.org/doc/html/latest/driver-api/driver-model/index.html
- Linux Kernel Source,
include/linux/export.handinclude/linux/notifier.h - Microsoft Docs, “Windows Driver Frameworks (WDF)” — https://learn.microsoft.com/en-us/windows-hardware/drivers/wdf/
- Apple Developer Documentation, “IOKit Fundamentals” — https://developer.apple.com/documentation/kernel/iokit