Rust 系列 04: eBPF + Uprobes 组合解析 Go 程序 gRPC 头部信息
1. Uprobes 简介
在上一篇文章中,我们探讨了 kprobes 并演示了无需修改源代码,即可以检测内核空间的 HTTP 服务器以提取 HTTP 协议头部信息的简单的 eBPF 示例。
在今天的文章中,我们将重点转向 uprobes(用户探针),其允许对用户空间应用程序进行类似的检测,解决了 kprobes 无法覆盖的使用场景。
与 kprobes 机制允许将 eBPF 程序附加到内核函数一样,uprobes 让我们能够将 eBPF 程序附着到用户空间二进制文件中的函数。这种方式为 Go 等语言编写的现代应用程序提供了强大的可观测性能力。特别是,我们将探讨如何通过将 uprobes 附加到标准库函数(例如 net/http
、google.golang.org/grpc
)和用户定义的逻辑来检测 Go 二进制文件。
要使用 uprobes 机制观测 Go 函数,我们必须了解 Go 语言函数参数在内存中布局。这对于 Go 1.17+ 尤为重要,因为它引入了基于寄存器的调用约定,改变了函数之间传递参数的方式。本文,我们将学习如何检查进程内存并通过理解 Go 的调用约定、内存寻址和指针解引用技术来提取函数参数。
Go 编写的二进制文件在云原生环境中非常普遍。使用 eBPF uprobes 在运行时无需修改或重新编译源代码对其进行检测,可以实现深度可观测性,成为生产环境调试和监控的理想选择。
在本文中,我们将:
- 了解 Go 如何在
Go 1.17
及更高版本中传递函数参数布局(例如 int、string、struct) - 使用
nm
和objdump
等工具定位 Go 二进制文件中的函数符号 - 将 uprobes 附加到 Go 函数并提取运行时参数
- 使用
delve
调试 Go 程序并检查用于函数挂钩的内存布局 - 应用这些技术通过 eBPF uprobes 从基于 Go 的 gRPC 服务解析 gRPC 头部
到本文结束时,你将将扎实理解如何利用 uprobes 对 Go 应用程序进行深度观测,为生产环境中的可观测性和调试开辟新的可能性。
2. 架构总览
3. 先决条件和设置
在深入技术细节之前,让我们建立测试环境和本文中需要用到的工具。
系统要求
- Linux x86_64(内核 4.1+ 支持 uprobe)
- root 权限或用于 eBPF 操作适合的能力(capabilities)
开发工具
-
Rust:用于 eBPF 程序开发和附加的用户空间框架
-
Go 1.17+:我们的检测示例的目标语言
-
delve:Go 调试器,用于检查内存布局并验证我们的方法
-
binutils:用于 nm、objdump 和其他二进制分析工具
安装和设置
如果尚未安装 delve,请执行以下命令:
|
|
启用 ptrace 以便 delve 附加到运行中的进程:
|
|
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 个寄存器来传递函数参数:
|
|
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 中成功提取函数参数,我们需要遵循以下系统化方法:
- 定位目标函数:获取我们要检测的 Go 函数的偏移地址
- 分析参数布局:确定参数是通过寄存器、栈还是两者的组合传递
- 将参数映射到位置:识别哪些特定寄存器或栈偏移包含我们的目标数据
有了这些信息,我们就可以在调用相应的 Go 函数时准确地探测并提取我们感兴趣的参数。
必要阅读
该方法基于 Go 的官方 ABI 规范。强烈建议阅读 Go ABI 内部文档,以了解管理 Go 如何将参数传递给函数的详细规则。有了这个基础,让我们继续讨论定位函数和确定内存偏移的实际步骤。
5. 在 Go 二进制文件中定位函数
在附加 uprobes 之前,我们需要确定 Go 二进制文件中我们要检测的特定函数。Go 提供了几种函数发现方法。
符号表和调试信息
Go 二进制文件包含将函数名称映射到其内存地址的符号表。然而,这些符号的可用性和格式取决于二进制文件的编译方式。
在我们的例子中,使用以下命令编译:
|
|
查找目标函数
使用这些工具探索 Go 二进制文件中的可用函数:
|
|
当然,也可以使用 objdump
或 go tool
来提取信息
6. 确定内存偏移
一旦我们在符号表中找到了目标函数,我们需要计算 uprobe 附加的正确偏移地址。此偏移表示函数在进程虚拟地址空间中的位置。
计算函数偏移
偏移地址是虚拟地址空间内存布局的一部分。我们将 uprobe 附加到此计算的偏移地址。
步骤 1:获取函数的虚拟地址
|
|
输出:
|
|
步骤 2:找到基加载地址
|
|
输出:
|
|
步骤 3:计算偏移
|
|
程序化偏移计算
在我们的 Rust 用户空间程序中,此偏移计算由辅助函数处理:
|
|
7. 深入探讨:Go 参数传递模式
根据官方ABI 规范检查 Go 如何将不同数据类型作为函数参数传递。
整数
每个简单类型占用一个寄存器:
|
|
eBPF 提取:
|
|
重要提示:如果函数有接收者,接收者占用 RAX,参数移至后续寄存器。
字符串:指针 + 长度结构
Go 字符串在内部表示为:
|
|
对于具有多个参数的函数:
|
|
eBPF 提取:
|
|
结构体:值传递与指针传递
按值传递结构体:各个字段按顺序分配给寄存器
“如果 T 是结构体类型,递归地对 V 的每个字段进行寄存器分配。”
按指针传递结构体:只有指针占用一个寄存器
“如果 T 是指针类型,将 V 分配给寄存器 I 并递增 I。”
选择极大地影响提取策略。这两种情况,请参见对应示例代码。
切片:三字段结构
Go 切片包含三个组件:
|
|
按值传递时,每个字段占用连续的寄存器。
浮点数: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 代码库后,我确定了完美的目标:
|
|
grpc-go 包中的这个函数在头部被发送到客户端之前处理解压缩的头字段 —— 正是我们所需要的!
使用 Delve 进行调试验证工作
但是 frame 参数位于何处 — 寄存器还是栈?是时候进行一些调试了:
|
|
完美匹配!Frame 指针位于 RDI 寄存器中,与我们对调用约定的理解完全一致。
我们需要解析的结构体
有了寄存器位置,我们现在需要解析两个关键结构:
- MetaHeadersFrame:包含帧元数据
- Fields:各个头部名称 - 值对
eBPF 实现
利用我们从前面部分获得的内存布局知识,我们现在可以在 eBPF 中遍历这些结构:
|
|
9. 自己尝试
完整工作代码:maheshrayas/blogs
该仓库包含:
测试结果
目前,基本实现将头部数据输出到内核跟踪管道:
目前,程序在 /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
- 原文作者:DavidDi
- 原文链接:https://www.ebpf.top/post/04-ebpf-uprobes-decoding-go-function-arguments-registers-memory-layout-to-parse-grpc-headers/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议. 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 最后更新时间:2025-08-23 21:17:15.021039587 +0800 CST

