compile kernel for ARM eBPF
Embedded Systems
William  

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.

Table of Contents

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.

ItemWhyQuick check
clang/llcProduce validated eBPF object codeclang –version; llc –version
bpftoolInspect binaries and produce skeletonsbpftool version
Cross compilerBuild the user-space loader the device can runarm-linux-gnueabihf-gcc –version
Device filesExact kernel version, .config, source treeuname -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.
ArtifactWhyQuick check
vmlinuxSymbol table and BTF carrierfile vmlinux; readelf -S vmlinux
.configReproducible feature setdiff with saved copy; record CONFIG_BPF
generated headersMatch struct layouts used by programscheck 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.

A modern workspace featuring a sleek laptop displaying a terminal window filled with intricate eBPF program code, highlighted in vibrant colors. In the foreground, a close-up view of a microcontroller setup, representing ARM architecture, with small circuit boards and tools for development. The middle ground includes detailed network diagrams and flowcharts illustrating the eBPF workflow, arranged neatly on a stylish desk alongside hardware components. The background has soft, diffused lighting to create a focused, tech-savvy atmosphere, with a hint of greenery visible through a window to evoke a sense of innovation. The scene should feel professional and inviting, emphasizing a minimalist yet efficient environment for building a toolchain on small filesystems.

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.

ApproachFootprintBest when
Host build + loaderSmallTiny rootfs, immutable device images
On-device buildLargeDevices with package managers or dev images
Daemon (BPFd/gobpf)MediumRemote 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.

StepWhyQuick check
clang + llcProduce valid bytecodereadelf -h program.o
Kernel includesMatch structs & helpersdiff headers with device
bpftool skeletonTyped map/program accessbpftool 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.

CheckCommandExpected
Loader ABIfile loaderARM or aarch64 ELF
eBPF objectreadelf -h prog.oET_REL, BPF sections present
tracefs mountedmount | grep trace/sys/kernel/debug/tracing available
Run smoke test./loader prog.o && cat trace_pipetrace 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.

FailureFast checkLikely fixWhen to retry
Verifier -22libbpf + bpftool logsFix C or rebuild against target headersAfter rebuilding object
Missing tracepointgrep trace_events or tracefsUse another hook or update device imageAfter confirming name
No BTFreadelf -w vmlinuxEnable BTF build or use CO-RE cautiouslyWhen BTF is generated
Memlock limitsulimit -l; dmesgRaise RLIMIT_MEMLOCK or system limitBefore 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?

Bytecode is architecture-agnostic at a high level, but the kernel verifier and ABI expectations differ. The running kernel needs matching BTF, compatible helpers, and the same struct layouts as used when the program was built. Mismatched headers or missing kernel features cause verifier rejections or runtime crashes.

Do I need the target kernel source on my x86 build host?

Yes. You need the exact kernel source and config from the ARM device to generate correct headers, vmlinux, and BTF. Those artifacts ensure data structures and offsets match the running kernel—this is not optional if you want stable loads across versions.

Which hooking points are the safest for portability: tracepoints or kprobes?

Tracepoints are safest: stable names and fixed data formats. Kprobes work but are fragile—symbols can move, inlining and optimization break offsets, and kernel updates change behavior. Use kprobes only when tracepoints don’t expose the needed context.

What minimal tools do I actually need on the build host?

Install clang, llc, bpftool, and a cross-compiler toolchain for ARM/arm64. Add objcopy and readelf for verification. These let you produce object bytecode, strip/inspect sections, and generate libbpf skeletons you can load on the device.

What target inputs should I collect before starting a build?

Collect the exact kernel version, the kernel .config used on the device, and the target kernel source tree or vmlinux with BTF. Also note architecture (arm vs arm64), libc variant, and toolchain triplet for the loader.

How do I pick the correct kernel version to build against?

Match the device’s running version exactly—patch level matters. If you control the device OS, rebuild or pin the kernel. If not, extract vmlinux and BTF from the device to guarantee correct offsets and helper availability.

Which kernel features must be enabled to support eBPF loaders?

Enable CONFIG_BPF, CONFIG_BPF_SYSCALL, CONFIG_BPF_JIT (optional), CONFIG_DEBUG_INFO_BTF, and relevant helper options like CONFIG_BPF_EVENTS for tracepoints. Also enable necessary arch support such as CONFIG_ARM64 and associated toolchain flags.

What build outputs should I preserve for future builds?

Keep vmlinux, the full kernel headers you used, the .config, and BTF .btf info. Store the libbpf skeletons and compiled .o artifacts. These let you reproduce loads without rediscovering offsets or feature flags.

When is “compile once, run everywhere” realistic?

It works when target kernels share the same version, config, and BTF. It fails across distributions or patch levels. For embedded fleets with homogeneous images, a single build can serve many devices; for mixed or upstream-updated kernels, rebuild per target.

Should I cross-compile the user-space loader or build on the device?

Cross-compiling is preferable for speed and reproducibility—produce a static or mostly static binary for tiny rootfs. Build-on-device works if the device has a full toolchain, but that’s rare on constrained systems.

What’s the lightweight alternative to BCC on embedded devices?

Use a small libbpf-based loader or a tiny custom loader that calls the bpf() syscall directly. That avoids Python, LLVM runtime depen­dencies, and reduces footprint compared to BCC while keeping modern features.

How does libbpf change the workflow compared to BCC?

libbpf shifts compile-time tasks into generated skeletons and runtime APIs. You compile eBPF into .o, generate a skeleton with bpftool, and link that into a small C loader. It replaces JIT runtime components and heavy Python layers from BCC.

How do I compile restricted C to eBPF bytecode for ARM targets?

Use clang with -target bpf and proper -D defines for the target architecture, then run llc to produce eBPF object code. Ensure include paths point to the kernel headers and arch-specific headers used by the target kernel.

What source defines and include paths matter for ARM and arm64?

Define __TARGET_ARCH_arm or __TARGET_ARCH_arm64 as needed and include arch-specific headers from the kernel tree plus tools/testing/selftests and linux/include paths. Missing arch headers leads to incorrect offsets and verifier failures.

When should I favor tracepoints over kprobes in source?

Prefer tracepoints when the data you need is exposed—those are stable across versions. Use kprobes only if tracepoints omit necessary context and you accept fragility and higher maintenance cost.

How do I generate and use a libbpf skeleton with bpftool?

Compile your .bpf.c into an object .o, then run bpftool gen skeleton on the .o to produce a .h skeleton. Include that header in your loader and call libbpf APIs to load and attach programs—this gives type-checked access to maps and programs.

How do I build a static loader suitable for BusyBox-only rootfs?

Cross-link the loader with musl or static glibc and strip unnecessary libs. Statically link libbpf and avoid dynamic plugins. Test on a minimal chroot or QEMU to ensure no runtime dependencies are missing.

What checks should I run after deploying artifacts to the device?

Use file(1) to verify binaries, readelf to inspect sections, and bpftool to list available maps/programs. Run basic load tests that attach to a harmless tracepoint and read output from trace_pipe or a ring buffer.

How do I fix "BPF program load failed: Invalid argument (-22)"?

Capture verifier logs by setting libbpf’s log level or using bpftool prog load with –verbose. The verifier output points to invalid instructions, missing helpers, or bad context access. Fix code or headers accordingly and rebuild.

What causes kernel mismatch problems and how do I resolve them?

Mismatches come from using headers that differ from the running kernel. Resolve by extracting the device’s vmlinux/BTF or copying its kernel source/config to the build host. Rebuild eBPF artifacts against those exact inputs.

Why do kprobe symbols fail to attach on some devices?

Symbols may be optimized away, inlined, or renamed between versions. Use /sys/kernel/debug/tracing/available_filter_functions to discover present symbols, prefer tracepoints, or use uprobes against stable user-space binaries instead.

What are common arm64 context-access pitfalls?

Older kernels use PT_REGS_PARM macros and different register layouts—accessing wrong fields breaks verifier checks. Match your code to the target kernel’s calling convention and use unambiguous helpers or BTF-determined offsets.

How do I handle missing BTF or unsupported instructions on older devices?

If BTF is absent, generate BTF from the target vmlinux or rely on CO-RE with manual offsets. Avoid newer BPF instructions unsupported by the device’s verifier—compile for max compatibility or update the kernel if possible.

What resource limits prevent loading BPF programs and how do I fix them?

memlock and RLIMIT_MEMLOCK commonly block maps and program loads. Increase limits in systemd service files or set ulimit before loading. Also check /proc/sys/kernel/bpf_jit_limit and related sysctls on constrained systems.

After I make the build repeatable, what’s next?

Automate artifact collection: vmlinux, .config, BTF, and skeletons. Add CI cross-build steps, version your toolchain, and maintain a reproducible release archive. Monitor verifier and kernel updates in your fleet and plan rebuild triggers.

Related: Bpftool: BPF Program Inspection and Debugging Guide

Related: CONFIG_BPF_SYSCALL: The Kernel Flag eBPF Programs Need

Related: Preverify eBPF Programs in CI Before Kernel Load