-
如何跟踪用户态程序中的函数调用,寄存器参数的读取 ?
-
如何使用
eBPF的uprobe探针 ? -
如何设置
eBPF程序的常量 ? -
如何使用
ringbuf接收eBPF内核态程序传递的事件 ? -
如何使用
cilium/ebpf库加载和接收eBPF内核态程序传递的事件 ? -
如何追踪
Go程序中goroutine的状态变化,但不想修改源代码或重新编译二进制文件 ?
使用 eBPF 的 uprobes 来检测 Go 运行时中的 runtime.casgstatus 函数. 这允许你在运行时捕获 goroutine 的状态转换.
goroutine.h 文件内容如下, 它定义了 goroutine 的状态,以及 goroutine_execute_data 结构体,用于存储 goroutine 状态变化的数据. goroutine 的状态必须 Go 运行时版本一致,否则可能出现不匹配的情况(用例版本是:go version go1.22.4 linux/amd64).
#ifndef EBPF_EXAMPLE_GOROUTINE_H
#define EBPF_EXAMPLE_GOROUTINE_H
enum goroutine_state
{
IDLE,
RUNNABLE,
RUNNING,
SYSCALL,
WAITING,
MORIBUND_UNUSED,
DEAD,
ENQUEUE_UNUSED,
COPYSTACK,
PREEMPTED,
};
struct goroutine_execute_data
{
enum goroutine_state old_state;
enum goroutine_state new_state;
u64 goid;
u32 pid;
u32 tgid;
};
#endif需要重点要跟踪的是 runtime.casgstatus 函数,这个函数在 runtime/proc.go 中定义:
// If asked to move to or from a Gscanstatus this will throw. Use the castogscanstatus
// and casfrom_Gscanstatus instead.
// casgstatus will loop if the g->atomicstatus is in a Gscan status until the routine that
// put it in the Gscan state is finished.
//
//go:nosplit
func casgstatus(gp *g, oldval, newval uint32) {
if (oldval&_Gscan != 0) || (newval&_Gscan != 0) || oldval == newval {
systemstack(func() {
// Call on the systemstack to prevent print and throw from counting
// against the nosplit stack reservation.
print("runtime: casgstatus: oldval=", hex(oldval), " newval=", hex(newval), "\n")
throw("casgstatus: bad incoming values")
})
}
lockWithRankMayAcquire(nil, lockRankGscan)
// See https://golang.org/cl/21503 for justification of the yield delay.
const yieldDelay = 5 * 1000
var nextYield int64
......goroutine 需要改变状态时,就会调用这个函数. 可以看到这个函数有三个参数,分别表示:
-
gp:指向goroutine的指针. -
old:旧的状态值.uint32类型. -
new:新的状态值.uint32类型.
goroutine 的状态值定义在 runtime2.go#L37中,枚举类型,在头文件中也做了相应的定义. (这里没有处理_Gscan状态,这个状态是给垃圾回收器使用的)
eBPF 内核态程序如下:
#include "vmlinux.h"
#include "goroutine.h"
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
const volatile int pid_target = 0;
#define GOID_OFFSET 0x98
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
const struct goroutine_execute_data *unused __attribute__((unused));
SEC("uprobe//home/fangyuan/code/go/src/github.com/767829413/advanced-go/eBPF/uprobes/demo/main:runtime.casgstatus")
int uprobe_runtime_casgstatus(struct pt_regs *ctx) {
void *gp = (void *)ctx->ax;
u32 oldval = (u32)ctx->bx;
u32 newval = (u32)ctx->cx;
u64 tgid_pid = bpf_get_current_pid_tgid();
u32 pgid = tgid_pid >> 32;
u32 pid = tgid_pid;
if (pid_target && pid_target != pgid)
return false;
struct goroutine_execute_data *data;
u64 goid;
if (bpf_probe_read_user(&goid, sizeof(goid), gp + GOID_OFFSET) == 0) {
data = bpf_ringbuf_reserve(&rb, sizeof(*data), 0);
if (data) {
data->pid = pid;
data->tgid = pgid;
data->goid = goid;
data->new_state = newval;
data->old_state = oldval; // Assuming you have added this field to the struct
bpf_printk("pgid:%d, pid:%d, goid: %lu, old state: %d, new state: %d", data->tgid, data->pid, goid, oldval, newval);
bpf_ringbuf_submit(data, 0);
}
}
return 0;
}
char LICENSE[] SEC("license") = "GPL";这段代码是一个使用 eBPF (Extended Berkeley Packet Filter) 技术的程序,用于跟踪 Go 语言程序中 goroutine 的状态变化. 让我们逐步解析这段代码:
-
头文件和包含:代码开始包含了必要的头文件,如
vmlinux.h(用于 eBPF 程序)、bpf相关的头文件,以及自定义的goroutine.h.goroutine.h文件中定义了goroutine_execute_data结构体,用于存储goroutine状态变化的数据. -
全局变量:
pid_target是一个可以在运行时修改的变量,用于过滤特定进程 ID. -
常量定义:
GOID_OFFSET定义了goroutine ID在goroutine结构体中的偏移量. 避免Go版本升级后,结构体偏移量变化. -
环形缓冲区映射:定义了一个名为
rb的BPF环形缓冲区映射,用于将数据从eBPF程序传递到用户空间. -
eBPF程序:uprobe_runtime_casgstatus是主要的eBPF程序,它被附加到Go运行时的runtime.casgstatus函数上. -
程序逻辑:
-
从
CPU寄存器中提取函数参数(goroutine指针、旧状态值、新状态值). -
获取当前进程的
PID和TGID(线程组ID). -
如果设置了
pid_target,则只处理匹配的进程. -
读取
goroutine ID. -
创建一个
goroutine_execute_data结构体,填充相关信息. -
使用
bpf_printk打印调试信息. -
将数据提交到环形缓冲区.
- 许可声明:声明使用 GPL 许可证,这是使用某些 BPF 辅助函数所必需的.
这个程序允许你监控 Go 程序中 goroutine 的状态变化,而无需修改原始程序. 它可以用于调试、性能分析或了解 Go 程序的并发行为.
这里在启动一个程序作为观测对象(github.com/767829413/advanced-go/eBPF/uprobes/demo/main)
./eBPF/uprobes/demo/main
Main goroutine ID: 1
Goroutine 0: Iteration 0
Goroutine 1: Iteration 0还要获取 pid
ps -ef | grep './eBPF/uprobes/demo/main'
fangyuan 374855 335569 0 15:02 pts/8 00:00:00 ./eBPF/uprobes/demo/main
fangyuan 379190 153170 0 15:05 pts/10 00:00:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn ./eBPF/uprobes/demo/main现在可以使用 ecc 编译,ecli 运行就可以了.
./ecc ./eBPF/uprobes/uprobes_goroutine.c ./eBPF/uprobes/goroutine.h
INFO [ecc_rs::bpf_compiler] Compiling bpf object...
INFO [ecc_rs::bpf_compiler] Generating export types...
INFO [ecc_rs::bpf_compiler] Generating package json..
INFO [ecc_rs::bpf_compiler] Packing ebpf object and config into ./eBPF/uprobes/package.json...
sudo ./ecli run ./eBPF/uprobes/package.json --pid_target=374855
INFO [faerie::elf] strtab: 0xa47 symtab 0xa80 relocs 0xac8 sh_offset 0xac8
INFO [bpf_loader_lib::skeleton::preload::section_loader] load runtime arg (user specified the value through cli, or predefined in the skeleton) for pid_target: Number(336281), real_type=<INT> 'int' bits:32 off:0 enc:signed, btf_type=BtfVar { name: "pid_target", type_id: 14, kind: GlobalAlloc }
INFO [bpf_loader_lib::skeleton::preload::section_loader] received bytes [153, 33, 5, 0]
INFO [bpf_loader_lib::skeleton::preload::section_loader] User didn't specify custom value for variable uprobe_runtime_casgstatus.___fmt, use the default one in ELF
INFO [bpf_loader_lib::skeleton::preload::section_loader] User didn't specify custom value for variable unused, use the default one in ELF
INFO [bpf_loader_lib::skeleton::preload::section_loader] User didn't specify custom value for variable __eunomia_dummy_goroutine_execute_data_ptr, use the default one in ELF
INFO [bpf_loader_lib::skeleton] Running ebpf program...
TIME OLD_STATE NEW_STATE GOID PID TGID
14:32:46 WAITING(4) RUNNABLE(1) 7 336284 336281
14:32:46 WAITING(4) RUNNABLE(1) 10 336284 336281
14:32:46 WAITING(4) RUNNABLE(1) 8 336284 336281
14:32:46 WAITING(4) RUNNABLE(1) 17 336284 336281
14:32:46 WAITING(4) RUNNABLE(1) 9 336284 336281
14:32:46 RUNNABLE(1) RUNNING(2) 9 336284 336281
14:32:46 RUNNING(2) SYSCALL(3) 9 336284 336281如果使用cilium/ebpf库, 套路还是一样的, 我们先产生“桩代码” : //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" -target bpf --go-package tool -output-dir tool Tool uprobes_goroutine.c -- -I/usr/include/bpf -I/usr/include/linux,然后实现 main.go 文件中具体的加载和 attach 等逻辑.
当然一开始我们先定义 GoroutineState 类型,与 C 语言的 enum goroutine_state 对应:
package goroutineState
import "fmt"
type GoroutineState uint32
const (
IDLE GoroutineState = iota
RUNNABLE
RUNNING
SYSCALL
WAITING
MORIBUND_UNUSED
DEAD
ENQUEUE_UNUSED
COPYSTACK
PREEMPTED
)
func (s GoroutineState) String() string {
switch s {
case IDLE:
return "IDLE"
case RUNNABLE:
return "RUNNABLE"
case RUNNING:
return "RUNNING"
case SYSCALL:
return "SYSCALL"
case WAITING:
return "WAITING"
case MORIBUND_UNUSED:
return "MORIBUND_UNUSED"
case DEAD:
return "DEAD"
case ENQUEUE_UNUSED:
return "ENQUEUE_UNUSED"
case COPYSTACK:
return "COPYSTACK"
case PREEMPTED:
return "PREEMPTED"
default:
return fmt.Sprintf("UNKNOWN(%d)", s)
}
}
// GoroutineExecuteData 与 C 语言的 struct goroutine_execute_data 对应
type GoroutineExecuteData struct {
OldState GoroutineState
NewState GoroutineState
Goid uint64
Pid uint32
Tgid uint32
}接下来就是正常套路,加载编译的 eBPF 程序,attach 到目标函数,然后读取 ringbuf 中的数据. 注意数据的解析,使用 binary.Read 从字节流中读取数据,使用小端序解码,然后转换为 GoroutineExecuteData 类型.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" -target bpf --go-package tool -output-dir tool Tool uprobes_goroutine.c -- -I/usr/include/bpf -I/usr/include/linux
package main
import (
"bytes"
"encoding/binary"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
"github.com/767829413/advanced-go/eBPF/uprobes/goroutineState"
"github.com/767829413/advanced-go/eBPF/uprobes/tool"
)
var pidTarget int
func main() {
// 解析命令行参数
flag.IntVar(&pidTarget, "pid", 0, "Target PID for filtering")
flag.Parse()
// Allow the current process to lock memory for eBPF resources.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
// 加载编译的 eBPF 程序
spec, err := tool.LoadTool()
if err != nil {
log.Fatalf("loading BPF program: %v", err)
}
// Load pre-compiled programs and maps into the kernel.
objs := tool.ToolObjects{}
if err := spec.LoadAndAssign(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
// Open a uprobe for the runtime.casgstatus function.
ex, err := link.OpenExecutable("/opt/ddns-go/ddns-go")
if err != nil {
log.Fatalf("opening executable: %v", err)
}
err = spec.RewriteConstants(map[string]interface{}{
"pid_target": uint32(pidTarget),
})
if err != nil {
log.Fatalf("rewriting constants: %v", err)
}
// up, err := ex.Uretprobe("runtime.casgstatus", objs.UprobeRuntimeCasgstatus, nil)
up, err := ex.Uprobe("runtime.casgstatus", objs.UprobeRuntimeCasgstatus, nil)
if err != nil {
log.Fatalf("creating uretprobe: %v", err)
}
defer up.Close()
// Open a ring buffer to receive events from the eBPF program.
rb, err := ringbuf.NewReader(objs.Rb)
if err != nil {
log.Fatalf("opening ringbuf reader: %v", err)
}
defer rb.Close()
// 捕获 SIGINT 和 SIGTERM 信号以正确退出
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
fmt.Println("Listening for events...")
// 读取 ring buffer 中的数据
go func() {
for {
// 读取事件
record, err := rb.Read()
if err != nil {
log.Printf("reading from ringbuf: %v", err)
return
}
// 将事件解析为 GoroutineExecuteData 结构
var data goroutineState.GoroutineExecuteData
err = binary.Read(bytes.NewReader(record.RawSample), binary.LittleEndian, &data)
if err != nil {
log.Printf("parsing event: %v", err)
return
}
// 输出 goroutine 信息
fmt.Printf("TGID: %d, PID: %d, GoID: %d, OldState: %s, NewState: %s\n",
data.Tgid, data.Pid, data.Goid, data.OldState.String(), data.NewState.String())
}
}()
// 等待退出信号
<-sig
fmt.Println("Exiting...")
}然后执行这个 main.go
sudo go run main.go --pid=374855
Listening for events...
TGID: 374855, PID: 374862, GoID: 36, OldState: WAITING, NewState: RUNNABLE
TGID: 374855, PID: 374862, GoID: 36, OldState: RUNNABLE, NewState: RUNNING
TGID: 374855, PID: 374862, GoID: 36, OldState: RUNNING, NewState: SYSCALL
TGID: 374855, PID: 374864, GoID: 33, OldState: WAITING, NewState: RUNNABLE
......Uprobes(用户级探针)是一种强大的追踪机制,允许你动态地检测用户空间应用程序. 与内核中预定义的静态跟踪点(内核跟踪点)不同,uprobes 可以附加到用户空间程序的任何指令上.
在这个例子中,我们使用 uprobe 来拦截 Go 程序中 runtime.casgstatus 函数的调用. 这个函数负责改变 goroutine 的状态,使其成为追踪 goroutine 状态转换的理想目标.
以下是该解决方案的工作原理:
-
设置 uprobe:在 Go 代码中,我们使用
cilium/ebpf库打开目标可执行文件,并将uprobe附加到runtime.casgstatus函数上. 这个 uprobe 链接到我们的 eBPF 程序UprobeRuntimeCasgstatus. -
eBPF 程序:每次调用
runtime.casgstatus时,eBPF程序uprobe_runtime_casgstatus都会被触发. 它接收一个struct pt_regs *ctx参数,其中包含函数调用时的 CPU 寄存器值. -
访问函数参数:eBPF 程序从
CPU寄存器中提取函数参数. 在这个例子中,ctx->ax包含goroutine指针,ctx->bx包含旧状态,ctx->cx包含新状态. -
处理和提交数据:程序然后从内存中读取
goroutine ID,创建一个包含相关信息的goroutine_execute_data结构体,并将其提交到环形缓冲区,供用户空间程序消费. -
过滤:程序包含一个
PID过滤器(pid_target),可以选择只追踪特定的进程.
这种方法允许你在不修改 Go 程序或其运行时的情况下,深入了解 goroutine 的行为. 它对于调试、性能分析和理解复杂的并发 Go 应用程序的行为特别有用.
然而,需要注意的是,这种方法依赖于 Go 运行时的内部细节,这些细节可能在不同的 Go 版本之间发生变化. 始终要针对你所针对的特定 Go 版本测试你的 eBPF 程序.
这里可以写一个简单的程序,模拟 runtime.casgstatus 的调用,然后在反汇编看看:
package main
import (
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runtime.Gosched() // 让出 CPU,触发 goroutine 状态变化
}()
wg.Wait()
}编译这个程序:
## 禁止编译器优化
go build -gcflags="-N -l" -o casgstatus_example ./github.com/767829413/advanced-go/eBPF/uprobes/casgstatus_example/casgstatus_example.go然后使用 go tool objdump 来反编译并查看 runtime.casgstatus 的调用:
go tool objdump casgstatus_example | grep -C 1 'runtime.casgstatus'可以看到三个参数分别放入了AX、BX、CX寄存器中.
根据Go ABI 规范,amd64 架构下的寄存器使用规则如下:
amd64 架构使用以下序列的 9 个寄存器来表示整数参数和结果:
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11
它使用 X0 – X14 作为浮点参数和结果.
基本原理:这些序列是从可用的寄存器中选择的,以便相对容易记住.
寄存器 R12 和 R13 是永久暂存寄存器. R15 是一个暂存寄存器,但动态链接的二进制文件除外.
基本原理:某些操作(如堆栈增长和反射调用)需要专用的暂存寄存器,以便在不损坏参数或结果的情况下操作调用帧.
| 寄存器 | Call meaning | Body meaning |
|---|---|---|
| RSP | Stack pointer | Fixed |
| RBP | Frame pointer | Fixed |
| RDX | Closure context pointer | Scratch |
| R12 | None | Scratch |
| R13 | None | Scratch |
| R14 | 当前 goroutine | Scratch |
| R15 | GOT reference temporary | Fixed if dynlink |
| X15 | Zero value | Fixed |
所以呢,这三个参数按照寄存器使用规则,也是要分别放在AX、BX、CX寄存器中.
-
PID是操作系统分配给每个进程的唯一标识符. 每个进程在系统中都有一个独立的PID,用来标识和管理进程. 父进程创建子进程时,子进程会继承父进程的环境,但会获得一个新的PID. -
你可以通过
getpid()系统调用来获取当前进程的PID. -
线程在
Linux中也被分配了一个PID,但它实际上是该线程的TID(线程ID).
-
PGID用于将一组相关的进程归类到一个进程组中. 多个进程可以共享同一个PGID,通常它们是由同一个父进程派生出来的. 一个进程组中的所有进程都可以由相同的控制终端来管理,尤其是在作业控制(如shell中的前台和后台任务)中. -
进程组的创建由一个进程启动新进程时完成. 最开始的进程会成为组长,并且它的
PGID等于它的PID. -
你可以通过
getpgid()或getpgrp()来获取进程的PGID.
区别与关系
-
PID是进程的唯一标识,而PGID用来标识一组相关的进程. 一个进程的PID可能会和它的PGID相同,特别是当这个进程是进程组组长时. -
通过进程组,可以实现对多个进程的统一管理,比如在终端中可以使用
kill -pgid来杀掉整个进程组中的所有进程. -
所有线程与它们的父进程共享相同的 PGID.
因此代码中的 pid_target 是用来过滤运行 pgid 的.