freenode
AnalysisKernel & Low-Level

BPF unwinding lets Rust and C++ keep their destructors

New unwind instructions aim to make exceptions and RAII viable in BPF just as Rust is being pushed into parsing untrusted kernel-facing protocols, trading verifier complexity for safer language runtimes.

BPF programs written in Rust or C++ still cannot panic or throw while holding a lock or a reference. That gap is no longer theoretical. As rust-bpf and C++ BPF experiments grow, and as Rust work elsewhere in the kernel targets untrusted wire formats, Alexei Starovoitov has landed a fourth revision of unwind support that treats destructor cleanup as a first-class control-flow path rather than something the verifier must refuse.

The tension is blunt. Starovoitov opens the series with the runtime contract higher-level languages assume: "A rust panic or a C++ exception has to run Drop impls or destructors of every frame it passes through." Today bpf_throw() does the opposite. "bpf_throw() discards the frames, so the verifier doesn't let a frame that owns a lock or a reference throw." In Rust the problem is acute because failure is ambient. "In rust every index and every unwrap() can panic." Without real unwinding, rust-bpf strips landing pads and maps panic to bpf_throw(), so "A prog that can panic with a guard held doesn't load." The series is an argument that this is worth fixing inside the instruction set, not only in compiler conventions or loader tables.

Starovoitov contrasts two designs. One approach, already sketched in earlier discussion, keeps the fast path clean by having LLVM emit an unwind table, libbpf ship it, and a kernel helper walk the stack and rewrite return addresses while the verifier invents hidden edges from calls to pads. He prices that at roughly 1400 lines in the kernel, new UAPI, two kfuncs, and hundreds of lines in libbpf. The alternative he merged is smaller and more explicit. The edge from a call to its landing pad becomes an instruction, and so does the other way out of a frame: call then unwind_goto to the pad on the exceptional path; r0 setup then unwind_exit to leave while running cleanups. "Pads are ordinary code. The verifier learns one bit: how the callee left." JITs emit nothing material for unwind_goto on the normal path, and ordinary exit stays ordinary exit. The surface is two new instruction shapes, one more conditional jump form and one more exit form. "No kfuncs, no BTF, no changes in libbpf and other loaders." At source level the entry point is the Itanium ABI's _Unwind_RaiseException(), which is what Rust and C++ runtimes already call on other architectures.

That choice pushes complexity into the verifier and the JITs rather than into tables and helpers. The verifier must track whether a frame left by normal return or by unwind, validate that landing pads only appear where unwind_goto can reach them, and hard-wire dead branches when a callee never actually unwinds or never returns. unwind_goto is essentially an exception-table cookie: no code on the happy path. unwind_exit fixes up the saved return address so control resumes at the caller's pad, carrying the subprogram return values the ABI already uses. On x86 the JIT looks up an extable entry and rewrites the return address; when no entry exists because the kernel entered an async or exception callback, unwind_exit degrades to plain exit. Private-stack programs need extra care so the register restored on the way to the pad matches what the call path saved. arm64 support took a different epilogue shape again, authenticating the link register as usual and adding the pad offset, after earlier drafts that experimented with pointer-authentication sequences Wang Yibo exercised in testing.

The patches did not land without friction. Automated review flagged stale auxiliary state around dead-code rewrites of unwind_goto targets and missing JIT or offload gates in instruction checks. Starovoitov argued several of the high-severity reports were not bugs in practice: on the architectures that implement unwinding, speculative walks and zext flags do not combine the way the warnings feared, and an orphaned trap jump left after deleting a dead call is unreachable. Nathan Chancellor reported an objtool warning about a relocation to a non-ENDBR target once the x86 JIT emitted calls into the unwind helper; Starovoitov sent a follow-up. Wang Yibo retested v3 and then v4 on arm64, including freplace attach behavior, a C++ exceptions program, and destructor ordering with wide returns in the R0/R2 pair. The C++ selftest still depends on LLVM lowering that remains under review, so stock clang skips it while a branch build passes catches, non-catches, and aggregate return cases.

The same week, a separate Rust thread shows why language-level cleanup and safe parsing keep colliding with kernel work. Alistair Francis proposed a Rust SPDM requester for authenticating devices over PCIe, MCTP, NVMe, and related transports. SPDM replies are untrusted until proven otherwise, and the specification runs to hundreds of pages. "As such we have the kernel parsing untrusted responses from a complex specification, which sounds like a possible exploit vector," Francis wrote. "This is the type of place where Rust excels!" Alice Ryhl praised the direction of the wire-format path: parsing done in safe Rust is "exactly what we want to see for parsing in Rust," with essentially no unsafe so bugs fail at compile time or in controlled runtime checks. Reviewers did not treat that as a blank check. Alexandre Courbot pushed on Clippy cleanliness, documentation, and lifetime discipline around device pointers passed into C transport callbacks. Miguel Ojeda restated process order: "The general rule is to add safe abstractions first, and their dependencies (i.e. other safe abstractions), then finally the users." Crypto and X.509 bindings remain on the critical path before a full SPDM stack looks idiomatic.

Read together, the unwind series and the SPDM work are one argument with two venues. BPF wants Rust and C++ because RAII and Result-style failure make resource ownership legible, but the verifier historically forbade the control flow those languages emit when something goes wrong. Kernel Rust wants to parse hostile, complicated protocols in safe code, but only after abstractions and dependency layering satisfy upstream norms. Starovoitov's instructions make the exceptional path visible as ordinary BPF code the verifier can step through, at the cost of more state, more JIT special cases, and more ways for dead-code and speculative paths to go wrong. Francis's SPDM tree shows the payoff side: untrusted device authentication is exactly where panic-free parsing and destructors matter, yet the same safety story still stalls on bindings and review.

What is resolved is narrow and concrete. unwind_goto and unwind_exit are in bpf-next with x86 and arm64 JIT support, verifier tracking, and selftests that cover attach, freplace rejection when unwind contracts disagree, and C++ exception paths when the toolchain can lower them. What remains open is whether the one-bit unwind model stays comprehensible as more global functions, exception callbacks, and private-stack combinations appear; whether LLVM's Rust and C++ BPF lowering will emit these pads by default instead of deleting them; and whether safer-language parsing of untrusted data elsewhere in the tree will arrive before the verifier surface needed to host those languages becomes its own reliability concern. The community has chosen explicit unwind instructions over table-walking helpers. It has not yet answered how much verifier complexity is acceptable so that a panic with a guard held can load.