Symbol Export and Module Namespaces
When the kernel is compiled it becomes one giant ELF object full of functions and variables, but a loadable kernel module linked into it at runtime may only call the handful of those symbols that the kernel has deliberately exported. Export is opt-in: a symbol is invisible to modules unless its defining file says
EXPORT_SYMBOL(name)(any module may link it) orEXPORT_SYMBOL_GPL(name)(only a module whoseMODULE_LICENSE()is GPL-compatible may link it — enforced at load time, perkernel/module/main.c). Exported symbols and their references live in dedicated ELF sections —__ksymtaband__ksymtab_gpl— that together form the kernel symbol table, the only API surface a module sees. Symbol namespaces (EXPORT_SYMBOL_NS) add a second gate on top: a namespaced symbol can only be linked by a module that explicitly opts in withMODULE_IMPORT_NS(NS). This note explains how export works mechanically in Linux 6.12 LTS, how the GPL and namespace gates are enforced, and why an unexported symbol simply does not exist as far as a module is concerned.
Mental Model
Think of the running kernel as a country with strict border control. The country contains tens of thousands of functions and global variables, but a visiting module is not allowed to refer to any of them by default — its undefined symbol references are dangling until the kernel resolves them. The kernel publishes a guest list: the kernel symbol table. Only names on that list can be called. There are two lists in one: a public one (__ksymtab, populated by EXPORT_SYMBOL) and a GPL-only one (__ksymtab_gpl, populated by EXPORT_SYMBOL_GPL) that a proprietary module is forbidden to read. Namespaces are like a visa requirement layered on top of the guest list: certain entries are stamped “you may only use this if you carry a visa for the USB_STORAGE country,” and the module must present that visa (MODULE_IMPORT_NS(USB_STORAGE)) or be turned away at the gate.
flowchart TB subgraph SRC["Source: drivers/usb/storage/usb.c"] E1["EXPORT_SYMBOL_NS_GPL(usb_stor_suspend, USB_STORAGE)"] end E1 -->|"compiler emits<br/>.export_symbol section"| OBJ["usb.o<br/>(license=GPL, ns=USB_STORAGE)"] OBJ -->|"modpost reads .export_symbol"| MP["modpost"] MP -->|"emits KSYMTAB_FUNC(...)"| KT["__ksymtab_gpl entry<br/>+ __ksymtab_strings"] MP -->|"emits a row"| SV["Module.symvers<br/>CRC | sym | mod | type | ns"] KT --> KSYM["Kernel symbol table<br/>(in vmlinux / .ko)"] subgraph LOAD["Module load: ums-karma.ko"] U["uses usb_stor_suspend"] IMP["MODULE_IMPORT_NS(USB_STORAGE)"] LIC["MODULE_LICENSE(\"GPL\")"] end KSYM -->|"find_symbol() scans<br/>__ksymtab + __ksymtab_gpl"| RES["resolve_symbol()"] U --> RES LIC -->|"gplok = true?"| RES IMP -->|"namespace imported?"| RES RES -->|"all checks pass"| BIND["symbol address patched in"] RES -->|"GPL gate fails"| F1["Unknown symbol (gplok=false)"] RES -->|"namespace gate fails"| F2["does not import it → -EINVAL"]
The export pipeline from source to load. What it shows: an EXPORT_SYMBOL_NS_GPL in source is lowered by the compiler into a .export_symbol section entry carrying the license and namespace strings; modpost reads that, emits the real KSYMTAB_* table entries and a Module.symvers row; at load time resolve_symbol() finds the symbol and applies two gates — the GPL gate (is the module GPL-compatible?) and the namespace gate (did the module import USB_STORAGE?). The insight to take: export is not a property of the symbol’s C declaration — it is a separate metadata record that travels through modpost into the binary symbol table, and the kernel makes the link decision at load, not at compile.
Mechanical Walk-through
Step 1 — the export macro becomes a .export_symbol record
In Linux 6.12 the export macros live in include/linux/export.h and are deliberately thin. The four public spellings are:
#define EXPORT_SYMBOL(sym) _EXPORT_SYMBOL(sym, "")
#define EXPORT_SYMBOL_GPL(sym) _EXPORT_SYMBOL(sym, "GPL")
#define EXPORT_SYMBOL_NS(sym, ns) __EXPORT_SYMBOL(sym, "", __stringify(ns))
#define EXPORT_SYMBOL_NS_GPL(sym, ns) __EXPORT_SYMBOL(sym, "GPL", __stringify(ns))The only difference between the plain and _GPL variants is a string: "" versus "GPL". The only difference between the plain and _NS variants is a third argument, the namespace, which __stringify(ns) turns from a bare token into a string literal — so EXPORT_SYMBOL_NS_GPL(usb_stor_suspend, USB_STORAGE) records the namespace "USB_STORAGE". (Note the namespace is written unquoted in 6.12; see the version callout below.)
What does the macro actually emit? Not a table entry directly. It emits an entry into a section literally named .export_symbol:
#define ___EXPORT_SYMBOL(sym, license, ns) \
.section ".export_symbol","a" ASM_NL \
__export_symbol_##sym: ASM_NL \
.asciz license ASM_NL \
.asciz ns ASM_NL \
__EXPORT_SYMBOL_REF(sym) ASM_NL \
.previousEach record is a label __export_symbol_<name> followed by two NUL-terminated strings — the license ("" or "GPL") and the namespace ("" or e.g. "USB_STORAGE") — and a reference to the symbol itself. This is the raw material the linker leaves in the object file. Critically, this is not yet the kernel symbol table — it is an intermediate representation that modpost will transform.
Uncertain
Verify: that the
.export_symbolintermediate-section design (rather than directly emitting___ksymtabsections from the macro) is exactly the 6.12 form. Reason: the export mechanism was reworked across several releases (Masahiro Yamada’s modpost/EXPORT_SYMBOL series, ~2022–2023), so older write-ups describe the macro emitting__ksymtabdirectly. I verified the.export_symbolform against the v6.12include/linux/export.hblob. To resolve: this is confirmed for v6.12; treat pre-6.x descriptions as outdated. uncertain
Step 2 — modpost turns .export_symbol into the real table
modpost (a host tool, scripts/mod/modpost.c) runs after linking each object and reads the .export_symbol section. Its check_export_symbol() decodes each record:
data = sym_get_data(elf, label); /* license */
if (!strcmp(data, "GPL")) {
is_gpl = true;
} else if (!strcmp(data, "")) {
is_gpl = false;
}
data += strlen(data) + 1; /* namespace */
s = sym_add_exported(name, mod, is_gpl, data);So modpost is where “is this a GPL-only export?” and “what namespace?” get parsed out of the strings. It then generates C source (compiled into the .mod.c companion file) that creates the actual table entries:
buf_printf(buf, "KSYMTAB_%s(%s, \"%s\", \"%s\");\n",
sym->is_func ? "FUNC" : "DATA", sym->name,
sym->is_gpl_only ? "_gpl" : "", sym->namespace);The KSYMTAB_FUNC/KSYMTAB_DATA macros (in include/linux/export-internal.h) place the entry into the __ksymtab section for a plain export or __ksymtab_gpl for a GPL one, and put the symbol name string into __ksymtab_strings. This is the section split that the runtime later relies on: which section the entry lands in is how the kernel knows whether a symbol is GPL-only.
Step 3 — what an entry actually is, on disk
A table entry is a struct kernel_symbol (kernel/module/internal.h):
struct kernel_symbol {
#ifdef CONFIG_HAVE_ARCH_PREL32_RELOCATIONS
int value_offset;
int name_offset;
int namespace_offset;
#else
unsigned long value;
const char *name;
const char *namespace;
#endif
};There are two layouts. On architectures that support 32-bit PC-relative relocations (CONFIG_HAVE_ARCH_PREL32_RELOCATIONS, true on x86-64 and arm64), the entry stores signed 32-bit offsets rather than full pointers — value_offset, name_offset, namespace_offset — each interpreted relative to its own address. The header comment explains the payoff: “this reduces the size by half on 64-bit architectures, and eliminates the need for absolute relocations” — meaning the table can live in read-only memory and does not need to be patched up by the loader for kernel address-space layout randomization. kernel_symbol_value() dereferences this: it calls offset_to_ptr(&sym->value_offset) in the relative case, or just returns sym->value in the absolute case.
The three fields map exactly to the three things EXPORT_SYMBOL_NS_GPL records: the value (the symbol’s address), the name (so the kernel can match a module’s reference by string), and the namespace (empty for non-namespaced exports).
Step 4 — runtime resolution and the two gates
When a module is loaded, every undefined symbol it references must be resolved. In simplify_symbols() (kernel/module/main.c) each SHN_UNDEF symbol triggers resolve_symbol_wait() → resolve_symbol(). The heart of resolution is find_symbol(), which scans the two tables:
bool find_symbol(struct find_symbol_arg *fsa)
{
static const struct symsearch arr[] = {
{ __start___ksymtab, __stop___ksymtab, __start___kcrctab,
NOT_GPL_ONLY },
{ __start___ksymtab_gpl, __stop___ksymtab_gpl,
__start___kcrctab_gpl,
GPL_ONLY },
};
...
}The boundary symbols __start___ksymtab … __stop___ksymtab are linker-generated markers bracketing the section, so the kernel knows the array’s extent without a length field. find_symbol() searches both the kernel’s own table and every already-loaded module’s table (a module can export symbols other modules consume — this is how usbcore exports to usb-storage).
The GPL gate is enforced inside find_exported_symbol_in_section():
if (!fsa->gplok && syms->license == GPL_ONLY)
return false;and gplok is decided in resolve_symbol():
.gplok = !(mod->taints & (1 << TAINT_PROPRIETARY_MODULE)),A module is marked TAINT_PROPRIETARY_MODULE if its MODULE_LICENSE() is missing or not GPL-compatible (Step 5). So a proprietary module gets gplok = false, the __ksymtab_gpl search section is skipped, and any GPL-only symbol it references is simply never found — it fails with the generic "Unknown symbol %s (err %d)" message, exactly as if the symbol didn’t exist. This is the mechanical meaning of “an unexported symbol is invisible to modules”: there is no entry in either table, so find_symbol() returns false, and the load aborts. GPL-only is a softer version of the same idea — the entry exists but in a section the proprietary module is not allowed to scan.
The namespace gate runs next, in verify_namespace_is_imported():
namespace = kernel_symbol_namespace(sym);
if (namespace && namespace[0]) {
for_each_modinfo_entry(imported_namespace, info, "import_ns") {
if (strcmp(namespace, imported_namespace) == 0)
return 0;
}
pr_err("%s: module uses symbol (%s) from namespace %s, but"
" does not import it.\n",
mod->name, kernel_symbol_name(sym), namespace);
return -EINVAL;
}If the resolved symbol carries a non-empty namespace, the kernel walks the module’s import_ns modinfo entries (one per MODULE_IMPORT_NS()) and demands a string match. No match → -EINVAL, the whole load fails. The error names the offending symbol and the namespace it lives in, which makes diagnosis trivial.
Step 5 — where the license taint is set
module_license_taint_check() is the source of the GPL gate’s gplok decision:
static void module_license_taint_check(struct module *mod, const char *license)
{
if (!license)
license = "unspecified";
if (!license_is_gpl_compatible(license)) {
if (!test_taint(TAINT_PROPRIETARY_MODULE))
pr_warn("%s: module license '%s' taints kernel.\n",
mod->name, license);
add_taint_module(mod, TAINT_PROPRIETARY_MODULE,
LOCKDEP_NOW_UNRELIABLE);
}
}A missing MODULE_LICENSE() defaults to "unspecified", which is not GPL-compatible, so the kernel both warns and sets the proprietary taint. license_is_gpl_compatible() (include/linux/license.h) is the canonical list of strings that count as GPL-compatible:
return (strcmp(license, "GPL") == 0
|| strcmp(license, "GPL v2") == 0
|| strcmp(license, "GPL and additional rights") == 0
|| strcmp(license, "Dual BSD/GPL") == 0
|| strcmp(license, "Dual MIT/GPL") == 0
|| strcmp(license, "Dual MPL/GPL") == 0);Anything else — "Proprietary", an empty/missing license, a typo like "GPLv2" — is non-compatible and taints. There is no separate license stored in the symbol table: the GPL-ness of an export is encoded purely by which section (__ksymtab vs __ksymtab_gpl) it lives in, and the GPL-ness of a module is its MODULE_LICENSE() string. The two meet at the gplok check.
Inspecting the Symbol Table from Userspace
Exported symbols (and all kernel symbols) are visible through /proc/kallsyms and, for modules specifically, the kernel tracks them via mod_kallsyms. module_kallsyms_on_each_symbol() (kernel/module/kallsyms.c) iterates a loaded module’s symbols, skipping undefined ones (SHN_UNDEF). Each /proc/kallsyms line is address type name [module], where the type character (T/t for text, D/d for data, etc.) is upper-case for global/exported. Note that the addresses are masked to zero for unprivileged readers (gated by kallsyms_show_value(), tied to kptr_restrict and capabilities) — the names are visible but the addresses are hidden to thwart kernel-address leaks. To see which symbols a .ko consumes and exports, modprobe --show-depends and modinfo are the practical tools; nm on the .ko shows the raw __ksymtab* entries.
# Symbols the kernel exports, with their module (if any):
$ grep ' usb_stor_suspend' /proc/kallsyms
ffffffffc04a1b30 t usb_stor_suspend [usb_storage]
# What namespaces a module imports (the visas it carries):
$ modinfo drivers/usb/storage/ums-karma.ko | grep import_ns
import_ns: USB_STORAGEThe first command shows the symbol exists and which module provides it; the second shows the consuming module carries the required namespace visa. If import_ns were missing, the module would refuse to load.
Symbol Namespaces in Practice
Namespaces (Documentation/core-api/symbol-namespaces.rst) exist because the kernel exports on the order of tens of thousands of symbols into one flat global namespace, and not all are meant for general use (LWN 760045). Some are “a convenient way of debugging kernel code”; others “are part of a large subsystem that consists of multiple modules, and should only be used within that particular subsystem.” A namespace lets a maintainer say “these symbols are DMA_BUF’s internal interface — if you want them, declare that you depend on DMA_BUF.” The dma-buf subsystem did exactly this, moving its exports from EXPORT_SYMBOL_GPL to EXPORT_SYMBOL_NS_GPL(..., DMA_BUF) (LWN 870601).
A subsystem with many exports avoids repeating the namespace on every line via DEFAULT_SYMBOL_NAMESPACE:
// At the top of the file, before any EXPORT_SYMBOL:
#undef DEFAULT_SYMBOL_NAMESPACE
#define DEFAULT_SYMBOL_NAMESPACE USB_COMMON
// Now plain EXPORT_SYMBOL() lands in the USB_COMMON namespace:
EXPORT_SYMBOL_GPL(usb_decode_ctrl);or in the subsystem Makefile: ccflags-y += -DDEFAULT_SYMBOL_NAMESPACE=USB_COMMON. The export.h macro honors it:
#ifdef DEFAULT_SYMBOL_NAMESPACE
#define _EXPORT_SYMBOL(sym, license) \
__EXPORT_SYMBOL(sym, license, __stringify(DEFAULT_SYMBOL_NAMESPACE))
#else
#define _EXPORT_SYMBOL(sym, license) __EXPORT_SYMBOL(sym, license, "")
#endifOn the consuming side, MODULE_IMPORT_NS() (include/linux/module.h) is just a modinfo tag:
#define MODULE_IMPORT_NS(ns) MODULE_INFO(import_ns, __stringify(ns))so each import becomes an import_ns=USB_STORAGE entry that verify_namespace_is_imported() matches at load. Forgetting the import is caught twice: modpost warns at build time ("module %s uses symbol %s from namespace %s, but does not import it."), and the load fails at runtime — unless the kernel was built with CONFIG_MODULE_ALLOW_MISSING_NAMESPACE_IMPORTS, which downgrades both to warnings. The kernel even ships make nsdeps, which auto-inserts the missing MODULE_IMPORT_NS() lines into your source.
Uncertain
Verify: that in 6.12 the namespace argument is an unquoted token (
EXPORT_SYMBOL_NS_GPL(sym, USB_STORAGE)), not a quoted string. Reason: the brief notes the syntax changed (token → string) around 6.13, and the original 2018 LWN write-up (760045) described an entirely different implementation (appending.NSto the symbol name and a__knsimportsection), which is not how 6.12 works. I verified the unquoted-token form against the v6.12export.h(__stringify(ns)) and the symbol-namespaces doc (“that argument needs to be a preprocessor symbol”). To resolve: for 6.13+ check whetherEXPORT_SYMBOL_NSnow takes a quoted string and__stringifywas dropped. uncertain
Failure Modes and How to Diagnose Them
“Unknown symbol” on insmod. dmesg shows <mod>: Unknown symbol <name> (err -2) (-ENOENT). Either the symbol is genuinely not exported, or — the classic trap — it is exported as EXPORT_SYMBOL_GPL and your module’s MODULE_LICENSE is not GPL-compatible, so gplok=false hid the GPL table from your module. Check whether the symbol is in __ksymtab_gpl (grep <name> /proc/kallsyms won’t tell you GPL-ness; check the source or modinfo of the exporting module). The fix is either a GPL-compatible license or finding a non-GPL alternative — there is no flag to bypass the GPL gate for a legitimately-loaded module.
“does not import it.” err -22 (-EINVAL) with the namespace named. Add the matching MODULE_IMPORT_NS(THAT_NAMESPACE) (or run make nsdeps). This is a build-time-detectable error that modpost already warned about — if you’re seeing it at load, you probably ignored the build warning or shipped a .ko built against a different kernel that namespaced the symbol.
Exported symbol you wrote isn’t visible. If EXPORT_SYMBOL(foo) is in a static function, or in an object that got dead-code-eliminated, the entry never reaches __ksymtab. modpost since the Yamada rework checks for static EXPORT_SYMBOL and errors. Confirm nm yourmod.ko | grep __ksymtab shows the entry.
License “taints kernel” warning even though you set a license. A typo: "GPLv2" or "GPL-2.0" is not in license_is_gpl_compatible()’s list (it wants "GPL" or "GPL v2"). The kernel silently treats the typo as proprietary.
Alternatives and Boundaries
Symbol export is the only sanctioned way for a module to call into the kernel; there is no dlsym()-style runtime lookup a well-behaved module should use. kallsyms_lookup_name() was unexported in 5.7 precisely to stop modules from grabbing un-exported internal symbols by name (the technique livepatch and some rootkits used). Namespaces are advisory in spirit but enforced in practice (load fails by default) — contrast with the GPL gate, which is a hard licensing boundary the kernel community treats as legally meaningful. For cross-module dependencies the symbol table doubles as the dependency graph: resolve_symbol() calls ref_module() to record that the consuming module depends on the exporting one, which is what makes rmmod refuse to unload a module others depend on (see Module Dependencies and depmod).
Production Notes
The GPL-only export is a live battleground: NVIDIA’s proprietary driver famously cannot link GPL-only symbols, which periodically breaks when a function it relied on is “upgraded” from EXPORT_SYMBOL to EXPORT_SYMBOL_GPL (e.g. the dma_buf move). This is intentional — maintainers use the _GPL suffix to signal “this is deep kernel internals, not a stable interface for out-of-tree binaries.” The CRC half of the table (__kcrctab / __kcrctab_gpl) feeds the per-symbol versioning that enforces the no-stable-ABI policy — that mechanism is the subject of Out-of-Tree Modules and the Kernel ABI. The namespace machinery, by contrast, is a documentation-and-intent tool: as LWN 798254 describes, it lets maintainers carve the flat symbol pile into self-documenting subsystems without breaking the global table.
See Also
- Loadable Kernel Modules — what a
.kois and how it is linked into the kernel - Module Loading insmod modprobe and kmod — the load path that drives
resolve_symbol() - Module Dependencies and depmod —
ref_module()and the dependency graph the symbol table induces - Module Parameters and Metadata Macros — sibling
MODULE_*macros (MODULE_LICENSE, modinfo) - Out-of-Tree Modules and the Kernel ABI — the CRC versioning that gates symbol linkage by ABI compatibility
- Module Signing and Kernel Lockdown — the orthogonal cryptographic gate on module loading
- Linux Device Drivers and Device Model MOC — the parent map (§6, The Kernel Module System)