A conversation with the eBPF verifier
Anyone writing eBPF programs for long enough will eventually have a conversation with the verifier. While developing several eBPF programs, I ran into a number of verifier rejections that were not immediately obvious from the source code. In this article I discuss two examples that required looking beyond the Rust source and into the generated BPF bytecode.
The verifier’s contract
The kernel verifier performs static analysis on every BPF program before it is loaded. Its job is to prove that the program is safe to execute under the rules of the eBPF execution model.
The verifier only sees the final BPF bytecode. It has no knowledge of the original Rust source, the transformations performed by LLVM, or the programmer’s intent. It reasons entirely from the instructions it receives: register states, control flow, memory access patterns, and pointer provenance.
This distinction is important when debugging verifier failures. A Rust program can look correct at the source level while the generated BPF contains control flow or register transformations that the verifier cannot prove safe.
1. Debug code and panic paths
I started with what looked like fairly straightforward code:
pub struct PktBuilder<'a> {
pub ctx: &'a TcContext,
pub cur_off: u64,
}
impl<'a> PktBuilder<'a> {
pub fn push_data_room(&mut self, len: u64) -> u64 {
let needed = (self.cur_off + len - self.ctx.len() as u64) as i32;
if needed > 0 {
if self.ctx.adjust_room(needed, 0, 0).is_err() {
return 0;
}
}
...
}
During program loading, however, the verifier rejected the program:
Error: eBPF program error the BPF_PROG_LOAD syscall returned Invalid argument (os error 22). Verifier output: last insn is not an exit or jmp
The error message suggested that the generated BPF bytecode contained a control-flow path that reached the end of the function without terminating.
Inspecting the Generated Program
Examining the generated instructions indeed showed something suspicious:
0000000000000000 <...push_data_room>:
...
...
20: b7 03 00 00 00 00 00 00 r3 = 0x0
21: b7 04 00 00 00 00 00 00 r4 = 0x0
22: 85 00 00 00 32 00 00 00 call 0x32
23: 95 00 00 00 00 00 00 00 exit
24: 85 10 00 00 00 00 00 00 call 0x0
25: 85 10 00 00 ff ff ff ff call -0x1
The verifier expects every execution path to terminate with either an exit or a jump to another valid instruction. And, as seen, this function ends with call instructions.
Two questions remained:
- what does
call -0x1actually call? - Why did the compiler generate it?
Following the relocation
call -0x1 is not the final call target. It is an ELF relocation placeholder that will be fixed up later by the loader.
Inspecting the relocation information:
25: 85 10 00 00 ff ff ff ff call -0x1
00000000000000c8: R_BPF_64_32 .text.unlikely.
The call was relocated into .text.unlikely.
Looking at that section:
Disassembly of section .text.unlikely.:
0000000000000000 <...panic_const_sub_overflow>:
0: 85 10 00 00 01 00 00 00 call 0x1
0000000000000008 <...panic_const_add_overflow>:
1: 85 10 00 00 00 00 00 00 call 0x0
0000000000000010 <...panic_fmt>:
2: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0x0 ll
0000000000000010: R_BPF_64_64 .rodata.str1.1
4: b7 02 00 00 08 00 00 00 r2 = 0x8
5: 85 00 00 00 06 00 00 00 call 0x6
6: 05 00 ff ff 00 00 00 00 goto -0x1 <...panic_fmt+0x20>
This revealed the missing piece.The compiler had generated Rust overflow-checking code which eventually reached my panic handler:
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe {
bpf_printk!(c"PANIC!!");
}
loop {}
}
Why was the panic path generated?
Looking at the relevant instructions:
14: 2d 23 0a 00 00 00 00 00 if r3 > r2 goto +0xa <...push_data_room+0xc8>
15: 1f 32 00 00 00 00 00 00 r2 -= r3
The compiler generated an overflow check around the subtraction.
The source expression was:
self.cur_off + len - self.ctx.len() as u64
All values involved are u64.
If:
(self.cur_off + len) < self.ctx.len()
then the subtraction would underflow. In Rust, integer overflow is checked and can trigger a panic path depending on compilation settings.
The fix
The solution was to make the overflow semantics explicit and remove the generated panic path.
For this expression, wrapping behavior was acceptable:
let needed =
self.cur_off
.wrapping_add(len)
.wrapping_sub(self.ctx.len() as u64) as i32;
Using explicit wrapping operations removes Rust’s overflow checks for this expression, so LLVM no longer needs to generate the associated panic path. The resulting eBPF no longer contains the unreachable-to-the-verifier overflow handling code, and the program is accepted.
However, this raised another question: why not just build eBPF programs in release mode?
A release build normally disables these overflow checks, avoiding the generated panic paths. For eBPF development this is also often the more realistic build mode, because debugging an eBPF program is very different from debugging normal userspace Rust. Traditional debugging techniques such as setting breakpoints and inspecting variables are usually not available.
So I tried a release build.
The original code:
let needed = (self.cur_off + len - self.ctx.len() as u64) as i32;
now compiled without the overflow checks.
The verifier was happy. But a different problem appeared.
The optimized release build changed the generated BPF in another way, and that caused a new verifier failure.
That is the topic of the next section.
2. Release Optimizations and pointer provenance
After switching to a release build, the next verifier failure looked very different:
6: (67) r2 <<= 32
R2 pointer arithmetic on pkt_end prohibited
At first glance, the error message did not match the source code. The relevant function was straightforward:
#[inline(never)]
fn helper(ctx: &TcContext) -> i32 {
let data = ctx.data();
let data_end = ctx.data_end();
if data + 14 > data_end {
return TCX_DROP;
}
TCX_PASS
}
The code performs a standard packet bounds check and contains no explicit arithmetic on data_end.
As with the previous example, the answer only became apparent after examining the generated instructions.
Inspecting the Generated BPF
The optimized build produced the following instruction sequence:
00000000000000f0 <...helper>:
30: 67 02 00 00 20 00 00 00 r2 <<= 0x20
31: 77 02 00 00 20 00 00 00 r2 >>= 0x20
32: 67 01 00 00 20 00 00 00 r1 <<= 0x20
33: 77 01 00 00 20 00 00 00 r1 >>= 0x20
34: 07 01 00 00 0e 00 00 00 r1 += 0xe
35: b7 00 00 00 02 00 00 00 r0 = 0x2
36: 2d 21 01 00 00 00 00 00 if r1 > r2 goto +0x1
37: b7 00 00 00 00 00 00 00 r0 = 0x0
38: 95 00 00 00 00 00 00 00 exit
While the debug build looked different:
0000000000000190 <...helper>:
50: 79 11 00 00 00 00 00 00 r1 = *(u64 *)(r1 + 0x0)
51: 61 12 50 00 00 00 00 00 r2 = *(u32 *)(r1 + 0x50)
52: 61 11 4c 00 00 00 00 00 r1 = *(u32 *)(r1 + 0x4c)
53: 07 01 00 00 0e 00 00 00 r1 += 0xe
54: b7 00 00 00 02 00 00 00 r0 = 0x2
55: 2d 21 01 00 00 00 00 00 if r1 > r2 goto +0x1
56: b7 00 00 00 00 00 00 00 r0 = 0x0
57: 95 00 00 00 00 00 00 00 exit
The most obvious difference is that the debug build directly loaded the values from data() and data_end() into r1 and r2, while the release build contained some shift operations.
Understanding the Shift Instructions
The pattern:
r2 <<= 32
r2 >>= 32
implements a zero-extension from 32 bits to 64 bits: it clears the upper 32 bits while preserving the lower 32 bits.
From a value perspective, both builds are equivalent. The important distinction is that the verifier does not only track values; it tracks pointer types and pointer origin.
The verifier understands that values obtained from the packet context have special semantics:
datais a packet pointerdata_endis a packet-end pointer
These pointer types are what make packet bounds checks possible:
if data + 14 > data_end
LLVM observed that the function only used two fields from the context and aggressively simplified the data flow. Conceptually the generated code behaved more like:
fn helper(data: u32, data_end: u32) -> i32 {
if data + 14 > data_end {
return TCX_DROP;
}
TCX_PASS
}
Although this is not the literal Rust signature, it closely matches the structure of the generated BPF.
Once the compiler had reduced the function to two 32-bit values, explicit zero-extension instructions appeared when those values were used in 64-bit registers.
Why the Verifier Rejected the Program
The verifier is intentionally conservative. It does not attempt to prove that:
(x << 32) >> 32
is equivalent to a no-op for pointer identity.
Instead, it sees arithmetic applied to a packet-end pointer and invalidates the pointer type information. The resulting state triggers:
R2 pointer arithmetic on pkt_end prohibited
The failure is therefore not related to the bounds check itself. It is a consequence of how the optimized code represents the pointer values.
Verifying the Hypothesis
One useful validation was to mark the helper with:
#[unsafe(no_mangle)]
With that change, the release build generated code equivalent to the debug build and the verifier accepted the program.
The significance of this result is not the attribute itself, but that it prevents LLVM from changing the function signature. The packet pointers remain tied to the original context loads, the explicit zero-extension sequence disappears, and verifier type tracking remains intact.
That strongly suggests the verifier failure originates from the optimization strategy rather than the program logic.
Why Aya Returns usize
A natural question is why Aya exposes packet addresses as usize instead of u64, given that eBPF registers are 64-bit.
The answer is that register width and pointer width are independent concepts.
eBPF registers are always 64-bit:
r0-r10 are 64-bit registers
However, LLVM’s BPF target uses a 32-bit pointer model:
core::mem::size_of::<usize>() == 4
for BPF builds.
As a result, Aya correctly exposes packet addresses as usize. When LLVM later places those values into 64-bit registers, it must perform some form of zero-extension, which is precisely what appeared in the optimized build.
Final thoughts
These examples reinforced a few practical lessons when working with eBPF.
-
Inspect the bytecode early, not as a last resort.
Both failures described in this article were difficult to understand from the Rust source alone. In the generated BPF, however, the cause was immediately visible. Tools such asllvm-objdump, ELF relocation inspection, and the verifier’s verbose log output are not advanced debugging techniques for unusual cases — they are part of the normal eBPF development workflow. -
Optimization changes the program.
A debug build and a release build of the same Rust source can produce BPF programs that look very different to the verifier. Debug builds may contain overflow checks and panic paths that are unsuitable for eBPF. On the other hand, aggressive optimization can transform the program in ways that are perfectly valid from the compiler’s perspective but interact differently with verifier analysis.In normal software development, this distinction is usually invisible. For eBPF, the generated instructions are part of the program’s correctness boundary. The way an intention is expressed in source code can determine whether LLVM emits a form that the verifier can prove safe.
-
Source code is only the beginning of the verification process.
Modern tooling, including AI-assisted code generation, can help produce Rust eBPF code quickly. However, generating code that is accepted by the verifier requires more than producing syntactically correct Rust. The program must preserve the assumptions made by the verifier through compilation and optimization.The most reliable workflow is to express the intended safety properties in source code, understand the verifier constraints that apply, and then inspect the generated BPF to confirm that the compiler preserved those properties.