Building Kernel Images With Kbuild
A free embedded Linux kernel development course lecture from EmbeddedPathashala
If you’ve ever typed make inside a kernel source tree and watched hundreds of lines scroll past, you’ve already met kbuild — the build system that turns thousands of loosely related C files into a single bootable kernel image. This lecture is part of our free linux kernel development course and walks through exactly how kbuild decides what to compile, why there are three or four different kernel image formats, and how to build one from source on your own machine. Whether you’re following this as part of a broader free embedded linux course or just trying to understand what happens when you type make zImage, this lecture gives you the mental model kbuild tutorials usually skip.
Kconfig
zImage
uImage
vmlinux
CROSS_COMPILE
free linux device drivers course
What You Will Learn
- How kbuild decides which files to compile using
obj-yandobj-mrules - The difference between vmlinux, Image, zImage, bzImage, and uImage
- How to cross-compile a kernel image for ARM and ARM64 targets
- Why building a uImage for a multi-platform ARM kernel needs LOADADDR
- How to inspect a build with verbose output and check the final binary with
size
Prerequisites
- A downloaded and extracted kernel source tree (see our earlier lecture on getting kernel source)
- A cross-compilation toolchain installed for your target architecture
- A working
.configfile — see our Kconfig/menuconfig lectures if you don’t have one yet
What Kbuild Actually Does
Kbuild is not one program — it’s a collection of Makefiles and shell scripts layered on top of GNU Make. When you type make at the top of the kernel tree, the top-level Makefile reads your .config file, then walks into every subdirectory that has buildable code, reading a local Makefile in each one. Those local Makefiles don’t compile things directly — they list files using two special variables, obj-y and obj-m, and let Kconfig decide which of those variables each file lands in.
Here’s an original, simplified example. Imagine a fictional driver directory drivers/ep_demo/ with two source files, and a Kconfig option called CONFIG_EP_SENSOR:
obj-y += ep_core.o
obj-$(CONFIG_EP_SENSOR) += ep_sensor.o
ep_core.o is built unconditionally every time, because obj-y always resolves to “yes.” ep_sensor.o is conditional: if you set CONFIG_EP_SENSOR=y in your .config, it becomes part of the final kernel binary. If you set it to m, kbuild compiles it as a separate loadable module (ep_sensor.ko) instead of linking it into the kernel image. If the option is unset, the file is skipped entirely — it never even gets compiled. This single mechanism is how a kernel with tens of thousands of possible drivers ends up producing one image containing only what you asked for.
Running A Full Build
For most targets, a single command handles everything — configuring dependencies, compiling every selected object file, and linking the result:
$ make -j $(nproc) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
ARCH tells kbuild which architecture-specific code under arch/ to pull in, and CROSS_COMPILE is prefixed onto every toolchain command (gcc, ld, objcopy, and so on) so the build uses your cross-compiler instead of your host compiler. The -j flag controls parallel jobs; matching it to your CPU core count is the usual guidance, since kbuild’s dependency graph parallelizes well.
Kernel Image Formats: What’s The Difference
A plain build produces vmlinux at the top of the source tree — the kernel as a full ELF binary, potentially containing debug symbols if CONFIG_DEBUG_INFO is enabled. Almost no real bootloader can load an ELF file directly, so kbuild post-processes vmlinux into a boot-ready format under arch/$ARCH/boot/. Which format you need depends entirely on your bootloader:
| Format | What it is | Typical bootloader |
|---|---|---|
Image |
vmlinux converted to a raw, uncompressed binary | ARM64 bootloaders, some embedded loaders |
zImage |
A compressed Image with a small self-extracting decompression stub attached | U-Boot (via bootz), most 32-bit ARM loaders |
bzImage |
The x86 “big zImage” format, handling images too large for the legacy real-mode boot format | GRUB, syslinux, most x86 bootloaders |
uImage |
A zImage with an extra 64-byte U-Boot header describing load address and entry point | Older U-Boot versions without bootz |
Building any of them uses the same pattern — just swap the make target:
$ make -j $(nproc) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage
$ make -j $(nproc) ARCH=x86_64 bzImage
$ make -j $(nproc) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image
The uImage And Multi-Platform LOADADDR Problem
Modern 32-bit ARM kernels almost always use multi-platform support, meaning one kernel binary can run on several different SoC families and picks the right one at boot time using the device tree. This creates a real complication for uImage specifically: the uImage header bakes in a fixed relocation address (where in physical RAM the kernel expects to be loaded), but different SoCs can have different RAM base addresses. A single multi-platform build has no one correct answer to bake in.
The fix is to tell mkimage explicitly which load address to target using LOADADDR, which you can find by checking the platform’s boot Makefile fragment under arch/arm/mach-*/ for the zreladdr-y value:
$ make -j $(nproc) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- \
LOADADDR=0x40008000 uImage
If you skip LOADADDR on a multi-platform build, mkimage will fail rather than guess — which is the correct behavior, since guessing wrong would produce a kernel that silently fails to boot.
Watching And Debugging The Build
By default kbuild prints a short summary line per file (CC for compile, LD for link, CHK for a generated header check). That’s fine when the build succeeds, but when it fails you usually need the real command line. Add V=1 to see it in full:
$ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- V=1 Image
[...]
aarch64-linux-gnu-gcc -Wp,-MMD,drivers/ep_demo/.ep_core.o.d -nostdinc \
-I./arch/arm64/include -Iinclude -D__KERNEL__ -O2 -Wall \
-c -o drivers/ep_demo/ep_core.o drivers/ep_demo/ep_core.c
This is often the fastest way to spot a missing include path, an accidentally-wrong CROSS_COMPILE prefix, or a stray warning-as-error flag.
Inspecting The Final Binary
Once the build finishes, it’s worth checking what you actually got. vmlinux is a standard ELF file, so ordinary binutils tools work on it — for example, checking section sizes with the cross-toolchain’s size command:
$ aarch64-linux-gnu-size vmlinux
text data bss dec hex filename
7345120 612480 6210048 14167648 d84fe0 vmlinux
Alongside vmlinux, the build also produces System.map — a plain-text symbol table mapping kernel addresses to function and variable names. It’s what tools like kallsyms and crash-analysis utilities use to turn a raw address in an oops trace back into a readable function name.
|
v
compile + link —> vmlinux (ELF) + System.map
|
v
arch-specific postprocessing (objcopy, gzip, mkimage)
|
v
Image / zImage / bzImage / uImage (boot-ready)
Real-World Use Case: Switching Image Formats Mid-Project
A common situation on embedded boards: you inherit a board support package built around an old U-Boot that only understands uImage, but you’d rather move to a newer U-Boot with bootz support so you can drop the LOADADDR bookkeeping entirely. Knowing that zImage and uImage are the same compressed payload with only a small header difference means you can build both from the same tree, test zImage with a U-Boot upgrade, and keep uImage as a fallback — without touching a single line of kernel or driver code.
Common Mistakes And Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
mkimage: Cannot get load address |
Missing LOADADDR on a multi-platform ARM uImage build |
Look up zreladdr-y in your SoC’s boot Makefile and pass LOADADDR= |
| Board hangs at “Starting kernel…” | Wrong image format for the bootloader (e.g. zImage fed to a loader expecting uImage) | Rebuild with the matching target from the table above |
| Build uses host gcc instead of cross-compiler | CROSS_COMPILE not set or typo’d |
Double-check the prefix matches your installed toolchain binaries exactly |
| Confusing errors with no useful detail | Default non-verbose output hides the real command | Re-run the failing target with V=1 |
Best Practices
- Always match
-jto your available CPU cores for faster iteration during driver development - Keep a note of your board’s exact
LOADADDRso you’re not re-deriving it every time you rebuild uImage - Prefer newer U-Boot with
bootz/bootisupport where possible — it removes an entire class of LOADADDR mistakes - Check
size vmlinuxafter enabling new drivers to catch unexpected bloat before flashing
Performance Considerations
Parallel builds scale close to linearly with core count up to a point, but disk I/O and available RAM (object files, ccache if used) can become the bottleneck on large trees. Using a tmpfs build directory or ccache is a common way to cut repeated build times significantly during active driver iteration.
Summary And Key Takeaways
- Kbuild decides what to compile using
obj-y/obj-mrules driven by your.config - vmlinux is the raw ELF output; Image/zImage/bzImage/uImage are bootloader-ready post-processed formats
- Multi-platform ARM uImage builds require an explicit
LOADADDR V=1andsizeare your first tools when a build fails or looks unexpectedly large
Conclusion
Once you see kbuild as “Kconfig decides the file list, Make compiles it, arch-specific scripts repackage the result for your bootloader,” the whole system stops feeling like a black box. That mental model is the foundation for everything else in this free linux kernel development course — from writing your own drivers to debugging boot failures on real hardware. In the next lecture, we’ll cover compiling device trees, the other artifact your bootloader needs alongside the kernel image itself.
Frequently Asked Questions
What’s the actual difference between zImage and uImage?
They’re the same compressed kernel payload. uImage just adds a 64-byte U-Boot header on top, recording things like load address and entry point, so older U-Boot versions without bootz know how to place it in memory.
Why does my uImage build fail only on some boards?
It usually fails specifically on multi-platform ARM builds, because a single kernel binary can target multiple SoCs with different RAM base addresses, and uImage needs one fixed address baked in via LOADADDR.
Do ARM64 kernels use zImage too?
No — ARM64 uses the Image target (optionally gzip-compressed as Image.gz), not zImage or uImage. The zImage/uImage formats are specific to 32-bit ARM.
What is obj-y actually doing under the hood?
It’s a Makefile variable that kbuild’s recursive make logic scans in every directory. Anything listed in obj-y is compiled and linked in unconditionally; obj-m entries become loadable modules instead.
Why does my build show CC and LD instead of full compiler commands?
That’s kbuild’s default quiet output mode, meant to keep build logs readable. Add V=1 to the make command to see the full underlying compiler and linker invocations.
Can I build vmlinux without producing a bootable image?
Yes — running plain make without specifying an image target (like zImage or Image) still produces vmlinux at the top of the tree, which is useful for debugging with tools like gdb or kgdb even before you generate a boot-ready image.
What is System.map used for?
It’s a plain-text table mapping every kernel symbol to its address. Debugging tools and crash analyzers use it to translate raw addresses from an oops or panic trace back into readable function names.
Continue Your Free Embedded Linux Journey
More lectures on kernel internals, drivers, and Bluetooth stack development are waiting for you on EmbeddedPathashala.
