From 9ef395bd261df5eb32476d13fda9c26d504ac09b Mon Sep 17 00:00:00 2001 From: yuxi4096 Date: Tue, 21 Jul 2026 18:17:31 -0700 Subject: [PATCH] docs: normalize punctuation in tutorials 50 through 54 --- src/50-tcx/README.md | 22 +++++++++++----------- src/50-tcx/README.zh.md | 22 +++++++++++----------- src/51-tcp-quarantine/README.md | 12 ++++++------ src/51-tcp-quarantine/README.zh.md | 8 ++++---- src/52-fsession-latency/README.zh.md | 24 ++++++++++++------------ src/53-egress-pacer/README.md | 20 ++++++++++---------- src/53-egress-pacer/README.zh.md | 20 ++++++++++---------- src/54-exec-image-inspector/README.md | 20 ++++++++++---------- src/54-exec-image-inspector/README.zh.md | 18 +++++++++--------- 9 files changed, 83 insertions(+), 83 deletions(-) diff --git a/src/50-tcx/README.md b/src/50-tcx/README.md index a7114c5..3edc5d0 100644 --- a/src/50-tcx/README.md +++ b/src/50-tcx/README.md @@ -1,8 +1,8 @@ # eBPF Tutorial by Example 50: Composable Traffic Control with TCX Links -Imagine you need to run two BPF programs on every inbound packet: one that collects statistics, and another that decides whether to accept the packet. With classic TC attachment, you would create a qdisc, assign each program a numeric priority, and hope no other application on the system picks the same priority numbers. If your stats-gathering application crashes, its filter stays attached—possibly interfering with the classifier. If a third-party CNI plugin runs `tc filter del` to clean up its own filters, it might accidentally remove yours too. +Imagine you need to run two BPF programs on every inbound packet: one that collects statistics, and another that decides whether to accept the packet. With classic TC attachment, you would create a qdisc, assign each program a numeric priority, and hope no other application on the system picks the same priority numbers. If your stats-gathering application crashes, its filter stays attached, possibly interfering with the classifier. If a third-party CNI plugin runs `tc filter del` to clean up its own filters, it might accidentally remove yours too. -**TCX** (Traffic Control eXtension) solves these problems. Introduced by Daniel Borkmann and merged in Linux 6.6, TCX provides a link-based attachment model for the TC ingress and egress hooks. Programs gain automatic cleanup on file descriptor close, explicit ordering through `BPF_F_BEFORE` and `BPF_F_AFTER` flags, and immunity from accidental deletion by other processes—all without touching qdiscs or filter priorities. +**TCX** (Traffic Control eXtension) solves these problems. Introduced by Daniel Borkmann and merged in Linux 6.6, TCX provides a link-based attachment model for the TC ingress and egress hooks. Programs gain automatic cleanup on file descriptor close, explicit ordering through `BPF_F_BEFORE` and `BPF_F_AFTER` flags, and immunity from accidental deletion by other processes, all without touching qdiscs or filter priorities. In this tutorial, we build a minimal demonstration: two TCX ingress programs on the loopback interface, with controlled execution order and observable counters. @@ -14,7 +14,7 @@ In this tutorial, we build a minimal demonstration: two TCX ingress programs on Classic `tc` BPF attachment (`cls_bpf`) was designed when a single operator controlled the entire Traffic Control pipeline. To attach a BPF program, you create a `clsact` qdisc, then add filters with handles and priorities. This model breaks down when multiple independent applications need to coexist: -1. **No ownership model.** A filter is identified by its handle and priority, not by the process that created it. Any process with sufficient privilege can delete any filter—there is no protection against one application removing another's program. +1. **No ownership model.** A filter is identified by its handle and priority, not by the process that created it. Any process with sufficient privilege can delete any filter; there is no protection against one application removing another's program. 2. **Priority collisions.** Two applications might independently choose priority 100. The second attachment silently replaces the first, with no error and no warning. @@ -22,7 +22,7 @@ Classic `tc` BPF attachment (`cls_bpf`) was designed when a single operator cont 4. **Shared control plane.** Both the `tc` CLI and libbpf-based programs use netlink to manage filters. Your BPF application competes with every other tc user on the system for this shared namespace. -These problems became critical in projects like Cilium, where BPF datapaths must coexist with third-party CNI plugins, observability agents, and security tools—all attaching to the same interfaces. +These problems became critical in projects like Cilium, where BPF datapaths must coexist with third-party CNI plugins, observability agents, and security tools, all attaching to the same interfaces. ### The Solution: Link-Based Attachment @@ -103,7 +103,7 @@ Both programs receive a `struct __sk_buff *skb` parameter. This is the same sock - `skb->protocol`: EtherType in network byte order (hence `bpf_ntohs()`) - `skb->ifindex`: the interface index where the packet arrived -For packet data access, you would use `bpf_skb_load_bytes()` or direct packet pointers—but this demo focuses on attachment mechanics rather than packet parsing. +For packet data access, you would use `bpf_skb_load_bytes()` or direct packet pointers, but this demo focuses on attachment mechanics rather than packet parsing. ### Global Variables as Counters @@ -129,7 +129,7 @@ classifier_link = bpf_program__attach_tcx(skel->progs.tcx_classifier, ifindex, NULL); ``` -This attaches `tcx_classifier` to the TCX ingress hook on the specified interface. Passing `NULL` for options uses defaults—the program appends to the chain. The chain now contains one program. +This attaches `tcx_classifier` to the TCX ingress hook on the specified interface. Passing `NULL` for options uses defaults: the program appends to the chain. The chain now contains one program. ### Step 2: Insert the Second Program Before the First @@ -142,7 +142,7 @@ stats_link = bpf_program__attach_tcx(skel->progs.tcx_stats, ifindex, &before_opts); ``` -The `bpf_tcx_opts` structure specifies that `tcx_stats` should be inserted *before* `tcx_classifier`. The `.relative_fd` field identifies the reference point—the file descriptor of the already-attached classifier. After this call, the chain is: `tcx_stats` → `tcx_classifier`. +The `bpf_tcx_opts` structure specifies that `tcx_stats` should be inserted *before* `tcx_classifier`. The `.relative_fd` field identifies the reference point: the file descriptor of the already-attached classifier. After this call, the chain is: `tcx_stats` → `tcx_classifier`. You could achieve the same ordering with `BPF_F_AFTER` and a different reference point. The key insight is that you express order directly, not through numeric priorities that might collide. @@ -160,7 +160,7 @@ err = bpf_prog_query_opts(ifindex, BPF_TCX_INGRESS, &query); After attachment, the loader queries the kernel for the live chain state. The result includes: -- **`revision`**: A monotonically increasing counter that increments on each chain modification. You can pass this as `expected_revision` when attaching to ensure the chain hasn't changed since you last observed it—useful for atomic multi-step updates. +- **`revision`**: A monotonically increasing counter that increments on each chain modification. You can pass this as `expected_revision` when attaching to ensure the chain hasn't changed since you last observed it (useful for atomic multi-step updates). - **`prog_ids[]`**: BPF program IDs in execution order. - **`link_ids[]`**: Corresponding BPF link IDs. @@ -219,7 +219,7 @@ Counters: **Reading the output:** -- **Revision 2**: The chain was modified twice—once when `tcx_classifier` attached (revision 1), and once when `tcx_stats` was inserted before it (revision 2). +- **Revision 2**: The chain was modified twice: once when `tcx_classifier` attached (revision 1), and once when `tcx_stats` was inserted before it (revision 2). - **Slot ordering**: Slot 0 is `tcx_stats` (the program we inserted with `BPF_F_BEFORE`); slot 1 is `tcx_classifier`. - **Protocol 0x0800**: IPv4 (the generated UDP packet). - **Length 46**: The 20-byte payload "tcx tutorial packet" plus headers. @@ -232,7 +232,7 @@ Counters: ## Comparison with Lesson 20 (Classic TC) -[Lesson 20](../20-tc/README.md) teaches the classic TC path: creating a `clsact` qdisc, attaching a `SEC("tc")` program as a filter, and inspecting packets with `__sk_buff`. That lesson remains valuable because the **data-plane model** is identical—TCX programs use the same context structure and the same helpers for packet access. +[Lesson 20](../20-tc/README.md) teaches the classic TC path: creating a `clsact` qdisc, attaching a `SEC("tc")` program as a filter, and inspecting packets with `__sk_buff`. That lesson remains valuable because the **data-plane model** is identical: TCX programs use the same context structure and the same helpers for packet access. TCX replaces the **control plane**: @@ -249,7 +249,7 @@ For new libbpf-based networking tools, TCX is the recommended attachment method. ## Summary -TCX modernizes TC program attachment by replacing qdisc-based plumbing with BPF link semantics. In this tutorial, we attached two ingress programs, controlled their execution order with `BPF_F_BEFORE`, queried the live chain state, and verified correct execution by observing counters. TCX provides safe ownership, explicit ordering, revision-aware updates, and backward compatibility with classic TC—making it the foundation for composable, multi-program traffic control in modern eBPF applications. +TCX modernizes TC program attachment by replacing qdisc-based plumbing with BPF link semantics. In this tutorial, we attached two ingress programs, controlled their execution order with `BPF_F_BEFORE`, queried the live chain state, and verified correct execution by observing counters. TCX provides safe ownership, explicit ordering, revision-aware updates, and backward compatibility with classic TC, making it the foundation for composable, multi-program traffic control in modern eBPF applications. For more eBPF tutorials, visit our repository at or . diff --git a/src/50-tcx/README.zh.md b/src/50-tcx/README.zh.md index 62dfea4..026d31b 100644 --- a/src/50-tcx/README.zh.md +++ b/src/50-tcx/README.zh.md @@ -1,8 +1,8 @@ # eBPF 入门实践教程第五十篇:使用 TCX Link 实现可组合的流量控制 -假设你需要在每个入站数据包上运行两个 BPF 程序:一个收集统计信息,另一个决定是否接受该数据包。使用经典 TC 挂载方式,你需要创建 qdisc,为每个程序分配一个数字优先级,然后祈祷系统上没有其他应用选择相同的优先级。如果你的统计收集应用崩溃了,它的 filter 会继续挂在那里——可能会干扰分类器。如果某个第三方 CNI 插件执行 `tc filter del` 清理自己的 filter,可能会顺带把你的也删掉。 +假设你需要在每个入站数据包上运行两个 BPF 程序:一个收集统计信息,另一个决定是否接受该数据包。使用经典 TC 挂载方式,你需要创建 qdisc,为每个程序分配一个数字优先级,然后祈祷系统上没有其他应用选择相同的优先级。如果你的统计收集应用崩溃了,它的 filter 会继续挂在那里,可能会干扰分类器。如果某个第三方 CNI 插件执行 `tc filter del` 清理自己的 filter,可能会顺带把你的也删掉。 -**TCX**(Traffic Control eXtension)解决了这些问题。它由 Daniel Borkmann 开发,于 Linux 6.6 合入内核,为 TC ingress 和 egress 挂载点提供了基于 link 的挂载模型。程序获得了文件描述符关闭时自动清理的能力、通过 `BPF_F_BEFORE` 和 `BPF_F_AFTER` 标志的显式排序,以及不被其他进程意外删除的保护——所有这些都不需要接触 qdisc 或 filter 优先级。 +**TCX**(Traffic Control eXtension)解决了这些问题。它由 Daniel Borkmann 开发,于 Linux 6.6 合入内核,为 TC ingress 和 egress 挂载点提供了基于 link 的挂载模型。程序获得了文件描述符关闭时自动清理的能力、通过 `BPF_F_BEFORE` 和 `BPF_F_AFTER` 标志的显式排序,以及不被其他进程意外删除的保护,所有这些都不需要接触 qdisc 或 filter 优先级。 本教程构建一个最小化的演示:在 loopback 接口上挂载两个 TCX ingress 程序,控制它们的执行顺序,并通过计数器观察执行情况。 @@ -14,7 +14,7 @@ 经典的 `tc` BPF 挂载(`cls_bpf`)是在单一操作者控制整个 Traffic Control 流水线的时代设计的。要挂载一个 BPF 程序,你需要创建 `clsact` qdisc,然后添加带有 handle 和 priority 的 filter。当多个独立应用需要共存时,这种模型就崩溃了: -1. **没有所有权模型。** Filter 通过 handle 和 priority 标识,而不是创建它的进程。任何有足够权限的进程都能删除任何 filter——一个应用删除另一个应用的程序毫无保护可言。 +1. **没有所有权模型。** Filter 通过 handle 和 priority 标识,而不是创建它的进程。任何有足够权限的进程都能删除任何 filter,一个应用删除另一个应用的程序毫无保护可言。 2. **优先级冲突。** 两个应用可能独立地选择优先级 100。第二次挂载会悄无声息地替换第一次,没有错误,没有警告。 @@ -22,7 +22,7 @@ 4. **共享控制面。** `tc` CLI 和基于 libbpf 的程序都使用 netlink 来管理 filter。你的 BPF 应用和系统上所有其他 tc 用户竞争这个共享命名空间。 -这些问题在 Cilium 等项目中变得非常严重,BPF 数据面必须与第三方 CNI 插件、可观测性 agent 和安全工具共存——它们都在同一个接口上挂载。 +这些问题在 Cilium 等项目中变得非常严重,BPF 数据面必须与第三方 CNI 插件、可观测性 agent 和安全工具共存,它们都在同一个接口上挂载。 ### 解决方案:基于 Link 的挂载 @@ -103,7 +103,7 @@ int tcx_classifier(struct __sk_buff *skb) - `skb->protocol`:网络字节序的 EtherType(因此需要 `bpf_ntohs()`) - `skb->ifindex`:数据包到达的接口索引 -要访问数据包内容,可以使用 `bpf_skb_load_bytes()` 或直接数据包指针——但本演示聚焦于挂载机制而非数据包解析。 +要访问数据包内容,可以使用 `bpf_skb_load_bytes()` 或直接数据包指针,但本演示聚焦于挂载机制而非数据包解析。 ### 全局变量作为计数器 @@ -129,7 +129,7 @@ classifier_link = bpf_program__attach_tcx(skel->progs.tcx_classifier, ifindex, NULL); ``` -这将 `tcx_classifier` 挂到指定接口的 TCX ingress 挂载点。`NULL` 选项表示使用默认值——程序追加到链末尾。此时链中有一个程序。 +这将 `tcx_classifier` 挂到指定接口的 TCX ingress 挂载点。`NULL` 选项表示使用默认值,程序追加到链末尾。此时链中有一个程序。 ### 第二步:把第二个程序插到第一个前面 @@ -142,7 +142,7 @@ stats_link = bpf_program__attach_tcx(skel->progs.tcx_stats, ifindex, &before_opts); ``` -`bpf_tcx_opts` 结构体指定 `tcx_stats` 应该插入到 `tcx_classifier` *前面*。`.relative_fd` 字段标识参考点——已挂载的 classifier 的文件描述符。这个调用之后,链的顺序是:`tcx_stats` → `tcx_classifier`。 +`bpf_tcx_opts` 结构体指定 `tcx_stats` 应该插入到 `tcx_classifier` *前面*。`.relative_fd` 字段标识参考点:已挂载的 classifier 的文件描述符。这个调用之后,链的顺序是:`tcx_stats` → `tcx_classifier`。 你可以用 `BPF_F_AFTER` 配合不同的参考点达到同样的排序效果。关键是你直接表达顺序,而不是通过可能冲突的数字优先级。 @@ -160,7 +160,7 @@ err = bpf_prog_query_opts(ifindex, BPF_TCX_INGRESS, &query); 挂载完成后,加载器查询内核中链的实时状态。结果包括: -- **`revision`**:每次链被修改时递增的单调计数器。挂载时可以把这个值作为 `expected_revision` 传入,确保链在你上次观察之后没有变化——这对原子多步更新很有用。 +- **`revision`**:每次链被修改时递增的单调计数器。挂载时可以把这个值作为 `expected_revision` 传入,确保链在你上次观察之后没有变化,这对原子多步更新很有用。 - **`prog_ids[]`**:按执行顺序排列的 BPF 程序 ID。 - **`link_ids[]`**:对应的 BPF link ID。 @@ -219,7 +219,7 @@ Counters: **输出解读:** -- **Revision 2**:链被修改了两次——`tcx_classifier` 挂载时(revision 1),`tcx_stats` 插入到它前面时(revision 2)。 +- **Revision 2**:链被修改了两次,`tcx_classifier` 挂载时(revision 1),`tcx_stats` 插入到它前面时(revision 2)。 - **Slot 顺序**:Slot 0 是 `tcx_stats`(用 `BPF_F_BEFORE` 插入的程序);slot 1 是 `tcx_classifier`。 - **Protocol 0x0800**:IPv4(生成的 UDP 包)。 - **Length 46**:20 字节的载荷 "tcx tutorial packet" 加上报头。 @@ -232,7 +232,7 @@ Counters: ## 与第 20 课(经典 TC)的对比 -[第 20 课](../20-tc/README.zh.md)讲的是经典 TC 路径:创建 `clsact` qdisc,挂载 `SEC("tc")` 程序作为 filter,用 `__sk_buff` 检查数据包。那一课仍然有价值,因为**数据面模型**是相同的——TCX 程序使用相同的上下文结构和相同的 helper 来访问数据包。 +[第 20 课](../20-tc/README.zh.md)讲的是经典 TC 路径:创建 `clsact` qdisc,挂载 `SEC("tc")` 程序作为 filter,用 `__sk_buff` 检查数据包。那一课仍然有价值,因为**数据面模型**是相同的,TCX 程序使用相同的上下文结构和相同的 helper 来访问数据包。 TCX 替换的是**控制面**: @@ -249,7 +249,7 @@ TCX 替换的是**控制面**: ## 总结 -TCX 用 BPF link 语义取代了基于 qdisc 的管道,使 TC 程序挂载现代化。本教程中,我们挂载了两个 ingress 程序,用 `BPF_F_BEFORE` 控制了它们的执行顺序,查询了实时链状态,并通过观察计数器验证了正确执行。TCX 提供了安全的所有权、显式排序、revision 感知的更新以及与经典 TC 的向后兼容——使其成为现代 eBPF 应用中可组合、多程序流量控制的基石。 +TCX 用 BPF link 语义取代了基于 qdisc 的管道,使 TC 程序挂载现代化。本教程中,我们挂载了两个 ingress 程序,用 `BPF_F_BEFORE` 控制了它们的执行顺序,查询了实时链状态,并通过观察计数器验证了正确执行。TCX 提供了安全的所有权、显式排序、revision 感知的更新以及与经典 TC 的向后兼容,使其成为现代 eBPF 应用中可组合、多程序流量控制的基石。 更多 eBPF 教程请访问我们的仓库 或 。 diff --git a/src/51-tcp-quarantine/README.md b/src/51-tcp-quarantine/README.md index a481c70..b1ac11d 100644 --- a/src/51-tcp-quarantine/README.md +++ b/src/51-tcp-quarantine/README.md @@ -2,13 +2,13 @@ ## The Problem -An outbound IP address has just been added to a threat-intelligence blocklist. Your firewall now rejects new connections to that address. But when you check `netstat`, you find a server already has an active TCP session to the same destination. You need to close that exact connection immediately—without killing the process, disrupting unrelated traffic, or waiting for the session to time out. +An outbound IP address has just been added to a threat-intelligence blocklist. Your firewall now rejects new connections to that address. But when you check `netstat`, you find a server already has an active TCP session to the same destination. You need to close that exact connection immediately, without killing the process, disrupting unrelated traffic, or waiting for the session to time out. This seemingly simple requirement is surprisingly difficult to achieve with traditional tools: - **Killing the process** disrupts all its connections, not just the problematic one. Worse, the process may immediately reconnect to the same destination after restarting. -- **Firewall rules** block new connections but have no effect on sessions that are already established. Even `iptables -m state --state ESTABLISHED` can only match existing connections for filtering future packets—it cannot tear down an active session. +- **Firewall rules** block new connections but have no effect on sessions that are already established. Even `iptables -m state --state ESTABLISHED` can only match existing connections for filtering future packets; it cannot tear down an active session. - **User-space tools** like `ss --kill` or `tcpkill` work by reading `/proc/net/tcp` and then injecting TCP RST packets. This approach suffers from a race condition: the socket state can change between reading the list and sending the RST. RST injection also requires guessing the correct sequence number and may fail against encrypted tunnels or certain network configurations. @@ -24,7 +24,7 @@ Before diving into the code, let's understand the two kernel features that make ### BPF Iterators -A BPF iterator is a special type of BPF program that the kernel invokes repeatedly—once for each element in some kernel data structure. Unlike tracepoints or kprobes that fire when specific events occur, iterators let you actively scan kernel state on demand. +A BPF iterator is a special type of BPF program that the kernel invokes repeatedly, once for each element in some kernel data structure. Unlike tracepoints or kprobes that fire when specific events occur, iterators let you actively scan kernel state on demand. The TCP iterator (`SEC("iter/tcp")`) iterates over all TCP sockets in the network namespace of the process that triggers it. User space initiates a scan by reading from an iterator file descriptor. Each `read()` call causes the kernel to invoke your BPF callback for a batch of sockets. When `read()` returns zero (EOF), the traversal is complete. @@ -34,7 +34,7 @@ Because the kernel controls the iteration and holds appropriate locks while invo Kfuncs (kernel functions) are a mechanism for BPF programs to call specific kernel functions directly. Unlike the older BPF helper functions which have a fixed ABI, kfuncs are regular kernel functions that are explicitly marked as callable from BPF. They can do things that helpers cannot, including operations that modify kernel state in complex ways. -The `bpf_sock_destroy` kfunc, introduced in Linux 6.5 (commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`), allows a BPF program running in an iterator context to forcibly close a socket. The kernel executes the full protocol-specific teardown: removing the socket from hash tables, sending FIN/RST as appropriate, and releasing resources. This happens synchronously—by the time the call returns, the socket is destroyed. +The `bpf_sock_destroy` kfunc, introduced in Linux 6.5 (commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`), allows a BPF program running in an iterator context to forcibly close a socket. The kernel executes the full protocol-specific teardown: removing the socket from hash tables, sending FIN/RST as appropriate, and releasing resources. This happens synchronously: by the time the call returns, the socket is destroyed. ## How the Tool Works @@ -141,7 +141,7 @@ int quarantine_tcp(struct bpf_iter__tcp *ctx) Let's break down the key elements: -**Configuration variables**: The three `const volatile` variables (`target_addr`, `target_port`, `apply`) live in the BPF program's read-only data section (`.rodata`). User space writes to them after opening the skeleton but before loading the program. The `const volatile` pattern tells the BPF verifier these are compile-time constants while still allowing user space to set them. This enables the verifier to optimize code paths—for example, when `apply` is false, the entire destruction branch can be eliminated. +**Configuration variables**: The three `const volatile` variables (`target_addr`, `target_port`, `apply`) live in the BPF program's read-only data section (`.rodata`). User space writes to them after opening the skeleton but before loading the program. The `const volatile` pattern tells the BPF verifier these are compile-time constants while still allowing user space to set them. This enables the verifier to optimize code paths; for example, when `apply` is false, the entire destruction branch can be eliminated. **Kfunc declaration**: The line `extern int bpf_sock_destroy(struct sock_common *sock) __ksym;` declares `bpf_sock_destroy` as an external kernel symbol. The `__ksym` annotation tells the loader to resolve this at load time by looking up the kernel function. @@ -358,7 +358,7 @@ The key workflow: 5. **Read results**: After iteration, we read statistics from `skel->bss->stats`. The BSS section is automatically memory-mapped, so these reads just access shared memory. -The iterator's `read()` loop discards the buffer contents because this tool doesn't produce output through the iterator interface—it only uses the traversal to drive the BPF callback. Other iterator programs might write data to the iterator (e.g., formatted socket information), but ours communicates exclusively through the BSS statistics. +The iterator's `read()` loop discards the buffer contents because this tool doesn't produce output through the iterator interface; it only uses the traversal to drive the BPF callback. Other iterator programs might write data to the iterator (e.g., formatted socket information), but ours communicates exclusively through the BSS statistics. ## Network Namespace Boundary diff --git a/src/51-tcp-quarantine/README.zh.md b/src/51-tcp-quarantine/README.zh.md index 57cc787..f0c5a30 100644 --- a/src/51-tcp-quarantine/README.zh.md +++ b/src/51-tcp-quarantine/README.zh.md @@ -2,7 +2,7 @@ ## 问题场景 -某个出站 IP 地址刚刚被加入威胁情报黑名单,防火墙已经更新规则阻止新连接。但当你用 `netstat` 检查时,发现服务器上已经存在一条到该地址的活跃 TCP 会话。你需要立即关闭这条连接——但不能杀掉进程,不能影响其他流量,也不能等待会话超时。 +某个出站 IP 地址刚刚被加入威胁情报黑名单,防火墙已经更新规则阻止新连接。但当你用 `netstat` 检查时,发现服务器上已经存在一条到该地址的活跃 TCP 会话。你需要立即关闭这条连接,但不能杀掉进程,不能影响其他流量,也不能等待会话超时。 这个看似简单的需求,用传统工具却很难实现: @@ -34,7 +34,7 @@ TCP 迭代器(`SEC("iter/tcp")`)会遍历触发它的进程所在网络命 Kfunc(内核函数)是 BPF 程序直接调用特定内核函数的一种机制。与老式的 BPF helper 函数(有固定 ABI)不同,kfunc 是普通的内核函数,只是被显式标记为可从 BPF 调用。它们可以执行 helper 无法完成的操作,包括以复杂方式修改内核状态。 -`bpf_sock_destroy` kfunc 在 Linux 6.5 引入(commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`),允许运行在迭代器上下文中的 BPF 程序强制关闭套接字。内核会执行完整的协议特定拆除过程:从哈希表移除套接字、根据需要发送 FIN/RST、释放资源。这是同步操作——调用返回时,套接字已被销毁。 +`bpf_sock_destroy` kfunc 在 Linux 6.5 引入(commit `4ddbcb886268af8d12a23e6640b39d1d9c652b1b`),允许运行在迭代器上下文中的 BPF 程序强制关闭套接字。内核会执行完整的协议特定拆除过程:从哈希表移除套接字、根据需要发送 FIN/RST、释放资源。这是同步操作:调用返回时,套接字已被销毁。 ## 工具工作原理 @@ -141,7 +141,7 @@ int quarantine_tcp(struct bpf_iter__tcp *ctx) 关键要素解读: -**配置变量**:三个 `const volatile` 变量(`target_addr`、`target_port`、`apply`)位于 BPF 程序的只读数据段(`.rodata`)。用户态在打开 skeleton 之后、加载程序之前写入它们。`const volatile` 模式告诉 BPF 验证器这些是编译期常量,同时仍允许用户态设置。这使验证器能够优化代码路径——例如当 `apply` 为 false 时,整个销毁分支可以被消除。 +**配置变量**:三个 `const volatile` 变量(`target_addr`、`target_port`、`apply`)位于 BPF 程序的只读数据段(`.rodata`)。用户态在打开 skeleton 之后、加载程序之前写入它们。`const volatile` 模式告诉 BPF 验证器这些是编译期常量,同时仍允许用户态设置。这使验证器能够优化代码路径,例如当 `apply` 为 false 时,整个销毁分支可以被消除。 **Kfunc 声明**:`extern int bpf_sock_destroy(struct sock_common *sock) __ksym;` 这行将 `bpf_sock_destroy` 声明为外部内核符号。`__ksym` 注解告诉加载器在加载时通过查找内核函数来解析它。 @@ -358,7 +358,7 @@ cleanup: 5. **读取结果**:迭代完成后,我们从 `skel->bss->stats` 读取统计数据。BSS 段是自动内存映射的,所以这些读取只是访问共享内存。 -迭代器的 `read()` 循环丢弃缓冲区内容,因为这个工具不通过迭代器接口产生输出——它只是利用遍历来驱动 BPF 回调。其他迭代器程序可能会向迭代器写入数据(例如格式化的套接字信息),但我们的工具完全通过 BSS 统计数据进行通信。 +迭代器的 `read()` 循环丢弃缓冲区内容,因为这个工具不通过迭代器接口产生输出,它只是利用遍历来驱动 BPF 回调。其他迭代器程序可能会向迭代器写入数据(例如格式化的套接字信息),但我们的工具完全通过 BSS 统计数据进行通信。 ## 网络命名空间边界 diff --git a/src/52-fsession-latency/README.zh.md b/src/52-fsession-latency/README.zh.md index 4c1e218..8d0ed80 100644 --- a/src/52-fsession-latency/README.zh.md +++ b/src/52-fsession-latency/README.zh.md @@ -12,7 +12,7 @@ ### 传统方案:两个程序配合哈希表 -最常见的做法是使用两个独立的 BPF 程序——一个附加到函数进入(fentry),一个附加到函数返回(fexit)。进入程序把时间戳存入一个以线程 ID 为键的哈希表;返回程序查找时间戳、计算延迟、删除条目: +最常见的做法是使用两个独立的 BPF 程序,一个附加到函数进入(fentry),一个附加到函数返回(fexit)。进入程序把时间戳存入一个以线程 ID 为键的哈希表;返回程序查找时间戳、计算延迟、删除条目: ``` 进入程序: 返回程序: @@ -27,15 +27,15 @@ - **哈希表开销**:每次函数调用都需要在进入时插入、在返回时查找并删除。对于像 `vfs_read` 这样的高频函数,开销会累积。 - **状态泄漏**:如果线程在进入后、返回前被杀死(比如 `kill -9`),哈希表条目永远不会被删除,造成内存泄漏。 -- **没有内在关联**:两个程序完全独立,唯一的联系是外部哈希表——没有任何保证它们追踪的是同一次函数调用。 +- **没有内在关联**:两个程序完全独立,唯一的联系是外部哈希表,没有任何保证它们追踪的是同一次函数调用。 ### kprobe/kretprobe:相同模式,更高开销 -kprobe 机制也是两个程序的结构,同样需要外部哈希表来关联。此外,kprobe 通过软件断点机制工作(用中断指令替换第一条指令),开销比 fentry 更大——后者使用内核的 ftrace 基础设施和 JIT 优化的调用序列。 +kprobe 机制也是两个程序的结构,同样需要外部哈希表来关联。此外,kprobe 通过软件断点机制工作(用中断指令替换第一条指令),开销比 fentry 更大,后者使用内核的 ftrace 基础设施和 JIT 优化的调用序列。 ### 用户态采样:统计性的,不是精确的 -像 `perf record` 这样的工具周期性采样堆栈,可以构建时间花在哪里的统计概貌。然而,采样无法测量单次函数调用的延迟。如果你需要捕获尾部延迟事件——偶尔出现的 100ms 读操作导致超时——统计采样可能完全错过它们。 +像 `perf record` 这样的工具周期性采样堆栈,可以构建时间花在哪里的统计概貌。然而,采样无法测量单次函数调用的延迟。如果你需要捕获尾部延迟事件(偶尔出现的 100ms 读操作导致超时),统计采样可能完全错过它们。 ## fsession 的解决方案 @@ -70,7 +70,7 @@ __u64 *started = bpf_session_cookie(ctx); *started = bpf_ktime_get_ns(); ``` -cookie 从进入到返回一直存在,之后自动清理——不可能泄漏。 +cookie 从进入到返回一直存在,之后自动清理,不可能泄漏。 ### 返回阶段可以访问函数参数 @@ -167,7 +167,7 @@ BPF 程序中的 `const volatile` 变量有特殊语义。它们被放在 `.roda `stats` 是 `.bss` 段的全局变量,程序运行后用户空间可以直接读取。 -ring buffer(`events`)大小是 256 KB——足够容纳数千个事件才会溢出。 +ring buffer(`events`)大小是 256 KB,足够容纳数千个事件才会溢出。 ```c /* @@ -179,7 +179,7 @@ extern bool bpf_session_is_return(void *ctx) __ksym; extern __u64 *bpf_session_cookie(void *ctx) __ksym; ``` -这是 **kfunc** 声明——导出给 BPF 程序调用的内核函数。`__ksym` 属性告诉验证器在加载时从运行中的内核解析这些符号,而不是期望它们在 BPF 对象中定义。 +这是 **kfunc** 声明,导出给 BPF 程序调用的内核函数。`__ksym` 属性告诉验证器在加载时从运行中的内核解析这些符号,而不是期望它们在 BPF 对象中定义。 ```c SEC("fsession/vfs_read") @@ -223,9 +223,9 @@ TGID 在 `bpf_get_current_pid_tgid()` 返回值的高 32 位;低 32 位是线 return 0; ``` -**返回阶段**:如果 cookie 是 0,说明进入阶段已经过滤了这次调用——直接返回。否则计算延迟并更新聚合计数器。`__sync_fetch_and_add` 提供原子更新,因为多个 CPU 可能同时执行这个程序。 +**返回阶段**:如果 cookie 是 0,说明进入阶段已经过滤了这次调用,直接返回。否则计算延迟并更新聚合计数器。`__sync_fetch_and_add` 提供原子更新,因为多个 CPU 可能同时执行这个程序。 -如果延迟低于阈值,到此为止——调用被计数但不产生事件。这让 ring buffer 只关注慢调用。 +如果延迟低于阈值,到此为止,调用被计数但不产生事件。这让 ring buffer 只关注慢调用。 ```c __sync_fetch_and_add(&stats.slow, 1); @@ -248,7 +248,7 @@ TGID 在 `bpf_get_current_pid_tgid()` 返回值的高 32 位;低 32 位是线 对于慢调用,递增 `slow` 计数器并尝试在 ring buffer 中预留空间。如果预留失败(缓冲区满),递增 `dropped` 让用户知道有事件丢失。成功后填入各字段并提交。 -注意 `count`(请求的字节数)可以直接访问——不需要在进入时存储。这就是 fsession 的优势:函数参数在返回阶段仍然可用。 +注意 `count`(请求的字节数)可以直接访问,不需要在进入时存储。这就是 fsession 的优势:函数参数在返回阶段仍然可用。 ### 用户空间加载器 @@ -384,7 +384,7 @@ sudo make test - **直方图**:用 `BPF_MAP_TYPE_ARRAY` 的延迟直方图替代逐事件上报 - **cgroup 过滤**:添加 cgroup ID 检查实现容器感知的追踪 -`dropped` 计数器仍然是 ring buffer 压力的信号——如果它在增长,要么增大缓冲区大小,要么提高阈值减少事件量。 +`dropped` 计数器仍然是 ring buffer 压力的信号。如果它在增长,要么增大缓冲区大小,要么提高阈值减少事件量。 ## 总结 @@ -392,7 +392,7 @@ sudo make test 1. **单个程序**:一个 BPF 程序处理进入和返回,用 `bpf_session_is_return()` 区分阶段 2. **内置关联**:8 字节的 session cookie 替代外部哈希表在两个阶段间传递数据 -3. **无状态泄漏**:内核管理 cookie 生命周期——不需要清理,不可能泄漏 +3. **无状态泄漏**:内核管理 cookie 生命周期,不需要清理,不可能泄漏 4. **返回时可访问参数**:函数参数在返回阶段仍然可用,无需显式存储 模式很简单:检查 `bpf_session_is_return()`,用 `bpf_session_cookie()` 管理调用级状态,直接访问参数和返回值。这个模式适用于任何需要关联函数进入和退出的场景。 diff --git a/src/53-egress-pacer/README.md b/src/53-egress-pacer/README.md index 6b8acd5..1ef6edf 100644 --- a/src/53-egress-pacer/README.md +++ b/src/53-egress-pacer/README.md @@ -2,11 +2,11 @@ ## The Problem: Testing Under Bandwidth Constraints -Suppose you need to test how your application behaves when bandwidth is limited. You create a veth pair to simulate a network link and want to cap egress at 64 Kbit/s while observing what happens when the queue fills up and packets start dropping. Traditional approaches—complex tc configurations or user-space proxies—work but are heavyweight for a quick validation. +Suppose you need to test how your application behaves when bandwidth is limited. You create a veth pair to simulate a network link and want to cap egress at 64 Kbit/s while observing what happens when the queue fills up and packets start dropping. Traditional approaches (complex tc configurations or user-space proxies) work but are heavyweight for a quick validation. Linux 6.16 introduced a new option: BPF qdisc. Instead of configuring existing schedulers, you can implement a complete queuing discipline directly in eBPF. This tutorial builds a FIFO rate limiter that demonstrates the full qdisc lifecycle: registering as the root qdisc, managing packet ownership through enqueue and dequeue, computing transmission times, using the watchdog timer for scheduling, and cleaning up when removed. -This example is designed for controlled interfaces like veth, TAP, or IFB—environments where you have full control and can safely experiment with packet scheduling. +This example is designed for controlled interfaces like veth, TAP, or IFB, environments where you have full control and can safely experiment with packet scheduling. > Complete source code: @@ -14,7 +14,7 @@ This example is designed for controlled interfaces like veth, TAP, or IFB—envi To see what BPF qdisc changes, compare it with three common approaches. -**Built-in schedulers are fixed algorithms.** tc provides TBF (Token Bucket Filter), HTB (Hierarchical Token Bucket), and others, but these are predefined behaviors in the kernel. If you need something custom—prioritizing by application type, adjusting rates based on real-time metrics—you must either patch the kernel or chain multiple tc classifiers together. +**Built-in schedulers are fixed algorithms.** tc provides TBF (Token Bucket Filter), HTB (Hierarchical Token Bucket), and others, but these are predefined behaviors in the kernel. If you need something custom (prioritizing by application type, adjusting rates based on real-time metrics), you must either patch the kernel or chain multiple tc classifiers together. **TC BPF programs cannot control timing.** Regular TC BPF programs can inspect packets and decide whether to pass or drop them, but the underlying qdisc still controls when packets actually transmit. [Lesson 20](https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/20-tc) shows this pattern: BPF decides pass/drop, but not "send this packet at time T." @@ -36,7 +36,7 @@ Here is the lifecycle: 4. **Reset**: When the qdisc is removed, the kernel calls `reset`. You iterate through any remaining packets, release their resources, and zero the counters. -Throughout this lifecycle, your BPF program owns the packets. From receiving an `skb` in `enqueue`, through holding it in your data structure, to returning it in `dequeue` or freeing it in `reset`—the BPF program is responsible. +Throughout this lifecycle, your BPF program owns the packets. From receiving an `skb` in `enqueue`, through holding it in your data structure, to returning it in `dequeue` or freeing it in `reset`, the BPF program is responsible. ![egress_pacer data flow: from configuration, attachment, enqueue, BPF FIFO, dequeue to transmit path, including policy-drop branch, watchdog loop, and reset lifecycle](https://github.com/eunomia-bpf/bpf-developer-tutorial/raw/main/src/53-egress-pacer/egress-pacer-flow.png) @@ -69,13 +69,13 @@ struct pacer_stats { - `enqueued` / `dequeued`: Packets that successfully entered and left the FIFO. - `policy_dropped`: Packets dropped at enqueue because the queue was full or allocation failed. -- `cleanup_dropped`: Packets still in the queue when reset runs—these were queued but never transmitted. +- `cleanup_dropped`: Packets still in the queue when reset runs. These were queued but never transmitted. - `bytes_dequeued`: Total bytes actually transmitted. - `max_qlen`: Peak queue depth observed. ### Compatibility Header -BPF qdisc uses "graph-object" kfuncs—kernel functions for managing BPF-owned linked lists and objects. This local header provides the necessary declarations: +BPF qdisc uses "graph-object" kfuncs, kernel functions for managing BPF-owned linked lists and objects. This local header provides the necessary declarations: ```c /* SPDX-License-Identifier: GPL-2.0 */ @@ -347,7 +347,7 @@ The `private(A)` macro places the lock, list head, and departure timestamp in a 6. Under the spin lock, set the packet's eligible time. If the queue was idle (`next_departure_ns` is in the past), this packet can leave immediately; otherwise it waits behind the previous departure. 7. Push the node onto the FIFO, update counters, unlock, and return success. -The `bpf_qdisc_skb_drop` call handles dropped packets—it adds the `skb` to the kernel's free list and updates qdisc statistics. +The `bpf_qdisc_skb_drop` call handles dropped packets: it adds the `skb` to the kernel's free list and updates qdisc statistics. #### The dequeue callback @@ -358,7 +358,7 @@ The `bpf_qdisc_skb_drop` call handles dropped packets—it adds the `skb` to the 3. Check if the current time has reached `eligible_ns`. If not, push the node back to the front, schedule a watchdog timer for `eligible_ns`, and return NULL. The kernel will call dequeue again when the timer fires. 4. If ready, extract the `skb` from the node using `bpf_kptr_xchg`, decrement counters, free the node, update statistics, and return the `skb`. -The watchdog mechanism (`bpf_qdisc_watchdog_schedule`) is how BPF qdisc implements transmission timing—you tell the kernel when to wake you, and it calls dequeue at that time. +The watchdog mechanism (`bpf_qdisc_watchdog_schedule`) is how BPF qdisc implements transmission timing: you tell the kernel when to wake you, and it calls dequeue at that time. #### The reset callback @@ -713,7 +713,7 @@ TEST-SUMMARY attempts=40 received=9 send_errors=31 span_ms=1024 observed_kbps=64 PASS: conflict refusal, bounded drops, pacing, accounting, normal/signal cleanup, and SIGKILL recovery succeeded ``` -What happened: the test sent 40 raw Ethernet frames (1024 bytes each, EtherType `0x88B5`) into a 64 Kbit/s queue limited to 8 packets. Nine packets made it through the queue, spaced over 1024ms—matching the expected rate. The other 31 sends hit the queue limit and went to `policy_dropped`. Exact numbers vary by system timing. +What happened: the test sent 40 raw Ethernet frames (1024 bytes each, EtherType `0x88B5`) into a 64 Kbit/s queue limited to 8 packets. Nine packets made it through the queue, spaced over 1024ms, matching the expected rate. The other 31 sends hit the queue limit and went to `policy_dropped`. Exact numbers vary by system timing. The test also exercises edge cases: - Pre-installs a `pfifo` to confirm the loader refuses to replace existing qdiscs. @@ -774,7 +774,7 @@ This tutorial demonstrated BPF qdisc through a complete FIFO rate limiter: - **dequeue** uses the watchdog timer to wake at the right moment and returns packets to the kernel. - **reset** frees any packets still queued when the qdisc is removed. -These three phases—receiving packets, timing their release, and cleaning up—form a reusable pattern for custom scheduling disciplines. +These three phases (receiving packets, timing their release, and cleaning up) form a reusable pattern for custom scheduling disciplines. This particular scheduler implements a single aggregate FIFO with fixed rate and queue limit set at load time. It is intentionally simple, suited for validating correctness on controlled interfaces. A production scheduler could build on this foundation to add fairness, dynamic policies, or state persistence across restarts. diff --git a/src/53-egress-pacer/README.zh.md b/src/53-egress-pacer/README.zh.md index 9998a89..943ca44 100644 --- a/src/53-egress-pacer/README.zh.md +++ b/src/53-egress-pacer/README.zh.md @@ -2,11 +2,11 @@ ## 问题背景:带宽受限场景下的测试 -假设你需要测试应用程序在低带宽条件下的行为。你创建了一对 veth 接口模拟网络链路,想把出口速率限制到 64 Kbit/s,同时观察队列满时的丢包情况。传统方案——复杂的 tc 配置或用户态代理——虽然可行,但对于快速验证来说过于笨重。 +假设你需要测试应用程序在低带宽条件下的行为。你创建了一对 veth 接口模拟网络链路,想把出口速率限制到 64 Kbit/s,同时观察队列满时的丢包情况。传统方案(复杂的 tc 配置或用户态代理)虽然可行,但对于快速验证来说过于笨重。 Linux 6.16 提供了一种新选择:BPF qdisc。你不再需要配置现有的调度器,而是可以用 eBPF 直接实现一个完整的排队规则。本教程构建一个 FIFO 限速器,演示 qdisc 的完整生命周期:注册为 root qdisc、在入队和出队过程中管理报文所有权、计算发送时间、使用 watchdog 定时器调度下次出队、以及在移除时清理资源。 -本示例适用于 veth、TAP 或 IFB 这类受控接口——你拥有完全控制权,可以安心进行报文调度实验。 +本示例适用于 veth、TAP 或 IFB 这类受控接口,你拥有完全控制权,可以安心进行报文调度实验。 > 完整源码: @@ -14,7 +14,7 @@ Linux 6.16 提供了一种新选择:BPF qdisc。你不再需要配置现有的 要理解 BPF qdisc 改变了什么,可以先和三种常见做法对照。 -**内置调度器是固定算法。** tc 提供了 TBF(令牌桶过滤器)、HTB(分层令牌桶)等多种选项,但这些都是内核中预定义的行为。如果你需要自定义的调度策略——比如按应用程序类型区分优先级,或者根据实时指标动态调整速率——就必须修改内核代码或串联多个 tc 分类器。 +**内置调度器是固定算法。** tc 提供了 TBF(令牌桶过滤器)、HTB(分层令牌桶)等多种选项,但这些都是内核中预定义的行为。如果你需要自定义的调度策略(比如按应用程序类型区分优先级,或者根据实时指标动态调整速率),就必须修改内核代码或串联多个 tc 分类器。 **TC BPF 程序无法控制发送时机。** 普通的 TC BPF 程序可以检查报文并决定放行或丢弃,但底层的 qdisc 仍然控制着报文的实际发送时间。[第 20 课](https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/20-tc)展示的正是这种模式:BPF 决定放行还是丢弃,但无法做到"在时刻 T 发送这个报文"。 @@ -36,7 +36,7 @@ BPF qdisc 解决了这些问题。从 Linux 6.16 开始,你可以用 eBPF 实 4. **重置**:当 qdisc 被移除时,内核调用 `reset`。你需要遍历所有剩余的报文,释放它们占用的资源,并将计数器清零。 -在整个生命周期中,你的 BPF 程序拥有报文的所有权。从 `enqueue` 接收 `skb`,到在你的数据结构中持有它,再到在 `dequeue` 中返回它或在 `reset` 中释放它——BPF 程序始终是责任方。 +在整个生命周期中,你的 BPF 程序拥有报文的所有权。从 `enqueue` 接收 `skb`,到在你的数据结构中持有它,再到在 `dequeue` 中返回它或在 `reset` 中释放它,BPF 程序始终是责任方。 ![egress_pacer 数据流:从配置、挂载、入队、BPF FIFO、出队到发送路径,包含 policy-drop 分支、watchdog 循环和 reset 生命周期](https://github.com/eunomia-bpf/bpf-developer-tutorial/raw/main/src/53-egress-pacer/egress-pacer-flow.png) @@ -69,13 +69,13 @@ struct pacer_stats { - `enqueued` / `dequeued`:成功进入和离开 FIFO 的报文数。 - `policy_dropped`:在入队阶段因队列已满或分配失败而丢弃的报文数。 -- `cleanup_dropped`:reset 运行时仍在队列中的报文数——这些报文已入队但从未发送。 +- `cleanup_dropped`:reset 运行时仍在队列中的报文数。这些报文已入队但从未发送。 - `bytes_dequeued`:实际发送的总字节数。 - `max_qlen`:观察到的队列深度峰值。 ### 兼容头文件 -BPF qdisc 使用"graph-object"kfunc——用于管理 BPF 所持有的链表和对象的内核函数。这个本地头文件提供了必要的声明: +BPF qdisc 使用 "graph-object" kfunc,用于管理 BPF 所持有的链表和对象的内核函数。这个本地头文件提供了必要的声明: ```c /* SPDX-License-Identifier: GPL-2.0 */ @@ -347,7 +347,7 @@ struct Qdisc_ops pacer = { 6. 在自旋锁保护下设置报文的可发送时间。如果队列之前是空闲的(`next_departure_ns` 在过去),这个报文可以立即发送;否则它需要排在上一个报文之后。 7. 把节点推入 FIFO 尾部,更新计数器,解锁,返回成功。 -`bpf_qdisc_skb_drop` 调用处理被丢弃的报文——它把 `skb` 加入内核的释放链表并更新 qdisc 统计。 +`bpf_qdisc_skb_drop` 调用处理被丢弃的报文:它把 `skb` 加入内核的释放链表并更新 qdisc 统计。 #### 出队回调 @@ -358,7 +358,7 @@ struct Qdisc_ops pacer = { 3. 检查当前时间是否已到达 `eligible_ns`。如果未到,把节点推回队首,为 `eligible_ns` 安排一个 watchdog 定时器,然后返回 NULL。定时器触发后,内核会再次调用 dequeue。 4. 如果已经可以发送,用 `bpf_kptr_xchg` 从节点取出 `skb`,递减计数器,释放节点,更新统计,返回 `skb`。 -watchdog 机制(`bpf_qdisc_watchdog_schedule`)是 BPF qdisc 实现发送时序的关键——你告诉内核什么时候唤醒你,它在那个时间调用 dequeue。 +watchdog 机制(`bpf_qdisc_watchdog_schedule`)是 BPF qdisc 实现发送时序的关键:你告诉内核什么时候唤醒你,它在那个时间调用 dequeue。 #### 重置回调 @@ -713,7 +713,7 @@ TEST-SUMMARY attempts=40 received=9 send_errors=31 span_ms=1024 observed_kbps=64 PASS: conflict refusal, bounded drops, pacing, accounting, normal/signal cleanup, and SIGKILL recovery succeeded ``` -发生了什么:测试向一个 64 Kbit/s、最多容纳 8 个报文的队列发送了 40 个原始以太网帧(每个 1024 字节,EtherType `0x88B5`)。9 个报文成功通过队列,分布在 1024ms 内——与预期速率吻合。其他 31 次发送触发了队列上限,计入 `policy_dropped`。具体数字因系统时序而异。 +发生了什么:测试向一个 64 Kbit/s、最多容纳 8 个报文的队列发送了 40 个原始以太网帧(每个 1024 字节,EtherType `0x88B5`)。9 个报文成功通过队列,分布在 1024ms 内,与预期速率吻合。其他 31 次发送触发了队列上限,计入 `policy_dropped`。具体数字因系统时序而异。 测试还覆盖了边界情况: - 预先安装一个 `pfifo`,确认加载器拒绝替换现有的 qdisc。 @@ -774,7 +774,7 @@ BPF qdisc 需要 Linux 6.16 或更高版本。内核必须启用 BTF 和 BPF JIT - **dequeue** 使用 watchdog 定时器在正确的时刻被唤醒,并将报文返回给内核。 - **reset** 释放 qdisc 移除时仍在队列中的所有报文。 -这三个阶段——接收报文、控制释放时机、清理资源——构成了自定义调度器的可复用模式。 +这三个阶段(接收报文、控制释放时机、清理资源)构成了自定义调度器的可复用模式。 这个调度器实现了一条聚合 FIFO,速率和队列上限在加载时固定。它有意保持简单,适合在受控接口上验证功能正确性。生产级调度器可以在此基础上添加公平性、动态策略或跨重启的状态持久化。 diff --git a/src/54-exec-image-inspector/README.md b/src/54-exec-image-inspector/README.md index e6ae32e..aab86ca 100644 --- a/src/54-exec-image-inspector/README.md +++ b/src/54-exec-image-inspector/README.md @@ -1,20 +1,20 @@ # eBPF Tutorial: Inspecting the Executable Image After exec -When a process calls `execve`, the kernel replaces its memory image with a new executable. But which executable, exactly? If the command line says `/usr/bin/wrapper.sh --config /etc/app.conf`, the actual running code might be a Python interpreter or a compiled binary launched three layers deep through wrapper scripts. Security tools, container runtimes, and troubleshooting utilities all need to know what the kernel *actually* installed—not just what the user typed. +When a process calls `execve`, the kernel replaces its memory image with a new executable. But which executable, exactly? If the command line says `/usr/bin/wrapper.sh --config /etc/app.conf`, the actual running code might be a Python interpreter or a compiled binary launched three layers deep through wrapper scripts. Security tools, container runtimes, and troubleshooting utilities all need to know what the kernel *actually* installed, not just what the user typed. -This tutorial builds a tool that captures that information at the kernel level. It hooks the moment credentials are committed after exec, schedules a deferred callback, then reads the installed executable's ELF header and reports the architecture, byte order, and file type. Along the way, it demonstrates two recent kernel features—BPF task work and file dynptr—that together solve a problem traditional eBPF approaches cannot. +This tutorial builds a tool that captures that information at the kernel level. It hooks the moment credentials are committed after exec, schedules a deferred callback, then reads the installed executable's ELF header and reports the architecture, byte order, and file type. Along the way, it demonstrates two recent kernel features (BPF task work and file dynptr) that together solve a problem traditional eBPF approaches cannot. > Complete source: ## The problem: reading file content from eBPF -Why would a security tool need to read the executable's content rather than just its path? Because the path alone does not identify what code will run. A hash of the executable's content can verify it matches a known-good binary. Embedded signatures or certificates can prove provenance. Specific byte patterns at known offsets can identify packing, obfuscation, or tampering. None of this information is available from the path—you must read the file's bytes. Doing so from eBPF, at the exact moment of exec, eliminates the race window that plagues user-space approaches. +Why would a security tool need to read the executable's content rather than just its path? Because the path alone does not identify what code will run. A hash of the executable's content can verify it matches a known-good binary. Embedded signatures or certificates can prove provenance. Specific byte patterns at known offsets can identify packing, obfuscation, or tampering. None of this information is available from the path; you must read the file's bytes. Doing so from eBPF, at the exact moment of exec, eliminates the race window that plagues user-space approaches. The most direct approach to inspecting an executable is reading `/proc//exe`, but this only works if the process is still alive. Short-lived processes exit before you can read them. Even if you catch them in time, the `/proc` filesystem is accessed from user space, creating a race window: by the time you read the symlink, the process might have called `execve` again. Tracepoints and kprobes can hook `sched_process_exec` to observe exec events synchronously, but these hooks run in what the kernel calls a **non-sleepable context**. This matters because of how Linux manages file data in memory. -When you read from a file, the kernel first checks whether the requested bytes are already in the **page cache**—a memory region that caches recently-accessed file data. If they are, the read completes immediately. If they are not (a **cold page**), the kernel must issue I/O to the storage device, and the calling context must **sleep** while waiting for that I/O to complete. +When you read from a file, the kernel first checks whether the requested bytes are already in the **page cache**, a memory region that caches recently-accessed file data. If they are, the read completes immediately. If they are not (a **cold page**), the kernel must issue I/O to the storage device, and the calling context must **sleep** while waiting for that I/O to complete. BPF programs attached to tracepoints and kprobes cannot sleep. They run with interrupts potentially disabled and locks held; sleeping would deadlock the system. If a BPF program tries to read file content and encounters a cold page, the read fails with `-EFAULT` instead of waiting for I/O. @@ -32,7 +32,7 @@ Combining these features, the design becomes: 2. In the hook (non-sleepable), identify the target process and schedule a task work callback 3. The callback runs in a sleepable context, where it can access the installed executable, read content from any offset (including cold pages), and send results to user space -This separation—identify the target in a non-sleepable hook, do the heavy lifting in a sleepable callback—is the key insight. +This separation (identify the target in a non-sleepable hook, do the heavy lifting in a sleepable callback) is the key insight. ## How BPF task work operates @@ -50,7 +50,7 @@ A dynptr wraps a pointer with bounds information that the BPF verifier can track - `bpf_dynptr_read(dst, len, dynptr, offset, flags)` reads content at the specified offset - `bpf_dynptr_file_discard(dynptr)` releases the dynptr's internal state -Every path that creates a dynptr—including error paths—must call `bpf_dynptr_file_discard` to release it. Failing to do so leaks internal resources. +Every path that creates a dynptr, including error paths, must call `bpf_dynptr_file_discard` to release it. Failing to do so leaks internal resources. In a non-sleepable context, `bpf_dynptr_read` only succeeds if the target bytes are already in the page cache. Accessing a cold page returns `-EFAULT`. In a sleepable context, the same call can trigger page fault handling and wait for I/O, so it succeeds even for cold pages. This difference is why task work matters: the callback runs in a sleepable context where file reads work reliably. @@ -58,7 +58,7 @@ In a non-sleepable context, `bpf_dynptr_read` only succeeds if the target bytes The user-space program forks a child process, but holds the child blocked on a pipe before it calls `execvp`. This gives the parent time to: -1. Note the child's PID (which becomes its TGID—thread group ID) +1. Note the child's PID (which becomes its TGID, thread group ID) 2. Open the BPF skeleton and write the target TGID into read-only data 3. Load and attach the BPF program 4. Create a ring buffer reader @@ -67,7 +67,7 @@ Only then does the parent release the child by writing to the pipe. This handsha When the child calls `execvp`, the `lsm/bprm_committed_creds` hook fires. The BPF program compares the current TGID with the configured target; if they match, it records a timestamp and schedules a task work callback. -The callback—`inspect_executable`—runs in the child's sleepable context. It: +The callback (`inspect_executable`) runs in the child's sleepable context. It: 1. Calls `bpf_get_task_exe_file` to get the installed executable (returning a referenced `struct file` that must be released with `bpf_put_file`) 2. Resolves the path with `bpf_path_d_path` @@ -380,7 +380,7 @@ void BPF_PROG(schedule_exec_inspection, struct linux_binprm *bprm) The entry point is `schedule_exec_inspection`, declared with `SEC("lsm/bprm_committed_creds")`. This LSM hook fires after the new executable's credentials have been installed, so `bprm->file` points to the file being executed. The hook itself is non-sleepable, but it can identify the target and schedule the deferred work. -The two `const volatile` variables—`target_tgid` and `probe_offset`—live in the `.rodata` section. User space writes values between `open()` and `load()`, and the verifier treats them as compile-time constants for optimization. +The two `const volatile` variables (`target_tgid` and `probe_offset`) live in the `.rodata` section. User space writes values between `open()` and `load()`, and the verifier treats them as compile-time constants for optimization. When the target matches, the program looks up `struct exec_work` from the single-element `pending` ARRAY map. This structure holds the `bpf_task_work` storage plus a timestamp and optional direct-probe result. A single slot suffices because this tool observes one child process per invocation. @@ -390,7 +390,7 @@ After saving the timestamp and direct-probe result, the hook calls `bpf_task_wor The callback `inspect_executable` calculates latency for diagnostics, then acquires the executable file with `bpf_get_task_exe_file`. This returns a referenced `struct file` that must be released with `bpf_put_file`. The callback resolves the path, creates a file dynptr, reads the 64-byte ELF header, and parses it. The `read_elf_u16` helper handles endianness: ELF files declare their byte order in the header, and multi-byte fields must be read accordingly. -Every path that creates a dynptr—success or failure—must call `bpf_dynptr_file_discard`. Finally, `bpf_ringbuf_output` sends the event to user space. +Every path that creates a dynptr, success or failure, must call `bpf_dynptr_file_discard`. Finally, `bpf_ringbuf_output` sends the event to user space. ### User-space loader diff --git a/src/54-exec-image-inspector/README.zh.md b/src/54-exec-image-inspector/README.zh.md index 13d0c54..f3cd433 100644 --- a/src/54-exec-image-inspector/README.zh.md +++ b/src/54-exec-image-inspector/README.zh.md @@ -2,19 +2,19 @@ 当进程调用 `execve` 时,内核会用新的可执行镜像替换它的内存映像。但具体是哪个可执行文件?如果命令行显示 `/usr/bin/wrapper.sh --config /etc/app.conf`,实际运行的代码可能是三层包装脚本深处的 Python 解释器或编译好的二进制。安全工具、容器运行时、故障排查工具都需要知道内核 *实际* 安装的是什么,而不仅仅是用户输入的命令行。 -本教程构建一个在内核层面捕获这些信息的工具。它在 exec 提交凭据后挂载钩子,安排一个延迟回调,然后读取已安装可执行文件的 ELF 头部,报告其架构、字节序和文件类型。过程中会演示两个较新的内核特性——BPF task work 和 file dynptr——它们组合起来可以解决传统 eBPF 方案无法处理的问题。 +本教程构建一个在内核层面捕获这些信息的工具。它在 exec 提交凭据后挂载钩子,安排一个延迟回调,然后读取已安装可执行文件的 ELF 头部,报告其架构、字节序和文件类型。过程中会演示两个较新的内核特性(BPF task work 和 file dynptr),它们组合起来可以解决传统 eBPF 方案无法处理的问题。 > 完整源码: ## 问题:从 eBPF 读取文件内容的困难 -为什么安全工具需要读取可执行文件的内容,而不仅仅是路径?因为路径本身并不能确定将要运行什么代码。对可执行文件内容的哈希可以验证它是否与已知的可信二进制匹配。嵌入的签名或证书可以证明来源。特定偏移处的特定字节模式可以识别加壳、混淆或篡改。这些信息都无法从路径获取——必须读取文件的字节。在 eBPF 中、在 exec 的精确时刻读取,消除了困扰用户态方案的竞态窗口。 +为什么安全工具需要读取可执行文件的内容,而不仅仅是路径?因为路径本身并不能确定将要运行什么代码。对可执行文件内容的哈希可以验证它是否与已知的可信二进制匹配。嵌入的签名或证书可以证明来源。特定偏移处的特定字节模式可以识别加壳、混淆或篡改。这些信息都无法从路径获取,必须读取文件的字节。在 eBPF 中、在 exec 的精确时刻读取,消除了困扰用户态方案的竞态窗口。 最直接的可执行文件检查方法是读取 `/proc//exe`,但这要求进程仍然存活。短命进程在你读取之前就已退出。即使抓住了时机,`/proc` 文件系统是从用户态访问的,与内核的 exec 事件之间存在竞态窗口:等你读到符号链接时,进程可能已经再次调用了 `execve`。 Tracepoint 和 kprobe 可以钩住 `sched_process_exec` 同步观察 exec 事件,但这些钩子运行在内核所谓的**不可睡眠上下文**中。为什么这很重要?这涉及到 Linux 如何管理内存中的文件数据。 -当你读取文件时,内核首先检查请求的字节是否已经在**页缓存**(page cache)中——这是一块缓存最近访问过的文件数据的内存区域。如果在,读取立即完成。如果不在(即**冷页**),内核必须向存储设备发起 I/O 请求,调用上下文必须**睡眠**等待 I/O 完成。 +当你读取文件时,内核首先检查请求的字节是否已经在**页缓存**(page cache)中,这是一块缓存最近访问过的文件数据的内存区域。如果在,读取立即完成。如果不在(即**冷页**),内核必须向存储设备发起 I/O 请求,调用上下文必须**睡眠**等待 I/O 完成。 挂载在 tracepoint 和 kprobe 上的 BPF 程序不能睡眠。它们运行时可能中断被禁用、持有锁;如果睡眠会导致系统死锁。当 BPF 程序尝试读取文件内容遇到冷页时,读取会以 `-EFAULT` 失败,而不是等待 I/O。 @@ -32,7 +32,7 @@ Linux 6.19 引入了 **file dynptr**,提供验证器跟踪的文件数据访 2. 在钩子中(不可睡眠)识别目标进程并安排 task work 回调 3. 回调在可睡眠上下文中执行,可以访问已安装的可执行文件、读取任意偏移的内容(包括冷页)、然后把结果发送给用户态 -这种分离——在不可睡眠的钩子中识别目标,在可睡眠的回调中完成重活——是关键思路。 +这种分离(在不可睡眠的钩子中识别目标,在可睡眠的回调中完成重活)是关键思路。 ## BPF task work 的工作机制 @@ -50,7 +50,7 @@ dynptr 用边界信息包装指针,BPF 验证器可以追踪这些边界。对 - `bpf_dynptr_read(dst, len, dynptr, offset, flags)` 读取指定偏移处的内容 - `bpf_dynptr_file_discard(dynptr)` 释放 dynptr 的内部状态 -每条创建 dynptr 的路径——包括错误路径——都必须调用 `bpf_dynptr_file_discard` 来释放它。忘记释放会泄漏内部资源。 +每条创建 dynptr 的路径(包括错误路径)都必须调用 `bpf_dynptr_file_discard` 来释放它。忘记释放会泄漏内部资源。 在不可睡眠上下文中,`bpf_dynptr_read` 只有在目标字节已经在页缓存中时才会成功。访问冷页返回 `-EFAULT`。在可睡眠上下文中,同样的调用可以触发缺页处理并等待 I/O,即使对冷页也能成功。这个差异就是 task work 的价值所在:回调运行在可睡眠上下文中,文件读取能可靠完成。 @@ -58,7 +58,7 @@ dynptr 用边界信息包装指针,BPF 验证器可以追踪这些边界。对 用户态程序 fork 出一个子进程,但在子进程调用 `execvp` 之前用管道阻塞它。这给父进程时间去: -1. 记录子进程的 PID(它会成为 TGID——线程组 ID) +1. 记录子进程的 PID(它会成为 TGID,线程组 ID) 2. 打开 BPF skeleton 并把目标 TGID 写入只读数据段 3. 加载并挂载 BPF 程序 4. 创建 ring buffer reader @@ -67,7 +67,7 @@ dynptr 用边界信息包装指针,BPF 验证器可以追踪这些边界。对 当子进程调用 `execvp` 时,`lsm/bprm_committed_creds` 钩子触发。BPF 程序比较当前 TGID 与配置的目标;如果匹配,它记录时间戳并安排 task work 回调。 -回调——`inspect_executable`——在子进程的可睡眠上下文中执行。它: +回调 `inspect_executable` 在子进程的可睡眠上下文中执行。它: 1. 调用 `bpf_get_task_exe_file` 获取已安装的可执行文件(返回一个带引用的 `struct file`,必须用 `bpf_put_file` 释放) 2. 用 `bpf_path_d_path` 解析路径 @@ -380,7 +380,7 @@ void BPF_PROG(schedule_exec_inspection, struct linux_binprm *bprm) 入口点是用 `SEC("lsm/bprm_committed_creds")` 声明的 `schedule_exec_inspection`。这个 LSM 钩子在新可执行文件的凭据安装后触发,此时 `bprm->file` 指向正在被执行的文件。钩子本身不可睡眠,但可以识别目标并安排延迟工作。 -两个 `const volatile` 变量——`target_tgid` 和 `probe_offset`——位于 `.rodata` 段。用户态在 `open()` 和 `load()` 之间写入值,验证器把它们当作编译期常量来优化。 +两个 `const volatile` 变量(`target_tgid` 和 `probe_offset`)位于 `.rodata` 段。用户态在 `open()` 和 `load()` 之间写入值,验证器把它们当作编译期常量来优化。 当目标匹配时,程序从单元素 `pending` ARRAY map 查找 `struct exec_work`。这个结构保存 `bpf_task_work` 存储以及时间戳和可选的直接探测结果。单个槽位就够了,因为本工具每次调用只观察一个子进程。 @@ -390,7 +390,7 @@ void BPF_PROG(schedule_exec_inspection, struct linux_binprm *bprm) 回调 `inspect_executable` 计算延迟用于诊断,然后用 `bpf_get_task_exe_file` 获取可执行文件。这返回一个带引用的 `struct file`,必须用 `bpf_put_file` 释放。回调解析路径、创建 file dynptr、读取 64 字节 ELF 头部并解析它。`read_elf_u16` 辅助函数处理字节序:ELF 文件在头部声明其字节序,多字节字段必须相应地读取。 -每条创建 dynptr 的路径——无论成功还是失败——都必须调用 `bpf_dynptr_file_discard`。最后,`bpf_ringbuf_output` 把事件发送给用户态。 +每条创建 dynptr 的路径(无论成功还是失败)都必须调用 `bpf_dynptr_file_discard`。最后,`bpf_ringbuf_output` 把事件发送给用户态。 ### 用户态加载器