Appearance
C++ 成员函数 uprobe 探针实验(perf probe 进阶)
更新时间: 2026-08-12
前置:先跑通 动态探针演示(D11) 的纯 C 函数
do_workuprobe 实验,本实验在其基础上专攻 C++ 特有的坑。
0 本实验要回答的问题
- C++ 成员函数在
perf probe -F里长什么样?为什么和源码里的名字不一样? - 重载、模板实例化、虚函数 override——同名不同符号,probe 怎么区分?
this指针能抓吗?static 成员函数有 this 吗?- static / 匿名 namespace /
-fvisibility=hidden的函数还能挂探针吗? - 内联/模板函数为什么经常报
Probe point not found?
1 背景与实验设计
纯 C 函数的 probe 只涉及符号名与 noinline 两个坑;换成 C++ 后,名字修饰(name mangling)和符号可见性(编译器控制)两个新维度会把简单问题放大。本实验用一个类覆盖 5 类成员函数形态(普通 / 重载 / static / 虚函数 / 模板),再加 3 类自由函数做可见性对照,共 10 个目标符号,逐个验证"能不能挂、能挂几个、能抓什么参数"。

实验分三层递进:
| 层级 | 内容 | 对应问题 |
|---|---|---|
| A 符号层 | nm -C / readelf -s / perf probe -F 三视角对比 | Q1、Q2 |
| B 挂载层 | 10 个符号逐个入口探针 + %return + 抓参 | Q2、Q3 |
| C 可见性层 | -fvisibility=hidden 变体 + strip 对照 | Q4 |
| D 内联层 | 去掉 noinline 后 -O2 重编 | Q5 |
2 代码设计
cpp
// cpp-members.cpp —— C++ 成员函数 perf uprobe 实验目标程序
//
// 目的:对比 C++ 各类函数形态对 perf probe 的影响——
// 普通成员函数 / 重载 / static 成员函数 / 虚函数 / 模板成员函数 /
// 匿名 namespace / static 自由函数 / 外部链接自由函数,
// 以及 -fvisibility=hidden 对探针可见性的影响。
//
// 编译(见 Makefile):
// make cpp-members # 默认可见性 -> cpp_members_demo
// make cpp-members-hidden # hidden 可见性 -> cpp_members_hidden
//
// 运行:./cpp_members_demo [iters] # iters 默认 1,供探针反复命中
//
// 实验命令(Linux x86-64):
// nm -C ./cpp_members_demo | grep -E 'Worker|compute|free'
// sudo perf probe -x ./cpp_members_demo -F | grep -E 'compute|free'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::compute(int) n=%si this=%di'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::s_compute n=%di'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::v_compute%return'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::t_compute<2> n=%si'
// sudo perf stat -e probe_cpp_members_demo:... -- ./cpp_members_demo 1000
#include <cstdint>
#include <cstdio>
#include <cstdlib>
// ================= 目标类:覆盖 5 类成员函数形态 =================
class Worker {
public:
// ① 普通成员函数:this = %rdi,参数从 %rsi 起
// noinline 必须:否则被内联后符号表里只剩占位,%return 探针找不到位置
__attribute__((noinline)) int compute(int n)
{
int s = 0;
for (int i = 0; i < n; ++i)
s += i * 3;
return s;
}
// ② 重载:compute(int) 与 compute(double) 是不同 mangled 符号
__attribute__((noinline)) double compute(double d)
{
return d * 2.5;
}
// ③ static 成员函数:没有 this,参数就是第一个寄存器参数 %rdi
__attribute__((noinline)) static int s_compute(int n)
{
return n * n + 1;
}
// ④ 虚函数(基类版本):多态调用经 vtable 分派到最终实现
__attribute__((noinline)) virtual int v_compute(int n)
{
return n + 100;
}
// ⑤ 模板成员函数:类内定义默认 inline,每个 N 实例化出一个独立符号
template <int N>
__attribute__((noinline)) int t_compute(int n)
{
return n * N;
}
};
// 派生类 override 虚函数
class FastWorker : public Worker {
public:
__attribute__((noinline)) int v_compute(int n) override
{
return n * 10;
}
};
// ================= 可见性对照的自由函数 =================
// 匿名 namespace:内部链接,符号只在 .symtab,不在 .dynsym
namespace {
__attribute__((noinline)) int hidden_free(int n)
{
return n - 7;
}
} // namespace
// static 自由函数:同样是内部链接
static __attribute__((noinline)) int static_free(int n)
{
return n + 5;
}
// 外部链接自由函数(对照基线)
__attribute__((noinline)) int global_free(int n)
{
return n * 2;
}
static uint64_t g_total = 0;
int main(int argc, char** argv)
{
int iters = 1;
if (argc > 1) {
iters = std::atoi(argv[1]);
}
if (iters <= 0) {
iters = 1;
}
Worker w;
FastWorker fw;
Worker* base = &fw; // 多态调用:虚分派到 FastWorker::v_compute
for (int it = 0; it < iters; ++it) {
g_total += (uint64_t)w.compute(10); // 普通成员函数
g_total += (uint64_t)w.compute(3.5); // 重载 double 版本
g_total += (uint64_t)Worker::s_compute(4); // static 成员函数
g_total += (uint64_t)w.v_compute(5); // 直接调用 -> Worker::v_compute
g_total += (uint64_t)base->v_compute(6); // 虚分派 -> FastWorker::v_compute
g_total += (uint64_t)w.t_compute<2>(7); // 模板实例化 <2>
g_total += (uint64_t)w.t_compute<8>(7); // 模板实例化 <8>
g_total += (uint64_t)hidden_free(9); // 匿名 namespace
g_total += (uint64_t)static_free(11); // static 自由函数
g_total += (uint64_t)global_free(13); // 外部链接自由函数
}
std::printf("total=%llu iters=%d\n", (unsigned long long)g_total, iters);
return 0;
}- 每个目标函数都带
__attribute__((noinline)):类内定义的成员函数默认是 inline 候选,不强制 noinline 的话,-O2下符号可能只剩占位,%return探针直接找不到位置(与do_work的教训同源)。 - 重载:
compute(int)与compute(double)是两条独立符号,用于演示"同名不同参"的区分。 - 虚函数:
Worker::v_compute(基类)+FastWorker::v_compute(override),主流程里同时有直接调用(对象静态类型已知 → 编译器 devirtualize 到基类实现)和多态调用(base->v_compute→ vtable 分派到派生实现),用于观察"一个探针只覆盖一种实现"。 - 模板:
t_compute<2>与t_compute<8>两个实例化,展示"模板实参参与符号名"。 - 可见性对照:匿名 namespace 函数、
static自由函数(内部链接)与global_free(外部链接)并列。
成员函数调用的寄存器布局(x86-64 SysV ABI)——这决定了抓参的写法:

对应到 perf probe --add:入口探针抓 this 用 %di,抓第一个具名参数用 %si(static 成员函数无 this,第一个参数就在 %di)。
cpp
// cpp-members.cpp —— C++ 成员函数 perf uprobe 实验目标程序
//
// 目的:对比 C++ 各类函数形态对 perf probe 的影响——
// 普通成员函数 / 重载 / static 成员函数 / 虚函数 / 模板成员函数 /
// 匿名 namespace / static 自由函数 / 外部链接自由函数,
// 以及 -fvisibility=hidden 对探针可见性的影响。
//
// 编译(见 Makefile):
// make cpp-members # 默认可见性 -> cpp_members_demo
// make cpp-members-hidden # hidden 可见性 -> cpp_members_hidden
//
// 运行:./cpp_members_demo [iters] # iters 默认 1,供探针反复命中
//
// 实验命令(Linux x86-64):
// nm -C ./cpp_members_demo | grep -E 'Worker|compute|free'
// sudo perf probe -x ./cpp_members_demo -F | grep -E 'compute|free'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::compute(int) n=%si this=%di'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::s_compute n=%di'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::v_compute%return'
// sudo perf probe -x ./cpp_members_demo --add 'Worker::t_compute<2> n=%si'
// sudo perf stat -e probe_cpp_members_demo:... -- ./cpp_members_demo 1000
#include <cstdint>
#include <cstdio>
#include <cstdlib>
// ================= 目标类:覆盖 5 类成员函数形态 =================
class Worker {
public:
// ① 普通成员函数:this = %rdi,参数从 %rsi 起
// noinline 必须:否则被内联后符号表里只剩占位,%return 探针找不到位置
__attribute__((noinline)) int compute(int n)
{
int s = 0;
for (int i = 0; i < n; ++i)
s += i * 3;
return s;
}
// ② 重载:compute(int) 与 compute(double) 是不同 mangled 符号
__attribute__((noinline)) double compute(double d)
{
return d * 2.5;
}
// ③ static 成员函数:没有 this,参数就是第一个寄存器参数 %rdi
__attribute__((noinline)) static int s_compute(int n)
{
return n * n + 1;
}
// ④ 虚函数(基类版本):多态调用经 vtable 分派到最终实现
__attribute__((noinline)) virtual int v_compute(int n)
{
return n + 100;
}
// ⑤ 模板成员函数:类内定义默认 inline,每个 N 实例化出一个独立符号
template <int N>
__attribute__((noinline)) int t_compute(int n)
{
return n * N;
}
};
// 派生类 override 虚函数
class FastWorker : public Worker {
public:
__attribute__((noinline)) int v_compute(int n) override
{
return n * 10;
}
};
// ================= 可见性对照的自由函数 =================
// 匿名 namespace:内部链接,符号只在 .symtab,不在 .dynsym
namespace {
__attribute__((noinline)) int hidden_free(int n)
{
return n - 7;
}
} // namespace
// static 自由函数:同样是内部链接
static __attribute__((noinline)) int static_free(int n)
{
return n + 5;
}
// 外部链接自由函数(对照基线)
__attribute__((noinline)) int global_free(int n)
{
return n * 2;
}
static uint64_t g_total = 0;
int main(int argc, char** argv)
{
int iters = 1;
if (argc > 1) {
iters = std::atoi(argv[1]);
}
if (iters <= 0) {
iters = 1;
}
Worker w;
FastWorker fw;
Worker* base = &fw; // 多态调用:虚分派到 FastWorker::v_compute
for (int it = 0; it < iters; ++it) {
g_total += (uint64_t)w.compute(10); // 普通成员函数
g_total += (uint64_t)w.compute(3.5); // 重载 double 版本
g_total += (uint64_t)Worker::s_compute(4); // static 成员函数
g_total += (uint64_t)w.v_compute(5); // 直接调用 -> Worker::v_compute
g_total += (uint64_t)base->v_compute(6); // 虚分派 -> FastWorker::v_compute
g_total += (uint64_t)w.t_compute<2>(7); // 模板实例化 <2>
g_total += (uint64_t)w.t_compute<8>(7); // 模板实例化 <8>
g_total += (uint64_t)hidden_free(9); // 匿名 namespace
g_total += (uint64_t)static_free(11); // static 自由函数
g_total += (uint64_t)global_free(13); // 外部链接自由函数
}
std::printf("total=%llu iters=%d\n", (unsigned long long)g_total, iters);
return 0;
}cpp
// cpp-members.cpp —— C++ 成员函数 perf uprobe 实验目标程序
// 普通成员函数 / 重载 / static 成员函数 / 虚函数 / 模板成员函数 /
// 匿名 namespace / static 自由函数 / 外部链接自由函数,
// 以及 -fvisibility=hidden 对探针可见性的影响。
// 运行:./cpp_members_demo [iters] # iters 默认 1,供探针反复命中
#include <cstdint>
#include <cstdio>
#include <cstdlib>
// ================= 目标类:覆盖 5 类成员函数形态 =================
class Worker {
public:
// ① 普通成员函数:this = %rdi,参数从 %rsi 起
__attribute__((noinline)) int compute(int n)
{
int s = 0;
for (int i = 0; i < n; ++i)
s += i * 3;
return s;
}
// ② 重载:compute(int) 与 compute(double) 是不同 mangled 符号
__attribute__((noinline)) double compute(double d)
{
return d * 2.5;
}
// ③ static 成员函数:没有 this,参数就是第一个寄存器参数 %rdi
__attribute__((noinline)) static int s_compute(int n)
{
return n * n + 1;
}
// ④ 虚函数(基类版本):多态调用经 vtable 分派到最终实现
__attribute__((noinline)) virtual int v_compute(int n)
{
return n + 100;
}
// ⑤ 模板成员函数:类内定义默认 inline,每个 N 实例化出一个独立符号
template <int N>
__attribute__((noinline)) int t_compute(int n)
{
return n * N;
}
};
// 派生类 override 虚函数
class FastWorker : public Worker {
public:
__attribute__((noinline)) int v_compute(int n) override
{
return n * 10;
}
};
// ================= 可见性对照的自由函数 =================
// 匿名 namespace:内部链接,符号只在 .symtab,不在 .dynsym
namespace {
__attribute__((noinline)) int hidden_free(int n)
{
return n - 7;
}
} // namespace
// static 自由函数:同样是内部链接
static __attribute__((noinline)) int static_free(int n)
{
return n + 5;
}
// 外部链接自由函数(对照基线)
__attribute__((noinline)) int global_free(int n)
{
return n * 2;
}
static uint64_t g_total = 0;
int main(int argc, char** argv)
{
int iters = 1;
if (argc > 1) {
iters = std::atoi(argv[1]);
}
if (iters <= 0) {
iters = 1;
}
Worker w;
FastWorker fw;
Worker* base = &fw; // 多态调用:虚分派到 FastWorker::v_compute
for (int it = 0; it < iters; ++it) {
g_total += (uint64_t)w.compute(10); // 普通成员函数
g_total += (uint64_t)w.compute(3.5); // 重载 double 版本
g_total += (uint64_t)Worker::s_compute(4); // static 成员函数
g_total += (uint64_t)w.v_compute(5); // 直接调用 -> Worker::v_compute
g_total += (uint64_t)base->v_compute(6); // 虚分派 -> FastWorker::v_compute
g_total += (uint64_t)w.t_compute<2>(7); // 模板实例化 <2>
g_total += (uint64_t)w.t_compute<8>(7); // 模板实例化 <8>
g_total += (uint64_t)hidden_free(9); // 匿名 namespace
g_total += (uint64_t)static_free(11); // static 自由函数
g_total += (uint64_t)global_free(13); // 外部链接自由函数
}
std::printf("total=%llu iters=%d\n", (unsigned long long)g_total, iters);
return 0;
}程序运行输出:total=439000 iters=1000(每轮 439,iters 次循环累加)。
3 实验预期
| # | 符号形态 | 实测 mangled 名(nm,GCC 7.3.1) | 绑定 | 入口探针 | %return | this 抓取 |
|---|---|---|---|---|---|---|
| 1 | compute(int) | _ZN6Worker7computeEi | WEAK | ✅ | ✅ | %di |
| 2 | compute(double) | _ZN6Worker7computeEd | WEAK | ✅ | ✅ | %di |
| 3 | s_compute(int) static 成员 | _ZN6Worker9s_computeEi | WEAK | ✅ | ✅ | 无 this |
| 4 | v_compute(int) 基类虚函数 | _ZN6Worker9v_computeEi | WEAK | ✅ | ✅ | %di |
| 5 | v_compute(int) override | _ZN10FastWorker9v_computeEi | WEAK | ✅ | ✅ | %di |
| 6 | t_compute<2>(int) | _ZN6Worker9t_computeILi2EEEii | WEAK | ✅ | ✅ | %di |
| 7 | t_compute<8>(int) | _ZN6Worker9t_computeILi8EEEii | WEAK | ✅ | ✅ | %di |
| 8 | 匿名 namespace hidden_free | _ZN12_GLOBAL__N_111hidden_freeEi | LOCAL | ✅ | ✅ | 无 this |
| 9 | static 自由函数 static_free | _ZL11static_freei | LOCAL | ✅ | ✅ | 无 this |
| 10 | 外部链接 global_free | _Z11global_freei | GLOBAL | ✅ | ✅ | 无 this |
实测核对修正(与初版预期不同的 4 处)
v_compute是 9 个字符(v_compute含下划线),mangled 名应为9v_compute而非8v_compute- 模板实例的 mangled 名与编译器 ABI 版本相关——GCC 7 为
_ZN6Worker9t_computeILi2EEEii(末尾E后含返回类型ii),GCC 8+ 才是_ZN6Worker9t_computeILi2EEi - static/外部链接自由函数同样有 mangling(
_ZL/_Z前缀),并非原名 - 类内定义成员函数默认绑定为 WEAK(GCC 对类内定义函数的默认),与外部链接自由函数的 GLOBAL 不同。
名词速补:ELF 符号绑定(Binding) ——
nm/readelf -s绑定列(WEAK/LOCAL/GLOBAL)是 ELF 符号表st_info字段低 4 位的值,语义范畴是**"链接期可见性与重名解析规则"**(在哪个范围可见、重名时谁说了算),与运行期无关:
绑定 st_info语义 本实验来源 LOCAL0 只对当前目标文件可见,链接器不用外部定义解析它;不同文件的同名 LOCAL 互不冲突 static 函数、匿名 namespace( hidden_free/static_free)GLOBAL1 跨目标文件可见,默认导出;重名冲突报 duplicate symbol 外部链接自由函数 global_freeWEAK3 "可选"全局:重名时强符号(GLOBAL)优先,没有强符号才用它;适合默认实现/可覆盖 类内定义成员函数(GCC 默认,模板/虚函数等 COMDAT 折叠的产物) 两个易混点:① 绑定(链接期)≠ 可见性(导出期):5.3 节的
HIDDEN是另一个字段(st_other),只管共享库导出(.dynsym),不参与链接期重名解析;② 绑定不影响 probe:perf probe按名解析地址只看符号是否存在,三者一视同仁都能挂,真正的拦路虎是符号不存在(内联、strip)。
关键预期(可证伪的断言):
- 虚函数探针只命中一种实现:
iters=N时,挂基类v_compute探针命中 N 次(只有直接调用);挂 override 探针也命中 N 次(只有多态调用)。两个探针都挂时命中互补,加起来 2N。 - 重载必须写全参数列表或 mangled 名:
--add 'Worker::compute'二义;Worker::compute(int)或_ZN6Worker7computeEi才唯一。 - 模板必须写全实例:
Worker::t_compute(不带<N>)无法定位;Worker::t_compute<2>只命中对应实例。 - 静态成员函数能挂、无 this:第一个寄存器参数就是具名参数
%di。 - 可见性:默认构建下 10 个符号都能挂;
-fvisibility=hidden变体只要不 strip,perf probe仍能按名挂载(探针解析走.symtab),但readelf --dyn-syms里符号消失、readelf -s里可见性变 HIDDEN;strip --strip-all后所有按名探针失效。 - 内联是最大的杀手:去掉 noinline 后
-O2重编,成员函数/模板实例符号消失或只剩占位,--add报Probe point not found。
4 实验命令(完整复现流程)
全部命令在 x86-64 Linux 上执行。四层实验共用一套产物,A/B 层用默认可见性变体,C 层用 hidden/strip 变体,D 层用去 noinline 变体。
4.1 编译产物(Makefile 目标)
bash
# 默认可见性(-O0 -g -fno-omit-frame-pointer)-> cpp_members_demo
make cpp-members
# hidden 可见性(追加 -fvisibility=hidden)-> cpp_members_hidden
make cpp-members-hidden
# D 层去 noinline 变体(-O2,sed 去掉 __attribute__((noinline)) 后重编)
sed 's/__attribute__((noinline))//g' cpp-members.cpp > cpp-members-inline.cpp
g++ -O2 -std=c++17 -g cpp-members-inline.cpp -o /tmp/demo_O2⚠️
-g必须保留(本次实测新增):perf probe -x按符号名解析需要 DWARF 调试信息辅助确认探针位置。实测去掉-g后连-O0编译的 15 字节函数都报Failed to find symbol,与优化级别/符号大小无关(对照矩阵见 5.5)。D 层命令里-O2 ... -g的-g不能省。
4.2 A 符号层
bash
nm -C ./cpp_members_demo | grep -E 'compute|free' # demangle 对照
readelf -sW ./cpp_members_demo | grep -E 'compute|free' # .symtab:绑定/可见性
readelf --dyn-syms ./cpp_members_demo | grep -E 'compute|free' # .dynsym
sudo perf probe -x ./cpp_members_demo -F | grep -E 'compute|free' # perf 视角4.3 B 挂载层(x86-64)
bash
# 注意:perf 版本决定语法。实测 CentOS7 自带 perf 3.10 不支持 'Class::func(int)'
# 形式(报 "There is non-digit char in line number"),必须用 nm 拿到的 mangled 名:
sudo perf probe -x ./cpp_members_demo --add '_ZN6Worker7computeEi this=%di n=%si'
sudo perf probe -x ./cpp_members_demo --add '_ZN6Worker7computeEi%return ret=$retval'
sudo perf probe -x ./cpp_members_demo --add '_ZN6Worker9s_computeEi n=%di'
sudo perf probe -x ./cpp_members_demo --add '_ZN6Worker9v_computeEi' # 基类虚函数
sudo perf probe -x ./cpp_members_demo --add '_ZN10FastWorker9v_computeEi' # override
sudo perf probe -x ./cpp_members_demo --add '_ZN6Worker9t_computeILi2EEEii n=%si'
sudo perf probe -x ./cpp_members_demo --add '_ZN6Worker9t_computeILi8EEEii n=%si'
sudo perf probe -l # 查看生成的事件名(组:探针)
sudo perf stat -e 'probe_cpp_members_demo:*' -- ./cpp_members_demo 1000
sudo perf record -e 'probe_cpp_members_demo:*' -o perf-cpp.data -- ./cpp_members_demo 5
sudo perf script -i perf-cpp.data
sudo perf probe --del '*'4.4 C 可见性对照
bash
readelf -sW ./cpp_members_hidden | grep -E 'compute|free' # 看 VIS 列
readelf --dyn-syms ./cpp_members_hidden | grep -E 'compute|free' # 预期:几乎为空
sudo perf probe -x ./cpp_members_hidden --add '_ZN6Worker7computeEi'
cp ./cpp_members_demo /tmp/demo_stripped && strip --strip-all /tmp/demo_stripped
sudo perf probe -x /tmp/demo_stripped --add '_ZN6Worker7computeEi' # 预期:not found4.5 D 内联层
bash
# 关键:noinline 属性在源码里(cpp-members.cpp 每个函数都标了),
# 直接 -O2 编译并不会内联!必须先去掉 noinline 再重编:
sed 's/__attribute__((noinline))//g' cpp-members.cpp > cpp-members-inline.cpp
g++ -O2 -std=c++17 -g cpp-members-inline.cpp -o /tmp/demo_O2
nm -C /tmp/demo_O2 | grep compute # 预期:符号消失或被合并
sudo perf probe -x /tmp/demo_O2 --add '_ZN6Worker7computeEi' # 预期:not found
# 对照:保留符号仍可挂载(vtable 引用 / 外部链接)
sudo perf probe -x /tmp/demo_O2 --add '_ZN10FastWorker9v_computeEi' # 预期:成功
sudo perf probe -x /tmp/demo_O2 --add '_Z11global_freei' # 预期:成功5 实验数据
实测环境:CentOS 7.9 / 内核 3.10.0-1160 / g++ (devtoolset-7) 7.3.1 / perf 3.10.0 / x86-64。编译参数
-O0 -g -std=c++17 -fno-omit-frame-pointer(D 层变体除外)。
5.1 符号层(A)记录
| 命令 | 观察点 | 实测记录 |
|---|---|---|
nm -C | 10 个符号的 demangled 名与地址 | 全部命中,地址如下: 类成员均为 W(WEAK):Worker::compute(int)@0x4008b0compute(double)@0x4008e8s_compute@0x400908v_compute@0x40091cFastWorker::v_compute@0x400930t_compute<2>@0x40094at_compute<8>@0x40095c自由函数: global_free@0x400675(T GLOBAL)static_free@0x400666(t LOCAL)匿名 namespace hidden_free@0x400657(t LOCAL) |
readelf -sW | 绑定与可见性 | 全部 FUNC DEFAULT。绑定:3 个 LOCAL( hidden_free / static_free / _GLOBAL__sub_I 等)、1 个 GLOBAL(global_free)、7 个 WEAK(类内成员函数)。可见性全部 DEFAULT(见 5.3 hidden 对照) |
readelf --dyn-syms | 动态符号表中还剩几个 compute/free | 0 个。.dynsym 仅 7 条,全是启动/外部引用:printf、atoi、__libc_start_main、__gmon_start__ 及 2 个 CXXABI vtable 引用。可执行文件的本地符号不进 .dynsym |
perf probe -F | perf 能列出几个;是否包含匿名 namespace/static | 10/10 全部列出,含匿名 namespace hidden_free 与 static_free。-F 输出为 demangle 后的可读名(Worker::compute 因重载出现两次) |
命令真实输出(nm -C,mangled 名见 5.1 表):
$ nm -C ./cpp_members_demo | grep -E 'compute|free'
0000000000400675 T global_free(int)
0000000000400666 t static_free(int)
0000000000400930 W FastWorker::v_compute(int)
0000000000400657 t (anonymous namespace)::hidden_free(int)
00000000004008e8 W Worker::compute(double)
00000000004008b0 W Worker::compute(int)
0000000000400908 W Worker::s_compute(int)
000000000040094a W int Worker::t_compute<2>(int)
000000000040095c W int Worker::t_compute<8>(int)
000000000040091c W Worker::v_compute(int)perf probe -F 输出(注意 Worker::compute 因重载出现两次,且匿名 namespace/static 符号以 demangle 可读名列出):
$ sudo perf probe -x ./cpp_members_demo -F | grep -E 'compute|free'
(anonymous namespace)::hidden_free
FastWorker::v_compute
Worker::compute
Worker::compute
Worker::s_compute
Worker::t_compute<2>
Worker::t_compute<8>
Worker::v_compute
global_free
static_free5.2 挂载层(B)记录
perf 3.10 不支持
Worker::compute(int)语法,按 mangled 名挂载(详见第 4 节)。iters=1000时 7 个探针全部命中 1000 次(perf stat 合计 7000 次,无 miss)。
| 探针(mangled 名) | perf stat 命中数(iters=1000) | perf script 抓到的 this / 参数(iters=5) |
|---|---|---|
_ZN6Worker7computeEi(入口) | 1000 ✅ | this=0x7ffe28a2b498、n=0xa(10) |
_ZN6Worker7computeEi__return | 1000 ✅ | ret=0x87(135) = compute(10) 的返回值 |
_ZN6Worker9s_computeEi | 1000 ✅ | n=0x4(4),无 this(static 成员第 1 参就在 %di) |
_ZN6Worker9v_computeEi(基类) | 1000 ✅ | 命中地址 0x40091c |
_ZN10FastWorker9v_computeEi(override) | 1000 ✅ | 命中地址 0x400930 |
_ZN6Worker9t_computeILi2EEEii | 1000 ✅ | n=0x7(7) |
_ZN6Worker9t_computeILi8EEEii | 1000 ✅ | n=0x7(7) |
perf stat 真实输出(iters=1000,7 探针 × 1000 = 7000 次命中):
$ sudo perf stat -e 'probe_cpp_members_demo:*' -- ./cpp_members_demo 1000
total=439000 iters=1000
Performance counter stats for './cpp_members_demo 1000':
1000 probe_cpp_members_demo:_ZN6Worker9t_computeILi8EEEii
1000 probe_cpp_members_demo:_ZN6Worker9t_computeILi2EEEii
1000 probe_cpp_members_demo:_ZN10FastWorker9v_computeEi
1000 probe_cpp_members_demo:_ZN6Worker9v_computeEi
1000 probe_cpp_members_demo:_ZN6Worker9s_computeEi
1000 probe_cpp_members_demo:_ZN6Worker7computeEi__return
1000 probe_cpp_members_demo:_ZN6Worker7computeEi
0.020125780 seconds time elapsedperf script 真实输出(iters=5,抓参——注意 ret=0x87 即 compute(10)=135,证明 $retval 抓的是真实返回值):
$ sudo perf record -e 'probe_cpp_members_demo:*' -o perf-cpp2.data -- ./cpp_members_demo 5
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.020 MB /root/perf-probe-exp/perf-cpp2.data (35 samples) ]
$ sudo perf script -i perf-cpp2.data
cpp_members_dem 22081 [003] 7533860.246563: probe_cpp_members_demo:_ZN6Worker7computeEi: (4008b0) this=0x7ffe28a2b498 n=0xa
cpp_members_dem 22081 [003] 7533860.246575: probe_cpp_members_demo:_ZN6Worker7computeEi__return: (4008b0 <- 400700) ret=0x87
cpp_members_dem 22081 [003] 7533860.246576: probe_cpp_members_demo:_ZN6Worker9s_computeEi: (400908) n=0x4
cpp_members_dem 22081 [003] 7533860.246580: probe_cpp_members_demo:_ZN6Worker9v_computeEi: (40091c)
cpp_members_dem 22081 [003] 7533860.246583: probe_cpp_members_demo:_ZN10FastWorker9v_computeEi: (400930)
cpp_members_dem 22081 [003] 7533860.246586: probe_cpp_members_demo:_ZN6Worker9t_computeILi2EEEii: (40094a) n=0x7
cpp_members_dem 22081 [003] 7533860.246589: probe_cpp_members_demo:_ZN6Worker9t_computeILi8EEEii: (40095c) n=0x7
(5 次循环 × 7 探针 = 35 条,其余 28 条同上循环重复)5.3 可见性层(C)记录
| 变体 | -F 可见 | 按名挂载 | .dynsym 数量 |
|---|---|---|---|
cpp_members_demo(默认) | 10/10 | ✅ 成功 | 7 条(0 个 compute/free) |
cpp_members_hidden(-fvisibility=hidden) | 10/10 | ✅ 成功 | 7 条(与默认完全相同) |
demo_stripped(strip --strip-all) | ⚠️ 假象(见下) | ❌ Failed to find symbol | 7 条 |
hidden 变体的关键观察(readelf -sW 的 VIS 列):global_free 变 GLOBAL HIDDEN、全部类成员变 WEAK HIDDEN、LOCAL 符号仍 DEFAULT——-fvisibility=hidden 只改 .symtab 的 Vis 字段,而 nm/perf 解析地址不读该字段,所以挂载照常成功。
$ readelf -sW ./cpp_members_hidden | grep -E 'compute|free'
43: 0000000000400657 15 FUNC LOCAL DEFAULT 14 _ZN12_GLOBAL__N_111hidden_freeEi
44: 0000000000400666 15 FUNC LOCAL DEFAULT 14 _ZL11static_freei
64: 00000000004008e8 32 FUNC WEAK HIDDEN 14 _ZN6Worker7computeEd
65: 0000000000400930 25 FUNC WEAK HIDDEN 14 _ZN10FastWorker9v_computeEi
70: 000000000040091c 19 FUNC WEAK HIDDEN 14 _ZN6Worker9v_computeEi
74: 0000000000400908 19 FUNC WEAK HIDDEN 14 _ZN6Worker9s_computeEi
76: 0000000000400675 14 FUNC GLOBAL HIDDEN 14 _Z11global_freei
...(LOCAL 行不变:仍 DEFAULT)strip 后的"假象":perf probe -x demo_stripped -F 表面上仍能列出 10 个符号,但这是 build-id cache(~/.debug/)假阳性——cp 出的副本与 cpp_members_demo 的 build-id 相同,perf 从缓存读到旧符号表。将 ~/.debug 移走后 -F 里 compute/free 全部消失,且 --add 始终报 Failed to find symbol:
$ sudo perf probe -x /tmp/demo_stripped --add '_ZN6Worker7computeEi'
Failed to find symbol _ZN6Worker7computeEi in /tmp/demo_stripped
Error: Failed to add events.名词速补:build-id 与
~/.debug/cache(本条用到的两个底层概念)
- build-id:链接器写入 ELF 的二进制唯一指纹(
.note.gnu.build-id节,160-bit SHA1 哈希,对全部代码/数据求摘要)。同一份源码每次构建哈希都不同,所以"build-id 相同 = 二进制内容相同"——本例cp出的demo_stripped与cpp_members_demo的 build-id 相同,正是假阳性成立的前提。readelf -n <程序> | grep 'Build ID'可见,详见 meta-sections.md。~/.debug/cache(build-id cache):perf/gdb 维护的本地符号仓库,按 build-id 组织成~/.debug/.build-id/ab/cdef...debug两级目录。此前对带-g的cpp_members_demo跑过perf record/perf probe,perf 就把它当时的符号快照归档进这个目录;之后strip掉符号、但 build-id 相同的副本再来解析时,perf 命中缓存读到的是旧符号表——于是-F列得出、--add却失败。把~/.debug移走(或换台机器)即现原形。符号分离/归档的完整工作流见 symbol-separation.md。
5.4 内联层(D)记录
复现第 4 节的关键:
noinline属性在源码里,直接-O2不会内联(两个变体符号表一致);必须sed去掉noinline后重编,且必须保留-g(见 5.5 的对照矩阵)。
| 变体 | nm -C 结果 | perf -F | 按名挂载 |
|---|---|---|---|
-O2 -fno-inline(保符号) | 10 个符号全保留,compute 等降为 LOCAL | 10/10 | ✅ 全部成功 |
-O2 但 noinline 仍在源码里 | 10 个符号全保留,与 -fno-inline 变体一致 | 10/10 | ✅ 全部成功 |
sed 去 noinline + -O2 | 只剩 3 个用户符号:main、global_free、FastWorker::v_compute;compute/s_compute/t_compute<2>/<8>/基类 v_compute/hidden_free/static_free 全部内联消失 | 2/10 | Worker::compute ❌ not found;FastWorker::v_compute/global_free ✅ 成功 |
命令真实输出(去 noinline + -O2 变体,nm -S 显示符号大小——注意 global_free 只剩 4 字节、FastWorker::v_compute 只剩 6 字节,是内联后为 vtable/外部引用保留的 stub):
$ g++ -O2 -std=c++17 -g cpp-members-inline.cpp -o /tmp/demo_O2
$ nm -S --size-sort /tmp/demo_O2 | grep -E 'compute|free| main'
00000000004005d0 0000000000000004 T _Z11global_freei
00000000004005e0 0000000000000006 W _ZN10FastWorker9v_computeEi
00000000004004a0 0000000000000052 T main
$ sudo perf probe -x /tmp/demo_O2 --add '_ZN6Worker7computeEi'
Probe point '_ZN6Worker7computeEi' not found.
Error: Failed to add events.
$ sudo perf probe -x /tmp/demo_O2 --add '_ZN10FastWorker9v_computeEi' # stub 仍可挂
Added new event:
$ sudo perf probe -x /tmp/demo_O2 --add '_Z11global_freei' # stub 仍可挂
Added new event:保留符号的原因:FastWorker::v_compute 是虚函数,vtable 必须引用 out-of-line 副本(即使函数体已被内联进 main,vtable 仍指向 0x4005e0 的 6 字节 stub);global_free 是外部链接自由函数,被 -O2 保留为 4 字节 stub。其余内部函数全被内联进 main(objdump 验证 main 中 12 个 call 仅存在于前两个变体)。
5.5 -g 缺省会阻塞按名挂载(本次实测新增)
排查 D 层时发现:perf probe -x 按名挂载依赖 -g 调试信息,与优化级别、符号大小无关。对照矩阵(同一源码、4 个函数)实测:
| 编译参数 | f_small(15B) | f_med(51B) | f_big(62B) | main(80B) |
|---|---|---|---|---|
-O0 -g | ✅ | ✅ | ✅ | ✅ |
-O0(无 -g) | ❌ | ❌ | ❌ | ✅ |
-O2 -g | ✅(4B) | ✅ | ✅ | ✅ |
-O2(无 -g) | ❌ | ❌ | ❌ | ✅ |
main即使无-g也能挂——它是唯一例外(可执行文件入口,perf 有特殊处理);其余符号无-g一律Failed to find symbol。这也是为什么 Makefile 的CPP_MEMBERS_FLAGS里-g必须保留。
6 实验分析
- mangling 是把双刃剑:
-F里看到的是 Itanium ABI 修饰名(_ZN6Worker7computeEi),它编码了类名、函数名、模板实参、参数类型——这既是"找不到符号"的坑,也是重载/模板天然可区分的好处。nm -C与-F输出对照即可互相翻译。 - 虚函数探针命中的是"最终实现":uprobe 挂在具体符号的指令地址上,虚调用本身经过 vtable 间接跳转,探针只能在跳转目标(某一种实现)上命中。一个探针 ≠ 覆盖整个虚调用链,排查多态热点时要给每个 override 分别挂。
- 抓参的本质是寄存器 vs DWARF:
-g编译且有调试信息时,perf probe可以用变量名/$params/$retval;无-g时只能按 ABI 寄存器硬抓(%di=this、%si=第 1 参),且优化后寄存器分配会漂移,这是 uprobe 抓参的固有脆弱性(USDT 预埋点在编译期就固定了位置,不受影响,见 探针原理篇)。 - 可见性对 uprobe 的真实影响路径:
-fvisibility=hidden改的是"导出"(.dynsym),而perf probe解析地址主要走.symtab,所以可执行文件不 strip 时 hidden 几乎不影响挂载;真正致命的是strip --strip-all删掉整个符号表。这与共享库场景不同——.so里 hidden 符号连.dynsym都没有,动态链接器/调试工具按名引用全部失效。补充:绑定(LOCAL/GLOBAL/WEAK,链接期重名规则)与可见性(DEFAULT/HIDDEN,导出期)是两个正交维度——前者只参与链接、后者只参与导出,perf probe两者都不读,见 3 节名词速补。 - 内联是 C++ 探针的头号杀手:类内定义默认 inline、模板隐式 inline,去掉
noinline后-O2下符号消失或地址被合并,%return尤其敏感。工程上要么-O0 -g、要么noinline、要么靠-fno-inline保符号。 - mangled 名的版本陷阱(本次实测新增):
v_compute是 9 字符(含下划线);模板实例的 mangled 名随编译器 ABI 版本变化——GCC 7 为_ZN6Worker9t_computeILi2EEEii(函数签名返回类型编码在尾部),GCC 8+ 才是_ZN6Worker9t_computeILi2EEi。跨编译器/跨版本排查时,不要手写 mangled 名,一律nm现取。 - perf 版本决定语法(本次实测新增):perf 3.10 不支持
Worker::compute(int)的::语法(报There is non-digit char in line number),只能喂 mangled 名;新版 perf(≥4.x)才支持 C++ 风格定义。排查"perf probe 报错"时先确认perf --version。 -F列表也会骗人(本次实测新增):strip 后-F能列出符号,是 build-id cache(~/.debug/)拿旧 build-id 副本解析出的假象(build-id 与 cache 是什么,见 5.3 名词速补),不代表可挂载。判断"能不能挂"的唯一标准是--add是否成功,-F只能当参考。-g是 uprobe 按名挂载的隐藏前提(本次实测新增):无-g编译时,除main外所有符号perf probe -x --add都报Failed to find symbol,与-O级别、符号大小无关(5.5 对照矩阵:-O015 字节函数无-g也失败)。发布镜像若 strip/去掉调试符号,动态插桩必须先恢复-g编译或改用地址挂载。
7 实验结论(回答第 0 节)
-F里是 mangled 名(_ZN6Worker7computeEi),与源码名一一对应,nm -C可还原;抓重载/模板必须用带参数类型或模板实参的全名。- 同名区分靠 mangled 名:重载看参数类型(
Ei/Ed),模板看实参(Li2E/Li8E),虚函数基类与 override 是不同符号,需分别挂载。 this可抓(%rdi),是隐藏的第 0 实参;static 成员函数没有 this,第一个参数就在%rdi。- 可见性分层:static / 匿名 namespace 是内部链接(
.symtabLOCAL),只要不 strip 大多可挂;-fvisibility=hidden只影响导出,不 strip 时照挂不误;strip --strip-all才会让所有按名探针失效。 - not found 的第一嫌疑是内联:类内/模板函数默认 inline,
sed去掉 noinline 后-O2重编即复现(10 个符号只剩 4 个);加回 noinline 或改-O0 -g即可恢复。 - 实际动手的四个提醒(本次实测新增):① 别手写 mangled 名,跨编译器版本会变(GCC7 模板实例名与 GCC8+ 不同),一律
nm现取;② 先查perf --version,老版本不支持::语法;③-F列得出 ≠ 挂得上,strip 后可能被 build-id cache 骗,最终以--add结果为准;④ 编译必须带-g,无调试符号时除main外一律Failed to find symbol。
8 一句话总结
C++ 的 probe 难度全在符号层——mangled 名决定了"挂哪个、挂几个",this 走 %rdi 可抓,而可见性(static/匿名 namespace/hidden)只要不 strip 就基本不挡路,真正的头号杀手是内联。
9 参考
- 动态探针演示(D11) —— 纯 C 函数 uprobe/USDT 基础实验(
do_work、noinline 教训、bench 基线) - 探针原理篇 —— uprobe/ftrace/USDT 机制对比
- tools/code/perf.md —— perf 工具主文档,§六.4
perf probe man perf-probe/man nm/man readelf—— 抓参语法与符号表字段- Itanium C++ ABI mangling 规范 ——
_Z/_N/L等编码规则