Comprehensive Guide to eBPF: From Basics to Advanced Usage

Part 1: Introduction to eBPF

What Is eBPF?

eBPF (extended Berkeley Packet Filter) is a groundbreaking technology in the Linux kernel that lets you run small, sandboxed programs in response to events in the kernel. Historically, BPF was used for filtering network packets, but eBPF expanded those capabilities significantly. Now you can attach eBPF programs to a wide range of events—system calls, function entry/exit, kernel tracepoints, user-level function calls, network events, and more—without modifying or recompiling the kernel.

eBPF Overview: Linux kernel with attached eBPF programs
Figure 1: Overview of eBPF architecture showing how programs attach to kernel events

Why Use eBPF?

  1. Observability
    Gain deep insight into system behavior (CPU usage, memory, network) by hooking into kernel functions.
  2. Networking
    Perform high-performance packet filtering or load balancing without the overhead of context switching to user space.
  3. Security
    Detect security anomalies (e.g., unauthorized file access) in real time.
  4. Performance
    Debug performance bottlenecks by tracing function calls, latencies, and more.

Key Benefits

Part 2: Core Concepts and Architecture

eBPF Architecture showing core components
Figure 2: Core components of eBPF: Programs, Verifier, JIT Compiler, and Maps

Core Components

  1. Programs: Written in a restricted version of C (sometimes with higher-level wrappers) and compiled to eBPF bytecode.
  2. Verifier: Checks your program for safety (no unbounded loops, valid memory access, etc.).
  3. JIT Compiler: Converts the bytecode into native machine instructions for efficiency.
  4. Maps: Special data structures for sharing data between user space and the kernel (e.g., hash maps, arrays, perf buffers).

Typical Workflow

  1. Write code in C (or Go with eBPF tooling)
  2. Use clang or a specialized library to compile your eBPF code into bytecode.
  3. Load the bytecode into the kernel via a user-space program (using a library like libbpf or libbpfgo).
  4. Attach your eBPF program to the desired hook (system call, kprobe, tracepoint, etc.).
  5. Observe data or enforce policies in real time!
eBPF Workflow from source to execution
Figure 3: eBPF program lifecycle from source code to kernel execution

eBPF vs. Traditional Kernel Modules

Part 3: Historical Context and Evolution

eBPF Evolution Timeline
Figure 4: Evolution of eBPF from classic BPF to modern capabilities

Part 4: A Simple "Hello World" Example

Let's walk through a minimal example. This will demonstrate how eBPF programs are loaded and how they communicate with user space.

Note: You'll need a recent Linux kernel (4.4 or above) and development tools like clang, llvm, and libbpf-dev (or a Go toolchain with libbpfgo bindings).

Step 1: The eBPF Program (C Code)

Create a file called hello_bpf.c:

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

// This macro defines our license. Required by the verifier.
char LICENSE[] SEC("license") = "GPL";

// eBPF program to run on some hook. For demonstration, it's just a no-op.
SEC("tracepoint/syscalls/sys_enter_execve")
int hello_world(void *ctx)
{
    bpf_printk("Hello from eBPF!\n");
    return 0;
}
Hello World eBPF Program Flow
Figure 5: Flow diagram of the Hello World eBPF program execution

Step 2: Compile the eBPF Program

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

Step 3: Create a User-Space Loader (C or Go)

Example in C using libbpf:

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

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

    // Open the eBPF object file
    obj = bpf_object__open_file("hello_bpf.o", NULL);
    if (libbpf_get_error(obj)) {
        fprintf(stderr, "Error opening BPF object file\n");
        return 1;
    }

    // Load into kernel
    err = bpf_object__load(obj);
    if (err) {
        fprintf(stderr, "Error loading BPF object: %d\n", err);
        return 1;
    }

    // Attach all programs in the object (e.g., the tracepoint)
    err = bpf_object__attach_skeleton(obj);
    if (err) {
        fprintf(stderr, "Error attaching BPF program: %d\n", err);
        return 1;
    }

    printf("eBPF program successfully loaded and attached!\n");
    // Keep the program running so we can see the trace messages
    while (1) {
        sleep(1);
    }
    return 0;
}

Example in Go with libbpfgo (simplified snippet):

package main

import (
    "fmt"
    "time"
    libbpf "github.com/aquasecurity/libbpfgo"
)

func main() {
    // Load the eBPF object
    bpfModule, err := libbpf.NewModuleFromFile("hello_bpf.o")
    if err != nil {
        panic(err)
    }
    defer bpfModule.Close()

    // Load and verify
    if err := bpfModule.BPFLoadObject(); err != nil {
        panic(err)
    }

    // Attach the program (tracepoint)
    prog, err := bpfModule.GetProgram("hello_world")
    if err != nil {
        panic(err)
    }
    _, err = prog.AttachTracepoint("syscalls", "sys_enter_execve")
    if err != nil {
        panic(err)
    }

    fmt.Println("eBPF program loaded and attached. Press Ctrl+C to exit.")

    for {
        time.Sleep(time.Second)
    }
}

Step 4: Run and Check Logs

  1. Open a terminal to watch kernel trace output:
    sudo cat /sys/kernel/debug/tracing/trace_pipe
  2. In another terminal, run your loader (C or Go).
  3. Execute any command (e.g., ls, pwd), and you should see Hello from eBPF! appear in your trace window.

Congratulations! You've just run your first eBPF program.

Part 5: Attaching to More Hooks: kprobes, uprobes, and More

Types of eBPF Hooks

  1. Kprobes and Kretprobes:
    • Use Case: Debugging and understanding kernel-level behavior without modifying the kernel code.
    • Example: Monitoring a system call's arguments and return values.
  2. Tracepoints:
    • Use Case: Observability and diagnostics, leveraging stable points in the kernel.
    • Example: Tracking the scheduler or block I/O operations.
  3. Uprobes and Uretprobes:
    • Use Case: Analyzing user-space application behavior.
    • Example: Profiling the performance of a database query function.
  4. Socket Filters:
    • Use Case: Capturing and analyzing traffic at the socket level.
    • Example: Implementing a custom packet filter for a specific application.
  5. Traffic Control (tc) Hooks:
    • Use Case: Controlling and shaping traffic at the network device level.
    • Example: Prioritizing traffic for specific applications or enforcing bandwidth limits.
  6. XDP (eXpress Data Path):
    • Use Case: High-speed packet processing directly in the NIC driver.
    • Example: Dropping malicious packets before they reach the TCP/IP stack.
  7. LSM (Linux Security Modules) Hooks:
    • Use Case: Enforcing security policies without modifying the kernel or using traditional LSMs like SELinux or AppArmor.
    • Example: Restricting file access for certain processes dynamically.
  8. Raw Tracepoints:
    • Use Case: Gaining access to critical kernel execution points with minimal overhead.
    • Example: Profiling lock contention or memory allocation latency.
  9. Perf Events:
    • Use Case: Monitoring system performance metrics.
    • Example: Counting cache misses or measuring CPU cycles consumed by a process.

kprobes and uprobes

Example: Suppose you want to trace every time a user-space function main in my_app is called. You can attach a uprobe on main:

sudo perf probe -x /path/to/my_app main

Then modify your eBPF C code to attach:

SEC("uprobe/my_app/main")
int trace_my_app_main(struct pt_regs *ctx)
{
    bpf_printk("my_app's main function is called!\n");
    return 0;
}

Perf Buffers and Maps

You often need to share data between your eBPF code and user space. That's where maps come in. Maps can be hash maps, arrays, LRU maps, ring buffers, and more. perf_event_array is a special map used for streaming arbitrary data.

For more information about eBPF maps and data flow, see:

Part 6: Real-World Use Cases and Projects

Observability with Tracy

Tracy is a project leveraging eBPF to trace system calls and events in real time for security and observability. It attaches eBPF programs to various syscalls and collects data in user space, providing insights into what's happening at the kernel level.

Network Filtering with Cilium

Cilium uses eBPF to enhance container networking in Kubernetes. It attaches to network events to perform high-speed packet routing and security filtering without user-space overhead.

For real-world examples and applications, check out these projects:

Security Monitoring

Many security tools use eBPF to track file access, process behavior, and network connections. Because eBPF runs in the kernel, these tools can detect anomalies in near real time.

Part 7: Summary of Conference Videos

1. Beginner's Guide to eBPF Programming with Go

2. Advanced eBPF Usage

Part 8: Where To Go From Here

  1. Dive Deeper into BPF Maps
    Learn about map types like hash maps, LRU maps, and ring buffers for advanced data sharing.
  2. Explore Additional Hooks
    • Tracepoints for kernel-level instrumentation.
    • kretprobes for function return tracing.
    • XDP for high-speed packet processing.
  3. Try Out Real Tools
    Use existing projects (e.g., BCC, bpftrace) that provide simpler abstractions over raw eBPF development.
  4. Contribute
    The eBPF ecosystem is active. Contribute to libbpf, libbpfgo, or build your own project to help the community.
  5. Stay Updated
    eBPF is evolving fast. New kernel versions and libraries introduce more features and improved verifier logic.

Final Thoughts

eBPF represents a massive leap in how developers interact with the Linux kernel—without the risks and friction of traditional kernel modules. By following the examples above, you can begin experimenting with eBPF programs yourself, attaching them to various events (syscalls, kprobes, tracepoints) for advanced observability, networking, and security use cases.

From simple "Hello World" programs to full-fledged security and monitoring tools, eBPF is an incredibly powerful (and safe) way to harness kernel power. Happy hacking!