在排查网络收包问题时,抓包工具只能看到线路上的报文,却回答不了另一个关键问题:这条消息最终被哪个进程、哪个线程收走了,当时进程处于什么状态。eBPF给了我们一个很好的切入点,就是在recvmsg系统调用触发时,通过bpf_get_current_task拿到当前任务的task_struct,再顺着内核结构体把进程、套接字、缓冲区等信息挖出来。这篇文章围绕这个思路展开,讲清楚原理、写法与注意事项。

bpf_get_current_task的工作原理
在eBPF程序中,每个辅助函数都有明确的用途,bpf_get_current_task的作用是返回一个指向当前进程task_struct的指针。注意它的返回值类型是u64,在BPF C代码里需要显式转换成struct task_struct *才能访问成员。内核在编译BPF字节码时会验证这个指针的来源是否可信,因此我们在读取字段时必须借助BPF CO-RE机制,用BPF_CORE_READ这类宏去访问,而不是直接解引用。
为什么在recvmsg场景下要拿task_struct?因为recvmsg是系统调用,触发时一定发生在目标进程的上下文里。此时current指针就指向正在收消息的那个任务,通过它可以拿到进程PID、TGID、进程名字comm,甚至顺着files指针找到对应的文件描述符表,从而把接收行为与具体进程精确关联起来。这比单纯在网卡或协议栈层面对包做统计要精准得多。
需要提醒的是,不同内核版本中task_struct布局差异较大,直接写task->pid这种代码在新内核上很可能被验证器拒绝或者编译失败。正确姿势是引入vmlinux.h并配合BPF_CORE_READ宏,让libbpf在加载时根据当前内核的BTF信息做重定位,这样一份代码可以跑在多个内核版本上。
在recvmsg触发点上挂载BPF程序
追踪recvmsg有几个常用挂载点:一是kprobe方式挂在sys_recvmsg或者__sys_recvmsg内核函数上,二是用tracepoint挂syscalls:sys_enter_recvmsg,三是针对UDP场景挂udp_recvmsg这个内核函数。三种方式的粒度不同:syscall级别能看到用户态参数,协议栈函数级别能看到更底层的收包细节,可以按需选择,也可以组合使用。
下面给出一个基于kprobe的完整示例,在recvmsg触发时读取当前任务信息并输出到环形缓冲区:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct event_t {
u32 pid;
u32 tgid;
char comm[16];
u64 ts_ns;
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
SEC("kprobe/__sys_recvmsg")
int BPF_KPROBE(trace_recvmsg, int fd, struct user_msghdr *msg)
{
struct event_t *e;
struct task_struct *task;
/* 通过辅助函数获取当前任务的task_struct指针 */
task = (struct task_struct *)bpf_get_current_task();
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
/* 用CO-RE方式读取字段,保证跨内核版本兼容 */
e->pid = BPF_CORE_READ(task, pid);
e->tgid = BPF_CORE_READ(task, tgid);
BPF_CORE_READ_STR_INTO(&e->comm, task, comm);
e->ts_ns = bpf_ktime_get_ns();
bpf_ringbuf_submit(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";用户态加载代码的核心逻辑是打开BPF对象、附加kprobe、然后消费ringbuf中的事件。libbpf 1.x之后的API已经统一成bpf_program__attach_kprobe形式,写起来非常简洁:
#include <bpf/libbpf.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
static volatile sig_atomic_t exiting = 0;
static void on_sigint(int sig) { exiting = 1; }
static int handle_event(void *ctx, void *data, size_t size)
{
struct event_t *e = data;
printf("tgid=%d pid=%d comm=%s ts=%llu\n",
e->tgid, e->pid, e->comm, e->ts_ns);
return 0;
}
int main(void)
{
struct bpf_object *obj;
struct ring_buffer *rb;
signal(SIGINT, on_sigint);
obj = bpf_object__open_file("recv_trace.bpf.o", NULL);
bpf_object__load(obj);
bpf_object__attach_skeleton_optional_or_manual(obj);
rb = ring_buffer__new(bpf_object__find_map_fd_by_name(obj, "events"),
handle_event, NULL, NULL);
while (!exiting)
ring_buffer__poll(rb, 100);
ring_buffer__free(rb);
bpf_object__close(obj);
return 0;
}编译时先用bpftool从当前内核生成vmlinux.h,再用clang以-target bpf编译BPF侧代码,用户态程序链接libbpf即可。运行之后随便起一个nc或者curl,就能在终端看到对应的tgid与进程名,这就是消息接收被任务级追踪的直接证据。
从task_struct深挖更多接收上下文
只输出PID和comm信息还不够,实际排查时往往要顺着task_struct继续往下走。比如通过BPF_CORE_READ(task, files)拿到文件表,再结合传入的fd参数定位到struct file,进而取出对应的socket结构,读取sk_rcvbuf、sk_data_ready等字段,就能知道接收缓冲区的占用情况。这一条链路在读到sk之后,等于把进程与网络端点串起来了。
另一个实用技巧是结合bpf_get_current_task_btf,它是bpf_get_current_task的强化版本,直接返回有类型的指针,省去手动强转,而且配合BTF后代码可读性更好。不过要注意它的可用内核版本略新,如果目标是兼容老内核,还是老函数加CO-RE读取更稳妥。
在做安全审计时,还可以对比current与套接字归属进程是否一致,识别出跨进程操作描述符的情况;在性能分析时,把recvmsg触发的时间戳与后续io_uring或epoll唤醒的时间戳做差,可以量化消息从到达内核到被应用感知的延迟。这些都是拿到task_struct之后自然延伸出来的分析手段。
验证器限制与常见踩坑点
eBPF验证器对指针解引用非常严格,直接写task->pid大概率会被拒绝。必须全部换成BPF_CORE_READ系列宏,或者在老代码里用bpf_probe_read_kernel显式拷贝内存。循环读取变长结构时要提前把循环边界告诉验证器,例如用#pragma unroll或者固定上限,否则加载阶段就会报错。
kprobe挂载点名称随内核版本变动也比较频繁,__sys_recvmsg在某些版本里参数顺序不同,甚至函数本身被内联。稳妥做法是先用bpftrace或者cat /proc/kallsyms确认目标符号存在,再决定挂载点;追求稳定可以直接换tracepoint,虽然信息略少但兼容性好得多。
最后是性能与开销的权衡。kprobe加上ringbuf输出在每秒几十万次recv调用的服务器上会产生可测量的开销,建议在事件里加采样条件,比如只记录tgid等于目标进程的事件,或者用hash map做聚合统计,等触发条件满足后再输出明细。这样既能拿到想要的信息,又能把对生产环境的影响压到最低。
小结
bpf_get_current_task把recvmsg的追踪从报文层面拉到了进程层面,这是很多传统工具做不到的能力。配合CO-RE读取和ringbuf输出,一套代码就能回答谁在收消息、收得频繁不频繁、缓冲区状态如何这一连串问题。掌握这个模式之后,同样的思路还可以平移到sendmsg、connect、accept等系统调用上,构建出一套完整的网络行为观测方案。
eBPFbpf_get_current_taskrecvmsg修改时间:2026-09-04 01:27:03