
Compile Kernel for ARM eBPF: My Proven Method
I start with a simple promise: my approach to compile kernel for ARM eBPF removes guesswork and fits tight devices.
I write from hands-on runs: I build the artifacts my build needs — config, headers, vmlinux, and BTF when available.
I explain why the process centers on matching the target kernel, not the CPU. I show how ebpf programs turn into generic bytecode, pass the verifier, then become native code via JIT.
Expect two common failures up front: missing kernel features and header mismatches. I design a workflow that builds artifacts on my host, compiles bytecode per kernel line, and cross-compiles a small loader for devices with tiny systems.
I validate each step with bpftool, file(1) checks, and a minimal tracepoint that prints through trace_pipe. This tutorial aims to be repeatable, practical, and rooted in real constraints.
Key Takeaways
- I focus on producing the exact artifacts your build needs: config, headers, vmlinux, and BTF when possible.
- Match the running kernel to avoid header and feature mismatches.
- Build bytecode once per kernel line; cross-compile a small loader for constrained devices.
- Watch for two main failures: missing features and kernel mismatch.
- Validate each step with bpftool, file(1), and a minimal tracepoint test.
What changes when you build eBPF for ARM embedded Linux</h2>
Bytecode itself is neutral; the real test is how the running kernel treats it. The verifier and JIT live on the device. That means the target kernel decides if your program loads and runs.
CPU or architecture differences rarely break programs. The eBPF VM emits generic bytecode. At load time the kernel JIT creates native instructions for the device CPU.
The common break is not the CPU but mismatched kernel headers and data structures. If your code reads a struct that shifted fields between releases, the verifier or runtime will fail.
I keep the target source or exact headers on my x86 build host. Those include files define the same structures and types my programs reference. BTF and CO-RE help: they supply type metadata that can adapt some field accesses. But they only help if the running kernel exposes matching info.
Choose stable hooks
I prefer tracepoints for portability: names and payloads are stable across releases. I use kprobes only when I accept symbol churn and ABI risk. In embedded devices watch vendor trees and binary modules—they change configs and functions unexpectedly.
Prerequisites on your x86 build host</h2>
Start by arming your x86 host with a minimal, tested toolchain and the exact target artifacts.
I keep the list short and practical. Install the basic tools I mention below. Verify each tool before you move on.
Essential tools and why they matter
- clang/LLVM — emits LLVM IR from restricted C and targets the bpf backend.
- llc — turns IR into object code the kernel will accept.
- bpftool — inspect .o files, generate a libbpf skeleton header, and sanity-check maps.
- Cross compiler toolchain — build the user-space loader binary for the device CPU.
Inputs to collect before you build
Capture three files every time: the exact kernel version string from the device, the device’s .config, and the matching kernel source tree or vendor drop.
| Item | Why | Quick check |
|---|---|---|
| clang/llc | Produce validated eBPF object code | clang –version; llc –version |
| bpftool | Inspect binaries and produce skeletons | bpftool version |
| Cross compiler | Build the user-space loader the device can run | arm-linux-gnueabihf-gcc –version |
| Device files | Exact kernel version, .config, source tree | uname -r; save .config; snapshot source |
Why not “close enough”? Minor version drift breaks struct layouts, helper availability, or verifier expectations. Save the files in a build folder. Repeatability beats guesswork.
Sanity check: verify clang supports the bpf target and bpftool runs on your host. The host should do the heavy lifting; the device only runs a small loader and the kernel must already have features enabled.
compile kernel for ARM eBPF without guesswork</h2>
I build against the exact version the board reports. No shortcuts. No “same major” guesses. That rule prevents mismatched structures and missing helpers.
Pin the exact version
Record uname -r and grab the vendor tree or source drop. Save the device .config. These three items are non-negotiable artifacts you will reuse.
Set architecture and cross settings
Use ARCH and CROSS_COMPILE in a clean output directory. Reproducible builds need a pristine output tree and the same toolchain flags.
Enable the features you need
- Enable BPF, BPF_SYSCALL, and BPF_JIT if present.
- Turn on debugfs/tracefs to use trace_pipe during tests.
- Build vmlinux with BTF when you plan CO-RE and libbpf workflows.
| Artifact | Why | Quick check |
|---|---|---|
| vmlinux | Symbol table and BTF carrier | file vmlinux; readelf -S vmlinux |
| .config | Reproducible feature set | diff with saved copy; record CONFIG_BPF |
| generated headers | Match struct layouts used by programs | check include/uapi and include/generated |
One-build-to-run-everywhere can work across CPUs because the VM is generic. It fails when type info or helpers differ. Archive artifacts and test on-device early.
Build a minimal eBPF toolchain path that works on small filesystems</h2>
Keep the device tiny — move heavy work to a host. I use two simple models that work in real embedded systems.
Two workable models
Model one: do all work on my x86 host and only push objects and a loader to the device. This avoids shipping LLVM, Python, or headers into a BusyBox rootfs.
Model two: build on-device when the target has package support and enough storage. Rarely practical — but useful when you must match an odd vendor tree live.

Lightweight vs full-stack approaches
A small loader binary that reads an ELF and attaches programs is my go-to. It is tiny, static-linkable, and easy to cross-build.
By contrast, BCC and a Python runtime give convenience but cost megabytes. On constrained systems I keep only what the runtime needs.
Where libbpf fits
libbpf replaces much of the old BCC runtime: ELF handling, map creation, relocations, and attachment logic live in one C library now. That saves space and makes a loader reusable.
| Approach | Footprint | Best when |
|---|---|---|
| Host build + loader | Small | Tiny rootfs, immutable device images |
| On-device build | Large | Devices with package managers or dev images |
| Daemon (BPFd/gobpf) | Medium | Remote RPC workflows, many devices |
Practical note: tools like gobpf or standalone ELF loaders let you reuse a single loader binary to attach multiple programs. If you want a hands-off pattern, read my guide on building a small toolchain on OpenWrt: build-bpf-toolchain on OpenWrt.
Compile eBPF programs into bytecode for ARM targets</h2>
I make the build step predictable: exact headers, arch defines, and only the includes the program needs.
Step 1 — turn restricted C into an ELF object with clang and llc. Use the target define (-D__TARGET_ARCH_aarch64 or -D__TARGET_ARCH_arm) so helper availability and context macros match the running system.
Include paths and flags
Pass include paths into the kernel source: include, tools/testing/selftests, and arch/*/include. These prevent subtle struct-layout and type mismatches that break verifier checks.
Attach points and tracing
I pick tracepoints first: names and payloads survive across version bumps. Use kprobes only when you accept symbol churn and watch PT_REGS_PARM* issues on older trees.
Workflow and libbpf
My flow: compile restricted C to bytecode ELF, verify with bpftool, then ship the object and a tiny loader. Generate a libbpf skeleton to get typed wrappers for maps and program handles—fewer bugs, less loader code.
| Step | Why | Quick check |
|---|---|---|
| clang + llc | Produce valid bytecode | readelf -h program.o |
| Kernel includes | Match structs & helpers | diff headers with device |
| bpftool skeleton | Typed map/program access | bpftool gen skeleton prog.o |
Cross-compile the user space loader and validate it on your ARM device</h2>
I keep one goal: ship a single user-space loader that runs on tiny rootfs images. The loader must be small, obvious, and easy to replace. No magic. No large runtimes.
Build a static or mostly-static loader when your rootfs is BusyBox-only
I prefer a static build when libc support on the board is limited. Static makes deployment predictable.
Mostly-static works when DNS or NSS is needed. Pick what the target machine supports and test it early.
Deploy and run: loading via bpf() system call and reading output from trace_pipe
The loader calls the bpf() system call to insert the program. The verifier runs — then the JIT emits native code and the program executes.
On the device I read /sys/kernel/debug/tracing/trace_pipe to confirm activity. Quick and reliable.
Verify artifacts on both machines with file(1) and simple runtime checks
My deploy bundle contains three items: the loader binary, the .o program, and any small config or data files the user program needs.
| Check | Command | Expected |
|---|---|---|
| Loader ABI | file loader | ARM or aarch64 ELF |
| eBPF object | readelf -h prog.o | ET_REL, BPF sections present |
| tracefs mounted | mount | grep trace | /sys/kernel/debug/tracing available |
| Run smoke test | ./loader prog.o && cat trace_pipe | trace lines show expected data |
Common failure points and the fixes that save the most time</h2>
A rejected load usually means the verifier refused the program. The error shows as Invalid argument (-22) in libbpf. Act on that output first — it tells you why the kernel stopped your code.
Capture verifier text: enable libbpf debug logs and run bpftool. If the log points to an instruction or an access check — you have a verifier complaint. Fix the code or rebuild with the device headers.
Fast diagnostic checklist
- Verifier output: libbpf debug + bpftool show exact failure.
- Header mismatch: rebuild against the device source and regenerate BTF if available.
- Hooks & symbols: confirm tracepoint names on the device; check /proc/kallsyms for kprobes.
- Resources: increase memlock and verify tracefs is writable.
Common platform pitfalls
Older arm64 trees change pt_regs layout. That breaks direct context reads and PT_REGS_PARM macros. When context access fails, switch to stable tracepoints or adapt field reads to target headers.
| Failure | Fast check | Likely fix | When to retry |
|---|---|---|---|
| Verifier -22 | libbpf + bpftool logs | Fix C or rebuild against target headers | After rebuilding object |
| Missing tracepoint | grep trace_events or tracefs | Use another hook or update device image | After confirming name |
| No BTF | readelf -w vmlinux | Enable BTF build or use CO-RE cautiously | When BTF is generated |
| Memlock limits | ulimit -l; dmesg | Raise RLIMIT_MEMLOCK or system limit | Before load attempts |
Remember: the verifier enforces security. Treat rejection as a correctness problem — not a random bug. If you need bpftool installed on your host, see the guide to install bpftool on Ubuntu.
Next steps once your ARM eBPF build is repeatable</h2>
After a successful load on the device, I codify the exact steps so I don’t waste time later.
I check in the device .config, build scripts, and pinned tool versions into source control. That makes each rebuild predictable and repeatable.
Next, move traces from printk to structured data: use maps and a small ring buffer so user space reads events without scraping trace_pipe. Build simple programs as examples: tracepoint counters, syscall-arg capture, then a tiny ring buffer pipeline.
Deploy with one reusable loader per architecture and multiple object files per task. Keep only loader and objects on the machine; keep full source and build outputs on the host.
Finally: treat verifier warnings as bugs, prefer stable hooks over risky probes, and run a smoke test after each update. Monitor updates, rebuild artifacts, and note what changed.
FAQ
Why does eBPF bytecode often move across CPU types but still fail on the target?
Do I need the target kernel source on my x86 build host?
Which hooking points are the safest for portability: tracepoints or kprobes?
What minimal tools do I actually need on the build host?
What target inputs should I collect before starting a build?
How do I pick the correct kernel version to build against?
Which kernel features must be enabled to support eBPF loaders?
What build outputs should I preserve for future builds?
When is “compile once, run everywhere” realistic?
Should I cross-compile the user-space loader or build on the device?
What’s the lightweight alternative to BCC on embedded devices?
How does libbpf change the workflow compared to BCC?
How do I compile restricted C to eBPF bytecode for ARM targets?
What source defines and include paths matter for ARM and arm64?
When should I favor tracepoints over kprobes in source?
How do I generate and use a libbpf skeleton with bpftool?
How do I build a static loader suitable for BusyBox-only rootfs?
What checks should I run after deploying artifacts to the device?
How do I fix "BPF program load failed: Invalid argument (-22)"?
What causes kernel mismatch problems and how do I resolve them?
Why do kprobe symbols fail to attach on some devices?
What are common arm64 context-access pitfalls?
How do I handle missing BTF or unsupported instructions on older devices?
What resource limits prevent loading BPF programs and how do I fix them?
After I make the build repeatable, what’s next?
Related: Bpftool: BPF Program Inspection and Debugging Guide
Related: CONFIG_BPF_SYSCALL: The Kernel Flag eBPF Programs Need
