Building Multi-File Linux Kernel Modules with Kbuild
How the Linux 6.x kernel build system links multiple C source files into a single loadable kernel module — explained from the ground up.
1. Why Would You Split a Kernel Module Across Multiple Files?
When you are writing a small learning exercise, having everything in a single .c file is perfectly fine. But real-world kernel modules — device drivers, file systems, protocol implementations — tend to grow quickly. Cramming thousands of lines into one file makes the code hard to read, hard to test, and a nightmare to maintain.
The same reasons that push userspace programmers toward breaking code into separate files apply here too. You might want one file that handles hardware register access, another for interrupt handling, and a third for the sysfs interface. Each file stays focused, and the team can work on different pieces in parallel without stomping on each other’s changes.
The good news is that the Linux kernel build system — called Kbuild — handles this pattern natively. You do not need any special linker scripts or complex build logic. A few lines in a Makefile are all it takes.
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules, it is Kbuild that reads your Makefile, compiles your source files with the correct kernel headers and compiler flags, and produces the final .ko file.
2. How the Kbuild System Processes a Makefile
When you build an out-of-tree kernel module, there are actually two Makefiles involved. Your module directory has a Makefile written for Kbuild. When you invoke make with the kernel source tree as the build root, the kernel’s own top-level Makefile orchestrates the build and then hands control to your Makefile for the module-specific rules.
You runmake modules |
→ | Kernel top-level Makefile takes control |
→ | Your module Makefile is parsed |
| Terminal command | Kernel build root | Module directory |
Your module Makefile’s job is straightforward: tell Kbuild which source files belong to your module and what the final module file should be called. Kbuild then takes care of finding the right compiler, applying the right flags for your running kernel version, and linking everything correctly.
In Linux 6.x, this mechanism has been stable for many years. The obj-m variable is the key entry point that tells Kbuild your module should be built as a loadable module (as opposed to being compiled into the kernel image itself).
3. The obj-m Directive and the -objs Trick
For a single-file module, a Makefile is very short. You just tell Kbuild the name of the module object:
# Single-file module example
# This builds hello.c into hello.ko
obj-m := hello.o
Kbuild sees hello.o listed under obj-m, looks for hello.c, compiles it, and produces hello.ko. Simple.
Now, what if your module is split across multiple .c files? This is where the -objs convention comes in. The pattern is:
- Give your module a label (e.g.,
projx). - Assign that label to
obj-mwith a.osuffix. - Use the same label with a
-objssuffix to list all the source object files that should be compiled and then linked together.
# Multi-file kernel module Makefile
# Final module will be named projx.ko
# Source files: prj1.c, prj2.c, prj3.c
obj-m := projx.o
projx-objs := prj1.o prj2.o prj3.o
Kbuild reads this and understands: compile prj1.c, prj2.c, and prj3.c separately into their own .o object files, then link all three into a single binary kernel module called projx.ko.
projx) must be the same on both lines. The obj-m line uses it with .o, and the -objs line uses it as a prefix. The label itself can be anything you want — it is just a name you invent.
| prj1.c | prj2.c | prj3.c |
| ↓ Kbuild compiles each file separately ↓ | ||
| prj1.o | prj2.o | prj3.o |
| ↓ Kbuild links all objects together ↓ | ||
| projx.ko — your final loadable kernel module | ||
4. What Happens Step by Step During the Build
It helps to understand what Kbuild actually does behind the scenes when you run the build command. Here is the sequence:
| 1 | Preprocessing: Kbuild runs the C preprocessor on each .c file, pulling in kernel headers for your running kernel version. |
| 2 | Compilation: Each source file is compiled into an intermediate .o object file using the correct compiler flags for your kernel build. |
| 3 | Linking: All the .o files listed in the -objs directive are combined into a single object file (an intermediate .o with all symbols merged). |
| 4 | Final module file: The linked object is packaged into a .ko (kernel object) file, which includes ELF sections for the kernel’s module loader to process. |
| 5 | Signing (optional): On systems with module signing enabled, the .ko is cryptographically signed so the kernel can verify its integrity before loading. |
After step 4, you have a projx.ko file that you can load with insmod projx.ko or modprobe projx. The .ko file is not a standard ELF executable — it is a relocatable object that the kernel’s module loader maps into kernel address space at load time.
.ko format gained support for BTF (BPF Type Format) sections embedded inside the module. These sections help tools like bpftool and pahole understand your module’s data types. Kbuild adds BTF automatically if your toolchain and kernel config support it — you do not need to do anything special.
6. Putting It All Together — a Practical Makefile
Here is a complete, annotated Makefile you can use as a starting point for any multi-file out-of-tree kernel module on Linux 6.x. Read each comment carefully — they explain what every line does.
# Kbuild Makefile for a multi-file out-of-tree kernel module
# Works with Linux 6.x
#
# Usage from your module directory:
# make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
# make -C /lib/modules/$(uname -r)/build M=$(pwd) clean
# --- Tell Kbuild we want a loadable module called mydrv.ko ---
obj-m := mydrv.o
# --- List all .c source files that make up this module ---
# Kbuild compiles each into a .o and then links them together
mydrv-objs := mydrv_main.o mydrv_hw.o mydrv_sysfs.o
# --- Optional: pass extra compiler flags only for this module ---
# -DDEBUG enables debug printk() calls inside the module
ccflags-y += -DDEBUG -Wall
# --- The two standard make targets (handled by the kernel build system) ---
all:
$(MAKE) -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
$(MAKE) -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
ccflags-y variable (or the newer CFLAGS_<filename>.o per-file variant). Avoid the older EXTRA_CFLAGS variable — it still works for now but is considered legacy.
Cross-Compiling for ARM Targets
When you are building a kernel module for an embedded ARM board running Linux 6.x, you need to point Kbuild at the correct kernel source tree for your target and tell it which cross-compiler to use. The module Makefile itself does not change — only the command you invoke changes:
# Cross-compile for ARM 64-bit (aarch64) target
# Adjust CROSS_COMPILE and KERNEL_DIR for your setup
export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-
KERNEL_DIR=/path/to/arm64/kernel/source
all:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
The ARCH and CROSS_COMPILE environment variables tell the kernel build system which architecture you are targeting and which toolchain prefix to use. The rest of the build proceeds exactly as on a native x86 machine — Kbuild handles all the architecture-specific details internally.
7. Interview Questions
obj-m variable tell the Kbuild system?.ko) rather than compiled into the vmlinux kernel image. The alternative, obj-y, compiles code directly into the kernel image..ko module?-objs directive in your Kbuild Makefile. Set obj-m := mymodule.o and then mymodule-objs := file1.o file2.o file3.o. Kbuild compiles each .c file separately and links the resulting object files into mymodule.ko.obj-m and obj-y?obj-m builds the code as a loadable module that can be inserted and removed at runtime using insmod/rmmod. obj-y compiles the code permanently into the kernel image — it is always present and cannot be unloaded. Device drivers that are needed at boot (like the root filesystem driver) typically use obj-y.EXPORT_SYMBOL()?EXPORT_SYMBOL() is better because it avoids duplicating the code in every module’s binary and in kernel memory.ARCH environment variable to the target architecture (e.g., arm64), set CROSS_COMPILE to your toolchain prefix (e.g., aarch64-linux-gnu-), and point -C at the kernel source tree built for your target, not the host machine’s kernel..ko (kernel object) extension. Unlike a userspace shared library (.so), a .ko file is a relocatable ELF object that is loaded directly into kernel address space by the kernel’s module loader. It does not go through the dynamic linker; instead, the kernel itself resolves its symbol references against the exported kernel symbol table.