eBPF Explained: A Developer's Guide to Safely Writing Kernel Code

For decades, the Linux kernel has been a sacred, untouchable space for most developers. The mantra was clear: a single mistake in kernel code doesn't just crash an application, it triggers a kernel panic, bringing the entire system to a halt. This well-founded fear kept a generation of programmers in user space. But what if you could harness the power of the kernel—its visibility into every system call, network packet, and memory operation—without that risk? This is the promise of eBPF.

eBPF, or extended Berkeley Packet Filter, is a revolutionary technology built into the Linux kernel that allows you to run sandboxed, high-performance programs directly in kernel space. Crucially, you can do this dynamically, without changing kernel source code or loading risky kernel modules. It's a virtual machine inside the kernel, designed for safety and speed.

This guide will demystify eBPF, peeling back the layers of complexity to reveal the core principles that make it so powerful and safe. We'll walk you through setting up your environment, writing your first kernel-level program, and observing it in action. By the end, you'll see why the kernel is no longer a place to fear, but a new frontier for innovation in observability, networking, and security.

What is eBPF and Why Isn't It Scary?

The Old Way vs. The eBPF Way

Traditionally, extending kernel functionality meant writing a Loadable Kernel Module (LKM). This approach is fraught with peril. A bug in an LKM, like a null pointer dereference, can instantly cause a kernel panic and crash the entire machine. Furthermore, LKMs are tied to specific kernel versions, creating a significant maintenance burden. Getting a module accepted into the mainline kernel is a long and arduous process, slowing down innovation.

eBPF offers a modern alternative. Instead of monolithic modules, you write small, event-driven programs that attach to specific hooks in the kernel (e.g., 'a system call is about to be executed' or 'a network packet has arrived'). These programs are loaded dynamically into the kernel at runtime and are JIT-compiled for near-native performance.

The key difference, and the source of its safety, is that eBPF programs are not trusted. Before being loaded, every eBPF program must pass a rigorous inspection by an in-kernel component called the Verifier, which ensures it can't harm the system.

The eBPF Verifier: Your Built-in Safety Net

The Verifier is the gatekeeper that makes eBPF safe. It's a static analysis tool that performs a deep inspection of your eBPF program's bytecode before it's allowed to run. If the program fails any of its checks, it is rejected and never attached to the kernel. This verification process happens in microseconds.

The Verifier performs several critical safety checks:

  • No Infinite Loops: It performs a Directed Acyclic Graph (DAG) check on the code's control flow to guarantee the program always terminates. This prevents the kernel from locking up.
  • Safe Memory Access: The Verifier tracks every possible register and stack state, ensuring your program can't access arbitrary kernel memory. You can only access data passed to you in the program's context and data stored in special eBPF 'maps'.
  • System Integrity: It ensures your program can't call just any kernel function. It is restricted to a small, stable set of 'helper functions' exposed by the eBPF API, which are themselves designed to be safe.

Because of the Verifier, an eBPF program cannot crash the kernel. The worst it can do is be rejected at load time. This fundamental guarantee is what allows you to write kernel code 'without fear'.

Practical Superpowers: Common eBPF Use Cases

The safety and performance of eBPF have unlocked a wide range of applications that were previously impractical:

  • Networking: eBPF can be attached to network interfaces at the earliest possible point (XDP) or to the traffic control (TC) layer. This allows for incredibly high-speed packet filtering, modification, and forwarding. It's the technology behind modern DDoS mitigation services, high-performance load balancers like Cilium, and complex virtual networking.
  • Observability: Because eBPF can see every system call, function entry/exit, and kernel tracepoint with minimal overhead (often just a few dozen nanoseconds), it's the perfect tool for performance monitoring. Projects like Pixie and bpftrace use eBPF to provide deep, system-wide insights into application behavior without requiring code instrumentation.
  • Security: eBPF enables the creation of powerful, context-aware security policies. A security tool can use eBPF to monitor for suspicious behavior—like a web server unexpectedly trying to access sensitive files—and block the action in real-time, right inside the kernel, before any damage is done. Projects like Falco and Tetragon leverage this for runtime security enforcement.

Your First eBPF Program: A 'Hello World' for the Kernel

Step 1: Setting Up Your Development Environment

To start, you'll need a few tools to compile your eBPF C code into eBPF bytecode and a library to help load it. The core requirements are `clang` and `llvm` for compilation, `libbpf-dev` for the user-space loader library, and the correct kernel headers for your running kernel.

On an Ubuntu-based system, you can install everything with a single command:

sudo apt-get update && sudo apt-get install -y clang llvm libelf-dev libbpf-dev linux-headers-$(uname -r)

For more complex projects, modern toolchains like `libbpf-bootstrap` provide a skeleton project with Makefiles and boilerplate code, making it an excellent starting point. For this guide, however, we'll stick to the fundamentals.

Step 2: Writing the eBPF Kernel Code (in C)

Our goal is to print a message every time any program on the system executes a new process, which is handled by the `execve` system call. We'll create a file named `hello.bpf.c`.

The eBPF program itself is remarkably simple:

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "GPL";

SEC("kprobe/__x64_sys_execve")
int bpf_prog1(struct pt_regs *ctx)
{
  bpf_printk("Hello from eBPF! execve called.\n");
  return 0;
}

Let's break this down:

  • `SEC("kprobe/__x64_sys_execve")`: This is a macro from `libbpf`. `SEC` stands for 'section'. It tells the compiler to place the following function into a specific section of the compiled object file. The user-space loader uses this section name to know where and how to attach the program. Here, we're telling it this is a `kprobe` (kernel probe) that should be attached to the start of the `__x64_sys_execve` kernel function.
  • `int bpf_prog1(struct pt_regs *ctx)`: This is our eBPF program's function signature. The kernel will invoke this function when the event occurs, passing a context `ctx` that contains information like register values.
  • `bpf_printk(...)`: This is an eBPF helper function provided by the kernel. It's a simple way to print formatted strings to the kernel's trace pipe, which is perfect for debugging. It's the kernel-space equivalent of `printf`.

Step 3: Writing the User-Space Loader

The eBPF code we just wrote runs in the kernel, but it doesn't get there by itself. We need a standard user-space program to orchestrate the process. This loader is responsible for opening the compiled eBPF object file, telling `libbpf` to load its contents into the kernel, and then requesting that the programs be attached to their specified hooks.

Create a file named `hello.user.c` with the following C code:

#include <stdio.h>
#include <unistd.h>
#include <bpf/libbpf.h>

int main(int argc, char **argv)
{
  struct bpf_object *obj;
  int err;

  obj = bpf_object__open_file("hello.bpf.o", NULL);
  if (libbpf_get_error(obj)) {
    fprintf(stderr, "ERROR: opening BPF object file failed\n");
    return 1;
  }

  err = bpf_object__load(obj);
  if (err) {
    fprintf(stderr, "ERROR: loading BPF object file failed\n");
    bpf_object__close(obj);
    return 1;
  }

  err = bpf_object__attach(obj);
  if (err) {
    fprintf(stderr, "ERROR: attaching BPF programs failed\n");
    bpf_object__close(obj);
    return 1;
  }

  printf("eBPF program loaded and attached. Press Ctrl+C to exit.\n");

  // A simple loop to keep the program running
  while (1) {
    sleep(1);
  }

  bpf_object__close(obj);
  return 0;
}

This loader uses the `libbpf` library to perform the core operations: `bpf_object__open_file` reads our compiled object, `bpf_object__load` sends the programs and maps to the kernel and triggers the Verifier, and `bpf_object__attach` hooks the verified program into the `execve` syscall. The program then waits, keeping the eBPF program active in the kernel.

Bringing it to Life: Compile, Load, and Observe

Compiling Your eBPF Code

First, we need to compile our eBPF C code (`hello.bpf.c`) into an object file. We use `clang` for this, specifying `bpf` as the target architecture.

clang -g -O2 -target bpf -c hello.bpf.c -o hello.bpf.o

Compiling and Running the Loader

Next, compile the user-space loader program using `gcc`. We need to link it against the `libbpf` library so it knows how to communicate with the kernel's eBPF subsystem.

gcc hello.user.c -o hello -lbpf

Now, run the loader. Loading eBPF programs requires root privileges, so we use `sudo`:

sudo ./hello

Seeing the Magic: Reading the Trace Output

If everything worked, your eBPF program is now loaded and attached inside the kernel. To see its output, you need to read from the kernel's trace pipe. Open a new terminal window and run the following command:

sudo cat /sys/kernel/debug/tracing/trace_pipe

The terminal will appear to hang, which is normal—it's waiting for output. Now, to trigger your eBPF program, simply run any command in another terminal, such as `ls`, `date`, or `whoami`. Each time you do, a new process is executed via `execve`, and you will see your 'Hello World' message appear in the `trace_pipe` terminal in real-time:

<...>-23224 [001] .... 3284.123456: bpf_trace_printk: Hello from eBPF! execve called.
<...>-23225 [002] .... 3285.654321: bpf_trace_printk: Hello from eBPF! execve called.

Congratulations! You have just safely executed custom code inside the Linux kernel.

Conclusion: The Kernel is Now Your Playground

We've journeyed from the traditional fear of kernel development to successfully running a custom program at the heart of the operating system. The key takeaways are simple: eBPF is a powerful, high-performance technology that allows you to safely extend the functionality of the Linux kernel. The safety isn't just an afterthought; it's a core design principle enforced by the in-kernel Verifier.

You have now written, compiled, loaded, and observed code running inside the Linux kernel without needing to recompile it or risk a system crash. The Verifier is your safety net, turning a once-dangerous territory into a playground for innovation.

Where do you go from here? The possibilities are vast. Try modifying the program to trace a different syscall, like `openat` or `connect`. The next major step in your eBPF journey is to learn about eBPF maps, the primary mechanism for sharing data between your kernel program and user space. This unlocks the ability to pass back rich data, not just simple debug prints.

To continue your exploration, here are some essential resources:

Building secure, privacy-first tools means staying ahead of security threats. At ToolShelf, all hash operations happen locally in your browser—your data never leaves your device, providing security through isolation.

Test the latest secure hash algorithms with our Hash Generator—completely offline and private, supporting SHA-256, SHA-512, SHA-3, and BLAKE2.

Stay secure & happy coding,
— ToolShelf Team