How are kernel modules loaded and unloaded dynamically in an operating system

How are kernel modules loaded and unloaded dynamically in an operating system

I remember the first time I ran rmmod on a module that had a driver still open under it — the system didn’t crash, it just refused, printing “Device or resource busy.” That small moment taught me more about how dynamic module loading actually works than any documentation had up to that point: it’s not a magic switch, it’s a carefully policed lifecycle with reference counting, symbol resolution, and dependency tracking baked in at every step.

This article walks through exactly what happens, mechanically, when a module is loaded and unloaded, using Linux as the primary example and comparing against Windows and other systems along the way.

Why Dynamic Loading Exists At All

A monolithic kernel could, in principle, be compiled with every driver and feature built directly in. The problem is scale and flexibility: a general-purpose OS needs to support thousands of possible devices, filesystems, and network protocols, but any given machine only uses a small fraction of them. Building everything in would bloat the kernel image and waste memory. Dynamic loading solves this by letting the kernel start small and grow on demand — loading exactly the code a given machine needs, exactly when it needs it, without a reboot or recompilation.

The Linux Loading Path, Step by Step

1. The module file itself

A compiled Linux kernel module is an ELF relocatable object file with a .ko extension, containing code, data, an exported/imported symbol table, and metadata sections (license, version magic, module parameters, dependency info).

2. Triggering a load

There are two common entry points:

  • insmod — a low-level tool that loads a specific .ko file directly, with no automatic dependency resolution.
  • modprobe — the higher-level tool almost everyone actually uses, which consults a dependency database (built by depmod from each module’s declared dependencies) and loads any prerequisite modules automatically before loading the requested one.
$ modprobe e1000e

Under the hood, both tools ultimately call the finit_module() (or older init_module()) system call, handing the kernel either a file descriptor or a raw memory buffer containing the module’s contents.

3. Kernel-side processing

Once the syscall is invoked, the kernel:

  1. Validates the ELF module and its version magic against the running kernel’s version and configuration.
  2. Allocates kernel memory for the module’s code and data sections.
  3. Resolves the module’s list of undefined symbols against the kernel’s exported symbol table (and against any already-loaded module’s exported symbols) — this is where a missing dependency produces the classic “unknown symbol” failure.
  4. Applies any relocations needed so the module’s code correctly references its final in-memory addresses.
  5. Increments the reference/usage count of every module it depends on (so a dependency can’t be unloaded out from under it).
  6. Calls the module’s registered module_init function.
  7. If that function returns 0, the module is marked live and appears in lsmod; if it returns a negative error code, the kernel tears the load down immediately.

4. Automatic loading via udev/kmod hooks

A huge share of module loading on a typical Linux desktop or server never involves a human typing modprobe at all. When new hardware is detected (a USB device is plugged in, for instance), the kernel emits a uevent, udev matches it against known device IDs, and automatically calls modprobe for the appropriate driver — all without user interaction.

The Linux Unloading Path

1. Triggering an unload

$ rmmod e1000e
# or, with automatic dependency-aware removal of now-unused modules:
$ modprobe -r e1000e

This maps to the delete_module() system call.

2. Kernel-side checks

The kernel checks the module’s usage count. This count reflects:

  • Whether any other module depends on it (via symbol usage)
  • Whether any code path is actively inside the module (tracked via try_module_get()/module_put() pairs)
  • Whether any open file descriptor, device, or registered resource still references it

If the count isn’t zero, rmmod fails outright (or, with the -w “wait” flag, blocks until it reaches zero, in kernels/configurations that support this).

3. Cleanup

If the module can be safely removed, the kernel calls its module_exit function, giving it a chance to unregister devices, free memory, cancel timers, and generally undo everything the entry point set up. Afterward, the kernel decrements the usage count of any modules it depended on, and frees the memory that held the module’s code and data.

A Simplified Sequence Diagram

User/udev            modprobe/insmod         Kernel
    |                        |                   |
    | request load           |                   |
    |----------------------->|                   |
    |                        | finit_module()     |
    |                        |------------------>|
    |                        |                   | resolve symbols
    |                        |                   | allocate memory
    |                        |                   | call module_init()
    |                        |                   |    |--- returns 0
    |                        |                   |    v
    |                        |     success       | module LIVE
    |                        |<------------------|
    |                        |                   |
    | ... later ...          |                   |
    | request unload         |                   |
    |----------------------->|                   |
    |                        | delete_module()    |
    |                        |------------------>|
    |                        |                   | check refcount == 0?
    |                        |                   |    |--- yes
    |                        |                   |    v
    |                        |                   | call module_exit()
    |                        |                   | free memory
    |                        |     success       |
    |                        |<------------------|

Reference Counting in Detail

Reference counting is the safety mechanism that makes dynamic unloading possible without constant crashes. Every module has an implicit usage counter. Anything that legitimately needs the module to stay resident should bump that counter with try_module_get(module) before using module code, and release it with module_put(module) when finished. try_module_get() deliberately fails (rather than blocking) if the module is already in the process of being unloaded, which prevents a nasty race condition where code could start using a module that’s mid-teardown.

Module Parameters at Load Time

Dynamic loading isn’t just “on or off” — modules can accept parameters at load time, letting the same compiled .ko file behave differently depending on how it’s loaded:

$ modprobe mymodule debug_level=2 buffer_size=4096
static int debug_level = 0;
module_param(debug_level, int, 0644);
MODULE_PARM_DESC(debug_level, "Debug verbosity level");

These are declared with module_param() in the source and parsed by the kernel during the load sequence, before module_init runs.

Windows: A Similar but Differently-Named Process

Windows kernel-mode drivers go through a broadly analogous lifecycle:

  • Drivers are loaded via the Service Control Manager (sc.exe, or automatically at boot/plug-and-play detection), which calls into the kernel’s IoCreateDriver/loader path.
  • A driver’s DriverEntry() routine plays the role of Linux’s module_init.
  • Unloading happens when the driver’s reference count (tracked through its device objects and any open handles) reaches zero, and the registered DriverUnload routine runs — directly analogous to Linux’s module_exit.
  • Plug-and-play driver loading (matching a newly detected device to the correct .sys driver file via its Hardware ID) is Windows’s equivalent of Linux’s udev-triggered modprobe.

macOS/iOS and Other UNIX Variants

  • macOS historically used kextload/kextunload for kernel extensions, with kextd handling dependency resolution similarly to modprobe. Apple has been steadily discouraging third-party kexts in favor of user-space System Extensions/DriverKit, specifically because dynamic kernel-code loading is a significant security exposure.
  • FreeBSD uses kldload/kldunload for kernel loadable modules (KLDs), with a very similar dependency and reference-counting model to Linux.
  • Solaris/illumos use modload/modunload, again following the same general shape: load into kernel memory, resolve symbols, run an init entry point, track usage, and only permit removal when safe.

Troubleshooting Common Load/Unload Problems

  • “Unknown symbol in module” — a dependency module isn’t loaded, or was built against a different kernel version. Check modinfo <module> for its declared dependencies and confirm they’re present with lsmod.
  • “Device or resource busy” on rmmod — something still holds a reference. Check lsmod‘s “Used by” column, and check for open file descriptors pointing at devices the module created (lsof can help on the user-space side).
  • Module loads but device doesn’t appear — the module_init function likely returned 0 without ever calling the actual device-registration function, or the parameters passed at load time steered it down an inactive code path.
  • Kernel taint warnings — loading out-of-tree or proprietary modules marks the kernel as “tainted” (visible in dmesg and /proc/sys/kernel/tainted), which is worth knowing about when debugging crashes, since kernel maintainers will often disregard bug reports from tainted systems.

Best Practices

  • Prefer modprobe over raw insmod/rmmod so dependency resolution happens automatically and correctly.
  • Always pair any code path that “borrows” a module’s functionality with correct try_module_get()/module_put() usage.
  • Use depmod -a after installing new out-of-tree modules so the dependency database stays accurate.
  • Test unload paths as rigorously as load paths — an untested __exit function is a very common source of production kernel bugs.
  • For hot-pluggable hardware, rely on udev rules and proper device-ID matching rather than manually loading drivers.

Module Signing and Secure Boot: Restricting What Can Load

Dynamic loading is powerful, but that same power — adding arbitrary privileged code to a running kernel — is exactly what makes it a security concern in certain environments. Modern Linux kernels support cryptographic module signing: a module built with a valid signature, checked against keys the kernel trusts (often tied into the system’s Secure Boot trust chain via MOK, the Machine Owner Key mechanism), is allowed to load; an unsigned or invalidly-signed module is rejected outright when CONFIG_MODULE_SIG_FORCE (or the equivalent boot parameter) is enabled.

$ modinfo mymodule.ko | grep -i sig
signer:         Example Vendor Module Signing Key
sig_key:        1A:2B:3C:...
sig_hashalgo:   sha256

This matters directly for the load/unload lifecycle because it adds a gate before the symbol-resolution step described earlier — an unsigned module never reaches the point of having its symbols resolved or its init function called at all under a strict-signing policy. It’s a good illustration of how the “essential flexibility” of dynamic loading and the “essential security” of controlling what code can enter kernel space are in constant tension, with different distributions and deployment types (a locked-down cloud VM image versus a hobbyist’s desktop kernel) making different trade-offs.

The Built-In vs. Loadable Decision at Kernel Build Time

Every driver and subsystem in the Linux kernel source tree that supports being a module presents a three-way choice at build configuration time (visible in make menuconfig or similar tools):

  • y — built directly into the kernel image, always present, never separately loadable or unloadable
  • m — built as a separate .ko module, loadable and unloadable dynamically
  • n — not built at all

This choice has real consequences beyond just “is it a file on disk.” A built-in (y) driver is available from the very earliest moment of boot, before any filesystem is even mounted — essential for whatever hardware is needed to reach the root filesystem in the first place. A module (m) driver isn’t available until something explicitly loads it, which is why initramfs images exist: they’re a small, temporary root filesystem, built specifically to bundle just enough modules (storage controllers, filesystem drivers) to get the real root filesystem mounted, at which point normal modprobe-based loading takes over for everything else.

A Deeper Look at depmod and the Dependency Database

depmod deserves more attention than it usually gets, because it’s the piece of infrastructure that makes modprobe‘s automatic dependency resolution possible at all. After modules are installed (typically under /lib/modules/$(uname -r)/), depmod scans every .ko file, reads its declared dependencies (embedded via each module’s MODULE_INFO/depends metadata, generated at build time from EXPORT_SYMBOL usage), and writes out modules.dep — a plain-text mapping from each module to the list of modules it needs loaded first.

# Example modules.dep entry
kernel/drivers/net/wireless/intel/iwlwifi/iwlwifi.ko: kernel/net/wireless/cfg80211.ko

Without an up-to-date modules.dep, modprobe has no way to know that loading iwlwifi first requires cfg80211 to already be present — which is exactly why installing an out-of-tree module without running depmod -a afterward is a classic source of “unknown symbol” load failures, even though the dependency module is physically present on disk.

Forced Unloading and Its Real Dangers

Linux offers a rmmod -f (force) option, and it’s worth being explicit about why this is dangerous rather than just inconvenient to avoid. Forcing removal bypasses the normal reference-count safety check, telling the kernel to call the module’s exit function and free its memory regardless of whether anything still depends on it. If some other code — another module, a lingering kernel thread, a pending timer — still holds a live reference or is mid-execution inside the module’s code, forcing removal creates exactly the use-after-free scenario discussed in the context of missing del_timer_sync() calls, except now deliberately triggered rather than accidentally caused by a coding bug. Kernels built with CONFIG_MODULE_FORCE_UNLOAD disabled don’t even offer this option at all, precisely because production and distribution kernel maintainers generally consider it too dangerous to expose without very deliberate justification. In practice, a module that refuses to unload cleanly almost always indicates a genuine bug (a missing module_put() somewhere, an unclosed file descriptor referencing a device the module created) that forcing removal papers over rather than fixes — the safer response is almost always to find and close the actual outstanding reference, or, if that’s not immediately possible, to simply leave the module loaded until a proper fix is available.

Live Patching: Loading Code Without Even the Usual Module Lifecycle

A more specialized variant of “adding code to a running kernel” deserves mention here because it pushes the load/unload concept even further: live patching (via kpatch or the kernel’s built-in livepatch framework) allows fixing bugs — often security vulnerabilities — in already-running kernel code, without even the disruption of unloading and reloading a whole module, and certainly without a reboot. A live patch is itself packaged as a special kind of kernel module, but instead of registering new functionality through the usual file_operations/callback registration patterns, it works by redirecting calls to specific existing kernel functions toward corrected replacement implementations, using the kernel’s ftrace infrastructure as the redirection mechanism. This is an important capability for environments — financial systems, telecommunications infrastructure, large cloud providers — where even a brief, planned reboot for a security patch carries real operational cost, and it represents the practical endpoint of dynamic loading’s original promise: not just adding new capability without a reboot, but fixing existing capability without one either.

Checking What’s Currently Loaded and Why

A practical, often-overlooked part of the loading/unloading lifecycle is simply auditing what’s currently resident. Beyond the basic lsmod (a thin wrapper around reading /proc/modules), modinfo <module> reveals a module’s declared author, license, version, parameters, and dependencies without needing it to even be loaded, which is genuinely useful when deciding in advance whether loading an unfamiliar out-of-tree module is safe or appropriate for a given system. Combined with checking dmesg output around the time of a load or unload event, and cross-referencing /proc/sys/kernel/tainted for any indication that non-GPL or out-of-tree code has entered the kernel, these tools together give a fairly complete operational picture of a running system’s module state — genuinely useful both for routine system administration and for the kind of after-the-fact investigation that follows an unexpected crash or performance regression.

Summary

Dynamic module loading and unloading is a carefully staged process: resolving symbols against the running kernel, running a well-defined entry point, tracking usage with reference counts, and only permitting teardown once nothing depends on the module anymore. Linux’s insmod/modprobe and rmmod/modprobe -r, layered on top of init_module()/finit_module() and delete_module() syscalls, implement this cleanly, and udev automates the common case of hardware-triggered loading. Windows, macOS, FreeBSD, and Solaris all implement conceptually identical mechanisms under different names, because the underlying problem — safely adding and removing executable code from a running, shared kernel address space — has the same fundamental shape everywhere.

FAQs

What’s the difference between insmod and modprobe? insmod loads exactly the file you give it with no dependency resolution; modprobe consults a dependency database and loads prerequisites automatically.

Why would rmmod refuse to remove a module? Because its usage/reference count isn’t zero — something (another module, an open device, an active code path) still depends on it.

Can a module be loaded automatically without a user running a command? Yes — udev detects new hardware via kernel uevents and calls modprobe automatically based on device ID matching.

Does unloading a module always free all its memory immediately? Yes, once the exit function completes and the kernel confirms nothing depends on it, the module’s code and data memory is freed back to the system.

Is dynamic module loading a security risk? It can be, since it allows adding arbitrary code to the kernel at runtime — which is why systems increasingly restrict it (module signing requirements, disabling loading in production Android kernels, Apple’s push toward user-space DriverKit).

References

  • Linux Kernel Documentation, “Module signing” — https://www.kernel.org/doc/html/latest/admin-guide/module-signing.html
  • Linux man pages, modprobe(8), insmod(8), rmmod(8), depmod(8)
  • Linux Kernel Source, kernel/module/main.c
  • Microsoft Docs, “Driver loading and installation” — https://learn.microsoft.com/en-us/windows-hardware/drivers/install/
  • FreeBSD Handbook, “Kernel Modules” — https://docs.freebsd.org/en/books/handbook/kernelconfig/
Total
0
Shares

Leave a Reply

Previous Post
Why are kernel modules essential for extending kernel functionality

Why are kernel modules essential for extending kernel functionality

Next Post
What programming languages are commonly used for writing kernel modules

What programming languages are commonly used for writing kernel modules

Related Posts