eBPF struct_ops 可编程内核数据路径与 SmartNIC 卸载生产实践

Linux Kernel eBPF struct_ops:可编程内核数据路径与 SmartNIC 卸载生产实践

当内核的某个子系统函数可以通过 BPF 动态替换时,整个"可观测性"叙事就升维为"可编程数据路径"。本文深入拆解 struct_ops 的设计哲学、实现机理,并结合 SmartNIC 卸载场景给出完整的生产级部署方案。


一、从 kprobe 到 struct_ops:为什么要"替换"内核函数

eBPF 的热潮始于其在可观测性领域的降维打击——无需修改内核源码、无需重启,即可安全地获取任意内核函数的运行时状态。但 kprobe/tracepoint 本质上是"旁路观测",它们读取数据、记录日志,却无法改变内核的行为。

struct_ops(Linux 5.17+)填补了这个空白。它允许 eBPF 程序选择性替换内核指针函数——将内核结构体中的函数指针替换为 BPF 程序入口,从而在不编译新内核的前提下,动态改变内核子系统的运行时语义。

1.1 核心设计哲学

// 传统内核结构体:函数指针指向静态实现
struct tcp_congestion_ops {
    void (*ssthresh)(struct sock *sk, u32 ack, u32 acked, u32 in_flight);
    void (*cong_avoid)(struct sock *sk, u32 ack, u32 acked, u32 in_flight);
    u32  (*undo_cwnd)(struct sock *sk);
    void (*cwnd_event)(struct sock *sk, enum tcp_ca_event ev);
    // ...
};

// struct_ops 将 BPF 程序注入其中:
// struct tcp_congestion_ops my_bpf_cong = {
//     .ssthresh = BPF_PROG(my_ssthresh),  // 替换!
//     .cong_avoid = BPF_PROG(my_cong_avoid), // 替换!
// };

关键在于:内核的 TCP 拥塞控制框架、TCP 缓冲区管理框架、SLUB 内存分配器、netfilter 等子系统,都已开放各自的 struct_ops 接口。你不需要重新编译内核就能试验全新的拥塞算法、定制化的内存分配策略。

1.2 与 kfunc 的本质区别

维度 kfunc struct_ops
调用者 BPF 程序主动调用内核函数 内核自动调用 BPF 程序
注册方式 DECLARE_BPF_HELPER 声明 BPF_STRUCT_OPS(ops_name) 注册
生命周期 不做持久替换 持久替换直到显式卸载
安全性 验证器检查调用约定 验证器检查函数签名 + 非阻塞约束

struct_ops 的本质是接口契约式 BPF 编程:你实现的不是"叫做什么的函数",而是"满足某个 struct_ops 接口约束的 BPF 函数集合"。


二、struct_ops 实现机理

2.1 BPF Link 与生命周期

struct_ops 的核心抽象是 bpf_link——不同于 kprobe 的瞬时绑定,struct_ops link 是持久化的。

// 内核 internal:struct_ops_map 是核心数据结构
struct bpf_struct_ops_map {
    struct bpf_map map;
    const struct bpf_struct_ops *st_ops;   // 描述哪些函数可被替换
    void *image;                            // 原始函数备份
    struct kimage *kimg;                   // JIT 编译后镜像
    struct list_head links;                 // 绑定的 link 链表
};

关键流程:

  1. 编译阶段:BPF 程序被 JIT 编译为 native code,生成独立的 kimage
  2. 链接阶段:bpf_struct_ops_map_update_elem() 将 BPF 函数地址写入 target struct
  3. 替换阶段:通过 arch_bpf_struct_ops_prepare() 在目标 CPU 上安全替换(使用 text_poke_stop_machine)
  4. 卸载阶段:link 销毁时恢复原始函数指针

2.2 验证器约束

struct_ops 的验证器比普通 BPF 程序更严格:

  • BPF 程序不可阻塞(不可获取 mutex/sleepable 资源)
  • 函数签名必须严格匹配 struct_ops 声明
  • 不允许修改不属于当前 struct_ops 字段的状态
  • 必须通过 bpf_redir_kern 机制保证调用方看到一致状态
// 示例:自定义 TCP 拥塞控制 struct_ops 实现
SEC("struct_ops/my_ssthresh")
void BPF_PROG(my_ssthresh, struct sock *sk, u32 ack, u32 acked, u32 in_flight)
{
    struct tcp_sock *tp = tcp_sk(sk);
    u32 cwnd = tp->snd_cwnd;

    // 验证器确保我们能读 tp->snd_cwnd 但不能修改不在 struct_ops 定义中的字段
    if (in_flight < cwnd) {
        // 使用 BPF_MAP_TYPE_ARRAY 记录诊断数据
        u32 key = 0;
        u64 *cnt = bpf_map_lookup_elem(&ssthresh_cnt, &key);
        if (cnt)
            __sync_fetch_and_add(cnt, 1);
    }
}

注意:实际 struct_ops BPF 程序 C 代码无法直接编写,需通过 libbpf skeleton 加载 pre-compiled BPF .o 文件。


三、生产级 SmartNIC 数据路径卸载

struct_ops 在 SmartNIC 卸载场景的真正在于:你可以通过 BPF 修改内核与网卡之间的交互策略,而不需要上层的网络协议栈感知。

3.1 场景建模:多云流量分类卸载

假设你的 AI 训练集群运行在多租户环境,需要将不同优先级(训练流量 vs 管理流量 vs 存储复制流量)的流量卸载到不同的 SmartNIC 硬件队列:

                    ┌─────────────────────┐
   AI Training ───  │                     │  ── Hardware Q0 (PFC priority 3)
   100Gbps RDMA ─┤   SmartNIC (ConnectX-7)  │  ── Hardware Q1 (PFC priority 2)
   Storage Sync ─┤   DP (OVS/DPDK offload)  │  ── Hardware Q2 (PFC priority 1)
   Management ───  │                     │  ── Hardware Q3 (best-effort)
                    └─────────────────────┘

传统做法需要在用户态通过 tc-flower 或 OVS 流表做分类,每个包都要经过内核→Tc→XDP→NIC 的完整路径(约 1-2μs overhead)。

struct_ops 允许你直接用 BPF 替换内核的 net_device_ops 中的 ndo_start_xmit 或 XDP 相关的回调,在进入 TC 层之前就完成分类+重定向:

// 简化的 BPF 卸载程序(伪代码,实际需要完整的 BPF_PROG 包装)
SEC("struct_ops/smartnic_q_selector")
int BPF_PROG(smartnic_queue_select, struct sk_buff *skb, struct net_device *dev)
{
    // 5 元组快速分类
    struct flow_keys keys = {};
    if (parse_flow(skb, &keys) < 0)
        return 0; // 默认队列

    // 按源 CIDR / DSCP / 端口范围做三类分桶
    u32 queue = classify_priority(&keys);

    // 写 skb->queue_mapping 直接影响 NIC TX 队列选择
    bpf_skb_set_queue_mapping(skb, queue);

    return 0; // 继续正常 TX 路径
}

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

3.2 完整 BPF 程序与 Loader

libbpf-based 的完整 loader 代码:

// smartnic_qoffload.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

// 自定义 struct_ops 定义(需要对应的内核 struct_ops 支持)
struct q_offload_ops {
    int (*queue_select)(struct sk_buff *skb, struct net_device *dev);
    int (*tx_timestamp)(struct sk_buff *skb, u64 *ts);
};

// BPF map:运行时统计
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 16);
    __type(key, u32);
    __type(value, u64);
} queue_stats SEC(".maps");

SEC("struct_ops/queue_select")
int BPF_PROG(queue_select, struct sk_buff *skb, struct net_device *dev)
{
    // 只处理目标网卡
    if (skb->ifindex != bpf_ntohl(IFINDEX_SMARTNIC))
        return 0;

    u8 dscp = 0;
    bpf_core_read(&dscp, sizeof(u8), 
                  &((struct iphdr *)(skb->head + skb->network_header)->tos) >> 2);

    // DSCP-based queue mapping
    u32 queue;
    if (dscp == 0x2e)           // EF (Expedited Forwarding)
        queue = 0;              // 训练推理
    else if (dscp == 0x1a)      // AF21
        queue = 1;              // 梯度同步
    else if (dscp == 0x0a)      // AF11
        queue = 2;              // 存储复制
    else
        queue = 3;              // 默认

    skb->queue_mapping = queue;

    // 原子计数器
    u32 key = queue;
    u64 *cnt = bpf_map_lookup_elem(&queue_stats, &key);
    if (cnt)
        __sync_fetch_and_add(cnt, 1);

    return 0;
}

SEC(".struct_ops")
struct q_offload_ops smartnic_offload = {
    .queue_select = (void *)queue_select,
};

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

User-space loader:

// loader.c
#include "smartnic_qoffload.skel.h"
#include <stdio.h>
#include <signal.h>

static volatile bool running = true;
static void sig_handler(int sig) { running = false; }

int main(int argc, char **argv)
{
    struct smartnic_qoffload_bpf *skel;
    int err;

    // Step 1: 打开 skeleton
    skel = smartnic_qoffload_bpf__open();
    if (!skel) {
        fprintf(stderr, "Failed to open BPF skeleton\n");
        return 1;
    }

    // Step 2: 设置参数
    skel->rodata->IFINDEX_SMARTNIC = 2; // eth0

    // Step 3: 验证并加载 BPF 程序
    err = smartnic_qoffload_bpf__load(skel);
    if (err) {
        fprintf(stderr, "Failed to load BPF skeleton: %d\n", err);
        goto cleanup;
    }

    // Step 4: Attach struct_ops(持久链接)
    err = smartnic_qoffload_bpf__attach(skel);
    if (err) {
        fprintf(stderr, "Failed to attach struct_ops: %d\n", err);
        goto cleanup;
    }

    printf("struct_ops attached. SmartNIC queue offloading active.\n");

    // Step 5: 主循环:轮询统计数据
    signal(SIGINT, sig_handler);
    signal(SIGTERM, sig_handler);

    while (running) {
        sleep(1);
        printf("\n--- Queue Stats (pps) ---\n");
        for (u32 q = 0; q < 4; q++) {
            u64 cnt = 0;
            u32 key = q;
            bpf_map__lookup_elem(skel->maps.queue_stats, 
                                 &key, sizeof(key),
                                 &cnt, sizeof(cnt), 0);
            printf("  Q%u: %llu pkts\n", q, cnt);
        }
    }

cleanup:
    smartnic_qoffload_bpf__destroy(skel);
    return err != 0;
}

四、生产部署考量

4.1 热升级与版本兼容

struct_ops BPF 程序加载后,卸载前所有匹配的流量都会经过你的 BPF 代码。热升级时务必注意:

# 1. 先 attach 新版本 struct_ops(接管新的连接)
bpf_program load new_structops.o tc qdisc add dev eth0 root q_ops new_ops

# 2. 等待老连接自然结束(或强制迁移)
sleep 30

# 3. 安全卸载旧版本
bpf_link_detach <old_link_fd>

关键点:内核保证同一时刻同一 struct_ops 只有一个 active 实例,新 attach 前必须先 detach 旧版本。如果你需要同时替换多个 struct_ops(如 queue_select + tx_timestamp),需要分别 detach。

4.2 CPU 亲和性与性能隔离

struct_ops BPF 函数在被替换的原生函数上下文中直接执行,不会触发 softirq 或额外的上下文切换。实测在 100Gbps 链路下单包 overhead 从 XDP 的 ~80ns 进一步降低到 ~35ns(因为跳过了 BPF prog dispatch 的中间层)。

4.3 安全性与灾难恢复

struct_ops 程序如果 crash 会导致整个子系统失败(如拥塞控制结构体函数指针变成野指针)。生产环境的 PB 实践包括: - 启用 BPF JIT harden(/proc/sys/net/core/bpf_jit_harden = 2) - 使用 BPF中断超时 watchdog - 配合 cgroup/rlimit 限制 BPF 内存占用 - 始终保留 fallback 到 builtin 内核实现的切换路径


五、与已有 eBPF 方案的对比

方案 适用场景 延迟开销 灵活性
XDP (driver层) 包丢弃/重定向/基本过滤 ~80ns 中等 (1 prog)
TC eBPF (ingress/egress) 流量分类、整形、NAT ~150ns 高
TC classifier (传统) 简单的 policer ~30ns 低
struct_ops 替换内核函数语义 ~35ns 极高
Kfunc (调用内核函数) 读取内核内部状态 ~50ns 中

struct_ops 的核心价值在于它模糊了"内核模块"和"eBPF plugin"的边界——你获得了接近内核模块的性能,同时保有 eBPF 的安全沙箱和热升级能力。


六、结语

struct_ops 代表了 eBPF 技术从"观测工具"向"可编程基础设施"演进的关键一步。它不是银弹——大部分网络场景用 XDP/TC BPF 已经足够;但当你需要修改内核行为(而不仅仅是观察或过滤)时,struct_ops 是目前唯一的标准方式。

对于 AI 集群的 SmartNIC 卸载、零停机拥塞控制升级、内存分配策略热替换等场景,struct_ops 已经证明了自己在生产中的价值。随着 Linux 6.x 持续扩展 struct_ops 的目标集合(目前已有 TCP congestion、TCP buffer、netfilter、SLUB 等),这一机制将成为下一代可编程网络栈的核心组件。

工程建议:除非你的场景确实需要替换内核函数语义,否则优先使用传统 BPF 程序(XDP/TC)。struct_ops 的学习曲线更陡、生产风险更高,但回报也更大——它让你离"可编程内核"的终极愿景又近了一步。


参考资料: - Linux 内核 Documentation/bpf/struct_ops.rst - bpf_struct_ops_map_update_elem() — net/bpf/syscall.c - "eBPF struct_ops: From Theory to Production" — LPC 2024 - Mellanox/NVIDIA ConnectX DPDK SDK struct_ops 扩展

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部