[Pre-RFC] BTF relocations

Summary

Add a #[btf_preserve_access] attribute for aggregate types. On builds targeting BPF targets (bpfel-unknown-none, bpfeb-unknown-none) that emit BTF (-C debuginfo=2), field and array-element accesses through values of attributed types emit BTF relocations, allowing eBPF loaders to relocate those accesses against the target Linux kernel's BTF data at load time.

Motivation

BTF, the BPF Type Format, is the type metadata format used by the Linux kernel and by eBPF tooling. eBPF loaders such as Aya and libbpf use BTF for Compile Once, Run Everywhere (CO-RE) relocations: the compiled program records which field or array element it intended to access, and the loader rewrites the bytecode to match the layout of the kernel it is about to run on.

clang and GCC are capable of emitting such relocations.

Rust can already target eBPF, but it does not currently have a way to emit these BTF access relocations. In practice that means Rust eBPF programs often have to pick one of three inconvenient options:

  • Vendor exact kernel type definitions and rebuild for each supported kernel layout.
  • Avoid typed field access and manually encode offsets, sacrificing readability and maintainability.
  • Write a module in C solely for accessing kernel types and use build.rs to link it to the Rust project.

The goal of this RFC is to give Rust codegen enough information to emit the same relocation-friendly IR that clang and GCC emit today, without introducing a new source-language model for kernel types. Rust users should be able to define the subset of kernel types they care about, mark those definitions as relocation-aware, then write normal field and indexing code against them.

Guide-level explanation

Suppose an eBPF program needs to read the pid and tgid fields from Linux's task_struct. The exact layout of task_struct varies across kernel versions, so using the full type definition or hard-coding byte offsets is not a robust option.

After enabling the feature:

#![feature(btf_preserve_access)]

the program can declare only the part of the kernel type it intends to use:

#[btf_preserve_access]
#[expect(non_camel_case_types, reason = "Linux kernel type")]
struct task_struct {
    pid: i32,
    tgid: i32,
}

The important part is the Rust type's shape and names. For CO-RE to work, the field names and nesting must correspond to the kernel BTF that the loader will relocate against.

Ordinary Rust field accesses then become relocation-aware:

fn process_task(task: &task_struct) {
    let pid = task.pid;
    let tgid = task.tgid;
}

Pattern matching over fields is also supported because it lowers to the same field projections:

fn process_task(task: &task_struct) {
    let task_struct { pid, tgid } = task;
}

Likewise, fixed-size arrays nested within an attributed type can use indexed accesses. For example, networking programs often work with [net_device][net-device], which contains name[IFNAMSIZ]:

#[btf_preserve_access]
#[expect(non_camel_case_types, reason = "Linux kernel type")]
struct net_device {
    name: [u8; 16],
}

fn process_dev(dev: &net_device) {
    let first_char = dev.name[0];
}

When compiling for a BPF target with BTF emission enabled, rustc records those accesses in the generated IR using BTF-preserving intrinsics. The eBPF loader then patches the bytecode so that the program uses the offsets and indices appropriate for the target kernel.

Outside BPF/BTF-producing builds, the attribute has no effect.

Reference-level explanation

Syntax

This RFC introduces an unstable built-in attribute:

#[btf_preserve_access]

The attribute is permitted on struct and union items. It is guarded by the btf_preserve_access feature gate.

Codegen

LLVM provides dedicated intrinsics for emitting BTF relocations:

  • @llvm.preserve.array.access.index for index projections from an array.
  • @llvm.preserve.struct.access.index for field projections from a struct.
  • @llvm.preserve.union.access.index for field projections from a union.

IRBuilder provides methods that language frontends can use for creating the intrinsic calls:

  • CreatePreserveArrayAccessIndex
  • CreatePreserveStructAccessIndex
  • CreatePreserveUnionAccessIndex

The implementation strategy is to expose corresponding hooks on the codegen backend abstraction:

  • btf_preserve_array_access_index
  • btf_preserve_struct_access_index
  • btf_preserve_union_access_index

The LLVM backend lowers these directly to the corresponding IRBuilder methods.

GCC's BPF backend also supports this relocation model through the preserve_access_index type attribute, which GCC documents as being equivalent to wrapping each access in __builtin_preserve_access_index built-in function.

The binary object keeps the relocations in the BTF.ext ELF section.

Backends that do not provide an equivalent preserve-access mechanism would still lower the hooks to ordinary projections.

Scope of preserved accesses

The attribute affects projections whose base type is marked with #[btf_preserve_access], as well as projections continuing from a value obtained through such an access. In practice this means:

  • task.pid preserves access information because task_struct is annotated.
  • task.values[i] preserves both the field projection and the array index when values is a fixed-size array.

MIR and compiler representation

No new Rust syntax beyond the attribute is needed, but rustc must carry the fact that a projection originates from an attributed aggregate far enough into codegen to choose the preserving intrinsics instead of ordinary GEP-like lowering.

One implementation strategy is:

  • Parse #[btf_preserve_access] as a built-in type attribute.
  • Record it in MIR and type metadata.
  • When lowering PlaceRef field and index projections in codegen, inspect the base aggregate type and dispatch to the preserving backend hooks.

Target and backend interactions

The attribute is intended for BPF targets, where BTF relocation emission is meaningful. On other targets, rustc accepts the attribute under the feature gate but emits ordinary accesses.

Alternative backends are not required to implement relocation emission for this RFC to be useful. The backend abstraction allows LLVM and GCC to provide the full feature immediately while other backends remain semantically correct. A backend that later gains BPF+BTF support can implement the same hooks without changing the Rust syntax.

In particular, a GCC backend implementation does not need a Rust-specific relocation design. It can reuse GCC's existing BPF CO-RE support while sharing the same Rust frontend attribute and MIR/codegen mechanism as the LLVM backend.

Drawbacks

This is a niche feature aimed at one target family and one ecosystem workflow. Yet it expands MIR and its entire code that deals with field and index projection with its concepts.

Rationale and alternatives

The main alternative is to emit BTF relocations in bpf-linker, which is a bitcode linker used exclusively for BPF targets.

That approach has the following disadvantages:

  • By link time, the compiler has already lowered field access into offset-based operations. Reconstructing the original typed access path requires bpf-linker to traverse the IR.
  • It prevents us from supporting ld type of linkers (e.g. binutils, lld) for BPF targets.

Prior art

clang and GCC support this feature through:

  • __attribute__((preserve_access_index)) that can be applied to a type, e.g.
struct task_struct {
	pid_t pid;
	pid_t tgid;
} __attribute__((preserve_access_index));
  • Built-in function __builtin_preserve_access_index that can be applied to a single field access, e.g.
pid_t pid = __builtin_preserve_access_index(task->pid);

In clang, that attribute causes accesses to lower to the same family of LLVM intrinsics mentioned previously.

In GCC, the entire BPF CO-RE mechanism relies on the __builtin_preserve_access_index built-in function. __attribute__((preserve_access_index)) is equivalent to implicitly wrapping all accesses to the type with the built-in.

Unresolved questions

  • Is btf_preserve_access the right long-term spelling for the feature gate and attribute, or should the name align more closely with preserve_access_index used in clang and GCC?
  • Apart from providing an equivalent to __attribute__((preserve_access_index)) (type-wide annotation), should we provide a way to annotate a single field access, like __builtin_preserve_access_index in clang and GCC does?
    • The argument against is that the type attribute is much more widely used and even the kernel type headers [provided by libbpf][libbpf-headers] and generated by [bpftool][bpftool] apply the type attribute.

Future possibilities

If cranelift eventually introduces BPF support, the backend hooks introduced here provide a natural reference point for emitting equivalent relocations there as well.

2 Likes

Here is code implementation, currently only for the LLVM backend:

I'm going to prepare a repository with practical eBPF code examples that work on top of this feature and post it here as well.

A couple of thoughts.

First, this is, in effect, about changing Rust's type layout algorithms in order to place the fields at the same offsets that the Linux kernel would place them. As such, I'm wondering whether the new attribute should be #[repr(btf)], given that it's mutually exclusive with other repr attributes and has the same purpose of changing where in a struct its data is stored.

The other thought is to wonder about the interaction of this with compile-time introspection. For example, macros like offset_of! normally assume that the offset of a field within a struct is known at compile time, whereas if it's being specified by relocations, that is not the case. There are existing exceptions where a field offset might not be known at compile time (e.g. a #[repr(C)] struct containing a u8 and a dyn Trait can't determine the offset of its second field because it depends on its alignment, which for dyn Trait can vary at runtime); but all the existing exceptions are for unsized types. Should that imply that BTF types should not be Sized? (Thinking about it, they are logically unSized because the size may change after the compile has already happened, due to the relocations, and thus the size is not known at compile time.)

2 Likes

Currently, in Rust, it is possible to replace every field access by a use of offset with a compile-time constant determined with offset_of! (except for field with a dyn Trait unsized tail). That's no longer true with this proposal. So this proposal is making deep changes to the foundations of the language itself, its operational semantics. This is way more than just a proposal for a bit of new syntax, and it doesn't just expand rustc implementation details like MIR, it expands the core language in a way that's relevant for unsafe code authors. The proposal needs to explain what the new operational semantics are, and how they are supposed to work in a tool like Miri.

At the very least, it seems like offset_of! on such fields should fail to compile. But the changes have to go deeper than that; the assumption of a fixed layout is deeply rooted everywhere in the compiler and the language.

How does this all work in C? Because C also assumes you can use offsetof on any struct field (and sizeof on any complete struct type, and everything implied by that).

I guess this kind of attribute has very similar consequences for the struct as the ARM scalable vectors that the sized hierarchy work is looking at. BTF provides relocations for both field offsets and the struct size, so this means that task_struct is Sized but not const Sized under the terminology introduced here. Its size is constant at runtime, but not known at compile time.

2 Likes

I like the idea. I'm not really attached to the naming used in C (I already added the btf prefix to the attributes, since I find the original Clang/GCC naming too generic). The good thing about having a separate repr is that it lets us enforce a C-compatible ABI.

First of all, let's clarify one thing. The offsetof macro is part of stddef.h in libc and therefore cannot be used in eBPF programs. In musl, the definition lowers either to __builtin_offsetof or to a field access:

What's available on BPF targets is a direct call to __builtin_offsetof. And that indeed does not emit relocations.

struct inner {
  int x;
  int y;
};

struct outer {
  int a;
  struct inner b;
} __attribute__((preserve_access_index));

unsigned long plain_offsetof(void) {
  return __builtin_offsetof(struct outer, b.y);
}

lowers to:

define dso_local i64 @plain_offsetof() #0 !dbg !7 {
  ret i64 8, !dbg !11
}

I do agree that offset_of! on relocatable types/fields should fail to compile, and we shouldn't repeat the mistake of C compilers silently emitting code that is not going to work. The same goes for core::mem::size_of, core::mem::align_of, and probably many other macros and const functions that I haven't thought of yet. I will take some time to go through the entire core API and figure out what else we need to add to that list.

At the same time, I think we could add an intrinsic btf_field_byte_offset that serves as a replacement for offset_of, but emits a relocation for annotated types when building for BPF targets with BTF. On LLVM, it would lower to the @llvm.bpf.preserve.field.info(..., FIELD_BYTE_OFFSET) intrinsic call. On GCC, it would call the __builtin_preserve_field_info(..., FIELD_BYTE_OFFSET) built-in function.

I actually managed to get it working already (again, only for LLVM; I haven't started on the GCC implementation yet), and the change doesn't seem difficult:

If you agree with this approach, I'm happy to update the RFC with it.

Miri does not work for BPF targets for unrelated reasons, and for similar reasons it does not run on embedded targets either: BPF binaries can't be executed on regular operating systems; they can be executed only by virtual machines designed for the BPF ISA, including the one provided by the Linux kernel. We don't really have any solution for testing BPF binaries in Aya right now, apart from extracting testable code into crates that can be used for unit tests with std on host targets. The same approach works for Miri as well.

Apart from the Linux kernel BPF VM, there are some user-space implementations, including rBPF and Solana's SBPF, but we haven't really tried using them to run unit tests. That said, if that ever works, I guess we could look into getting Miri running on one of them as well.

But I don't think that should be a requirement for this proposal.

The approach of treating #[repr(btf)] types as Sized and not const Sized sounds great to me. But given that the RFC mentioned above has not been accepted and this distinction is not yet implemented, the only option for now seems to be treating them as not Sized. I will try implementing that and see whether it has any consequences that make it unworkable.

Why? Can't it just compile down to emitting a relocation so that it evaluates to the correct offset?

I believe that would be sufficient for most use-cases.

As a more general comment, I do think the sized hierarchy work is the right way to approach this issue. Perhaps one could expose some entirely different way to access btf data? For example, a macro that takes a struct name and field name and returns the offset. The program can then use that value on a raw pointer explicitly.

1 Like

A few questions:

  • Can a relocation affect whether two fields overlap (e.g. if a struct changes to a union)?
  • Can a relocation change an array index?
  • Can a relocation affect the number of pointer dereferences necessary to access a field?
  • Is it ever possible to construct one of these structs? Or does the potential for additional fields Rust doesn't know about mean construction must be forbidden?
  • Do we need an analogue of #[link_name] for struct and field names?
  • What is the failure mode like? If you make a mistake or run on an unsupported kernel version, and the relocations aren't sufficient to make offets or types line up, is the verifier guaranteed to reject the program?
  • Is there any connection to/similarity with Swift stable ABI?

I didn't mean to suggest that this should actually be implemented in Miri before an RFC can be accepted, sorry if I was unclear. What I meant is that the semantics of this type need to be described at a level of abstraction and detail that it is clear how they would be implemented in Miri. This is crucial; we should not add new pieces to the language that we don't know how to describe at the level of the Abstract Machine. "It emits this kind of machine code" is not an acceptable specification for a language feature.

I am saying this with my t-opsem hat on. It is literally the responsibility of my team to ensure we have a proper Abstract Machine with an operational semantics for Rust. We're happy to help you work this out (come find us on Zulip), but an RFC without a proper opsem discussion is not going to be accepted.

FWIW this will be very non-trivial because Rust has generics. Today one can write a generic function that takes an arbitrary T (implicitly: T: Sized) and then obtain its size as a compile-time constant. This is not something we can break. That's why people are saying that the Sized hierarchy work is a prerequisite for supporting this kind of a type.