016.优化:uprobe与kprobe结合千大事

前面咱们不是提过ebpf函数取参数的问题

int uprobe_malloc(struct pt_regs *ctx)

malloc的函数原型

void *malloc(size_t size);

那能不能写成

int uprobe_malloc(size_t size)

这样取参数多方便

我就想起了之前写kprobe程序,是可以这样的

然后我就试了一下,确实可以

int BPF_KPROBE(uprobe_malloc, size_t size)

需要用到宏BPF_KPROBE、BPF_KRETPROBE

BPF_KPROBE / BPF_KRETPROBE 是 libbpf 提供的 kprobe/retprobe 函数声明宏
用来自动从 pt_regs 中解析函数参数

但是ChatGPT不推荐这样写,原因见副笔记

uprobe不推荐使用BPF_KPROBE
结论先给: 不推荐,也不算“正确写法”。
这段代码语义上是错位的,只是“可能能编译”,但概念和工程实践都是不对的

SEC("uprobe")
int BPF_KPROBE(uprobe_malloc, size_t size)
{
    bpf_printk("malloc(%lu)\n", size);
    return 0;
}

下面我用判定 → 原因 → 正确写法 → 为什么有人会这么写还能跑,把这事讲清楚。


一、为什么说这是「不对的写法」

核心问题只有一句话:

BPF_KPROBE** 是为 kprobe 设计的参数解包宏,
**你却把它用在了 SEC("uprobe") 上。

这在概念层面是错位的


二、把角色彻底分清(这是关键)

1️⃣ SEC("uprobe") 决定了什么?

SEC("uprobe")

它决定的是:

这是一个 uprobe 程序(用户态探针)

也就是说:

  • hook 的是 用户态函数
  • 参数来自 用户态 ABI
  • 触发点是 ELF + uprobes

2️⃣ BPF_KPROBE 是为谁设计的?

BPF_KPROBE(...)

它的设计前提是:

kprobe / kretprobe(内核态)

  • 参数从 struct pt_regs 里按 内核调用约定
  • 解决的是 不同架构寄存器布局的问题

📌 它的语义是:
“这是一个 kprobe 风格的函数声明”


三、那 uprobe 应该用什么?

✅ 正确答案:BPF_UPROBE / BPF_URETPROBE

libbpf 提供了对称的一组宏

场景 正确宏
kprobe BPF_KPROBE
kretprobe BPF_KRETPROBE
uprobe BPF_UPROBE
uretprobe BPF_URETPROBE

四、你的代码的「正确写法」

✅ 正确、规范、工程级写法

SEC("uprobe")
int BPF_UPROBE(uprobe_malloc, size_t size)
{
    bpf_printk("malloc(%lu)\n", size);
    return 0;
}

这才是:

  • 语义正确
  • 架构无关
  • libbpf 官方推荐
  • 教学上 绝对不会被挑错 的写法

五、那为什么「用 BPF_KPROBE 也可能能跑」?

这是一个非常容易误导人的点,但你作为讲课的人,一定要讲清楚。

原因只有一个:

在 x86_64 上,kprobe 和 uprobe 在“寄存器取参”这件事上恰好长得很像

也就是说:

  • 第一个参数 → rdi
  • 第二个参数 → rsi

所以:

BPF_KPROBE(...)

在 uprobe 场景下:

  • 可能刚好取到了对的寄存器
  • 但这是 ABI 巧合
  • 不是语义保证

📌 一旦:

  • 换架构(arm64)
  • 换编译器
  • 换调用约定
  • 换函数签名

👉 就会悄悄出 bug


六、为什么这在“课程 / 工程”里必须禁止?

因为这是典型的:

“碰巧能跑,但概念是错的代码”

在教学里如果不纠正,会导致学生:

  • 以为 BPF_KPROBE 是“万能宏”
  • 分不清 kprobe / uprobe
  • 把 ABI 差异当成 CO-RE 能解决的问题(这是大坑)

七、你可以在课上这样一句话“定性”(非常好用)

**能不能跑,不代表写得对;
**eBPF 里最忌讳“ABI 巧合正确”。

或者更狠一点:

**BPF_KPROBE 写 uprobe,
****就像用内核 API 读用户内存——
**有时不炸,但一定不对。


八、最终结论(可直接当板书)

❌ 不推荐:

SEC("uprobe")
int BPF_KPROBE(...)

✅ 正确写法:

SEC("uprobe")
int BPF_UPROBE(...)

一句话终极总结

SEC("uprobe")** 决定“挂在哪”,
BPF_*PROBE 决定“按谁的 ABI 解包参数”,
**这两者必须语义一致。

BPF_UPROBE展开

#define BPF_UPROBE(name, args...)  BPF_KPROBE(name, ##args)

#define BPF_KPROBE(name, args...)					    \
name(struct pt_regs *ctx);						    \
static __always_inline typeof(name(0))					    \
____##name(struct pt_regs *ctx, ##args);				    \
typeof(name(0)) name(struct pt_regs *ctx)				    \
{									    \
	_Pragma("GCC diagnostic push")					    \
	_Pragma("GCC diagnostic ignored \"-Wint-conversion\"")		    \
	return ____##name(___bpf_kprobe_args(args));			    \
	_Pragma("GCC diagnostic pop")					    \
}									    \
static __always_inline typeof(name(0))					    \
____##name(struct pt_regs *ctx, ##args)

#define ___bpf_kprobe_args(args...)     ___bpf_apply(___bpf_kprobe_args, ___bpf_narg(args))(args)

我们就只干一件事:把宏完整、逐步地展开,让你清楚:

BPF_KPROBE / BPF_UPROBE** 到底是怎么把 pt_regs 里的寄存器,变成你函数参数的**


一、先给结论(先立住整体认知)

BPF_KPROBE / BPF_UPROBE** 本质是一个“三段式函数包装宏”**:

  1. 对外暴露的 BPF 程序入口函数(签名是 struct pt_regs *ctx
  2. 一个内联的“真正业务函数”(你写的参数形式)
  3. 一个“参数解包器”(把寄存器 → C 参数)

二、你给出的宏(我们逐段拆)

你给的是(略微整理过):

#define BPF_KPROBE(name, args...)                          \
name(struct pt_regs *ctx);                                 \
static __always_inline typeof(name(0))                     \
____##name(struct pt_regs *ctx, ##args);                   \
typeof(name(0)) name(struct pt_regs *ctx)                  \
{                                                          \
    _Pragma("GCC diagnostic push")                         \
    _Pragma("GCC diagnostic ignored \"-Wint-conversion\"")\
    return ____##name(___bpf_kprobe_args(args));            \
    _Pragma("GCC diagnostic pop")                          \
}                                                          \
static __always_inline typeof(name(0))                     \
____##name(struct pt_regs *ctx, ##args)

⚠️ BPF_UPROBE 的结构几乎一模一样
只是把 ___bpf_kprobe_args 换成了 ___bpf_uprobe_args


三、你的代码长这样

int BPF_UPROBE(uprobe_malloc, size_t size)

我们一步一步展开。


四、第一步展开:替换 nameargs

int uprobe_malloc(struct pt_regs *ctx);
static __always_inline int
____uprobe_malloc(struct pt_regs *ctx, size_t size);

int uprobe_malloc(struct pt_regs *ctx)
{
    _Pragma("GCC diagnostic push")
    _Pragma("GCC diagnostic ignored \"-Wint-conversion\"")
    return ____uprobe_malloc(___bpf_uprobe_args(size));
    _Pragma("GCC diagnostic pop")
}

static __always_inline int
____uprobe_malloc(struct pt_regs *ctx, size_t size)

⚠️ 注意:
真正写代码的地方,其实是下面这个函数:

____uprobe_malloc(struct pt_regs *ctx, size_t size)

五、第二步:___bpf_uprobe_args(size) 是什么?

这是关键中的关键

bpf_tracing.h 里(简化):

#define ___bpf_uprobe_args(args...) \
    ___bpf_uprobe_arg(args)

___bpf_uprobe_arg(size) 会被展开成类似:

x86_64 下(示意)

(size_t)PT_REGS_PARM1(ctx)

也就是说:

return ____uprobe_malloc(
    (size_t)PT_REGS_PARM1(ctx)
);

六、最终“完全展开后的等价代码”(教学级)

你这行:

int BPF_UPROBE(uprobe_malloc, size_t size)
{
    bpf_printk("malloc(%lu)\n", size);
    return 0;
}

等价于你手写下面这坨代码:

SEC("uprobe")
int uprobe_malloc(struct pt_regs *ctx)
{
    size_t size = (size_t)PT_REGS_PARM1(ctx);
    bpf_printk("malloc(%lu)\n", size);
    return 0;
}

👉 这就是全部真相。


七、那为什么要搞这么复杂?

1️⃣ 架构无关

  • PT_REGS_PARM1 在 x86 / arm64 下不同
  • 宏统一处理

2️⃣ 类型友好(CO-RE 友好,但不是 CO-RE)

(size_t)PT_REGS_PARM1(ctx)
  • 有类型
  • 有编译期检查
  • 但 ❗ 这不是 BTF / CO-RE

3️⃣ verifier 友好

  • 内联函数
  • 无函数指针
  • verifier 能完整展开 CFG

八、为什么要有两个函数(name / ____name)?

这是一个非常经典的内核/编译器技巧

函数 角色
name(struct pt_regs *ctx) BPF 程序入口(SEC 绑定的函数)
____name(ctx, args…) 真正写逻辑的地方

这样做的好处:

  • 入口函数签名固定(BPF 要求)
  • 逻辑函数签名友好(给人写)

九、BPF_KPROBEBPF_UPROBE 的唯一区别

解包器
BPF_KPROBE ___bpf_kprobe_args()
BPF_UPROBE ___bpf_uprobe_args()

👉 区别只在“寄存器 ABI 解释规则”


十、一句话终极总结(非常重要)

BPF_KPROBE / BPF_UPROBE**
****不是魔法,也不是 CO-RE,
****它只是把 pt_regs → 参数 的脏活,
**用宏和内联函数帮你写完了。

posted @ 2026-06-02 00:35  BlackSnow  阅读(14)  评论(0)    收藏  举报