docs: add comprehensive BPF Arena tutorial detailing features, use cases, and examples

This commit is contained in:
yunwei37
2025-10-04 23:15:03 -07:00
parent b88ab2ae0e
commit 0e19d48331
3 changed files with 920 additions and 458 deletions

View File

@@ -1,55 +1,314 @@
# BPF Iterators Tutorial
# eBPF Tutorial by Example: BPF Iterators for Kernel Data Export
## What are BPF Iterators?
Ever tried monitoring hundreds of processes and ended up parsing thousands of `/proc` files just to find the few you care about? Or needed custom formatted kernel data but didn't want to modify the kernel itself? Traditional `/proc` filesystem access is slow, inflexible, and forces you to process tons of data in userspace even when you only need a small filtered subset.
BPF iterators allow you to iterate over kernel data structures and export formatted data to userspace via `seq_file`. They're a modern replacement for traditional `/proc` files with **programmable, filterable, in-kernel data processing**.
This is what **BPF Iterators** solve. Introduced in Linux kernel 5.8, iterators let you traverse kernel data structures directly from BPF programs, apply filters in-kernel, and output exactly the data you need in any format you want. In this tutorial, we'll build a dual-mode iterator that shows kernel stack traces and open file descriptors for processes, with in-kernel filtering by process name - dramatically faster than parsing `/proc`.
## Real-World Example: Task Stack Iterator
## Introduction to BPF Iterators: The /proc Replacement
### The Problem with Traditional Approach
### The Problem: /proc is Slow and Rigid
Traditional Linux monitoring revolves around the `/proc` filesystem. Need to see what processes are doing? Read `/proc/*/stack`. Want open files? Parse `/proc/*/fd/*`. This works, but it's painfully inefficient when you're monitoring systems at scale or need specific filtered views of kernel data.
The performance problem is systemic. Every `/proc` access requires a syscall, kernel mode transition, text formatting, data copy to userspace, and then you parse that text back into structures. If you want stack traces for all "bash" processes among 1000 total processes, you still read all 1000 `/proc/*/stack` files and filter in userspace. That's 1000 syscalls, 1000 text parsing operations, and megabytes of data transferred just to find a handful of matches.
Format inflexibility compounds the problem. The kernel chooses what data to show and how to format it. Want stack traces with custom annotations? Too bad, you get the kernel's fixed format. Need to aggregate data across processes? Parse everything in userspace. The `/proc` interface is designed for human consumption, not programmatic filtering and analysis.
Here's what traditional monitoring looks like:
**Traditional method** (using `/proc` or system tools):
```bash
# Show all process stack traces
cat /proc/*/stack
# Find stack traces for all bash processes
for pid in $(pgrep bash); do
echo "=== PID $pid ==="
cat /proc/$pid/stack
done
```
**Problems:**
1. ❌ **No filtering** - Must read ALL processes, parse in userspace
2. ❌ **Fixed format** - Cannot customize output
3. ❌ **High overhead** - Context switches, string formatting, massive output
4. ❌ **Post-processing** - All filtering/aggregation in userspace
5. ❌ **Inflexible** - Want different fields? Modify kernel!
This spawns `pgrep` as a subprocess, makes a syscall per matching PID to read stack files, parses text output, and does all filtering in userspace. Simple to write, horrible for performance.
### BPF Iterator Solution
### The Solution: Programmable In-Kernel Iteration
BPF iterators flip the model. Instead of pulling all data to userspace for processing, you push your processing logic into the kernel where the data lives. An iterator is a BPF program attached to a kernel data structure traversal that gets called for each element. The kernel walks tasks, files, or sockets, invokes your BPF program with each element's context, and your code decides what to output and how to format it.
The architecture is elegant. You write a BPF program marked `SEC("iter/task")` or `SEC("iter/task_file")` that receives each task or file during iteration. Inside this program, you have direct access to kernel struct fields, can filter based on any criteria using normal C logic, and use `BPF_SEQ_PRINTF()` to format output exactly as needed. The kernel handles the iteration mechanics while your code focuses purely on filtering and formatting.
When userspace reads from the iterator file descriptor, the magic happens entirely in the kernel. The kernel walks the task list, calls your BPF program for each task passing the task_struct pointer. Your program checks if the task name matches your filter - if not, it returns 0 immediately with no output. If it matches, your program extracts the stack trace and formats it to a seq_file. All this happens in kernel context before any data crosses to userspace.
The benefits are transformative. **In-kernel filtering** means only relevant data crosses the kernel boundary, eliminating wasted work. **Custom formats** let you output binary, JSON, CSV, whatever your tools need. **Single read operation** replaces thousands of individual `/proc` file accesses. **Zero parsing** because you formatted the data correctly in the kernel. **Composability** works with standard Unix tools since iterator output comes through a normal file descriptor.
### Iterator Types and Capabilities
The kernel provides iterators for many subsystems. **Task iterators** (`iter/task`) walk all tasks giving you access to process state, credentials, resource usage, and parent-child relationships. **File iterators** (`iter/task_file`) traverse open file descriptors showing files, sockets, pipes, and other fd types. **Network iterators** (`iter/tcp`, `iter/udp`) walk active network connections with full socket state. **BPF object iterators** (`iter/bpf_map`, `iter/bpf_prog`) enumerate loaded BPF programs and maps for introspection.
Our tutorial focuses on task and task_file iterators because they solve common monitoring needs and demonstrate core concepts applicable to all iterator types.
## Implementation: Dual-Mode Task Iterator
Let's build a complete example demonstrating two iterator types in one tool. We'll create a program that can show either kernel stack traces or open file descriptors for processes, with optional filtering by process name.
### Complete BPF Program: task_stack.bpf.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Kernel task stack and file descriptor iterator */
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
char _license[] SEC("license") = "GPL";
#define MAX_STACK_TRACE_DEPTH 64
unsigned long entries[MAX_STACK_TRACE_DEPTH] = {};
#define SIZE_OF_ULONG (sizeof(unsigned long))
/* Filter: only show stacks for tasks with this name (empty = show all) */
char target_comm[16] = "";
__u32 stacks_shown = 0;
__u32 files_shown = 0;
/* Task stack iterator */
SEC("iter/task")
int dump_task_stack(struct bpf_iter__task *ctx)
{
struct seq_file *seq = ctx->meta->seq;
struct task_struct *task = ctx->task;
long i, retlen;
int match = 1;
if (task == (void *)0) {
/* End of iteration - print summary */
if (stacks_shown > 0) {
BPF_SEQ_PRINTF(seq, "\n=== Summary: %u task stacks shown ===\n",
stacks_shown);
}
return 0;
}
/* Filter by task name if specified */
if (target_comm[0] != '\0') {
match = 0;
for (i = 0; i < 16; i++) {
if (task->comm[i] != target_comm[i])
break;
if (task->comm[i] == '\0') {
match = 1;
break;
}
}
if (!match)
return 0;
}
/* Get kernel stack trace for this task */
retlen = bpf_get_task_stack(task, entries,
MAX_STACK_TRACE_DEPTH * SIZE_OF_ULONG, 0);
if (retlen < 0)
return 0;
stacks_shown++;
/* Print task info and stack trace */
BPF_SEQ_PRINTF(seq, "=== Task: %s (pid=%u, tgid=%u) ===\n",
task->comm, task->pid, task->tgid);
BPF_SEQ_PRINTF(seq, "Stack depth: %u frames\n", retlen / SIZE_OF_ULONG);
for (i = 0; i < MAX_STACK_TRACE_DEPTH; i++) {
if (retlen > i * SIZE_OF_ULONG)
BPF_SEQ_PRINTF(seq, " [%2ld] %pB\n", i, (void *)entries[i]);
}
BPF_SEQ_PRINTF(seq, "\n");
return 0;
}
/* Task file descriptor iterator */
SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
struct seq_file *seq = ctx->meta->seq;
struct task_struct *task = ctx->task;
struct file *file = ctx->file;
__u32 fd = ctx->fd;
long i;
int match = 1;
if (task == (void *)0 || file == (void *)0) {
if (files_shown > 0 && ctx->meta->seq_num > 0) {
BPF_SEQ_PRINTF(seq, "\n=== Summary: %u file descriptors shown ===\n",
files_shown);
}
return 0;
}
/* Filter by task name if specified */
if (target_comm[0] != '\0') {
match = 0;
for (i = 0; i < 16; i++) {
if (task->comm[i] != target_comm[i])
break;
if (task->comm[i] == '\0') {
match = 1;
break;
}
}
if (!match)
return 0;
}
if (ctx->meta->seq_num == 0) {
BPF_SEQ_PRINTF(seq, "%-16s %8s %8s %6s %s\n",
"COMM", "TGID", "PID", "FD", "FILE_OPS");
}
files_shown++;
BPF_SEQ_PRINTF(seq, "%-16s %8d %8d %6d 0x%lx\n",
task->comm, task->tgid, task->pid, fd,
(long)file->f_op);
return 0;
}
```
### Understanding the BPF Code
The program implements two separate iterators sharing common filtering logic. The `SEC("iter/task")` annotation registers `dump_task_stack` as a task iterator - the kernel will call this function once for each task in the system. The context structure `bpf_iter__task` provides three critical pieces: the `meta` field containing iteration metadata and the seq_file for output, the `task` pointer to the current task_struct, and a NULL task pointer when iteration finishes so you can print summaries.
The task stack iterator shows in-kernel filtering in action. When `task` is NULL, we've reached the end of iteration and can print summary statistics showing how many tasks matched our filter. For each task, we first apply filtering by comparing `task->comm` (the process name) against `target_comm`. We can't use standard library functions like `strcmp()` in BPF, so we manually loop through characters comparing byte by byte. If the names don't match and filtering is enabled, we immediately return 0 with no output - this task is skipped entirely in the kernel without crossing to userspace.
Once a task passes filtering, we extract its kernel stack trace using `bpf_get_task_stack()`. This BPF helper captures up to 64 stack frames into our `entries` array, returning the number of bytes written. We format the output using `BPF_SEQ_PRINTF()` which writes to the kernel's seq_file infrastructure. The special `%pB` format specifier symbolizes kernel addresses, turning raw pointers into human-readable function names like `schedule+0x42/0x100`. This makes stack traces immediately useful for debugging.
The file descriptor iterator demonstrates a different iterator type. `SEC("iter/task_file")` tells the kernel to call this function for every open file descriptor across all tasks. The context provides `task`, `file` (the kernel's struct file pointer), and `fd` (the numeric file descriptor). We apply the same task name filtering, then format output as a table. Using `ctx->meta->seq_num` to detect the first output lets us print column headers exactly once.
Notice how filtering happens before any expensive operations. We check the task name first, and only if it matches do we extract stack traces or format file information. This minimizes work in the kernel fast path - non-matching tasks are rejected with just a string comparison, no memory allocation, no formatting, no output.
### Complete User-Space Program: task_stack.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Userspace program for task stack and file iterator */
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include "task_stack.skel.h"
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}
static void run_iterator(const char *name, struct bpf_program *prog)
{
struct bpf_link *link;
int iter_fd, len;
char buf[8192];
link = bpf_program__attach_iter(prog, NULL);
if (!link) {
fprintf(stderr, "Failed to attach %s iterator\n", name);
return;
}
iter_fd = bpf_iter_create(bpf_link__fd(link));
if (iter_fd < 0) {
fprintf(stderr, "Failed to create %s iterator: %d\n", name, iter_fd);
bpf_link__destroy(link);
return;
}
while ((len = read(iter_fd, buf, sizeof(buf) - 1)) > 0) {
buf[len] = '\0';
printf("%s", buf);
}
close(iter_fd);
bpf_link__destroy(link);
}
int main(int argc, char **argv)
{
struct task_stack_bpf *skel;
int err;
int show_files = 0;
libbpf_set_print(libbpf_print_fn);
/* Parse arguments */
if (argc > 1 && strcmp(argv[1], "--files") == 0) {
show_files = 1;
argc--;
argv++;
}
/* Open BPF application */
skel = task_stack_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
/* Configure filter before loading */
if (argc > 1) {
strncpy(skel->bss->target_comm, argv[1], sizeof(skel->bss->target_comm) - 1);
printf("Filtering for tasks matching: %s\n\n", argv[1]);
} else {
printf("Usage: %s [--files] [comm]\n", argv[0]);
printf(" --files Show open file descriptors instead of stacks\n");
printf(" comm Filter by process name\n\n");
}
/* Load BPF program */
err = task_stack_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load BPF skeleton\n");
goto cleanup;
}
if (show_files) {
printf("=== BPF Task File Descriptor Iterator ===\n\n");
run_iterator("task_file", skel->progs.dump_task_file);
} else {
printf("=== BPF Task Stack Iterator ===\n\n");
run_iterator("task", skel->progs.dump_task_stack);
}
cleanup:
task_stack_bpf__destroy(skel);
return err;
}
```
### Understanding the User-Space Code
The userspace program showcases how simple iterator usage is once you understand the pattern. The `run_iterator()` function encapsulates the three-step iterator lifecycle. First, `bpf_program__attach_iter()` attaches the BPF program to the iterator infrastructure, registering it to be called during iteration. Second, `bpf_iter_create()` creates a file descriptor representing an iterator instance. Third, simple `read()` calls consume the iterator output.
Here's what makes this powerful: when you read from the iterator fd, the kernel transparently starts walking tasks or files. For each element, it calls your BPF program passing the element's context. Your BPF code filters and formats output to a seq_file buffer. The kernel accumulates this output and returns it through the read() call. From userspace's perspective, it's just reading a file - all the iteration, filtering, and formatting complexity is hidden in the kernel.
The main function handles mode selection and configuration. We parse command-line arguments to determine whether to show stacks or files, and what process name to filter for. Critically, we set `skel->bss->target_comm` before loading the BPF program. This writes the filter string into the BPF program's global data section, making it visible to kernel code when the program runs. This is how we pass configuration from userspace to kernel without complex communication channels.
After loading, we select which iterator to run based on the `--files` flag. Both iterators use the same filtering logic, but produce different output - one shows stack traces, the other shows file descriptors. The shared filtering code demonstrates how BPF programs can implement reusable logic across different iterator types.
## Compilation and Execution
Navigate to the bpf_iters directory and build:
```bash
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/features/bpf_iters
make
```
The Makefile compiles the BPF program with BTF support and generates a skeleton header containing the compiled bytecode embedded in C structures. This skeleton API makes BPF program loading trivial.
Show kernel stack traces for all systemd processes:
**Our implementation** (`task_stack.bpf.c`):
```bash
# Show only systemd tasks with kernel stack traces
sudo ./task_stack systemd
```
**Benefits:**
1. ✅ **In-kernel filtering** - Only selected processes sent to userspace
2. ✅ **Custom format** - Choose exactly what fields to show
3. ✅ **Low overhead** - Filter before copying to userspace
4. ✅ **Programmable** - Add statistics, calculations, aggregations
5. ✅ **Dynamic** - Load different filters without kernel changes
### Performance Comparison
| Operation | Traditional `/proc` | BPF Iterator |
|-----------|-------------------|--------------|
| Read all stacks | Parse 1000+ files | Single read() call |
| Filter by name | Userspace loop | In-kernel filter |
| Data transfer | MB of text | KB of relevant data |
| CPU usage | High (parsing) | Low (pre-filtered) |
| Customization | Recompile kernel | Load new BPF program |
## Example Output
Expected output:
```
$ sudo ./task_stack systemd
Filtering for tasks matching: systemd
=== BPF Task Stack Iterator ===
@@ -63,143 +322,61 @@ Stack depth: 6 frames
[ 4] do_syscall_64+0x7e/0x170
[ 5] entry_SYSCALL_64_after_hwframe+0x76/0x7e
=== Summary: 2 task stacks shown ===
=== Summary: 1 task stacks shown ===
```
## How It Works
### 1. BPF Program (`task_stack.bpf.c`)
```c
SEC("iter/task")
int dump_task_stack(struct bpf_iter__task *ctx)
{
struct task_struct *task = ctx->task;
// In-kernel filtering by task name
if (target_comm[0] != '\0' && !match_name(task->comm))
return 0; // Skip this task
// Get kernel stack trace
bpf_get_task_stack(task, entries, MAX_DEPTH * SIZE_OF_ULONG, 0);
// Format and output to seq_file
BPF_SEQ_PRINTF(seq, "Task: %s (pid=%u)\n", task->comm, task->pid);
return 0;
}
```
### 2. Userspace Program (`task_stack.c`)
```c
// Attach iterator
link = bpf_program__attach_iter(skel->progs.dump_task_stack, NULL);
// Create iterator instance
iter_fd = bpf_iter_create(bpf_link__fd(link));
// Read output
while ((len = read(iter_fd, buf, sizeof(buf))) > 0) {
printf("%s", buf);
}
```
## Available Iterator Types
The kernel provides many iterator types:
### System Iterators
- `iter/task` - Iterate all tasks/processes
- `iter/ksym` - Kernel symbols (like `/proc/kallsyms`)
- `iter/bpf_map` - All BPF maps in system
- `iter/bpf_link` - All BPF links
### Network Iterators
- `iter/tcp` - TCP sockets (replaces `/proc/net/tcp`)
- `iter/udp` - UDP sockets
- `iter/unix` - Unix domain sockets
- `iter/netlink` - Netlink sockets
### Map Iterators
- `iter/bpf_map_elem` - Iterate map elements
- `iter/sockmap` - Socket map entries
### Task/Process Iterators
- `iter/task_file` - Task file descriptors (like `/proc/PID/fd`)
- `iter/task_vma` - Task memory mappings (like `/proc/PID/maps`)
## Use Cases
### 1. Performance Monitoring
- Track high-latency network connections
- Monitor stuck processes (long-running syscalls)
- Identify memory-hungry tasks
### 2. Debugging
- Capture stack traces of specific processes
- Dump kernel state for analysis
- Trace system calls in real-time
### 3. Security
- Monitor process creation patterns
- Track network connection attempts
- Audit file access patterns
### 4. Custom `/proc` Replacements
- Create application-specific views
- Filter and aggregate kernel data
- Reduce userspace processing overhead
## Building and Running
Show open file descriptors for bash processes:
```bash
# Build
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/features/bpf_iters
make
# Run - show all tasks
sudo ./task_stack
# Run - filter by task name
sudo ./task_stack systemd
sudo ./task_stack bash
sudo ./task_stack --files bash
```
## Key Differences: Iterator Types
Expected output:
### Kernel Iterators (`SEC("iter/...")`)
- **Purpose**: Export kernel data to userspace
- **Output**: seq_file (readable via read())
- **Activation**: Attach, create instance, read FD
- **Example**: Task stacks, TCP sockets, kernel symbols
```
Filtering for tasks matching: bash
### Open-Coded Iterators (`bpf_for`, `bpf_iter_num`)
- **Purpose**: Loop constructs within BPF programs
- **Output**: Internal program variables
- **Activation**: Execute during program run
- **Example**: Sum numbers, count elements, iterate arrays
=== BPF Task File Descriptor Iterator ===
## Advantages Over Traditional Approaches
COMM TGID PID FD FILE_OPS
bash 12345 12345 0 0xffffffff81e3c6e0
bash 12345 12345 1 0xffffffff81e3c6e0
bash 12345 12345 2 0xffffffff81e3c6e0
bash 12345 12345 255 0xffffffff82145dc0
| Feature | Traditional `/proc` | BPF Iterators |
|---------|-------------------|---------------|
| **Filtering** | Userspace only | In-kernel |
| **Performance** | High overhead | Minimal overhead |
| **Customization** | Kernel rebuild | Load BPF program |
| **Format** | Fixed | Fully programmable |
| **Statistics** | Userspace calc | In-kernel aggregation |
| **Security** | No filtering | LSM hooks available |
| **Deployment** | Static | Dynamic (load anytime) |
=== Summary: 4 file descriptors shown ===
```
## Summary
Run without filtering to see all tasks:
BPF iterators are **game-changing** for system observability:
```bash
sudo ./task_stack
```
1. **Performance**: Filter in kernel, only send relevant data
2. **Flexibility**: Load different programs for different views
3. **Power**: Access raw kernel structures with type safety (BTF)
4. **Safety**: Verified by BPF verifier, can't crash kernel
5. **Portability**: CO-RE ensures binary works across kernel versions
This shows stacks for every task in the system. On a typical desktop, this might display hundreds of tasks. Notice how fast it runs compared to parsing `/proc/*/stack` for all processes - the iterator is dramatically more efficient.
They enable creating **custom, high-performance system monitoring tools** without modifying the kernel!
## When to Use BPF Iterators vs /proc
Choose **BPF iterators** when you need filtered kernel data without userspace processing overhead, custom output formats that don't match `/proc` text, performance-critical monitoring that runs frequently, or integration with BPF-based observability infrastructure. Iterators excel when you're monitoring many entities but only care about a subset, or when you need to aggregate and transform data in the kernel.
Choose **/proc** when you need simple one-off queries, are debugging or prototyping where development speed matters more than runtime performance, want maximum portability across kernel versions (iterators require relatively recent kernels), or run in restricted environments where you can't load BPF programs.
The fundamental trade-off is processing location. Iterators push filtering and formatting into the kernel for efficiency and flexibility, while `/proc` keeps the kernel simple and does all processing in userspace. For production monitoring of complex systems, iterators usually win due to their performance benefits and programming flexibility.
## Summary and Next Steps
BPF iterators revolutionize how we export kernel data by enabling programmable, filtered iteration directly from BPF code. Instead of repeatedly reading and parsing `/proc` files, you write a BPF program that iterates kernel structures in-kernel, applies filtering at the source, and formats output exactly as needed. This eliminates massive overhead from syscalls, mode transitions, and userspace parsing while providing complete flexibility in output format.
Our dual-mode iterator demonstrates both task and file iteration, showing how one BPF program can export multiple views of kernel data with shared filtering logic. The kernel handles complex iteration mechanics while your BPF code focuses purely on filtering and formatting. Iterators integrate seamlessly with standard Unix tools through their file descriptor interface, making them composable building blocks for sophisticated monitoring pipelines.
> If you'd like to dive deeper into eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit our website at <https://eunomia.dev/tutorials/>.
## References
- **BPF Iterator Documentation:** <https://docs.kernel.org/bpf/bpf_iterators.html>
- **Kernel Iterator Selftests:** Linux kernel tree `tools/testing/selftests/bpf/*iter*.c`
- **Tutorial Repository:** <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/features/bpf_iters>
- **libbpf Iterator API:** <https://github.com/libbpf/libbpf>
- **BPF Helpers Manual:** <https://man7.org/linux/man-pages/man7/bpf-helpers.7.html>
Examples adapted from Linux kernel BPF selftests with educational enhancements. Requires Linux kernel 5.8+ for iterator support, BTF enabled, and libbpf. Complete source code available in the tutorial repository.