近期在公司虚拟机上写代码遇到Segmentation Fault 和 std::bad_alloc问题,但是项目庞大,在不了解功能、代码连接关系的时候很难追踪具体是什么地方出了问题。网络上许多关于GDB的教程仅仅停留在简单的示例中的调试,对于复杂的项目结构(多文件,多作用域,......)来说显得有些不够用,因此记录一下终端GDB调试复杂项目的经验,也希望可以帮助到被同样问题困扰的朋友。
1. 修改编译选项以生成支持GDB的可执行文件
首先在项目编译时我们需要开启debug模式,对于大型项目来说,通常我们使用Makefile或CMakeList来进行文件管理,要让编译得到的可执行文件能够被gdb处理,我们需要在编译时增加命令。
对于Makefile来说,我们一般通过修改CXXFLAG后的参数来增加对GDB的支持,对于GDB来说,我们主要需要考虑的参数有两个:
(必需)-g : 在编译项目时,使用 -g 选项来生成调试信息。这个选项告诉编译器在生成的二进制文件中包含足够的调试信息,方便GDB进行调试。
(可选)-O0 : 这个选项可以禁用编译器的自动优化,否则优化可能会改变代码的执行顺序或使某些变量在调试器中不可见,或是导致GDB的报错位置与文件中实际位置不一致,增大debug难度。
这里是一个修改Makefile以生成支持GDB的可执行文件的例子:
CXXFLAGS = -std=c++17 -Wall -O0 -g
对于CMakeList来说,实现方式是类似的,替换成修改CMAKE_CXX_FLAGS_DEBUG:
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0")
2. 使用GDB进行调试
2.1 进入GDB
在完成第一步以后,我们就可以得到一个可执行文件了,我们就假设它的名字是exec。接下来我们要在GDB中打开它:
> gdb .../exec
Reading symbols from .../exec...
或者这样:
> gdb
> (gdb) file .../exec
Reading symbols from .../exec...
> (gdb)
在GDB读取好可执行文件的信息后,我们就可以正式开始打断点debug了。
2.2 设置断点
要在GDB中打断点,我们需要传入两个参数:需要打断点的文件,以及设置断点的位置/函数。对于结构较复杂的项目,打断点往往需要传递完整的路径信息,在此演示两种打断点的方式,并附上查询特定函数的方法。
如果项目中的文件的名称是唯一的,那么我们可以直接给GDB提供文件名,然后把断点设置在我们想要的位置:
> (gdb) b test.hh:35
Breakpoint 1 at 0x87e76f4: file path/to/your/file/test.hh:35.
或者我们也可以通过把断点设置在特定函数的开头。GDB似乎不支持直接查找特定函数中包含的函数,所以我们通过正则匹配的方式把函数名传递给GDB进行查找:
> (gdb) info functions testFunction //这种方式能够查询项目的所有文件中,名字内包含testFunction的函数:
File path/to/func/test.hh:
41: void testFunction(); //我们想要的函数
File another/path/to/anotherFile/other.cc:
189: int testFunctionAnother(int, float); //其他文件中的函数
在查找完成后,可以用list来查看该函数所在位置的信息。我们把断点设置在函数testFunction的入口处:
> (gdb) list path/to/func/test.hh:testFunctions
###################################
### 此处会展示testFunctions的信息 ###
###################################
> (gdb) b path/to/func/test.hh:testFunctions
Breakpoint 2 at 0x99e6e22: path/to/func/test.hh:testFunctions.
关于断点的设置和操作,网上有许多介绍更加细致的教程,这里就只介绍一下复杂项目中函数的查询方式,更进一步的断点操作就不做过多赘述了。
2.3 运行GDB
在GDB读取好可执行文件的信息后,我们就可以正式开始打断点debug了。我个人面对的项目是需要设置传入参数的,因此并不像网上许多教程那样只是简单地输入run就可以开始运行。
如果你的项目代码运行时不需要传入参数,只需要输入run,GDB就会开始运行程序直到断点,或是遇到导致程序crash的问题(比如Segmentation Fault)为止:
> (gdb) run
而对于需要传入参数的项目,我们需要在运行时指定要传入的指令。这里我们假设文件需要通过 -s 来读取位于 ~/test/case1/路径下的文件file。为了展示GDB下改变路径和传入指令的过程,我们先通过cd命令来移动到~/test/路径下,再读取file文件:
> (gdb) cd ~/test
Working directory /home/test
> (gdb) run .../exec -s ./file
前文简单提到了关于设置断点的操作。对于知道想要追踪的代码位置的情况来说,如果你设置了断点,那么程序会在运行到断点位置后停下,之后我们可以尝试单步运行代码或是查询当前变量的值。但是在面对Segmentation Fault之类的问题时,往往我们并不清楚代码中是什么位置出了问题,而通过打断点来进行单步调试对于复杂项目来说会显得有些不太现实。
然而,好消息是,当GDB在run的过程中遇到会导致程序崩溃的问题时能够自动停下。也就是说,我们不需要设置任何断点,GDB能够自动停止在出现问题的位置:
Thread 1 "exec" received signal SIGSEGV, Segmentation fault.
接下来我们输入where, 能够看到导致程序crash的代码和调用关系。通常来说这里可能会报出很多信息,因为GDB会完整地展示整个函数的调用过程。例如,main() -> func1() -> func2()导致了Segmentation Fault, 那么GDB会把这3个函数都抓取出来:
> (gdb) where
#0 0x000000005841628 in func2 ()
#1 0x0000000005845d7 in func1 ()
#2 0x00000000565ea43 in main ()
使用frame命令可以仔细查看特定报错的信息,而用backtrace命令可以查看函数之间调用时传递的参数和函数的内部变量值:
> (gdb) frame 0 //对应上方的#0, 展示func1中的详细信息
...
> (gdb) backtrace full
...
在通过以上操作得到报错函数信息后,我们就可以通过进一步查询相关函数、追踪特定变量或在可疑的位置打断点来实现精确的问题追踪。对于我个人遇到的情况而言,GDB在这里并没有给我明确指出具体是哪一行导致程序崩溃,但GDB告诉了我导致崩溃的函数,于是我通过调用的函数来进一步排查出现问题的地方(例如,析构函数报错,所以我会选择优先排查class中的clear功能)。