跳转到主要内容

eBPF 实践:使用 Uprobe 追踪用户态 Rust 应用

eBPF,即扩展的Berkeley包过滤器(Extended Berkeley Packet Filter),是Linux内核中的一种革命性技术,它允许开发者在内核态中运行自定义的“微程序”,从而在不修改内核代码的情况下改变系统行为或收集系统细粒度的性能数据。

本文讨论如何使用 Uprobe 和 eBPF 追踪用户态 Rust 应用,包括如何获取符号名称并 attach、获取函数参数、获取返回值等。本文是 eBPF 开发者教程的一部分,更详细的内容可以在这里找到:https://eunomia.dev/tutorials/ 源代码在 GitHub 仓库 中开源。

Uprobe

Uprobe是一种用户空间探针,uprobe探针允许在用户空间程序中动态插桩,插桩位置包括:函数入口、特定偏移处,以及函数返回处。当我们定义uprobe时,内核会在附加的指令上创建快速断点指令(x86机器上为int3指令),当程序执行到该指令时,内核将触发事件,程序陷入到内核态,并以回调函数的方式调用探针函数,执行完探针函数再返回到用户态继续执行后序的指令。

uprobe 适用于在用户态去解析一些内核态探针无法解析的流量,例如 http2 流量,https 流量,同时也可以分析程序运行时、业务逻辑等。关于 Uprobe 的更多信息,可以参考:

Uprobe 在内核态 eBPF 运行时,也可能产生比较大的性能开销,这时候也可以考虑使用用户态 eBPF 运行时,例如 bpftime。bpftime 是一个基于 LLVM JIT/AOT 的用户态 eBPF 运行时,它可以在用户态运行 eBPF Uprobe 程序,和内核态的 eBPF 兼容,由于避免了内核态和用户态之间的上下文切换,bpftime 的 Uprobe 开销比内核少约 10 倍,并且也更容易扩展。

Rust

Rust 是一种开源的系统编程语言,注重安全、速度和并行性。它于2010年由Graydon Hoare在Mozilla研究中心开发,并于2015年发布了第一个稳定版本。Rust 语言的设计哲学旨在提供C++的性能优势,同时大幅减少内存安全漏洞。Rust在系统编程领域逐渐受到欢迎,特别是在需要高性能、安全性和可靠性的应用场景,例如操作系统、文件系统、游戏引擎、网络服务等领域。许多大型技术公司,包括Mozilla、Google、Microsoft和Amazon等,都在使用或支持Rust语言。

可以参考 Rust 官方网站 了解更多 Rust 语言的信息,并安装 Rust 的工具链。

最简单的例子:Symbol name mangling

我们先来看一个简单的例子,使用 Uprobe 追踪 Rust 程序的 main 函数,代码如下:

pub fn hello() -> i32 {
    println!("Hello, world!");
    0
}
 
fn main() {
    hello();
}

构建和尝试获取符号:

$ cd helloworld
$ cargo build
$ nm -C target/debug/helloworld | grep 'helloworld::main'
0000000000013ec0 t helloworld::main

rustc 使用 符号名称修饰 为链接器编码唯一的符号名称。Rust 1.97 默认使用 v0 名称修饰,而 nm -C 会显示稳定的反修饰名称。将符号交给 bpftrace 之前,可以先通过地址解析原始符号:

$ main_address=$(nm -C target/debug/helloworld | awk '$2 ~ /^[tT]$/ && $3 ~ /^helloworld::main(::h[[]]+)?$/ && !matched { found = $1; matched = 1 } END { if (matched) print found }')
$ main_symbol=$(nm target/debug/helloworld | awk -v address="$main_address" '$1 == address && $2 ~ /^[tT]$/ && !matched { found = $3; matched = 1 } END { if (matched) print found }')
$ echo "$main_symbol"
_RNvCsiXtUZV4ocyZ_10helloworld4main

-C symbol-mangling-version rustc 选项可以选择名称修饰方案,但从当前二进制解析可以避免依赖某一种方案。现在可以追踪 main

$ sudo bpftrace -e "uprobe:target/debug/helloworld:${main_symbol} { printf(\"Function hello-world called\n\"); }"
Attaching 1 probe...
Function hello-world called

追踪多次调用的函数并获取返回值

对于一个更复杂的例子,包含多次调用并演示如何获取返回值:

use std::env;
 
pub fn hello(i: i32, len: usize) -> i32 {
    println!("Hello, world! {} in {}", i, len);
    i + len as i32
}
 
fn main() {
    let args: Vec<String> = env::args().collect();
 
    // Skip the first argument, which is the path to the binary, and iterate over the rest
    for arg in args.iter().skip(1) {
        match arg.parse::<i32>() {
            Ok(i) => {
                let ret = hello(i, args.len());
                println!("return value: {}", ret);
            }
            Err(_) => {
                eprintln!("Error: Argument '{}' is not a valid integer", arg);
            }
        }
    }
}

首先,我们需要使用 debug 模式构建,因为 hello 函数在 release 模式下会被内联,不会有自己的符号:

$ cd args
$ cargo build
$ nm -C target/debug/helloworld | grep 'helloworld::hello'
0000000000016f90 t helloworld::hello

注意,在 release 模式(cargo build --release)下,只会出现 main 函数符号,因为 hello 在优化过程中被内联了。

Rust 1.97 默认使用 v0 符号名称修饰。bpftrace 需要原始符号,因此先通过稳定的反修饰名称找到地址,再解析对应的原始符号:

$ hello_address=$(nm -C target/debug/helloworld | awk '$2 ~ /^[tT]$/ && $3 ~ /^helloworld::hello(::h[[]]+)?$/ && !matched { found = $1; matched = 1 } END { if (matched) print found }')
$ hello_symbol=$(nm target/debug/helloworld | awk -v address="$hello_address" '$1 == address && $2 ~ /^[tT]$/ && !matched { found = $3; matched = 1 } END { if (matched) print found }')
$ echo "$hello_symbol"
_RNvCsiXtUZV4ocyZ_10helloworld5hello
$ sudo bpftrace -e "uprobe:target/debug/helloworld:${hello_symbol} { printf(\"Function hello called\n\"); }"
Attaching 1 probe...
Function hello called
Function hello called
Function hello called
Function hello called

当我们使用多个参数运行程序时,bpftrace 正确地捕获了所有对 hello 函数的调用:

$ ./target/debug/helloworld 1 2 3 4
Hello, world! 1 in 5
return value: 6
Hello, world! 2 in 5
return value: 7
Hello, world! 3 in 5
return value: 8
Hello, world! 4 in 5
return value: 9

我们也可以使用 Uretprobe 和同一个已解析的符号来获取返回值:

$ sudo bpftrace -e "uretprobe:target/debug/helloworld:${hello_symbol} { printf(\"Function hello returned: %d\n\", retval); }"
Attaching 1 probe...
Function hello returned: 6
Function hello returned: 7
Function hello returned: 8
Function hello returned: 9

通过反修饰名称解析原始符号的方式同时适用于 Rust 的 legacy 和 v0 名称修饰方案。

参考资料

继续阅读

最后更新
2026年7月14日
首次发布
2025年2月10日
贡献者
github-actions[bot], yunwei37, 云微

这个页面有帮助吗?