The problem is deceptively simple: you write an eBPF program, compile it, and need to know whether it will actually load on the kernel your users are running. Unlike a userspace binary where ldd gives you a quick read on shared library dependencies, an eBPF object file carries its requirements implicitly, scattered across helper call IDs, map type enumerations, and program type attributes encoded in the ELF sections.
bpfvet answers the question directly: feed it a compiled .bpf.o file, and it computes the minimum kernel version that can load and run the program. No changelog spelunking, no cross-referencing kernel commits, no spinning up a VM to test whether the verifier rejects the load.
Why BPF Portability Is Harder Than It Looks
eBPF programs are not ordinary shared objects. They run inside the kernel, verified by the in-kernel BPF verifier, and their correctness depends entirely on the kernel’s available helper surface and internal type layout. When you call bpf_probe_read() in a kprobe program, you are not linking against a library that ships with the binary. You are making a runtime call into a helper that must exist in the running kernel, identified by a numeric ID defined in include/uapi/linux/bpf.h.
This means every helper function, every map type, and every program type carries a specific kernel version where it was first introduced. Use BPF RingBuf (BPF_MAP_TYPE_RINGBUF) and your program hard-requires kernel 5.8. Use bpf_probe_read_kernel() instead of the older bpf_probe_read(), and you need at least 5.5. Use global variables in your BPF C code, which libbpf maps to a .data section, and you need 5.2 for the full BTF support that makes relocation work correctly.
The kernel BPF features list is long, and it does not map cleanly onto distribution kernel versions. Enterprise distributions backport selectively, meaning RHEL 8’s 4.18-based kernel carries some features from 5.x but not others. The only way to be sure is to test against each target kernel or inspect at the right level of abstraction.
The Distribution Fragmentation Reality
If you are shipping an eBPF-based tool and targeting production systems in 2025, the kernel version landscape looks roughly like this:
- RHEL 8 / CentOS Stream 8: kernel 4.18 (with selective backports)
- Ubuntu 20.04 LTS: kernel 5.4 (HWE kernel can reach 5.15)
- Debian 11 Bullseye: kernel 5.10
- Amazon Linux 2: kernel 5.10
- RHEL 9 / Rocky 9: kernel 5.14
- Ubuntu 22.04 LTS: kernel 5.15
That spread from 4.18 to 5.15 covers a large swath of BPF feature terrain. Tools like Cilium, Falco, and Tetragon all document minimum kernel requirements carefully and enforce them, because accidentally depending on a feature absent from a supported distribution is not a hypothetical risk. It has happened in production, repeatedly.
The traditional approach is to maintain a manual feature list in documentation, run integration tests against a matrix of kernels, or use bpftool feature to probe what the running kernel supports. But those are all runtime checks. They tell you what a specific kernel supports. bpfvet flips the direction: it tells you what a specific BPF program requires.
What bpfvet Actually Inspects
A compiled .bpf.o file is a standard ELF object. The BPF bytecode lives in named sections (kprobe/sys_execve, xdp, tc, etc.), and the BPF instructions encode helper calls as BPF_CALL instructions with the helper ID in the immediate field. Map definitions live in the maps or .maps section, with BTF-defined maps using the latter format introduced by libbpf. Program types are inferred from section name prefixes.
bpfvet reads the ELF, disassembles the BPF instructions to enumerate helper calls, parses map section structs to identify map types, and maps each finding to the kernel version that introduced it. The minimum kernel version for the program is then the maximum of all those individual version floors.
$ bpfvet ./my_program.bpf.o
Minimum kernel version: 5.8
Transport: RingBuf
CO-RE: yes
BTF: present
Warnings:
bpf_probe_read used at offset 0x1a4 (superseded by bpf_probe_read_kernel since 5.5)
The superseded helper detection deserves attention on its own. bpf_probe_read() still works on modern kernels because it was not removed, but using it means reading kernel memory through an API that does not distinguish kernel from user addresses. The kernel community deprecated it in favor of bpf_probe_read_kernel() and bpf_probe_read_user(), introduced in 5.5, which make the access intent explicit and eliminate a class of subtle memory safety bugs. Code calling the old helper was often written before the split existed or was copy-pasted without updating. bpfvet surfacing this functions as a lint pass, not just a compatibility report.
CO-RE, BTF, and Where the Portability Story Gets Complicated
CO-RE (Compile Once, Run Everywhere) is the approach libbpf uses to make BPF programs portable across kernel versions without recompiling. It encodes field access operations as relocations in the ELF, using BTF (BPF Type Format) to describe structure layouts at compile time, then resolves those relocations against the running kernel’s BTF at load time. If the kernel’s version of struct task_struct moved a field to a different offset between 5.10 and 5.15, libbpf patches the BPF instructions at load time to use the correct offset.
The critical nuance is that CO-RE requires the running kernel to expose BTF, which became fully available in kernel 5.2. Kernels before that either lack BTF entirely or have only partial support, though some distributions like RHEL 8 backport BTF availability specifically because tools in their ecosystem need it.
More importantly, CO-RE does not help with helper availability. If your program calls bpf_ringbuf_reserve(), no relocation mechanism will provide it on a 5.4 kernel. The portability coverage that CO-RE provides is scoped to data structure layout, not kernel feature presence. bpfvet reporting both CO-RE coverage and BTF presence gives a clear picture of which part of your portability story is handled by relocation and which part remains a hard kernel version floor that cannot be abstracted away.
How This Compares to Existing Tooling
bpftool feature probe is the closest existing tool, but it operates on the running kernel, not on a BPF object file. It tells you what the current kernel supports; it says nothing about what your program requires. Correlating the two is manual work.
bpftool prog dump can show loaded program instructions and metadata, but the program has to successfully load first, which means it already passed kernel verification. That is too late to catch compatibility issues in a CI pipeline.
libbpf itself does minimal probing before loading: bpf_probe_helper() and bpf_probe_prog_type() check feature availability at runtime on the host kernel. Again, a runtime API, not static analysis of the object.
bpfvet occupies the static analysis slot that was previously empty. It fits naturally into a build step or CI check, operating on build artifacts before they reach any target system.
A Practical Integration
For a tool targeting Ubuntu 20.04 LTS (kernel 5.4) as the oldest supported platform, the enforcement becomes mechanical:
# .github/workflows/bpf-compat.yml
- name: Check BPF compatibility
run: |
MIN_VERSION=$(bpfvet ./build/my_program.bpf.o --json | jq -r '.min_kernel')
# fail the build if computed floor exceeds stated support target
python3 -c "import sys; v='$MIN_VERSION'; parts=list(map(int,v.split('.'))); sys.exit(1 if (parts[0],parts[1]) > (5,4) else 0)"
The explicit structured output makes this automatable without parsing prose. This is the same shift that tools like cargo-msrv brought to the Rust ecosystem, where you declare a minimum supported version and have tooling enforce it continuously against actual code rather than against a manually maintained document.
Where the Tool Sits in the Broader Ecosystem
The BPF ecosystem has matured substantially over the past several years. The infrastructure for writing, compiling, and loading BPF programs (libbpf, bpf-tools, BTF, CO-RE, bpf-linker for Rust via aya) is solid and well-documented. What has lagged is tooling for reasoning about programs as artifacts with requirements, independent of any running kernel.
The ELF format gives you everything you need: helper IDs, map type definitions, program section names, and BTF data are all present in the compiled object. Extracting a minimum kernel version from that data is an analysis problem over structured binary data, not a hard research problem. The fact that a dedicated tool did not exist until now reflects how much of the BPF community has been focused on the happy path, building on cutting-edge kernels where compatibility is not a constraint.
For anyone distributing BPF-based software across a heterogeneous fleet, bpfvet answers a question that previously required either deep manual research or empirical testing. That is a useful addition to the build toolchain, and the approach of working from compiled ELF rather than source is correct: source-level analysis would require understanding every BPF frontend (C with Clang, Rust with aya, Go with ebpf-go), while ELF analysis is frontend-agnostic.