不能直接用 tracepoint:exceptions:page-fault-user,因其在5.10+内核被移除、架构受限、不区分缺页类型且不暴露虚拟地址和映射信息;需hook内核缺页函数并结合mmap特征识别Go内存区域。

为什么 page-fault 事件在 eBPF 中不能直接用 tracepoint:exceptions:page-fault-user?
因为 Linux 内核的 exceptions:page-fault-user tracepoint 并非稳定接口:它在 5.10+ 内核中被移除,且即使存在,也只在特定架构(如 x86)上触发,ARM64 默认不启用。更关键的是,该 tracepoint 不区分缺页类型(major/minor),也不暴露触发缺页的虚拟地址和内存映射信息——而这恰恰是定位 Go 程序内存问题的核心。
真正可靠的方式是 hook do_page_fault(ARM64)或 do_user_addr_fault(x86_64)内核函数,但 Go 程序的缺页行为有特殊性:大量缺页发生在 mmap 分配的匿名内存(如堆、goroutine 栈)上,而 Go runtime 自己管理这些区域,不会走常规的 handle_mm_fault 路径。所以必须同时覆盖:
-
handle_mm_fault—— 普通用户态 mmap 缺页(如大块切片首次访问) -
do_anonymous_page/do_swap_page—— 匿名页/换入页路径(Go 堆分配常见) -
__do_sys_mmap+__do_sys_munmap—— 辅助判断哪些 vma 是 Go runtime 创建的(通过prot和flags推断)
如何让 eBPF 程序识别 Go runtime 分配的内存区域?
Go runtime 在调用 mmap 分配堆或栈时,会设置特定的 prot 和 flags 组合:比如 goroutine 栈通常为 PROT_READ|PROT_WRITE + MAP_ANONYMOUS|MAP_PRIVATE|MAP_STACK;而堆区(如 runtime.sysAlloc)常用 MAP_ANONYMOUS|MAP_PRIVATE,且 len 往往是 2MB 对齐(大页 hint)。eBPF 无法直接读取 userspace 的 runtime.mheap,但可通过以下方式交叉验证:
- 在
__do_sys_mmap返回路径中,用bpf_get_current_pid_tgid()匹配 Go 进程 PID,并记录返回地址(ret)、addr、len、prot、flags到 per-CPU hash map - 在
handle_mm_faultentry 中,用args->address查找最近一次 mmap 的vma起始地址(需配合bpf_probe_read_kernel读取struct vm_area_struct的vm_start字段) - 若命中且
vm_flags & VM_WRITE && vm_flags & VM_ANON,再检查该 vma 是否在已记录的 mmap 地址范围内 —— 若是,大概率是 Go runtime 分配
注意:vm_area_struct 在不同内核版本字段偏移不同,必须用 #include + bpf_core_read(CO-RE)读取,硬编码 offset 在 5.15+ 上会崩溃。
怎样从缺页上下文中安全提取 Go 协程 ID 和函数调用栈?
eBPF 不能直接调用 runtime.g 或解析 Go 的 G 结构体(无 DWARF 符号时不可靠),但可利用两个事实:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- Go 协程切换时,会把当前
G*存入寄存器(x86_64 是gs:0x0,ARM64 是tpidr_el0) - Go 1.17+ 使用 PC-相对调用,栈帧中
runtime.gentraceback调用前,rbp(x86)或fp(ARM64)指向 goroutine 栈底附近
实操建议:
- 在
handle_mm_faultkprobe 中,用bpf_get_current_task()获取task_struct*,再通过bpf_core_read(&g_ptr, sizeof(g_ptr), &task->thread.uw.fpsimd_state)(不推荐)或更稳妥的:用bpf_probe_read_kernel(&g_ptr, sizeof(g_ptr), (void*)gs_base + 0)读取 gs 段首字节(Go runtime 将G*放在gs:0) - 用
bpf_get_stackid(ctx, &stack_map, 0)获取内核栈,再用bpf_get_stackid(ctx, &go_stack_map, BPF_F_USER_STACK)获取用户栈 —— 但 Go 用户栈可能被 runtime 移动,需配合runtime.curg地址校验 - 最轻量方式:不解析
G,只记录current->pid+current->comm(即 "myapp")+ 缺页地址 + 时间戳,后端用pprof或perf script关联 Go 符号
为什么用 libbpfgo 而不是 ebpf-go 加载程序?
ebpf-go(aka cilium/ebpf)对 CO-RE 支持较弱,且默认使用 perf_events 输出,难以与 Go runtime 的 GC 周期对齐;而 libbpfgo 直接封装 libbpf C API,支持:
- 自动加载
BTF并重写bpf_core_read字段访问(避免内核结构体变更导致的 crash) - 用
ringbuf替代perf_buffer,降低丢包率(缺页事件密集时perf_buffer易 overflow) - 允许在 Go 侧注册
RingbufCallback,直接将事件反序列化为 struct,无需unsafe.Pointer手动解析
示例关键代码片段(Go 侧):
spec, err := LoadMemfaultEbpf()
if err != nil {
log.Fatal(err)
}
obj := &MemfaultEbpfObjects{}
if err := spec.LoadAndAssign(obj, &ebpf.CollectionOptions{
Programs: ebpf.ProgramOptions{LogLevel: 1},
}); err != nil {
log.Fatal(err)
}
rb, err := libbpfgo.NewRingBuffer("events", obj.IssuePagefault.RingBuf, func(data []byte) {
var evt PageFaultEvent
binary.Read(bytes.NewReader(data), binary.LittleEndian, &evt)
log.Printf("pid=%d addr=0x%x major=%t", evt.Pid, evt.Addr, evt.Major)
})
if err != nil {
log.Fatal(err)
}
rb.Start()
真正难的不是写 eBPF,而是让 Go runtime 不把 eBPF map 当成可回收内存 —— 必须在 Go 初始化时用 C.mmap 预占一段虚拟地址,再用 bpf_map__set_initial_value 绑定 map,否则 runtime GC 可能误判并释放 map 内存。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










