Operations 23 min read

Build Your First eBPF Hello World: From Compilation to Tracepoint Debugging

This tutorial walks through installing the required toolchain, writing a minimal eBPF program, compiling it with eunomia‑bpf or cilium/ebpf, loading it from user space, attaching it to a tracepoint, and observing kernel‑level output, while explaining each step and the underlying concepts.

BirdNest Tech Talk
BirdNest Tech Talk
BirdNest Tech Talk
Build Your First eBPF Hello World: From Compilation to Tracepoint Debugging

Introduction

Every story starts with a "Hello World" program, and eBPF is no exception. This article shows how to write a simple eBPF program that traces a system call, covering the full development workflow from environment setup to runtime debugging.

Problem Statement

The goal is to create the simplest possible eBPF Hello World program and understand how the kernel‑side logic and the user‑side loader interact.

Required Environment

CPU: Intel(R) Core(TM) i3‑2310M @ 2.10GHz (4 logical cores)

Memory: 8 GB

OS: Ubuntu 24.04.1 LTS (Noble Numbat)

Linux kernel: 6.8.0‑44‑generic

LLVM/Clang: 18.1.3

GCC: 13.2.0

eunomia‑bpf (ecc): 0.3.4

cilium/ebpf: v0.16.0

eBPF Program Structure

An eBPF program consists of two parts:

Kernel‑mode code – the actual BPF logic that runs in the kernel.

User‑mode code – a loader that compiles, loads, and monitors the kernel program. The ip command can also be used for loading/unloading.

Solution Overview

After choosing a development framework (BCC, libbpf, cilium/ebpf, or eunomia‑bpf), you can start writing both the kernel and user components. The following sections demonstrate two concrete methods.

Method 1: Using eunomia‑bpf

Step 1 – Download the ecli runner:

$ wget https://aka.pw/bpf-ecli -O ecli && chmod +x ./ecli
$ ./ecli -h

Step 2 – Download the compiler toolchain ( ecc ) to turn C source into a BPF object or WASM module:

$ wget https://github.com/eunomia-bpf/eunomia-bpf/releases/latest/download/ecc && chmod +x ./ecc
$ ./ecc -h
Usage: ecc [OPTIONS] <SOURCE_PATH> [EXPORT_EVENT_HEADER]

Step 3 – (Optional) Compile inside Docker:

$ docker run -it -v `pwd`:/src ghcr.io/eunomia-bpf/ecc-$(uname -m):latest \
    # The current directory must contain *.bpf.c and *.h files
    export PATH=PATH:~/.eunomia/bin
    Compiling bpf object...
    Packing ebpf object and config into /src/package.json...

Using eunomia‑bpf automates compilation and embeds metadata, so the user‑space loader can run the program without writing any additional code.

Compilation is fully automated, reducing build‑time errors.

Running is "click‑free" – the generated metadata is sufficient for execution.

Method 2: Using cilium/ebpf

1. Install dependencies

LLVM and Clang – needed to compile the BPF program.

Go – for writing the user‑space loader. bpf2go – generates Go bindings from the compiled BPF object.

2. Write the kernel‑mode C program ( minimal.bpf.c )

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

SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx)
{
    char msg[] = "Hello, eBPF!";
    bpf_trace_printk(msg, sizeof(msg));
    return 0;
}

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

This program attaches to the sys_enter_execve tracepoint and prints a static string via bpf_trace_printk.

3. Generate Go bindings

//go:generate bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" bpf hello.bpf.c

package main

import (
    "log"
    "C"
    // other imports omitted for brevity
)

func main() {
    objs := bpfObjects{}
    if err := loadBpfObjects(&objs, nil); err != nil {
        log.Fatalf("loading objects: %v", err)
    }
    defer objs.Close()
    // Attach, run, and wait for signals …
}

4. Write the user‑space loader ( minimal.go )

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go --go-package minimal -output-dir minimal Minimal minimal.bpf.c -- -I/usr/include/bpf -I/usr/include/linux

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"

    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/rlimit"
    minimal "github.com/smallnest/ebpf-study/01-helloworld/minimal"
)

func main() {
    if err := rlimit.RemoveMemlock(); err != nil {
        log.Fatalf("failed to remove memlock: %v", err)
    }
    objs := minimal.MinimalObjects{}
    if err := minimal.LoadMinimalObjects(&objs, nil); err != nil {
        log.Fatalf("loading objects: %v", err)
    }
    defer objs.Close()

    tp, err := link.Tracepoint("syscalls", "sys_enter_write", objs.HandleTp, nil)
    if err != nil {
        log.Fatalf("opening tracepoint: %v", err)
    }
    defer tp.Close()

    log.Println("eBPF program loaded and attached. Press Ctrl+C to exit.")
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
    <-sigs
}

5. Compile and run

$ go generate
$ go build -o minimal
$ sudo ./minimal
$ sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "BPF triggered sys_enter_write"

The loader removes the default memlock limit, loads the BPF object, attaches it to the sys_enter_write tracepoint, and waits for a termination signal.

Discussion of Core Concepts

Include the appropriate kernel headers ( <linux/bpf.h>, <bpf/bpf_helpers.h>, etc.).

Declare a license (usually "Dual BSD/GPL" or "GPL").

Define a BPF function with a void *ctx argument and an int return value.

Use helper functions such as bpf_get_current_pid_tgid() and bpf_trace_printk() to access kernel data and emit debug output.

Extended Learning

Tracepoints

Tracepoints are static kernel instrumentation points that can be enabled after the fact. They were introduced in Linux 2.6.32 (2009) and provide a stable API. Common ways to list available tracepoints:

Read /sys/kernel/debug/tracing/available_events:

sudo cat /sys/kernel/debug/tracing/available_events

Use perf list tracepoint: sudo perf list tracepoint Use bpftrace -l 'tracepoint:*': sudo bpftrace -l 'tracepoint:*' Browse the filesystem under /sys/kernel/debug/tracing/events/.

SEC Section Types

The SEC macro tags a function or map with a specific ELF section name, which determines how the kernel treats the program. Common sections include: SEC("tp/syscalls/sys_enter_write") – tracepoint for the write syscall entry. SEC("kprobe/do_sys_open") – kprobe for the kernel function do_sys_open. SEC("xdp") – XDP program that processes network packets. SEC("socket") – socket filter program.

…and many others such as kretprobe, uprobe, cgroup/skb, perf_event, raw_tp, lsm, etc.

Common eBPF CO‑RE Macros

Compile‑Once‑Run‑Everywhere (CO‑RE) macros hide kernel‑version differences. Examples: BPF_CORE_READ(dst, src, field) – safely read a struct field regardless of offset. BPF_CORE_READ_STR_INTO(dst, size, src, field) – read a string field into a buffer. BPF_PROBE_READ(dst, size, src) – generic safe read. bpf_core_type_exists(type) – test if a type exists in the running kernel. bpf_core_field_exists(field) – test if a field exists. BPF_CURRENT_PID_TGID() – obtain current PID/TGID. BPF_GET_CURRENT_COMM(comm) – obtain current process name.

Example using CO‑RE macros in a socket program:

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

SEC("socket")
int socket_prog(struct __sk_buff *skb)
{
    struct task_struct *task = (struct task_struct *)bpf_get_current_task();
    __u32 pid_tgid = bpf_get_current_pid_tgid();
    __u32 pid = pid_tgid >> 32;
    char comm[16];
    BPF_CORE_READ_STR_INTO(comm, sizeof(comm), task, comm);
    struct tcphdr *tcp_header;
    tcp_header = bpf_skb_load_bytes(skb, ETH_HLEN + sizeof(struct iphdr), sizeof(struct tcphdr));
    if (!tcp_header)
        return 0;
    __u8 tcp_flags = BPF_CORE_READ_BITFIELD(tcp_header, flags);
    bpf_printk("Process %s (PID %d) sent a packet with TCP flags: %x", comm, pid, tcp_flags);
    return 0;
}

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

Advanced Example – XDP Packet Filter

The following XDP program drops TCP packets destined for port 80 and passes everything else:

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>

SEC("xdp")
int xdp_prog(struct xdp_md *ctx)
{
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    struct ethhdr *eth = data;
    if (data + sizeof(*eth) > data_end)
        return XDP_PASS;
    if (eth->h_proto == bpf_htons(ETH_P_IP)) {
        struct iphdr *ip = data + sizeof(*eth);
        if ((void *)(ip + 1) > data_end)
            return XDP_PASS;
        if (ip->protocol == IPPROTO_TCP) {
            struct tcphdr *tcp = (void *)ip + sizeof(*ip);
            if ((void *)(tcp + 1) > data_end)
                return XDP_PASS;
            __u16 src_port;
            BPF_CORE_READ_INTO(&src_port, tcp, source);
            if (src_port == bpf_htons(80))
                return XDP_DROP;
        }
    }
    return XDP_PASS;
}

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

References

bpf2go – https://pkg.go.dev/github.com/cilium/ebpf/cmd/bpf2go

iovisor/bcc issue #139 – https://github.com/iovisor/bcc/issues/139

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

eBPFtracepointHello Worldkernel programmingcilium/ebpfCO-REeunomia-bpf
BirdNest Tech Talk
Written by

BirdNest Tech Talk

Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.