CPP Debug Analyze 01

Last updated on a minute ago

C++ Debug/Analyze 专题指南-01

GDB篇

GDB是Linux下非常好用且强大的调试工具,虽然它是命令行模式的调试工具,能够让用户在程序运行时观察程序的内部结构和内存的使用情况。

一般来说,GDB主要帮助你完成下面四个方面的功能:

  1. 按照自定义的方式启动运行需要调试的程序。
  2. 可以使用指定位置和条件表达式的方式来设置断点。
  3. 程序暂停时的值的监视。
  4. 动态改变程序的执行环境。

GDB的用法

常用调试命令

GDB 的主要功能就是监控程序的执行流程。这也就意味着,只有当源程序文件编译为可执行文件并执行时,并且该文件中必须包含必要的调试信息(比如各行代码所在的行号、包含程序中所有变量名称的列表(又称为符号表)等),GDB才会派上用场。

所以在编译时需要使用 gcc/g++ -g 选项编译源文件,才可生成满足 GDB 要求的可执行文件。

此处可以参考之前的内存泄漏专题指南,描述了如何加入”-g”以及验证是否存在调试信息

调试技巧可以参考: https://www.cnblogs.com/ZY-Dream/p/14540499.html

调试命令 (缩写) 作用
(gdb) break (b) 在源代码指定的某一行设置断点,其中xxx用于指定具体打断点位置
(gdb) run (r) 执行被调试的程序,其会自动在第一个断点处暂停执行。
(gdb) continue (c) 当程序在某一断点处停止后,用该指令可以继续执行,直至遇到断点或者程序结束。
(gdb) next (n) 令程序一行代码一行代码的执行。
(gdb) step(s) 如果有调用函数,进入调用的函数内部;否则,和 next 命令的功能一样。
(gdb) until (u)
(gdb) until (u) location
当你厌倦了在一个循环体内单步跟踪时,单纯使用 until 命令,可以运行程序直到退出循环体。 until n 命令中,n 为某一行代码的行号,该命令会使程序运行至第 n 行代码处停止。
(gdb) print (p) 打印指定变量的值,其中 xxx 指的就是某一变量名。
(gdb) list (l) 显示源程序代码的内容,包括各行代码所在的行号。
(gdb) finish(fi) 结束当前正在执行的函数,并在跳出函数后暂停程序的执行。
(gdb) return(return) 结束当前调用函数并返回指定值,到上一层函数调用处停止程序执行。
(gdb) jump(j) 使程序从当前要执行的代码处,直接跳转到指定位置处继续执行后续的代码。
(gdb) quit (q) 终止调试。

GDB调试执行异常崩溃的程序

在大多数情况下,我们遇到异常崩溃的程序才会使用GDB来定位问题,而GDB又有可能影响效率,所以若不确定能否必现问题,则coredump文件是较好的选择

Linux操作系统中,当程序执行发生异常崩溃时,系统可以将发生崩溃时的内存数据、调用堆栈情况等信息自动记录下载,并存储到一个文件中,该文件通常称为core 文件,Linux 系统所具备的这种功能又称为核心转储(core dump

生成Core方法

需要确认当前会话的ulimit –c,若为0,则不会产生对应的coredump,需要进行修改和设置

1
2
root@in-dev-docker:/apollo# ulimit -c
0

当为0时,即便程序core dump了也不会有core文件留下。我们需要让core文件能够产生,设置core大小为无限:

1
ulimit -c unlimited
*更改core dump生成路径

因为core dump默认会生成在程序的工作目录,但是有些程序存在切换目录的情况,导致core dump生成的路径没有规律,

所以最好是自己建立一个文件夹,存放生成的core文件。

1
2
mkdir -p /tmp/coredump
echo /tmp/coredump/core.%e.%p > /proc/sys/kernel/core_pattern

调试程序和Core文件

1
2
3
gdb 可执行程序
core-file path2corefile
bt 可以查看准确信息

VSCODE GDB调试c++程序

鉴于GDB需要手动交互略显麻烦,查看代码也不够清晰,基于Vscode GUI的DEBUG工具链就显得尤为简单

参考:https://code.visualstudio.com/docs/cpp/config-linux

Apollo docker环境的GDB运行

对于简单的c++程序,参考👆上面的链接即可,对于apollo这种以so被调用的形式,则需要定制相关的文件

  1. /apollo/.vscode目录下添加launch.jsontasks.json文件

  2. 修改tasks.json文件

    此处可以添加多个task,并且在launch.json中preLaunchTask处被预先执行,配置环境

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
    {
    "label": "env",
    "type": "shell",
    "command": "source",
    "args": [
    "/apollo/cyber/setup.bash"
    ]
    }
    ]
    }
  3. 修改launch.json文件

    此处定义了debug的程序和配置文件,同时指定了源码路径

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    {
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
    {
    "name": "(gdb) 启动",
    "type": "cppdbg",
    "request": "launch",
    "preLaunchTask": "env",
    "program": "/apollo/bazel-bin/cyber/mainboard/mainboard",
    "args": [
    "-d",
    "modules/omnisense/drivers/lidar/innovusion/dag/01_single.dag"
    ],
    "stopAtEntry": false,
    "cwd": "/apollo",
    "environment": [],
    "externalConsole": false,
    "MIMode": "gdb",
    "setupCommands": [
    {
    "description": "为 gdb 启用整齐打印",
    "text": "-enable-pretty-printing",
    "ignoreFailures": true
    },
    {
    "description": "将反汇编风格设置为 Intel",
    "text": "-gdb-set disassembly-flavor intel",
    "ignoreFailures": true
    }
    ]
    }
    ]
    }
  4. 接着就可以按下F5,即可正常运行了,常用的断点等功能也能正常使用

UnitTest单元测试篇

unittest的重要性不用多说,可以让你的程序代码质量大幅提升、协助你进行良好的程序设计。

而单元测试的覆盖率越高,则也可以认为代码可能存在的bug越少。

并且良好的单元测试也有利于code review。

在工具上,我们会使用下面这些:

  • Google Test

  • gcov是由GCC工具链提供的代码覆盖率生成工具。

    对于代码覆盖率工具所做的工作,可以简单的理解为:标记一次运行过程中,哪些代码被执行过,哪些没有执行。

    因此,即便没有测试代码,直接运行编译产物也可以得到代码的覆盖率。只不过,通常情况下这样得到的覆盖率较低罢了

  • lcov是gcov工具的图形前端。它收集多个源文件的gcov数据,并生成描述覆盖率的HTML页面。生成的结果中会包含概述页面,以方便浏览。

UnitTest Bazel实战

google test官网详细描述了bazel或cmake的使用流程,而Apollo中的bazel也集成了gtest

当我们写完了unittest后,只需使用如下命令:

1
2
3
./apollo.sh test omnisense/path2module
#./apollo.sh test是对bazel test的封装,通过后不会输出过程信息,即--test_output=errors,可以使用如下命令直接运行原始命令,也可以做更多定制
#bazel test --test_output=all --test_tag_filters=-cpplint modules/omnisense/path2module/...

待编译完成后,则会自动执行单元测试,若没有问题,则会输出PASSED,显示如下:

1
2
3
4
5
6
7
8
9
10
11
(05:20:01) INFO: Analyzed 27 targets (0 packages loaded, 0 targets configured).
(05:20:01) INFO: Found 26 targets and 1 test target...
(05:20:14) INFO: Elapsed time: 13.599s, Critical Path: 12.71s
(05:20:14) INFO: 2 processes: 1 internal, 1 local.
(05:20:14) INFO: Build completed successfully, 2 total actions
//modules/omnisense/drivers/lidar/innovusion/driver:adapter_component_test PASSED in 12.7s

(05:20:14) INFO: Build completed successfully, 2 total actions
==============================================
[ OK ] Done testing omnisense/drivers. Enjoy
==============================================

Coverage代码覆盖率

在进行单元测试之后,我们当然希望能够直观的看到我们的测试都覆盖了哪些代码。

理论上,如果我们能做到100%的覆盖我们的所有代码,则可以说我们的代码是没有Bug的。

但实际上,100%的覆盖率要比想象得困难。对于大型项目来说,能够达到80% ~ 90%的语句覆盖率就已经很不错了。

覆盖率的类型

先来看一下,当我们在说“覆盖率”的时候我们到底是指的什么。

实际上,代码覆盖率有下面几种类型:

  • 函数覆盖率:描述有多少比例的函数经过了测试。
  • 语句覆盖率:描述有多少比例的语句经过了测试。
  • 分支覆盖率:描述有多少比例的分支(例如:if-elsecase语句)经过了测试。
  • 条件覆盖率:描述有多少比例的可能性经过了测试。

这其中,函数覆盖率最为简单,就不做说明了。

语句覆盖率是我们最常用的。因为它很直观的对应到我们写的每一行代码。

而分支覆盖率和条件覆盖率可能不太好理解

以下面这个C语言函数为例:

1
2
3
4
5
6
7
int foo (int x, int y) {
int z = 0;
if ((x > 0) && (y > 0)) {
z = x;
}
return z;
}

这个函数中包含了一个if语句,因此if语句成立或者不成立构成了两个分支。所以如果只测试了if成立或者不成立的其中之一,其分支覆盖率只有 1/2 = 50%

而条件覆盖率需要考虑每种可能性的情况。

对于if (a && b)这样的语句,其一共有四种可能的条件:

  1. a = true, b = true
  2. a = true, b = false
  3. a = false, b = true
  4. a = false, b = false

综上,在编写代码的时候,尽可能的减少代码嵌套,并且简化逻辑运算是一项很好的习惯。便于测试的代码也是便于理解和维护的,反之则反。

有了这些概念之后,我们就可以看懂测试报告中的覆盖率了。

Coverage Bazel实战

由于Apollo Bazel中集成了LCOV,其他编译环境的配置则不在此展开,可以自行搜索。

当完成上面的UintTest后,我们就可以检查覆盖率了:

1
./apollo.sh coverage omnisense/path2module

一切正常的话,会输出以下信息:

1
2
3
4
5
6
7
Overall coverage rate:
lines......: 50.4% (1260 of 2499 lines)
functions..: 59.9% (235 of 392 functions)
==============================================
[ OK ] Done bazel coverage for omnisense/drivers.
==============================================
[INFO] Coverage report was generated under /apollo/.cache/coverage

能看到其中50.4%59.9%则是输出的覆盖率了,相关的html格式报告则存放于/apollo/.cache/coverage路径下。

此时我们可以进入到路径/apollo/.cache/coverage,然后运行一个http server,则就可以在浏览器中查看细节了:

1
python3 -m http.server [8000]

此时打开浏览器,输入127.0.0.1:8000,则会显示类似如下的界面。

截屏2022-11-02 16.35.47

可以看到,除了第三方库httplib.h头文件,其他的代码覆盖率都能达到90%。

而如果先前也已经完成了内存泄漏的检查,到这里基本上可以认为自己的代码已经相当稳定了,是可以交付生产或者进行Code Review的了。

Perf/Profiling性能/分析篇

Bottlenecks occur in surprising places, so don’t try to second guess and put in a speed hack until you’ve proven that’s where the bottleneck is. - Rob Pike

性能分析,Linux下有一个非常好用的工具,叫做perf。几乎每个发行版都有它的安装包。perf诞生于2009年,是一个内核级的工具;另外,还有一个工具叫做gperftools(pprof),是google的产品,是一个应用级别的产品。

不得不提的一点是,perf和gperftools对性能的影响,虽然不是特别大,但也尽量不要在线上环境使用它们,这个性能损耗率大概在30%左右。

由于perf仅适用于linux,且对内核版本有要求,而gperftools则没有这个限制,使用也会更宽泛,我们便以此来示范

gperftool介绍

gperftools包含了一套分析工具,在此我们只使用它的性能分析模块(cpu profiler)

安装

1
2
sudo apt-get update
sudo apt-get install google-perftools libgoogle-perftools-dev

也可以使用源码安装,

1
2
3
4
5
6
git clone https://github.com/gperftools/gperftools.git
cd gperftools/
./autogen.sh
./configure
make
make install

使用

参考https://gperftools.github.io/gperftools/cpuprofile.html

它的使用较为简单,一般情况下仅需链接-lprofiler即可,动态库和静态库都支持

Cmake

只需要**TARGET_LINK_LIBRARIES(${TARGET} tcmalloc profiler)**即可

用例

一般情况下,我们使用性能分析有以下几种需求或场景

  • 程序运行即开始性能分析(即从启动程序到结束)

    1
    CPUPROFILE=./path2save.prof ./execute

    此处的CPUPROFILE即是指定路径

  • 监控部分函数

    1
    2
    3
    4
    5
    6
    7
    8
    #include <gperftools/profiler.h>

    int main(){
    ProfilerStart("test.prof"); // 指定所生成的profile文件名
    func_prof();
    ProfilerStop(); // 结束profiling
    return 0;
    }
  • 分析一直运行的程序

    一直运行的程序由于不能正常退出,所以不能采用上面的方法。我们可以用信号量来开启/关闭性能分析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    #include <gperftools/profiler.h>
    #include <stdio.h>
    #include <signal.h>
    #include <unistd.h>

    void gprofStartAndStop(int signum) {
    static int isStarted = 0;
    if (signum != SIGUSR1) return;

    //通过isStarted标记未来控制第一次收到信号量开启性能分析,第二次收到关闭性能分析。
    if (!isStarted){
    isStarted = 1;
    ProfilerStart("test.prof");
    printf("ProfilerStart success\n");
    }else{
    ProfilerStop();
    printf("ProfilerStop success\n");
    }
    }

    int main(){
    signal(SIGUSR1, gprofStartAndStop);

    while(1){
    printf("call f\n");
    func_prof();
    sleep(1);//为了防止死循环,导致信号处理函数得不到调度
    }
    return 0;
    }

    通过kill命令发送信号给进程来开启/关闭性能分析:

    1
    2
    3
    用top命令查看进程的PID
    kill -s SIGUSR1 PID //第一次运行命令启动性能分析
    kill -s SIGUSR1 PID //再次运行命令关闭性能分析,产生test.prof

    这种方式适合灵活关闭profile,不用重启启动服务,适合在线上查看。

分析

当生成了profile后,就可以使用google-pprof工具来转换为我们能分析的文件。

以下为常用的几种目标输出:

1
2
3
google-pprof bazel-bin/path2module.so /tmp/cpu.prof --text > /tmp/gprof.txt
google-pprof bazel-bin/path2module.so /tmp/cpu.prof --svg > /tmp/gprof.svg
google-pprof bazel-bin/path2module.so /tmp/cpu.prof --web > /tmp/gprof.html

以输出的svg文件为例,我们可以使用浏览器打开,其中占用面积越大则意味着消耗了更多的cpu

截屏2022-11-04 11.35.12

树上的每个节点代表一个函数,节点数据格式:

  1. 函数名 或者 类名+方法名
  2. 不包含内部函数调用的样本数 (百分比)
  3. 包含内部函数调用的样本数 (百分比) ,如果没有内部调用函数则这一项数据不显示

Apollo Bazel实战

Apollo Bazel中集成了pprof,其他的环境或者更详细的配置可以参考https://gperftools.github.io/gperftools/cpuprofile.html

当我们需要进行性能分析时,只需要使用如下命令编译:

1
./apollo.sh build_prof omnisense/path2module

需要注意的事,一般类似的工具都需要运行流程可正常退出(此处若异常退出则无法统计结果)

当然,有时候我们只想要统计部分时间或者部分函数,那我们就需要参考👆上面的用例进行定制代码

运行的时候,只需要增加环境变量,填入我们希望存放的.prof路径即可

1
CPUPROFILE=/tmp/cpu.prof mainboard -d modules/omnisense/drivers/lidar/innovusion/dag/01_single.dag 

当运行结束或者我们Ctrl+C退出时,将会在命令行输出以下信息,即为收集到了相关信息:

1
PROFILE: interrupts/evictions/bytes = 3851/899/52752

火焰图(Flame Graphs)

火焰图(flame graph)是性能分析的利器,通过它可以快速定位性能瓶颈点。一般perf或gperftools产生的数据可以导出为火焰图,以方便将数据呈现得更为直观。

安装

火焰图时脚本工具,只需要将其仓库下载即可使用

1
2
mkdir -p /tmp/FlameGraph && cd /tmp/FlameGraph
git clone https://github.com/brendangregg/FlameGraph.git

gperftools生成火焰图

在上节生成了profile文件后,我们只需要:

1
2
pprof bazel-bin/path2module.so /tmp/cpu.prof --collapsed > /tmp/flame.prof
/tmp/FlameGraph/flamegraph.pl /tmp/flame.prof > ./flame.svg

将会在当前目录生成svg文件,这时候就可以用浏览器打开并交互了

截屏2022-11-04 13.37.51

火焰图有以下特征(这里以 on-cpu 火焰图为例):

  • 每一列代表一个调用栈,每一个格子代表一个函数
  • 纵轴展示了栈的深度,按照调用关系从下到上排列。最顶上格子代表采样时,正在占用 cpu 的函数。
  • 横轴的意义是指:火焰图将采集的多个调用栈信息,通过按字母横向排序的方式将众多信息聚合在一起。需要注意的是它并不代表时间。
  • 横轴格子的宽度代表其在采样中出现频率,所以一个格子的宽度越大,说明它是瓶颈原因的可能性就越大。
  • 火焰图格子的颜色是随机的暖色调,方便区分各个调用信息。
  • 其他的采样方式也可以使用火焰图, on-cpu 火焰图横轴是指 cpu 占用时间,off-cpu 火焰图横轴则代表阻塞时间。
  • 采样可以是单线程、多线程、多进程甚至是多 host

参考文献与推荐读物