BPF Links and Attachment Lifecycle
A
bpf_linkis the modern kernel object that represents one attachment of a BPF program to a hook, exposed to userspace as a file descriptor. It is the answer to a once-chronic problem: before links, attaching a program (an XDP program to a NIC via netlink, a tracing program to a kprobe via aperf_eventioctl) created kernel state that no file descriptor owned, so if the attaching process crashed, the attachment leaked — the program kept running with nothing left to detach it (asphaltt, “Introduce bpf_link”). A link fixes this by tying the attachment’s lifetime to an fd: created by the [[The bpf() Syscall|bpf()]] commandBPF_LINK_CREATE, the returned fd owns the attachment, and when the last fd to it closes, the kernel automatically detaches the program (bpf UAPI header, v6.12; syscall.c, v6.12). To survive its creator you pin the link into bpffs; to swap the program under a live attachment without dropping a single event you callBPF_LINK_UPDATE.bpf_linkwas introduced in Linux 5.7 (per secondary corroboration; the upstream commit was unreachable — see the flag below) and is now the default attach path for nearly every program type.
This note is pinned to Linux 6.12 LTS (released 2024-11-17) for the kernel-side mechanism (kernel/bpf/syscall.c) and bpf() command set (include/uapi/linux/bpf.h), and to libbpf 1.4.7 for the userspace API signatures. The bpf_link abstraction itself predates 6.12 — it landed in 5.7 — and individual program types gained link support across many releases (perf-event links from ~5.15); those dated milestones are flagged where they matter.
Mental Model
Think of a link as a handle on an attachment, not on a program. The program (a bpf_prog) is a separate kernel object with its own refcount; it can be loaded and exist with zero attachments. An attachment — “program P is wired to hook H” — used to be invisible kernel state with no userspace representation. The link makes that state a first-class object you can hold, count references to, pin, introspect, and update.
flowchart LR subgraph US["Userspace"] FD["link fd<br/>(owns the attachment)"] PIN["bpffs pin<br/>/sys/fs/bpf/my_link<br/>(extra reference)"] end subgraph K["Kernel"] LINK["struct bpf_link<br/>refcnt, ops, prog*"] PROG["struct bpf_prog<br/>(verified bytecode)"] HOOK["hook<br/>(XDP / tracepoint / cgroup ...)"] LINK -->|"holds a ref"| PROG LINK -->|"installed at"| HOOK end FD -->|"refcnt++"| LINK PIN -->|"refcnt++"| LINK FD -. "close() -> refcnt--" .-> LINK
The reference graph around a bpf_link. What it shows: the link sits between userspace handles (the fd, and optionally a bpffs pin) and the kernel objects it wires together — it holds a reference on the program and is installed at the hook. Every fd and every pin contributes one to the link’s refcount. The insight to take: the attachment lives exactly as long as the link’s refcount is non-zero. Close the only fd and the count hits zero → detach. Add a pin first and the count never reaches zero on fd close → the attachment persists. Lifetime is refcounting, not magic.
Creating a Link: BPF_LINK_CREATE
bpf_link attachments are created by the BPF_LINK_CREATE command of [[The bpf() Syscall|the bpf() syscall]]. Its UAPI doc string is precise: “Attach an eBPF program to a target_fd at the specified attach_type hook and return a file descriptor handle for managing the link” (bpf UAPI header, v6.12). The argument struct in union bpf_attr carries the program to attach, the target, the attach type, and per-type extras:
struct { /* struct used by BPF_LINK_CREATE command */
union {
__u32 prog_fd; /* eBPF program to attach */
__u32 map_fd; /* struct_ops to attach */
};
union {
__u32 target_fd; /* target object to attach to or ... */
__u32 target_ifindex; /* target ifindex */
};
__u32 attach_type; /* attach type */
__u32 flags; /* extra flags */
/* ... per-attach-type unions: perf_event, kprobe_multi, tracing, tcx, netkit ... */
} link_create;The two halves of the first union capture the two creators: an ordinary program (prog_fd) for tracing/networking/cgroup hooks, or a struct_ops map (map_fd) for the operations-table program types like sched_ext. The target says where: an ifindex for XDP, a cgroup fd for cgroup hooks, a perf-event fd for perf-based probes, and so on. On success the kernel allocates a struct bpf_link, installs it at the hook, takes a reference on the program, and returns an fd — “A new file descriptor (a nonnegative integer)” (bpf UAPI header, v6.12).
From userspace you rarely build bpf_attr by hand. libbpf exposes both a low-level bpf_link_create(int prog_fd, int target_fd, enum bpf_attach_type, const struct bpf_link_create_opts *) (libbpf bpf.h, v1.4.7) and the high-level bpf_program__attach(const struct bpf_program *prog), “generic function for attaching a BPF program based on auto-detection of program type, attach type, and extra parameters,” which returns a struct bpf_link * (libbpf libbpf.h, v1.4.7). Skeletons’ <obj>__attach() calls the latter for each auto-attachable program.
Lifetime: How fd-Close Detaches
The lifetime guarantee is implemented as ordinary file-descriptor refcounting in kernel/bpf/syscall.c. Each link fd is a file whose operations table is bpf_link_fops, whose .release callback fires when the last reference to that file drops (syscall.c, v6.12):
static const struct file_operations bpf_link_fops = {
#ifdef CONFIG_PROC_FS
.show_fdinfo = bpf_link_show_fdinfo,
#endif
.release = bpf_link_release,
.read = bpf_dummy_read,
.write = bpf_dummy_write,
};When you close() the link fd (or the process exits and the kernel closes all its fds), bpf_link_release runs:
static int bpf_link_release(struct inode *inode, struct file *filp)
{
struct bpf_link *link = filp->private_data;
bpf_link_put_direct(link);
return 0;
}bpf_link_put_direct is the refcount-drop. The link carries an atomic64_t refcnt; the put decrements it and, only if it reaches zero, frees the link:
static void bpf_link_put_direct(struct bpf_link *link)
{
if (!atomic64_dec_and_test(&link->refcnt))
return;
bpf_link_free(link);
}bpf_link_free is where the actual detach happens — it calls the link’s type-specific release op and then drops the program reference:
static void bpf_link_free(struct bpf_link *link)
{
const struct bpf_link_ops *ops = link->ops;
...
if (link->prog) {
/* detach BPF program, clean up used resources */
ops->release(link);
bpf_prog_put(link->prog);
}
...
}So the full chain on the last close is: close(fd) → bpf_link_release → bpf_link_put_direct → atomic64_dec_and_test reaches 0 → bpf_link_free → link->ops->release(link) performs the per-type detach (an XDP link removes the program from the netdev, a tracepoint link unregisters, etc.), then bpf_prog_put releases the program reference (syscall.c, v6.12). The symmetric bpf_link_inc (atomic64_inc(&link->refcnt)) is how additional holders — notably a bpffs pin — bump the count. This refcount is the entire lifetime story: detach is not a special event, it is simply what bpf_link_free does when the count hits zero.
The decisive consequence for operators: a BPF tool that loads programs through links and then crashes does not leak attachments. Process death closes its fds; the link refcounts drop to zero; the programs detach. This is the property the pre-link era lacked.
Pinning: Surviving the Creating Process
The flip side of auto-detach is that a tool which wants its attachment to outlive it must keep a reference alive after its fds close. That is what pinning to bpffs does. bpftool link pin LINK FILE (or libbpf’s bpf_link__pin(link, path), which “pins the BPF link to a file in the BPF FS … This increments the link’s reference count”) creates a bpffs inode that holds its own reference on the link (bpftool-link(8), v6.12; libbpf libbpf.h, v1.4.7). Now closing the last fd drops the refcount by one but the inode’s reference keeps it above zero — the program stays attached. bpf_link__unpin removes the inode and decrements the count again. The general fd/pin lifetime model (shared by programs and maps too) lives in BPF Object Pinning and Lifetime; the bpffs filesystem itself is covered in Map Pinning and bpffs — this note only states the one load-bearing fact that a pin is an extra reference that prevents fd-close auto-detach.
bpftool link detach LINK is the explicit counterpart: it force-detaches a link from its hook (the program stays loaded), useful when a link was pinned and you want to tear it down without finding the holder.
Atomic Program Replacement: BPF_LINK_UPDATE
A live attachment can have its program swapped atomically — without a detach/reattach window in which events would be missed — via BPF_LINK_UPDATE, doc’d as “Update the eBPF program in the specified link_fd to new_prog_fd” (bpf UAPI header, v6.12). The argument struct supports an optional compare-and-swap:
struct { /* struct used by BPF_LINK_UPDATE command */
__u32 link_fd; /* link fd */
union {
__u32 new_prog_fd; /* new program fd to update link with */
__u32 new_map_fd; /* new struct_ops map fd to update link with */
};
__u32 flags; /* extra flags */
union {
/* expected link's program fd; specified only if BPF_F_REPLACE is set */
__u32 old_prog_fd;
__u32 old_map_fd;
};
} link_update;If you set the BPF_F_REPLACE flag ((1U << 2)), the kernel compares the link’s current program against old_prog_fd and refuses the update unless they match — a compare-and-swap that prevents racing updates from clobbering each other (bpf UAPI header, v6.12). Without the flag the swap is unconditional. The userspace wrappers are bpf_link_update(int link_fd, int new_prog_fd, const struct bpf_link_update_opts *) (with old_prog_fd and flags in the opts) and the higher-level bpf_link__update_program(struct bpf_link *, struct bpf_program *) (libbpf bpf.h / libbpf.h, v1.4.7). This is how a long-running agent ships a new version of a program with zero gap in coverage — the canonical example being a struct_ops/sched_ext scheduler that hot-swaps its policy, or an xdp dispatcher updating its dataplane.
Introspection: Finding Links by ID
Every link, like every program and map, has a stable, global, monotonically-assigned id. The pair of bpf() commands BPF_LINK_GET_NEXT_ID (iterate all link ids) and BPF_LINK_GET_FD_BY_ID (turn an id into a fresh fd) let any sufficiently-privileged process enumerate and re-acquire links it did not create (bpf UAPI header, v6.12). libbpf wraps them as bpf_link_get_next_id(__u32 start_id, __u32 *next_id) and bpf_link_get_fd_by_id(__u32 id) (libbpf bpf.h, v1.4.7). This is exactly what bpftool link show does under the hood: it walks ids, opens an fd for each, queries BPF_OBJ_GET_INFO_BY_FD, and prints the link id, type, the program id it holds, type-specific attributes, and — via /proc/<pid>/fdinfo scanning — the PIDs that hold an fd to each link (bpftool-link(8), v6.12). The “who holds this link” column is what makes leaked or orphaned attachments diagnosable — a luxury the legacy paths could not offer because there was no object to enumerate.
The Contrast: Legacy Attach Had No Owning Handle
To appreciate why links exist, look at what came before. Each program type had its own bespoke attach path, and none produced an object whose lifetime tracked the attachment:
- XDP and tc were attached over netlink (
rtnetlink), the same socket interface used to configure interfaces and routes. Attaching an XDP program set a property on the netdevice; detaching meant issuing another netlink message. The attachment was a property of the interface, not of any fd the attaching process held — kill the process and the program stayed bound to the NIC with nothing tracking it. - kprobe, uprobe, tracepoint, and perf-event programs were attached by opening a
perf_eventfd (viaperf_event_open(2)) and then issuingioctl(perf_fd, PERF_EVENT_IOC_SET_BPF, prog_fd)— a legacy path available since Linux 4.1 (cilium/ebpf probes). Here there was an fd (the perf-event fd), but its semantics were the perf event’s, not a clean attachment handle, and the older raw-tracepoint path had no clean owner at all.
The common defect: attachments were ad-hoc kernel state with no uniform owning handle, so they leaked on process crash and there was no general way to enumerate, pin, or atomically update them (asphaltt, “Introduce bpf_link”). The “pinnable bpf_link” work (kernel 5.7) introduced one uniform object across program types, gradually retrofitting each path. Perf-based program types gained proper link support later — the perf-event link via BPF_LINK_CREATE is a kernel 5.15+ feature, with the PERF_EVENT_IOC_SET_BPF ioctl remaining as the fallback for older kernels (cilium/ebpf probes). Modern libbpf hides this: bpf_program__attach() tries the link path and falls back to the legacy ioctl on kernels too old to support a link.
Uncertain
Verify: (1) that
bpf_linkfirst appeared in Linux 5.7 via the commit “bpf: Introduce pinnable bpf_link abstraction”; and (2) the exact kernel release in which perf-event / kprobe attachment viaBPF_LINK_CREATE(BPF_PERF_EVENTlink type) became available (stated here as 5.15). Reason: the three primary sources for the 5.7 claim (the lore.kernel.org cover letter, the git.kernel.org commit70ed506c3bbc, and a netdev mirror) were all access-denied during research, so 5.7 rests on secondary corroboration (the asphaltt write-up); the 5.15 perf-link claim comes from the cilium/ebpf docs describing that library’s fallback strategy, not a kernel changelog. To resolve: read the merge tag on the upstreambpf_linkintroduction commit and on the commit introducingBPF_PERF_EVENTlink support. uncertain
Failure Modes and Common Misunderstandings
“My program detached as soon as my program exited.” That is the designed behavior — the link fd closed, the refcount hit zero, the kernel detached. If you want persistence, pin the link before exit, or keep the process (or its fd) alive. New users coming from the netlink-XDP world (where the attachment survived process death) are surprised by this; it is the safety feature, not a bug.
“I pinned the link but it still detached.” Pinning a link is not the same as pinning the program. A pin on the link holds the attachment; but you must pin the link, not just the program — pinning only the program keeps the bytecode loaded while the attachment still drops when the link fd closes. Use bpftool link pin, not bpftool prog pin, to persist an attachment.
“BPF_LINK_UPDATE returned -EINVAL.” The most common cause is updating to a program whose type/attach-type is incompatible with the link, or — with BPF_F_REPLACE set — a mismatch between the current program and the old_prog_fd you supplied (the compare-and-swap failed because someone else updated first).
Not every program type supports links. Some attach mechanisms still predate or sit outside the link model. When bpf_program__attach() cannot make a link it may use a legacy path, which means that attachment does not get the auto-detach/pin/update guarantees. Check bpftool link show — if your attachment does not appear there, it is not link-backed.
Alternatives and When to Choose Them
For the handful of program types and kernels where links are unavailable, the legacy paths (netlink for XDP/tc, PERF_EVENT_IOC_SET_BPF for perf-based probes) still work and are what libbpf falls back to. Choose them only when forced by an old kernel; on any kernel that supports links for your program type, prefer the link — you gain crash-safe lifetime, pinning, atomic update, and introspection for free.
For persistent attachments managed outside any one process — a system service that should attach BPF at boot and keep it attached regardless of the manager’s lifetime — pin the link to bpffs and let a separate controller manage it by id/path. This is the pattern systemd’s BPF integration and long-running agents use.
Production Notes
Crash-safety is the headline operational win. A monitoring agent built on links can be killed, OOM’d, or upgraded and its in-flight attachments clean themselves up — no orphaned XDP programs wedged on a NIC, no stuck kprobes. Conversely, deployments that want BPF to persist across agent restarts pin links to bpffs and re-acquire them by path on startup, getting zero-downtime upgrades.
Atomic BPF_LINK_UPDATE is what makes hot-reloading dataplanes practical: Cilium-style XDP/tc dispatchers and sched_ext schedulers swap their program under load without a detach window. And bpftool link show is the operational source of truth — when something is mysteriously intercepting packets or firing on a function, enumerating links (and the PIDs holding them) is how you find the owner, which was simply impossible in the legacy era.
Uncertain
Verify: the precise function name and ordering of the bpffs-pin reference acquisition on a
bpf_link(i.e. that pinning takes abpf_link_incvia the inode and that unpin/inode-eviction does the matching put) in 6.12kernel/bpf/syscall.c/kernel/bpf/inode.c. Reason: the refcount-via-pin behavior is stated correctly at the conceptual level and matches libbpf’s documented “increments the link’s reference count,” but the exact kernel call path (bpf_link_incfrom inode pinning) was inferred from the refcount model rather than read line-by-line frominode.c. To resolve: readbpf_obj_do_pin/the link pin path inkernel/bpf/inode.cat v6.12. uncertain
See Also
- BPF Object Pinning and Lifetime — the general fd/pin lifetime model for programs, maps, and links; the home of the pinning mechanism this note only references
- Map Pinning and bpffs — the bpffs filesystem and map-specific pinning
- BPF Skeletons and bpftool —
<obj>__attach()creates links;bpftool linkinspects them - The bpf() Syscall — the
BPF_LINK_CREATE/BPF_LINK_UPDATE/BPF_LINK_GET_FD_BY_IDcommands - libbpf and the BPF Loader —
bpf_program__attach/bpf_link__*userspace wrappers - BPF Program Types — which hook each link attaches to; the
SEC()taxonomy - XDP (eXpress Data Path) — the netlink legacy-attach example and a modern link target
- kprobe and uprobe BPF Programs — the
PERF_EVENT_IOC_SET_BPFlegacy path and its link successor - struct_ops and sched_ext —
struct_opslinks (created from a map fd) and atomic policy swap viaBPF_LINK_UPDATE - Linux eBPF MOC — parent map of content