Operating Systems · Kernel Engineering
How a Loadable Module Resolves Symbols Against the Kernel
A module is an unlinked ELF object, not a program. How the loader binds its undefined names, why exported symbols carry version CRCs, and what vermagic refuses to load.
A kernel module is not a program that the kernel runs. It is an ELF relocatable object (the same kind of file a compiler emits before the linker touches it) carrying sections of code and data whose internal references are still symbolic or self-relative. Everything a normal program gets from ld.so at startup, a module gets from the kernel’s module loader: section placement, symbol binding, relocation, and a final answer about whether the result is compatible with the running kernel. The loader is small, and most module bugs that look mysterious are really failures of one of those four steps.
The object file the loader receives
Compiling a module produces a .ko, which is an ELF file of type ET_REL: relocatable, with no program headers and no load address. The sections are the familiar ones (.text, .rodata, .data, .bss) plus a handful that exist only for modules. .init.text holds code that runs once during load and is discarded afterwards; .exit.text holds code that runs only on unload and is not even compiled when the kernel is built without module unloading. .modinfo holds a sequence of null-terminated key=value strings describing license, author, and parameters, which is what modinfo reads without loading anything. .gnu.linkonce.this_module holds a partially initialised struct module, and its size is a hard ABI constraint: if the struct differs by a single field between the headers a module was built against and the running kernel, the loader rejects the image before executing any of it.
Two system calls do the loading. init_module takes a pointer to an image already copied into the caller’s address space, a length, and an argument string. finit_module takes an open file descriptor instead, which lets the kernel read the image itself and check a signature over exactly the bytes it will execute, closing the window in which a file could change between check and use. Unloading is delete_module, which by default refuses while the module’s reference count is non-zero and can be told to wait or to give up immediately.
Placement matters on x86-64. The kernel is built with a small code model that assumes everything sits in the top two gigabytes of the canonical address space, so a call between two functions is a 32-bit relative displacement. Modules are therefore allocated inside the module region, which is positioned within reach of kernel text rather than wherever free virtual memory happens to be. A module placed outside that window would need a different call sequence for every reference into the kernel, and the relocation types emitted by the compiler assume it will not be.
Relocation is the mechanical part. Each section that refers to an address carries a companion relocation section listing, for every such site, an offset, a symbol index, and a type. On x86-64 the common types are R_X86_64_64 for an absolute 64-bit address in data, R_X86_64_PC32 for a self-relative reference, and R_X86_64_PLT32 for a call that may go through a trampoline. The loader walks these entries, writes the resolved value at the stated offset, and moves on. A relocation whose symbol cannot be resolved is a load failure, not a warning.
Binding names to addresses
Only a fraction of the kernel’s symbols are visible to modules. EXPORT_SYMBOL(name) places an entry in the __ksymtab section, and that table (not the full symbol table) is what the loader searches. The distinction is deliberate: exporting a symbol is a promise that its signature and semantics will be maintained, so the set is curated and small relative to the several tens of thousands of symbols a typical kernel image contains.
#include <linux/module.h>
#include <linux/kernel.h>
static int scale_factor = 2;
/* Visible to other modules; the prototype in a header is part of the promise. */
int scale(int value)
{
return value * scale_factor;
}
EXPORT_SYMBOL(scale);
static int __init scale_init(void)
{
pr_info("scale: factor %d\n", scale_factor);
return 0;
}
static void __exit scale_exit(void) { }
module_init(scale_init);
module_exit(scale_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Exported arithmetic helper");
Modern kernels store the table entries as relative offsets from the entry itself rather than as absolute pointers, so the table needs no relocation pass at boot and stays valid wherever the image is placed. The loader adds the offsets back when it looks a name up. Entries also carry an optional namespace string, and a module that exports under a namespace must be matched by an explicit import declaration in the consumer. Namespaces exist because two subsystems exporting the same short name is otherwise indistinguishable from a bug; with them, a symbol is only visible to modules that asked for that namespace by name.
Resolution failure produces the single most common module error: an undefined symbol. The message names the symbol and the module, and the errno is negative. The usual causes are a missing export in the kernel, a function that exists but is only declared static, and a module built against headers from a different kernel version where the symbol had a different name.
EXPORT_SYMBOL_GPL splits the table by license. Symbols exported this way are only resolvable by modules whose declared license is GPL-compatible, which the loader determines from the license string in .modinfo. This is a licensing boundary enforced by the linker, not a technical one, and it is checked at resolution time: the module either binds the symbol or fails to load. Separately, loading any module whose license is not GPL-compatible sets the kernel’s proprietary-module taint flag, which appears in every subsequent warning and oops report, a marker that a bug report describes a kernel the maintainers did not build.
Two independent compatibility checks
The first check is the version CRC. When CONFIG_MODVERSIONS is enabled, the build runs a tool over each exported symbol’s declaration, hashes the type signature, and stores the resulting 32-bit value. The kernel keeps a table of those CRCs, and each module records, in a __versions section, the CRC it expects for every symbol it imports. At load time the two are compared. A match means the compiler on both sides agreed about the number, order, and types of the arguments, which is the property that actually matters: a function whose body changed but whose signature did not is safe to bind, while a function that gained a parameter is not, even though its name is unchanged. A mismatch fails the load with a message stating that the module disagrees about the version of the symbol.
The CRC is a hash of a declaration, not a proof. It cannot see a change in semantics behind an identical signature, and it cannot see a change in a structure that the function dereferences. That gap is why the second check exists.
The second check is vermagic, a string embedded in every module at build time and compared byte for byte with the running kernel’s. It contains the kernel release and a set of configuration tags: whether the kernel is SMP, whether it is preemptible, whether module unloading is enabled, and whether modversions is on. Each of those changes the layout of structures or the code paths a module will call into, so the string is a coarse but effective guard. A module built for a different release, or for the same release with a different preemption model, is refused outright rather than loaded and left to fail later in a way that corrupts memory. The field is capped at 64 bytes, which is generous for the tags and short enough to keep the module header small.
Neither check covers the case that produces the worst failures: a module built against headers that match the running kernel’s version string but not its configuration. A structure that gains a field under one CONFIG_ option changes size, and a module compiled without that option will index past the end of it. This is why distribution kernels, which carry a configuration of their own, cannot load modules built against a hand-compiled kernel of the same release. The string catches the common cases and the CRC catches the rest only where a signature is involved; the honest summary is that a module is compatible with exactly the kernel it was built against.
What fails, and how loudly
Load failures are reported through the kernel log and through the error return of the loading call, and the two carry different amounts of information. A missing symbol, a vermagic mismatch, and a struct-size mismatch all return an error to the caller, but the log line is what identifies which check failed. Reading it in order (unknown symbol first, then version disagreement, then version magic) usually localises the problem to a stale build tree, a wrong header path, or a module built for a different configuration.
Unload has its own failure mode. A module whose reference count is held by an open file, a registered callback, or a thread it started will not unload, and forcing the unload leaves those references dangling. The kernel cannot verify that nothing will call into freed module text later, so a forced removal taints the kernel permanently. The rule the loader enforces is mechanical (count to zero, then free) and modules that hand out their own references must arrange for those references to be dropped, which is the same lifetime problem that appears in Kconfig and Conditional Compilation when a configuration option removes the code that would have done the dropping.
Freed init sections are the other asymmetry worth knowing. Code marked __init is discarded after the module’s init function returns, and its memory is returned to the allocator. Any pointer to an __init function stored in a structure that outlives initialisation becomes a call into reused memory. The compiler cannot catch it because the pointer is just a value; the section attributes only control placement. When a module crashes minutes after loading with an instruction pointer in an implausible location, a retained init-section pointer is one of the first things to check, and the oops report is what makes that check possible.
References
- Linux Kernel Module Programming Guide — Peter Jay Salzman, Michael Burian, Ori Pomerantz; maintained by sysprog21
- Building External Modules — Linux kernel documentation
- Linux Device Drivers, 3rd edition — Corbet, Rubini & Kroah-Hartman, O'Reilly 2005
- Tool Interface Standard: Executable and Linkable Format (ELF) — TIS Committee, 1995