Cross-Compiling Linux Kernel Modules for ARM: Build Setup & Workflow

Cross-Compiling Linux Kernel Modules for ARM: Free Linux Device Drivers Course | EmbeddedPathashala

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.

📚 Chapter 4, Part 2 ⏱ ~22 min read 🎓 Free Linux Device Drivers Course

🎓 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
Prerequisites
You should be familiar with writing a basic Linux kernel module and building it with a Makefile on a native x86_64 system. You should have read the previous part of this free Linux kernel development course on debug kernel configuration. Basic understanding of what a compiler and linker do is assumed.

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.

Cross-Compilation: Host and Target Relationship
💻
Host Machine
x86_64 (64-bit Intel/AMD)
Fast CPU, lots of RAM
Where you write & compile

cross-compiler
produces ARM binary
🤖
Target Device
ARM32 / ARM64
Embedded board or SBC
Where the module runs
The cross-compiler runs on x86_64 but produces ARM machine code that the target processor understands

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:

Cross-Toolchain Prefix Naming Convention
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
Toolchain vs. Kernel Version
The toolchain version (GCC version) must be compatible with the kernel version you are building. For Linux 6.x, you need at least GCC 11 or later. Ubuntu 22.04 LTS ships with GCC 11, and Ubuntu 24.04 ships with GCC 13 — both are suitable for Linux 6.x kernel module cross-compilation.

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.

Why Naive Cross-Compilation of a Kernel Module Fails
❌ Wrong Approach
ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make

Problem: 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"
Shell Profile Tip
If you are always building for the same embedded target, add the 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:

Required Contents of the Target Kernel Source Directory (KDIR)
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

1
Verify the cross-toolchain is installed
Run the cross-compiler with no arguments. It should fail with “no input files” — this confirms it is installed and in your PATH. A “command not found” error means installation failed or the binary is not in your PATH.
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
2
Obtain and prepare the target kernel source
You need the kernel source tree for the exact kernel running on your target device. Clone or download the appropriate source, then configure and at minimum run 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.
3
Write your module code
Write a simple kernel module. The module source code is completely architecture-independent — the same C file compiles for x86_64 and ARM. The cross-compiler and kernel headers handle everything architecture-specific.
/* 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);
4
Cross-compile the module
Run make with the correct KDIR, ARCH, and CROSS_COMPILE values:
make KDIR=~/linux \
     ARCH=arm \
     CROSS_COMPILE=arm-linux-gnueabihf-
If successful, you will see a my_driver.ko file in the current directory.
5
Verify the output binary is correct
Use the 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.
6
Copy the module to the target and load it
Transfer the .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:

ERROR: Kernel configuration is invalid.
include/generated/autoconf.h or include/config/auto.conf are missing.
Cause: The KDIR you specified has not been configured yet — the .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.
insmod: ERROR: could not insert module my_driver.ko: Invalid module format
Cause: The kernel version encoded in the module does not match the kernel version running on the target device. This happens when your KDIR contains a different kernel version than what is actually booted.
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.
cc1: error: bad value (‘armv6’) for ‘-march=’ switch
Cause: The GCC version of your cross-toolchain is too old to support the march flag used by the kernel configuration. This is common when targeting Raspberry Pi OS (which uses ARMv6 for Pi Zero/1).
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.
arm-linux-gnueabihf-gcc: command not found
Cause: The cross-compiler binary is not in your 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 is missing. Modules may not have correct dependencies.
Cause: The 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 KDIR variable 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.ko after every cross-compilation to verify the output architecture before transferring to the target.
  • Use ssh and scp to work with a networked target, or rsync for 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) and CROSS_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 -C flag tells kbuild to use the target kernel source, and M= tells it where your module source is.
  • Use file my_module.ko to 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

Q1. Do I need to compile the entire Linux kernel before I can cross-compile a module?
Not necessarily. Running 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.
Q2. My target device is a Raspberry Pi 4 (64-bit). Should I use ARCH=arm or ARCH=arm64?
This depends on which operating system you have installed on the Pi 4. Raspberry Pi OS “Lite” 64-bit and Ubuntu for Pi 4 run in 64-bit mode, so use 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.
Q3. Can I cross-compile for RISC-V targets using the same approach?
Yes, exactly the same approach. Use 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.
Q4. How do I know which kernel version is running on my target device?
Run 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.
Q5. Can I cross-compile a kernel module for an ARM device without having the actual device?
You can cross-compile the module (produce the .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.
Q6. What is the difference between arm-linux-gnueabi- and arm-linux-gnueabihf-?
The 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.
Q7. I am using Buildroot for my embedded Linux project. Should I use the Buildroot SDK toolchain or an apt-installed toolchain?
Always use the Buildroot SDK toolchain. Buildroot generates a specific toolchain tuned to your exact target configuration (C library version, ABI, optimization flags). Using a generic apt-installed toolchain may cause subtle ABI mismatches, especially around C library symbol versions. Find the Buildroot SDK toolchain in your build output at output/host/usr/bin/.
Q8. Can QEMU be used to test cross-compiled kernel modules without physical hardware?
Yes. QEMU with user-mode emulation (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

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

Leave a Reply

Your email address will not be published. Required fields are marked *