本文地址:https://www.ebpf.top/post/04-ebpf-uprobes-decoding-go-function-arguments-registers-memory-layout-to-parse-grpc-headers

1. Uprobes 简介

上一篇文章中,我们探讨了 kprobes 并演示了无需修改源代码,即可以检测内核空间的 HTTP 服务器以提取 HTTP 协议头部信息的简单的 eBPF 示例。

在今天的文章中,我们将重点转向 uprobes(用户探针),其允许对用户空间应用程序进行类似的检测,解决了 kprobes 无法覆盖的使用场景。

与 kprobes 机制允许将 eBPF 程序附加到内核函数一样,uprobes 让我们能够将 eBPF 程序附着到用户空间二进制文件中的函数。这种方式为 Go 等语言编写的现代应用程序提供了强大的可观测性能力。特别是,我们将探讨如何通过将 uprobes 附加到标准库函数(例如 net/httpgoogle.golang.org/grpc)和用户定义的逻辑来检测 Go 二进制文件。

要使用 uprobes 机制观测 Go 函数,我们必须了解 Go 语言函数参数在内存中布局。这对于 Go 1.17+ 尤为重要,因为它引入了基于寄存器的调用约定,改变了函数之间传递参数的方式。本文,我们将学习如何检查进程内存并通过理解 Go 的调用约定、内存寻址和指针解引用技术来提取函数参数。

Go 编写的二进制文件在云原生环境中非常普遍。使用 eBPF uprobes 在运行时无需修改或重新编译源代码对其进行检测,可以实现深度可观测性,成为生产环境调试和监控的理想选择。

在本文中,我们将:

  • 了解 Go 如何在 Go 1.17 及更高版本中传递函数参数布局(例如 int、string、struct)
  • 使用 nmobjdump 等工具定位 Go 二进制文件中的函数符号
  • 将 uprobes 附加到 Go 函数并提取运行时参数
  • 使用 delve 调试 Go 程序并检查用于函数挂钩的内存布局
  • 应用这些技术通过 eBPF uprobes 从基于 Go 的 gRPC 服务解析 gRPC 头部

到本文结束时,你将将扎实理解如何利用 uprobes 对 Go 应用程序进行深度观测,为生产环境中的可观测性和调试开辟新的可能性。

2. 架构总览

image

3. 先决条件和设置

在深入技术细节之前,让我们建立测试环境和本文中需要用到的工具。

系统要求

  • Linux x86_64(内核 4.1+ 支持 uprobe)
  • root 权限或用于 eBPF 操作适合的能力(capabilities)

开发工具

  • Rust:用于 eBPF 程序开发和附加的用户空间框架

  • Go 1.17+:我们的检测示例的目标语言

  • delve:Go 调试器,用于检查内存布局并验证我们的方法

  • binutils:用于 nm、objdump 和其他二进制分析工具

安装和设置

如果尚未安装 delve,请执行以下命令:

1
$ go install github.com/go-delve/delve/cmd/dlv@latest

启用 ptrace 以便 delve 附加到运行中的进程:

1
$ echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

Go 示例程序

在本教程中,我们将使用两个 Go 应用程序:

sample_go:一个简单的 Go 程序,旨在帮助我们分析不同参数类型(int、string、struct)的 Go 内存布局和调用约定。

sample_grpc:一个 Go gRPC 服务器,我们将使用 Rust+eBPF 跟踪器对其进行检测以提取 gRPC 头部。

Rust + eBPF

trace-grpc-headers。查看 readme 以启动并运行它

4. Go 调用约定:基于寄存器的革命(Go 1.17+)

注意:本文接受基于 amd64 架构。

从 Go 1.17 开始,Go 编译器采用了基于寄存器的调用约定,摒弃了早期版本中使用的传统纯栈方法。这一变化极大地影响了我们如何在 eBPF uprobes 中提取函数参数。

寄存器分配策略

Go 1.17+ 在 x86_64 上使用一组预定义的 9 个寄存器来传递函数参数:

1
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11
Register Call meaning Return meaning Body meaning
RSP Stack pointer Same Same
RBP Frame pointer Same Same
RDX Closure context pointer Scratch Scratch
R12 Scratch Scratch Scratch
R13 Scratch Scratch Scratch
R14 Current goroutine Same Same
R15 GOT reference temporary if dynlink Same Same
X15 Zero value (*) Same Scratch

检测策略

要在 eBPF uprobe 中成功提取函数参数,我们需要遵循以下系统化方法:

  1. 定位目标函数:获取我们要检测的 Go 函数的偏移地址
  2. 分析参数布局:确定参数是通过寄存器、栈还是两者的组合传递
  3. 将参数映射到位置:识别哪些特定寄存器或栈偏移包含我们的目标数据

有了这些信息,我们就可以在调用相应的 Go 函数时准确地探测并提取我们感兴趣的参数。

必要阅读

该方法基于 Go 的官方 ABI 规范。强烈建议阅读 Go ABI 内部文档,以了解管理 Go 如何将参数传递给函数的详细规则。有了这个基础,让我们继续讨论定位函数和确定内存偏移的实际步骤。

5. 在 Go 二进制文件中定位函数

在附加 uprobes 之前,我们需要确定 Go 二进制文件中我们要检测的特定函数。Go 提供了几种函数发现方法。

符号表和调试信息

Go 二进制文件包含将函数名称映射到其内存地址的符号表。然而,这些符号的可用性和格式取决于二进制文件的编译方式。

在我们的例子中,使用以下命令编译:

1
2
# 保留所有符号并禁用优化
go build -gcflags="all=-N -l" -o sample

查找目标函数

使用这些工具探索 Go 二进制文件中的可用函数:

1
2
3
4
5
# 列出所有带地址的函数:
nm -n myapp | grep " T "

# 搜索特定函数:
nm -n myapp | grep "main.hello"

当然,也可以使用 objdumpgo tool 来提取信息

6. 确定内存偏移

一旦我们在符号表中找到了目标函数,我们需要计算 uprobe 附加的正确偏移地址。此偏移表示函数在进程虚拟地址空间中的位置。

计算函数偏移

偏移地址是虚拟地址空间内存布局的一部分。我们将 uprobe 附加到此计算的偏移地址。

步骤 1:获取函数的虚拟地址

1
nm -n server | grep MyHandler

输出:

1
0000000000498f60 T main.MyHandler

步骤 2:找到基加载地址

1
readelf -l server | grep "LOAD"

输出:

1
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000

步骤 3:计算偏移

1
2
offset = function_address - base_load_address
offset = 0x498f60 - 0x400000 = 0x98f60

程序化偏移计算

在我们的 Rust 用户空间程序中,此偏移计算由辅助函数处理:

1
2
3
4
5
6
7
8
// 计算目标函数的偏移
let offset = calculate_function_offset(&binary_path, "main.MyHandler")?;

// 将 uprobe 附加到计算的偏移
go_sk
    .progs
    .handle_hello_int
    .attach_uprobe(false, pid, &binary_path, offset as usize)?;

7. 深入探讨:Go 参数传递模式

根据官方ABI 规范检查 Go 如何将不同数据类型作为函数参数传递。

整数

每个简单类型占用一个寄存器:

1
2
3
4
func hello_int(x int) int {
    fmt.Printf("hello_int %d\n", x)
    return x
}

eBPF 提取:

1
 int x = (int)ctx->ax;

重要提示:如果函数有接收者,接收者占用 RAX,参数移至后续寄存器。

字符串:指针 + 长度结构

Go 字符串在内部表示为:

1
2
3
4
type string struct {
    ptr *byte // 指向数据的指针
    len int // 长度
}

对于具有多个参数的函数:

1
2
3
4
func hello_int_string(x int, y string) int {
    fmt.Printf("hello_int_string %d %s\n", x, y)
    return x
}

eBPF 提取:

1
2
3
    int x = (int)ctx->ax;            // 第一个参数(int)-> RAX
    void *str_ptr = (void *)ctx->bx; // 字符串数据指针 -> RBX
    long str_len_raw = ctx->cx;      // 字符串长度(作为 long)-> RCX

结构体:值传递与指针传递

按值传递结构体:各个字段按顺序分配给寄存器

“如果 T 是结构体类型,递归地对 V 的每个字段进行寄存器分配。”

按指针传递结构体:只有指针占用一个寄存器

“如果 T 是指针类型,将 V 分配给寄存器 I 并递增 I。”

选择极大地影响提取策略。这两种情况,请参见对应示例代码

切片:三字段结构

Go 切片包含三个组件:

1
2
3
4
5
type slice struct {
    ptr *elementType  // 数据指针
    len int          // 元素计数  
    cap int          // 容量
}

按值传递时,每个字段占用连续的寄存器。

浮点数:XMM 挑战

浮点数使用单独的 XMM 寄存器(X0-X14),而不是通用寄存器。这带来了限制:

  • 可提取:通过指针传递的结构体中的浮点数(从内存中解引用)
  • 不可直接提取:单个浮点参数或按值传递的结构体中的浮点数

eBPF 当前无法直接访问 XMM 寄存器。有关详细信息,请参见StackOverflow 讨论

解决方法:使用基于指针的结构体传递进行浮点提取。

8. 实际应用:gRPC 头部提取

现在到了激动人心的部分,基于上述所学基础来解决一个实际问题:无需修改源代码,从运行中的 Go 服务中提取 gRPC 头部信息。

挑战:gRPC 与 HTTP/1.1

与我们上篇博客中解析 HTTP/1.1 头部(纯文本)不同,gRPC 提出了更大的挑战。gRPC 头部使用 HPACK 压缩,使得从网络数据包直接解析几乎不可能。

但这正是我们的 uprobe 知识发挥强大作用的地方 —— 我们可以在 Go 运行时解压缩头部后拦截它们。

找到要挂钩的正确函数

主要的参考来自于可观测工具 Pixie 的讨论。然而,他们的示例针对较旧的 Go 版本,不适用于 Go 1.17+ 的基于寄存器的调用约定。

在深入研究 gRPC-Go 代码库后,我确定了完美的目标:

1
2
3
4
5
func (t *http2Server) operateHeaders(
    ctx context.Context, 
    frame *http2.MetaHeadersFrame, 
    handle func(*ServerStream)
) error

grpc-go 包中的这个函数在头部被发送到客户端之前处理解压缩的头字段 —— 正是我们所需要的!

使用 Delve 进行调试验证工作

但是 frame 参数位于何处 — 寄存器还是栈?是时候进行一些调试了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
dlv attach 366200
(dlv) break google.golang.org/grpc/internal/transport.(*http2Server).operateHeaders
(dlv) continue

# 当断点命中时...
(dlv) args
frame = ("*golang.org/x/net/http2.MetaHeadersFrame")(0xc00020e0c0)

(dlv) regs
Rdi = 0x000000c00020e0c0  # 找到了!Frame 指针在 RDI 中

完美匹配!Frame 指针位于 RDI 寄存器中,与我们对调用约定的理解完全一致。

我们需要解析的结构体

有了寄存器位置,我们现在需要解析两个关键结构:

  1. MetaHeadersFrame:包含帧元数据
  2. Fields:各个头部名称 - 值对

eBPF 实现

利用我们从前面部分获得的内存布局知识,我们现在可以在 eBPF 中遍历这些结构:

1
void *frame_ptr = (void *)ctx->di;

9. 自己尝试

完整工作代码:maheshrayas/blogs

该仓库包含:

  • 具有各种头部类型的示例 gRPC 服务器
  • 带有详细注释的完整 eBPF 程序
  • 用于 uprobe 附加的 Rust 用户空间代码
  • 使用和验证的详细说明:readme.md

测试结果

目前,基本实现将头部数据输出到内核跟踪管道:

image

目前,程序在 /sys/kernel/tracing/trace_pipe 中打印所有头部键、值,当然我们也可以像之前的示例一样使用 Maps(RingBuffer 或 PerfEventArray)将头部传递到用户空间。

10. 调试、故障排除和经验教训

使用 Go uprobes 可能具有挑战性,尤其是在处理内存布局解析和参数位置检测时。以下是可能遇到的关键问题和解决方法。

内存布局解析:沉默的杀手

最令人沮丧的错误来自不正确的内存地址解析。单个字节偏移错误可能导致:

  • 提取垃圾数据
  • 在极端情况下导致内核恐慌
  • 无明显症状的静默失败

最佳实践:

  • 解引用之前始终验证提取的指针
  • 在 eBPF 程序中使用边界检查
  • 先测试简单数据类型,然后再转向复杂结构体。这就是我开始使用简单类型的方式,并且我附上了示例供您熟悉。

寄存器与栈:持续的谜团

确定参数是通过寄存器还是栈传递仍然很棘手。虽然本文使用 delve 进行此分析并理解 ABI 规范,但我并不完全相信这是最可靠的方法。

潜在的替代方法(仍在研究中):

  • DWARF 调试信息:从调试符号解析函数签名和调用约定
  • 使用 go tool compile -S 进行静态分析
  • 使用 objdump -d 进行汇编检查

征集意见:如果你有确定 Go 参数传递的静态分析技术经验,我很想听听您的见解!欢迎联系或为存储库做出贡献。

11. 结论

在本文中,我们从理解 Go 的基于寄存器的调用约定,到使用 eBPF uprobes 成功从运行中的应用程序中提取 gRPC 头部。我们学习了如何在 Go 二进制文件中定位函数、计算内存偏移、解析不同的参数类型,并将这些技术应用于解决实际的可观测性挑战。

我们涵盖的技术构成了强大的运行时内省能力的基础。通过将 uprobe 检测与通过环形缓冲区的高效用户空间通信相结合,我们可以构建复杂的可观测性工具,无需任何应用程序代码更改即可运行。

  • 原文链接:https://dev.to/maheshrayas/04-ebpf-uprobes-decoding-go-function-arguments-registers-memory-layout-to-parse-grpc-headers-6n8
  • 作者:Mahesh Rayas