复杂 C++ 项目堆栈保留以及 eBPF 性能分析

在构建和维护复杂的 C++ 项目时,性能优化和内存管理是至关重要的。当我们面对性能瓶颈或内存泄露时,可以使用eBPF(Extended Berkeley Packet Filter)和 BCC(BPF Compiler Collection)工具来分析。如我们在Redis Issue 分析:流数据读写导致的“死锁”问题(1)文中看到的一样,我们用 BCC 的 profile 工具分析 Redis 的 CPU 占用,画了 CPU 火焰图,然后就能比较容易找到耗时占比大的函数以及其调用链。
在这里插入图片描述

个人博客原文地址 复杂 C++ 项目堆栈保留以及 eBPF 性能分析

这里使用 profile 分析的一个大前提就是,服务的二进制文件要保留函数的堆栈信息。堆栈信息是程序执行过程中函数调用和局部变量的记录,当程序执行到某一点时,通过查看堆栈信息,我们可以知道哪些函数被调用,以及它们是如何相互关联的。这对于调试和优化代码至关重要,特别是在处理性能问题和内存泄露时。

但是在实际的项目中,我们用 eBPF 来分析服务的性能瓶颈或者内存泄露的时候,往往会拿不到函数调用堆栈,遇到各种 unknown 的函数调用链。这是因为生产环境为了减少二进制文件的大小,通常不包含调试信息。此外,就算生产环境编译 C++ 代码的时候用了 -g 生成了调试信息,也可能拿不到完整的函数调用堆栈。这里面的原因比较复杂,本文将展开聊一下这个问题。

程序的堆栈信息

在计算机科学中,堆栈(Stack)是一种基本的数据结构,它遵循后进先出(LIFO)的原则。这意味着最后一个被添加到堆栈的元素是第一个被移除的。堆栈在程序设计中有很多用途,其中最常见的是在函数调用和局部变量存储中的应用。

在程序执行过程中,堆栈被用于管理函数调用,这称为“调用堆栈”“执行堆栈”。当一个函数被调用时,一个新的堆栈帧被创建并压入调用堆栈。这个堆栈帧包含:

  1. 返回地址:函数执行完成后,程序应该继续执行的内存地址。
  2. 函数参数:传递给函数的参数。
  3. 局部变量:在函数内部定义的变量。
  4. 帧指针:指向前一个堆栈帧的指针,以便在当前函数返回时恢复前一个堆栈帧的上下文。

当函数执行完成时,其堆栈帧被弹出,控制返回到保存的返回地址。堆栈在内存中的分布如下图:

函数调用堆栈内存分布图

DWARF 格式的堆栈信息

函数调用堆栈的信息在二进制文件中以 DWARF 格式保存。DWARF 是一种用于表示程序的调试信息的标准格式,广泛应用于Unix和Linux系统。它是一种非常灵活和可扩展的格式,能够表示丰富的调试信息,包括但不限于源代码行号、变量名、数据类型、堆栈帧以及它们的关系。

DWARF由一系列的“调试节”组成,每个节包含特定类型的调试信息。比如 .debug_info: 包含关于程序结构的信息,如变量、类型和过程。.debug_line: 包含源代码行号和地址信息的映射,这对于在调试器中定位源代码位置非常有用。可以在 DWARF 官网 上看到具体格式标准,比如当前的 Version 5 版本,有一个 PDF 记录详细的规范。

How debuggers work: Part 3 - Debugging information 这篇文章用实际代码,结合 objdump 和 readelf 工具,深入探讨了 DWARF 调试信息格式,值得一读。

对于 C++ 项目来说,为了在编译时生成包含 DWARF 调试信息的二进制文件,需要使用编译器的编译选项。对于 GCC 和 Clang 编译器,这通常是通过使用 -g 标志来完成的。下面是一个简单的示例代码:

// dwarf.cpp
#include <iostream>

void say_hello() {
    std::cout << "Hello, World!" << std::endl;
}

int main() {
    say_hello();
    return 0;
}

在生成的 ELF 二进制文件中,我们用 objdump 的 [-h|--section-headers|--headers] 选项,可以打印出所有的 section headers。如果用 -g 编译,生成文件包含 DWARF 调试信息,主要有 debug_aranges.debug_info 等section。没有 -g 选项的时候,生成的二进制文件则没有这些section。

编译带 DWARF 调试信息的 ELF section

如果二进制 ELF 文件带了 DWARF 信息,用 GDB 调试的时候,就可以设置函数行断点、单步执行代码、检查变量值,并查看函数调用堆栈等。此外,传统的性能分析工具 perf,也可以读取 DWARF 信息来解析函数调用堆栈,如下命令即可:

$ perf record --call-graph dwarf ./my_program

Frame Pointer 解析堆栈

虽然 DWARF 信息对于调试非常有用,但基于 eBPF 的工具不能读取 DWARF 里面的堆栈信息。在 eBPF 中使用另外方法读取堆栈信息,那就是帧指针(frame pointer),帧指针可以为我们提供完整的堆栈跟踪。帧指针是 perf 的默认堆栈遍历,也是目前 bcc-tools 或 bpftrace 唯一支持的堆栈遍历技术。

为了在生成的二进制文件中保留帧指针,要确保在编译程序时启用帧指针。这可以通过使用编译器标志来完成,例如在 GCC 中使用 -fno-omit-frame-pointer。下面是一个简单的示例代码:

// fp_demo_write.cpp
#include <unistd.h>
#include <chrono>
#include <thread>

void functionA() {
    const char* message = "Inside functionA\n";
    write(STDOUT_FILENO, message, 16);
    // cout 的函数调用堆栈不在 main 中;
    // std::cout << "Inside functionA" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

void functionB() {
    functionA();
    const char* message = "Inside functionB\n";
    write(STDOUT_FILENO, message, 16);
}

void functionC() {
    functionB();
    const char* message = "Inside functionC\n";
    write(STDOUT_FILENO, message, 16);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

int main() {
    while (true) {
        functionC();
    }
    return 0;
}

-fno-omit-frame-pointer 编译后,可以用 profile 拿到 cpu 耗时的函数调用堆栈,之后用 FlameGraph 可以拿到 cpu 火焰图。

$ g++ fp_demo_write.cpp -fno-omit-frame-pointer -o fp_demo_write
$ profile -F 999 -U -f --pid $(pgrep fp_demo_write)  60 > fp_demo_write.stack
$ ../FlameGraph/flamegraph.pl fp_demo_write.stack > fp_demo_write.svg

这里 CPU 火焰图如下,可以看到整体函数调用链路,以及各种操作的耗时:
在这里插入图片描述

上面示例函数中,我们用 write(STDOUT_FILENO, message, 16); 来打印字符串,这里一开始用了c++的 std::cout 来打印,结果 cpu 火焰图有点和预期不一样,可以看到和 __libc_start_call_main 同级别的,有一个 unknown 函数帧,然后在这里面有 writestd::basic_ostream<char, std::char_traits<char> >::~basic_ostream() 函数。

在这里插入图片描述

理论上这里所有的函数都应该在 main 的函数栈里面的,但是现在并列有了一个 unknown 的调用堆栈。可能是和 C++ 标准库 glibc 的内部工作方式和缓冲机制有关,在使用 std::cout 写入数据时,数据不会立即写入标准输出,而是存储在内部缓冲区中,直到缓冲区满或显式刷新。这里的输出由 glibc 控制,所以调用堆栈不在 main 中。

如果想验证我们的二进制文件是否有帧指针的信息,可以用 objdump 拿到反汇编内容,然后看函数的开始指令是不是 push %rbp; mov %rsp,%rbp 即可。对于前面的例子,我们可以看到反汇编结果如下:

验证二进制汇编中有帧指针 rbp

GCC/G++ 编译器中,是否默认使用-fno-omit-frame-pointer选项依赖于编译器的版本和目标架构。在某些版本和/或架构上,可能默认保留帧指针。如果没有保留帧指针,生成的二进制汇编代码中就没有相关 rbp 的部分。在我的机器上,默认编译也是有帧指针的,用 -O2 开启编译优化后生成的二进制中就没有帧指针了,如下所示:

二进制汇编中没有帧指针 rbp

再用 profile 来分析的话,就拿不到完整的函数调用栈信息了,如下图:
在这里插入图片描述

在实际的项目开发中,建议在默认编译选项中加上 -fno-omit-frame-pointer,方便后面进行分析。在Linux 发行版 fedora 的 wiki 上可以看到有人就提议,默认开启 Changes/fno-omit-frame-pointer,并列举了这样做的好处以及可能的性能损失。

复杂 C++ 项目编译

上面的例子中都是编译一个简单的 cpp 文件,对于实际项目来说,可能有很多 cpp 文件,同时还有各种复杂的第三方库依赖。如何使最后编译的二进制文件保留完整的堆栈信息,就会变得有挑战。下面我们将重点来看,对于有复杂第三方依赖的项目,编译选项-fno-omit-frame-pointer 如何影响最终生成的二进制文件。

动态链接与静态链接

C++ 项目依赖第三方库有两种链接方式,静态链接和动态链接。静态链接是在编译时将所有库文件的代码合并到一个单一的可执行文件中,这意味着可执行文件包含了它所需要的所有代码,不依赖于外部的库文件。与静态链接不同,动态链接不会将库代码合并到可执行文件中。相反,它在运行时动态地加载库,这意味着可执行文件只包含对库的引用,而不是库的实际代码。

下面是静态链接和动态链接的一些特点:

特点静态链接动态链接
部署难度简单,只需分发一个文件较复杂,需要确保可执行文件能找到依赖的库
启动时间通常更快,因为没有额外的加载开销可能较慢,因为需要在运行时加载库
文件大小通常较大,因为包含所有依赖的代码通常较小,因为只包含对库的引用
内存占用通常较高,每个实例都有其自己的库副本通常较低,多个实例可以共享同一份库的内存
兼容性可以更好地控制版本,因为库是嵌入的,不受外部库更新的影响可能面临兼容性问题,如果外部库更新并且不向后兼容

对于一个大型 C++项目来说,具体选择哪种链接方式可能看团队的权衡。总的来说,项目模块之间所有可能的依赖关系可以归类为下图的几种情形:
在这里插入图片描述

图片由 Graphviz 渲染,图片源码如下:

digraph G {
    // 设置图的布局方向为从左到右
    // rankdir=LR;
    
    // 设置节点的形状和样式
    node [shape=box, style=filled, color=lightblue];
    
    // 设置边的样式
    edge [color=blue, fontcolor=black];
    
    // 定义节点和边
    main -> static_X;
    main -> static_A;
    main -> dynalic_Y;
    main -> dynalic_B;
    main -> utils_cpp;
    
    static_X -> static_X1;
    static_A -> dynalic_A1;
    dynalic_Y -> dynalic_Y1;
    dynalic_B -> static_B1;
    
    // 设置排名,使相关的节点在同一级
    {rank=same; static_X; static_A; dynalic_Y; dynalic_B;utils_cpp}
}

这其中最常见的依赖方式是静态链接库依赖其他静态链接库,动态链接库依赖其他动态链接库,后面的分析会基于这两种依赖关系。动态库 A 依赖静态库 B 是可行的,并且在某些情况下是有意义的。例如,如果静态库 B 包含一些不经常变化的代码,而动态库 A 包含一些经常更新的代码。不推荐在静态库 B 中依赖动态库 A,因为静态库通常被视为独立的代码块,不依赖于外部的动态链接。

静态链接的堆栈

接下来我们分析在静态链接情况下,如果中间有第三方依赖没有带编译选项 -fno-omit-frame-pointer,会带来怎么样的影响。

假设有一个 main.cpp 依赖了 utils.cpp 和静态库 static_A,静态库 static_A 依赖了静态库 static_B,这里static_A 编译的时候没带上 -fno-omit-frame-pointer,但是其他都带了-fno-omit-frame-pointer,最终生成的二进制文件中,各静态库和 cpp 文件中的函数会有帧指针吗?这种情况下 eBPF 和 BCC 的工具能最大程度地解析出堆栈信息吗?

我们在本地创建一个完整的示例项目,包含上面的各种依赖关系,代码结构如下,完整代码在 Gist 上:

$ FP_static_demo tree
.
├── main.cpp
├── Makefile
├── static_A
│   ├── static_A.cpp
│   └── static_A.h
├── static_B
│   ├── static_B.cpp
│   └── static_B.h
├── utils.cpp
└── utils.h

然后在编译生成的二进制文件中,发现 static_A 里面的函数没有帧指针,但是 static_B 和其他函数都有帧指针。运行二进制后,用 ebpf 的 profile 命令来分析 cpu 耗时堆栈,命令如下:

$ profile -F 999 -U -f --pid $(pgrep main)  60 > depend_main.stack
$ ./FlameGraph/flamegraph.pl depend_main.stack > depend_main.svg

在生成的 cpu 火焰图中,拿到的函数调用堆栈是错乱的,如下图:

在这里插入图片描述

正常如果没丢失帧指针的话,火焰图应该如下图所示,
在这里插入图片描述
通过上面的实验看到,profile 工具分析性能时,依赖帧指针来重建调用堆栈。即使只丢失中间某个依赖库的帧指针,整体函数的调用堆栈就会错乱,并不是只丢失这中间的部分函数调用堆栈。

还是上面的场景,如果我们在依赖的最底层 static_B 编译的时候不保存堆栈信息,但是其他部分都保存,那么生成的二进制文件中,只有 static_B 中的函数没有帧指针。再次用 profile 分析 cpu 堆栈,发现虽然只是最后一层函数调用没有帧指针,但是 BCC tools 分析拿到的堆栈信息还是有问题,如下图,printStaticAfunction_entry 被混到了同一层。这里多次运行,得到的堆栈信息图还可能不一样,不过都是错误的。
在这里插入图片描述

动态链接的堆栈

动态链接情况下,如果中间有第三方依赖没有带编译选项 -fno-omit-frame-pointer,理论上应该和静态链接一样,堆栈信息会错乱,不过还是写一个例子来验证下。还是上面的 main.cpp 和函数调用关系,把所有静态依赖改成动态依赖,重新改了下目录结构如下:

$ tree
.
├── dynamic_A
│   ├── dynamic_A.cpp
│   └── dynamic_A.h
├── dynamic_B
│   ├── dynamic_B.cpp
│   └── dynamic_B.h
├── main.cpp
├── Makefile
├── utils.cpp
└── utils.h

完整代码还是在 Gist 上。正常堆栈如下图:

在这里插入图片描述

修改 Makefile,只在编译 dynamic_A 的的时候忽略堆栈,生成的 CPU 火焰图如下:

在这里插入图片描述
修改 Makefile,只在编译 dynamic_B 的的时候忽略堆栈,生成的 CPU 火焰图如下:

在这里插入图片描述

和我们前面猜想一致,一旦丢失了部分堆栈信息,分析出来的堆栈图就会有错乱。

参考文章

Practical Linux tracing ( Part 1/5) : symbols, debug symbols and stack unwinding
How debuggers work: Part 3 - Debugging information
Understanding how function call works
Hacking With GDB

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/953990.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

unity学习18:unity里的 Debug.Log相关

目录 1 unity里的 Debug.log相关 2 用Debug.DrawLine 和 Debug.DrawRay画线 2.1 画线 1 unity里的 Debug.log相关 除了常用的 Debug.Log&#xff0c;还有另外2个 Debug.Log("Debug.Log"); Debug.LogWarning("Debug.LogWarning"); Debug.LogErro…

IoTDB 常见问题 QA 第三期

关于 IoTDB 的 Q & A IoTDB Q&A 第三期持续更新&#xff01;我们将定期汇总我们将定期汇总社区讨论频繁的问题&#xff0c;并展开进行详细回答&#xff0c;通过积累常见问题“小百科”&#xff0c;方便大家使用 IoTDB。 Q1&#xff1a;查询最新值 & null 数据相加方…

MySQL数据库(SQL分类)

SQL分类 分类全称解释DDLData Definition Language数据定义语言&#xff0c;用来定义数据库对象&#xff08;数据库&#xff0c;表&#xff0c;字段&#xff09;DMLData Manipulation Language数据操作语言&#xff0c;用来对数据库表中的数据进行增删改DQLData Query Languag…

Swift 趣味开发:查找拼音首字母全部相同的 4 字成语(上)

概述 Swift 语言是一门现代化、安全、强大且还算性感的语言。在去年 WWDC 24 中苹果正式推出了秃头码农们期待许久的 Swift 6.0&#xff0c;它进一步完善了 Swift 语言的语法和语义&#xff0c;并再接再厉——强化了现代化并发模型的安全性和灵活性。 这里我们不妨用 Swift 来…

C++ STL之容器介绍(vector、list、set、map)

1 STL基本概念 C有两大思想&#xff0c;面向对象和泛型编程。泛型编程指编写代码时不必指定具体的数据类型&#xff0c;而是使用模板来代替实际类型&#xff0c;这样编写的函数或类可以在之后应用于各种数据类型。而STL就是C泛型编程的一个杰出例子。STL&#xff08;Standard …

VUE3 + Ant Design Vue4 开发笔记

异常记录 [Vue warn]: Extraneous non-props attributes (options) were passed to component but could not be automatically inherited because component renders fragment or text root nodes 定位原因解决方法 错误的中文释义&#xff1a;[Vue 警告]&#xff1a;传递给…

QT跨平台应用程序开发框架(2)—— 初识QT

目录 一&#xff0c;创建helloworld 1.1 通过图形化 1.2 通过代码 1.3 通过编辑框 1.4 使用按钮 二&#xff0c;对象树 2.1 关于对象树 2.2 演示释放流程 三&#xff0c;乱码问题 3.1 为什么会有乱码问题 3.2 解决乱码问题 四&#xff0c;认识Qt坐标系 五&#xf…

【搭建JavaEE】(3)前后端交互,请求响应机制,JDBC数据库连接

前后端交互 Apache Tomat B/S目前主流。 tomat包含2部分&#xff1a; apache容器 再认识servlet 抽象出的开发模式 项目创建配置 maven javaeetomcat 忽略一些不用的文件 webapp文件夹 HiServlet 这里面出现了webinfo&#xff0c;这个别删因为这个呢&#xff0c;是这这个这…

美摄科技PC端视频编辑解决方案,为企业打造专属的高效创作平台

在当今这个信息爆炸的时代&#xff0c;视频已成为不可或缺的重要内容形式&#xff0c;美摄科技推出了PC端视频编辑解决方案的私有化部署服务&#xff0c;旨在为企业提供一款量身定制的高效创作平台。 一、全面功能&#xff0c;满足企业多样化需求 美摄科技的PC端视频编辑解决…

探索图像编辑的无限可能——Adobe Photoshop全解析

文章目录 前言一、PS的历史二、PS的应用场景三、PS的功能及工具用法四、图层的概念五、调整与滤镜六、创建蒙版七、绘制形状与路径八、实战练习结语 前言 在当今数字化的世界里&#xff0c;视觉内容无处不在&#xff0c;而创建和编辑这些内容的能力已经成为许多行业的核心技能…

STM32-笔记41-RTC(实时时钟)

一、什么是RTC&#xff1f; 实时时钟的缩写是RTC(Real_Time Clock)。RTC 是集成电路&#xff0c;通常称为时钟芯片。 实时时钟是一个独立的定时器。 RTC模块拥有一组连续计数的计数器&#xff0c;在相应软件配置下&#xff0c;可提供时钟日历的功能。修改计数器的值可以重新设…

51c自动驾驶~合集46

我自己的原文哦~ https://blog.51cto.com/whaosoft/13050104 #世界模型会是L3自动驾驶的唯一解吗 三维空间占有率&#xff08;3D Occupancy&#xff09;预测的目的是预测三维空间中的每个体素是否被占有&#xff0c;如果被占有&#xff0c;则对应的体素将被标记。3D Semant…

mybatis-spring @MapperScan走读分析

接上一篇文章&#xff1a;https://blog.csdn.net/qq_26437925/article/details/145100531&#xff0c; 本文注解分析mybatis-spring中的MapperScan注解&#xff0c;则将容易许多。 目录 MapperScan注解定义ConfigurationClassPostProcessor扫描注册beanDefinitionorg.mybatis.s…

Apache PAIMON 学习

参考&#xff1a;Apache PAIMON&#xff1a;实时数据湖技术框架及其实践 数据湖不仅仅是一个存储不同类数据的技术手段&#xff0c;更是提高数据分析效率、支持数据驱动决策、加速AI发展的基础设施。 新一代实时数据湖技术&#xff0c;Apache PAIMON兼容Apache Flink、Spark等…

SQL面试题1:连续登陆问题

引言 场景介绍&#xff1a; 许多互联网平台为了提高用户的参与度和忠诚度&#xff0c;会推出各种连续登录奖励机制。例如&#xff0c;游戏平台会给连续登录的玩家发放游戏道具、金币等奖励&#xff1b;学习类 APP 会为连续登录学习的用户提供积分&#xff0c;积分可兑换课程或…

电商系统,核心通用架构案例设计方案浅析

文章目录 一、用户系统案例设计1、用户信息的存储方案2、用户注册确保唯一3、用户数据合并方案4、用户敏感信息加密存储5、数据传输安全性6、多用户数据隔离性7、防止恶意注册8、用户好友关系存储方案9、用户登录token方案10、会员优先处理设计 二、网关系统设计1、网关的功能2…

【EI 会议征稿】第四届材料工程与应用力学国际学术会议(ICMEAAE 2025)

2025 4th International Conference on Materials Engineering and Applied Mechanics 重要信息 大会官网&#xff1a;www.icmeaae.com 大会时间&#xff1a;2025年3月7-9日 大会地点&#xff1a;中国西安 截稿时间&#xff1a;2025年1月24日23:59 接受/拒稿通知&#xf…

SQL面试题2:留存率问题

引言 场景介绍&#xff1a; 在互联网产品运营中&#xff0c;用户注册量和留存率是衡量产品吸引力和用户粘性的关键指标&#xff0c;直接影响产品的可持续发展和商业价值。通过分析这些数据&#xff0c;企业可以了解用户行为&#xff0c;优化产品策略&#xff0c;提升用户体验…

【Rust自学】11.7. 按测试的名称运行测试

喜欢的话别忘了点赞、收藏加关注哦&#xff0c;对接下来的教程有兴趣的可以关注专栏。谢谢喵&#xff01;(&#xff65;ω&#xff65;) 11.7.1. 按名称运行测试的子集 如果想要选择运行的测试&#xff0c;就将测试的名称&#xff08;一个或多个&#xff09;作为cargo test的…

深入浅出 Android AES 加密解密:从理论到实战

深入浅出 Android AES 加密解密&#xff1a;从理论到实战 在现代移动应用中&#xff0c;数据安全是不可忽视的一环。无论是用户隐私保护&#xff0c;还是敏感信息的存储与传输&#xff0c;加密技术都扮演着重要角色。本文将以 AES&#xff08;Advanced Encryption Standard&am…