Cross-Compiling Linux Kernel Modules for ARM: A Complete Guide for Embedded Linux Developers
Learn how cross-compilation works for Linux kernel modules in Linux 6.x, how to set up ARCH and CROSS_COMPILE correctly, and how to build for ARM targets from an x86_64 host machine.
🎓 What You Will Learn
- What cross-compilation means and why it is essential for embedded Linux development
- How the ARCH and CROSS_COMPILE environment variables control the kernel build system
- What a cross toolchain is and how to get the right one for your target architecture
- Why naive cross-compilation of a kernel module fails and the exact reason it does
- The correct way to point the module build system at the right kernel source tree
- How to write a Makefile that supports both native and cross-compiled builds
- Common errors you will encounter and exactly how to fix each one
- How Linux 6.x cross-compilation differs from older kernel versions
What is Cross-Compilation and Why Does it Matter for Linux Kernel Module Development?
In a typical software development scenario, you write code on your laptop or desktop and compile it on the same machine where it will run. The compiler produces code for the processor architecture of your own machine. This is called native compilation.
Cross-compilation is the opposite: you compile code on one machine (the host), but the resulting binary runs on a completely different machine with a different processor architecture (the target). The host is almost always a fast x86_64 desktop or laptop. The target is typically an embedded device with an ARM processor — something like a Raspberry Pi, a BeagleBone, an STM32MP1 evaluation board, or a custom industrial controller.
For embedded Linux device driver development, cross-compilation is not optional — it is the standard workflow. Embedded target devices are often not powerful enough to compile code themselves, or they may not even have a C compiler installed. More importantly, the kernel module you build must match the exact kernel version and configuration running on the target device. Getting this wrong causes the module to refuse to load with a version mismatch error.
Where you write & compile
cross-compiler
produces ARM binary
Where the module runs
Understanding the Two Key Environment Variables: ARCH and CROSS_COMPILE
The Linux kernel build system (called kbuild) uses two special environment variables to know that it is doing a cross-compilation rather than a native build. Understanding these two variables is the most important conceptual step in cross-compiling kernel modules.
ARCH — The Target Architecture
The ARCH variable tells kbuild which processor architecture the output code should target. This determines which architecture-specific header files and build rules are used. For ARM 32-bit targets, you set ARCH=arm. For ARM 64-bit targets (like Raspberry Pi 4, NVIDIA Jetson), you set ARCH=arm64. For RISC-V targets, it is ARCH=riscv.
If you do not set ARCH, kbuild defaults to the architecture of the host machine (x86_64 on a standard developer laptop). This is correct for native builds but wrong for cross-compilation.
CROSS_COMPILE — The Toolchain Prefix
The CROSS_COMPILE variable tells kbuild which compiler to use. It is a prefix — a string that gets prepended to tool names like gcc, ld, objcopy, etc. For example, if you set CROSS_COMPILE=arm-linux-gnueabihf-, then kbuild will call arm-linux-gnueabihf-gcc to compile C code, arm-linux-gnueabihf-ld to link, and so on.
The toolchain prefix follows a standardized naming convention that tells you exactly what it targets:
| arm | – | linux | – | gnueabihf | – | |
| Target CPU (ARM 32-bit) |
OS ABI (Linux) |
C Library ABI (GNU EABI, HardFloat) |
Trailing dash (tool suffix follows) |
Common Cross-Toolchain Prefixes for Embedded Linux in 2025
| Target Architecture | Typical ARCH Value | Toolchain Prefix (CROSS_COMPILE) | Common Use Case |
|---|---|---|---|
| ARM 32-bit (Cortex-A) | arm |
arm-linux-gnueabihf- |
Raspberry Pi 2/3, BeagleBone, i.MX6 |
| ARM 32-bit (Cortex-M, no MMU) | arm |
arm-none-eabi- |
Bare metal or RTOS (not Linux) |
| ARM 64-bit (Cortex-A) | arm64 |
aarch64-linux-gnu- |
Raspberry Pi 4/5, Jetson Nano, RK3588 |
| RISC-V 64-bit | riscv |
riscv64-linux-gnu- |
StarFive VisionFive 2, Lichee Pi 4A |
| MIPS 32-bit | mips |
mips-linux-gnu- |
OpenWRT routers, legacy embedded |
How to Get a Cross-Compilation Toolchain
You have several options for obtaining a cross-compiler toolchain. The easiest approach on Ubuntu/Debian is using the package manager:
# For ARM 32-bit (arm-linux-gnueabihf-*)
sudo apt-get install gcc-arm-linux-gnueabihf binutils-arm-linux-gnueabihf
# For ARM 64-bit (aarch64-linux-gnu-*)
sudo apt-get install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
# Verify installation
arm-linux-gnueabihf-gcc --version
aarch64-linux-gnu-gcc --version
You can also use toolchains from Arm Ltd (formerly Linaro) or build your own with tools like Buildroot or Yocto. For this tutorial, the apt-installed toolchain is sufficient.
Why Cross-Compiling a Kernel Module Is More Complex Than Cross-Compiling a Regular Program
Cross-compiling a regular user-space C program is straightforward: set the compiler, compile, done. Cross-compiling a kernel module is harder because a kernel module is not a standalone program — it is a piece of code that plugs into a running kernel. This creates a strict requirement: the module must be built against the exact kernel source tree of the kernel it will run on.
When you build a kernel module, the module build system (kbuild) needs to find the right kernel headers and configuration for the target kernel. If you point it at the wrong place — for example, at the kernel headers of your host machine — the module will be built for the wrong kernel version and will be rejected when you try to load it with insmod.
|
❌ Wrong Approach
ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- makeProblem: kbuild looks for the kernel source at the default path on your host machine ( /lib/modules/$(uname -r)/build).This points to the x86_64 kernel headers of your host OS — not the ARM kernel of your target device! Result: Module built for wrong kernel version → insmod: ERROR: could not insert module: Invalid module format
|
→ |
✓ Correct Approach
ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make -C /path/to/target-kernel-src M=$(pwd)The -C flag tells kbuild to change into the target kernel source directory first. The M=$(pwd) flag tells it where your module source is.Now kbuild uses the ARM kernel configuration and headers → module is built for the correct target kernel ✓ |
Writing a Cross-Compilation-Ready Makefile for Linux 6.x
The solution is to write a Makefile that uses a KDIR variable pointing to the correct kernel source tree for the target. Here is a production-quality Makefile pattern that supports both native and cross-compiled builds cleanly:
# Makefile for a cross-compilation-ready Linux kernel module
# Supports both native (x86_64) and cross-compiled (ARM/ARM64) builds
# Compatible with Linux 6.x
# Module name - change this to match your module
MODULE_NAME := my_driver
# -------------------------------------------------------
# KDIR: Path to the target kernel source tree
# Override this when cross-compiling:
# make KDIR=/path/to/arm-kernel-src ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
# For native builds, this defaults to the running kernel's headers
# -------------------------------------------------------
KDIR ?= /lib/modules/$(shell uname -r)/build
# Object files that make up this module
obj-m := $(MODULE_NAME).o
# -------------------------------------------------------
# Build targets
# -------------------------------------------------------
all:
$(MAKE) -C $(KDIR) M=$(shell pwd) modules
clean:
$(MAKE) -C $(KDIR) M=$(shell pwd) clean
# Install the module (native only)
install:
$(MAKE) -C $(KDIR) M=$(shell pwd) modules_install
.PHONY: all clean install
With this Makefile, a native build (for the same machine you are compiling on) requires no extra arguments:
make
A cross-compiled ARM 32-bit build requires three additional pieces of information:
make KDIR=/path/to/raspberry-pi-linux-src \
ARCH=arm \
CROSS_COMPILE=arm-linux-gnueabihf-
And an ARM 64-bit cross-compiled build looks like this:
make KDIR=/path/to/arm64-kernel-src \
ARCH=arm64 \
CROSS_COMPILE=aarch64-linux-gnu-
Setting Environment Variables Persistently
Typing the ARCH and CROSS_COMPILE variables on every make command gets tedious quickly. You can export them as shell environment variables for your current terminal session:
# Set for the current shell session (ARM 32-bit example)
export ARCH=arm
export CROSS_COMPILE=arm-linux-gnueabihf-
export KDIR=/home/ravi/raspberry-pi-linux
# Now a plain 'make' uses these values
make
# Verify the variables are set correctly
echo "ARCH=$ARCH"
echo "CROSS_COMPILE=$CROSS_COMPILE"
echo "KDIR=$KDIR"
export lines to your ~/.bashrc or project-specific .envrc file so you do not need to set them manually every time.
What the Target Kernel Source Directory Must Contain
The KDIR path you provide cannot be just any directory — it must be the configured and built kernel source tree for the exact kernel running on your target device. Specifically, it must contain:
| File / Directory | Why It Is Needed |
|---|---|
| .config | The kernel configuration that was used to build the target kernel. Must match what is running on the device. |
| include/ | Kernel header files. Your module’s #include <linux/module.h> and similar includes resolve here. |
| Module.symvers | Symbol version information for the kernel. Required to correctly version-check exported symbols your module depends on. |
| scripts/ | Build scripts including Makefile.build, mod/modpost, and others. kbuild calls these scripts during the module build process. |
| arch/arm/ or arch/arm64/ | Architecture-specific headers and build rules for the target CPU. |
If you are building for a Raspberry Pi, you need to download the Raspberry Pi kernel source for the exact kernel version running on your Pi (check with uname -r on the Pi) and build it at least to the point where the kernel headers are prepared. The official Raspberry Pi OS documentation describes this process.
Step-by-Step: Cross-Compiling a Kernel Module for ARM in Linux 6.x
arm-linux-gnueabihf-gcc
# Expected output: arm-linux-gnueabihf-gcc: fatal error: no input files
# Also check the version
arm-linux-gnueabihf-gcc --version
# Example: arm-linux-gnueabihf-gcc (Ubuntu 13.2.0-4ubuntu3) 13.2.0
make modules_prepare so the build infrastructure is ready for out-of-tree module builds.
# Example: Clone Linux 6.6 LTS (a common choice for embedded in 2025)
git clone --depth 1 --branch v6.6 https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
cd linux
# Configure for ARM (use a defconfig that matches your target)
make ARCH=arm multi_v7_defconfig
# Prepare the headers and scripts (faster than a full kernel build)
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules_prepare
After this, the source directory contains everything needed to build out-of-tree modules for this kernel.
/* my_driver.c - A simple test module for cross-compilation */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("EmbeddedPathashala");
MODULE_DESCRIPTION("Cross-compilation test module for Linux 6.x");
static int __init my_driver_init(void)
{
pr_info("my_driver: module loaded on target\n");
return 0;
}
static void __exit my_driver_exit(void)
{
pr_info("my_driver: module unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
make KDIR=~/linux \
ARCH=arm \
CROSS_COMPILE=arm-linux-gnueabihf-
If successful, you will see a my_driver.ko file in the current directory.
file command to confirm the compiled module targets the right architecture. An x86_64 module will say “x86-64”, an ARM 32-bit module will say “ARM”, and an ARM 64-bit module will say “ARM aarch64”.
file my_driver.ko
# Expected output for ARM 32-bit cross-compiled module:
# my_driver.ko: ELF 32-bit LSB relocatable, ARM, EABI5 version 1 (SYSV), not stripped
# For ARM64 (AArch64):
# my_driver.ko: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), not stripped
If you see “x86-64” instead of “ARM”, something went wrong with ARCH or CROSS_COMPILE.
.ko file to your target device using scp or any file transfer method. Then load it with insmod or modprobe.
# Copy to Raspberry Pi (replace with your Pi's IP)
scp my_driver.ko pi@192.168.1.100:/home/pi/
# On the Raspberry Pi:
sudo insmod /home/pi/my_driver.ko
# Verify it loaded
lsmod | grep my_driver
# Check the kernel log for the module's printk output
dmesg | tail -5
# Remove the module
sudo rmmod my_driver
Cross-Compilation Troubleshooting Guide
Cross-compilation failures have very specific causes. Here are the errors you will most likely encounter and exactly how to fix them:
include/generated/autoconf.h or include/config/auto.conf are missing.
.config file is missing or make modules_prepare has not been run.Fix: Inside the KDIR directory, run
make ARCH=arm <yourboard>_defconfig followed by make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules_prepare.
Fix: Run
uname -r on the target to get the exact kernel version. Make sure your KDIR contains source for that exact version. Rebuild the module.
Fix: Use the Raspberry Pi-specific toolchain (
arm-rpi-linux-gnueabihf) for Pi Zero/1 targets, or update to a newer GCC. For Raspberry Pi 2/3/4, the standard arm-linux-gnueabihf works fine.
PATH.Fix: Install the toolchain with
sudo apt-get install gcc-arm-linux-gnueabihf, or if using a manually downloaded toolchain, add its bin/ directory to PATH: export PATH=$PATH:/path/to/toolchain/bin.
Module.symvers file was not generated because the kernel in KDIR was not fully built (or only modules_prepare was run, which does not generate it).Fix: Run a full kernel build in KDIR with
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc). This will generate Module.symvers. For development purposes, the warning can often be ignored if your module only uses standard kernel APIs and not unusual exported symbols.
Native vs. Cross-Compiled Kernel Module Build: Side-by-Side Comparison
| Aspect | Native Build (x86_64 host = x86_64 target) | Cross Build (x86_64 host → ARM target) |
|---|---|---|
| Compiler invoked | gcc (system GCC) |
arm-linux-gnueabihf-gcc |
| ARCH variable | Not needed (defaults to host) | ARCH=arm (required) |
| CROSS_COMPILE | Not needed (empty) | CROSS_COMPILE=arm-linux-gnueabihf- |
| KDIR | /lib/modules/$(uname -r)/build |
Path to ARM kernel source tree |
| Output .ko architecture | ELF 64-bit x86-64 | ELF 32-bit ARM |
| Where to load the .ko | Same machine | Transfer to ARM target first |
| Testing with insmod | Direct on host | Only on ARM target device |
Cross-Compilation Changes in Linux 6.x vs Older Kernels
If you are following older tutorials written for Linux 4.x or 5.x kernels, there are some important differences to be aware of in Linux 6.x:
| Area | Old Behavior (Linux 4.x/5.x) | Linux 6.x Behavior |
|---|---|---|
| Module compression | Modules stored as uncompressed .ko |
Distributions may compress modules as .ko.zst (zstd) by default — affects modprobe behavior |
| LLVM/Clang support | GCC was the only practical choice | Full LLVM cross-compilation supported: make ARCH=arm64 LLVM=1 |
| Module signing | Optional for development | Secure Boot enabled systems require module signing — CONFIG_MODULE_SIG_FORCE |
| Rust module support | Not available | Experimental Rust kernel modules possible with RUST=1 |
| Required GCC version | GCC 5.1 minimum | GCC 11 minimum for Linux 6.x |
Best Practices for Cross-Compiling Kernel Modules in Professional Embedded Linux Development
- Always use an explicit
KDIRvariable in your Makefile rather than relying on defaults — this prevents accidentally building against the wrong kernel headers. - Match your cross-compiler version to what the target distribution uses. If your Raspberry Pi OS was built with GCC 12, use a GCC 12 cross-compiler on your host.
- Keep your kernel source tree in a known, documented location (like
~/kernel-src/rpi-linux-6.6) and document the exact kernel version in your project’s README. - Use
file my_module.koafter every cross-compilation to verify the output architecture before transferring to the target. - Use
sshandscpto work with a networked target, orrsyncfor efficient incremental transfers of multiple modules. - When working with Yocto or Buildroot-based embedded distributions, use the SDK or toolchain they provide, not a generic distribution toolchain — this ensures ABI compatibility.
🌟 Key Takeaways
- Cross-compilation builds code on an x86_64 host that runs on an ARM (or other architecture) target — standard practice in embedded Linux device driver development.
- Two environment variables control the cross-compilation:
ARCH(target architecture name) andCROSS_COMPILE(cross-compiler prefix). - A kernel module must be built against the exact kernel source tree for the kernel running on the target — pointing at the host kernel headers is the most common mistake.
- The Makefile must use
-C $(KDIR) M=$(pwd)— the-Cflag tells kbuild to use the target kernel source, andM=tells it where your module source is. - Use
file my_module.koto confirm the compiled module is for the correct architecture before deploying to the target. - Linux 6.x requires GCC 11 or later for cross-compilation and adds LLVM/Clang support as an alternative toolchain option.
Frequently Asked Questions
make modules_prepare inside the target kernel source tree is usually sufficient — it prepares the headers and build scripts without doing a full kernel compilation. However, to get a correct Module.symvers file, a full build is needed. For most simple module development, modules_prepare is enough.ARCH=arm64 and CROSS_COMPILE=aarch64-linux-gnu-. Older Raspberry Pi OS versions ran in 32-bit mode even on the Pi 4, requiring ARCH=arm. Run uname -m on the Pi to check — aarch64 means 64-bit, armv7l means 32-bit.ARCH=riscv and CROSS_COMPILE=riscv64-linux-gnu- (for 64-bit RISC-V). Install the toolchain with sudo apt-get install gcc-riscv64-linux-gnu on Ubuntu 22.04 or later.uname -r on the target device. This shows the full kernel version string, such as 6.6.28-v8+. You need the kernel source for this exact version to build a compatible module..ko file) without having the device — you just need the right kernel source tree and toolchain. However, you will not be able to test loading the module without the target hardware or an appropriate emulator like QEMU.hf suffix stands for “hard float.” gnueabihf toolchains pass floating-point arguments in FPU registers (hardware float), which is faster but requires the target CPU to have an FPU. gnueabi (soft float) passes floats in integer registers, which is slower but works on all ARM cores. For modern ARM Cortex-A processors (Raspberry Pi 2/3/4, BeagleBone Black), always use the hf variant.output/host/usr/bin/.qemu-arm-static) can run ARM binaries on an x86_64 host. For kernel module testing, you need full system emulation (qemu-system-arm or qemu-system-aarch64) with a complete ARM kernel image and root filesystem. This lets you test the module loading behavior without physical hardware, though hardware-specific drivers that depend on real peripherals cannot be fully tested this way.References and Further Reading
- Linux Kernel Documentation: Kernel Build System (kbuild) Makefiles
- Linux Kernel Documentation: Building External Modules
- Linux Kernel Documentation: Building Linux with Clang/LLVM
- Buildroot Manual: Toolchain Configuration
Master Linux Kernel and Device Driver Development — For Free
EmbeddedPathashala is a completely free Linux kernel programming and embedded systems course. No paywalls, no fees. Just clear, practical tutorials for engineers who want to go deep.
View Full Course Index Subscribe on YouTube