背景
作为C/C++开发人员,保证程序正常运行是最基本也是最主要的目的。而为了保证程序正常运行,调试则是最基本的手段,熟悉这些调试方式,可以方便我们更快的定位程序问题所在,提高开发效率。
在开发过程,如果程序的运行结果不符合预期,
- 第一时间就是打开GDB进行调试,在对应的地方设置断点,然后分析原因
当线上服务出了问题
- 第一时间查看进程在不在,如果不在的话,是否生成了coredump文件,如果有,则使用gdb调试coredump文件,否则通过dmesg来分析内核日志来查找原因。
概念
GDB是一个由GNU开源组织发布的、UNIX/LINUX操作系统下的、「基于命令行的、功能强大的程序调试工具」。
GDB支持断点、单步执行、打印变量、观察变量、查看寄存器、查看堆栈等调试手段。在Linux环境软件开发中,GDB是主要的调试工具,用来调试C和 C++程序(也支持go等其他语言)。
常用命令
断点
断点是我们在调试中经常用的一个功能,我们在指定位置设置断点之后,程序运行到该位置将会暂停,这个时候我们就可以对程序进行更多的操作,比如查看变量内容,堆栈情况等等,以帮助我们调试程序。
以设置断点的命令分为以下几类:
| 命令 | 作用 |
|---|---|
| break [file]:function | 在文件file的function函数入口设置断点 |
| break [file]:line | 在文件file的第line行设置断点 |
| info breakpoints | 查看断点列表 |
| break [+/-]offset | 在当前位置偏移量为[+-]offset处设置断点 |
| break *addr | 在地址addr处设置断点 |
| break … if expr | 设置条件断点,仅仅在条件满足时 |
| ignore n count | 接下来对于编号为n的断点忽略count次 |
| clear | 删除所有断点 |
| clear function | 删除所有位于function内的断点 |
| delete n | 删除指定编号的断点 |
| enable n | 启用指定编号的断点 |
| disable n | 禁用指定编号的断点 |
| save breakpoints file | 保存断点信息到指定文件 |
| source file | 导入文件中保存的断点信息 |
| break | 在下一个指令处设置断点 |
| clear [file:]line | 删除第line行的断点 |
watchpoint
watchpoint是一种特殊类型的断点,类似于正常断点,是要求GDB暂停程序执行的命令。区别在于watchpoint没有驻留某一行源代码中,而是指示GDB每当某个表达式改变了值就暂停执行的命令。
watchpoint分为硬件实现和软件实现两种。前者需要硬件系统的支持;后者的原理就是每步执行后都检查变量的值是否改变。GDB在新建数据断点时会优先尝试硬件方式,如果失败再尝试软件实现。
| 命令 | 作用 |
|---|---|
| watch variable | 设置变量数据断点 |
| watch var1 + var2 | 设置表达式数据断点 |
| rwatch variable | 设置读断点,仅支持硬件实现 |
| awatch variable | 设置读写断点,仅支持硬件实现 |
| info watchpoints | 查看数据断点列表 |
| set can-use-hw-watchpoints 0 | 强制基于软件方式实现 |
使用数据断点时,需要注意:
- 当监控变量为局部变量时,一旦局部变量失效,数据断点也会失效
- 如果监控的是指针变量p,则watch *p监控的是p所指内存数据的变化情况,而watch p监控的是p指针本身有没有改变指向
最常见的数据断点应用场景:「定位堆上的结构体内部成员何时被修改」。由于指针一般为局部变量,为了解决断点失效,一般有两种方法。
| 命令 | 作用 |
|---|---|
| print &variable | 查看变量的内存地址 |
| watch (type )address | 通过内存地址间接设置断点 |
| watch -l variable | 指定location参数 |
| watch variable thread 1 | 仅编号为1的线程修改变量var值时会中断 |
catchpoint
从字面意思理解,是捕获断点,其主要监测信号的产生。
- 例如c++的throw,或者加载库的时候,产生断点行为。
| 命令 | 含义 |
|---|---|
| catch fork | 程序调用fork时中断 |
| tcatch fork | 设置的断点只触发一次,之后被自动删除 |
| catch syscall ptrace | 为ptrace系统调用设置断点 |
在command命令后加断点编号,可以定义断点触发后想要执行的操作。在一些高级的自动化调试场景中可能会用到
命令行
| 命令 | 作用 |
|---|---|
| run arglist | 以arglist为参数列表运行程序 |
| set args arglist | 指定启动命令行参数 |
| set args | 指定空的参数列表 |
| show args | 打印命令行列表 |
程序栈
| 命令 | 作用 |
|---|---|
| backtrace [n] | 打印栈帧 |
| frame [n] | 选择第n个栈帧,如果不存在,则打印当前栈帧 |
| up n | 选择当前栈帧编号+n的栈帧 |
| down n | 选择当前栈帧编号-n的栈帧 |
| info frame [addr] | 描述当前选择的栈帧 |
| info args | 当前栈帧的参数列表 |
| info locals | 当前栈帧的局部变量 |
多进程、多线程
GDB在调试多进程程序(程序含fork调用)时,默认只追踪父进程。可以通过命令设置,实现只追踪父进程或子进程,或者同时调试父进程和子进程。
多进程
| 命令 | 作用 |
|---|---|
| info inferiors | 查看进程列表 |
| attach pid | 绑定进程id |
| inferior num | 切换到指定进程上进行调试 |
| print $_exitcode | 显示程序退出时的返回值 |
| set follow-fork-mode child | 追踪子进程 |
| set follow-fork-mode parent | 追踪父进程 |
| set detach-on-fork on | fork调用时只追踪其中一个进程 |
| set detach-on-fork off | fork调用时会同时追踪父子进程 |
在调试多进程程序时候,默认情况下,除了当前调试的进程,其他进程都处于挂起状态,所以,如果需要在调试当前进程的时候,其他进程也能正常执行,那么通过设置set schedule-multiple on即可。
多线程
多线程开发在日常开发工作中很常见,所以多线程的调试技巧非常有必要掌握。
默认调试多线程时,一旦程序中断,所有线程都将暂停。如果此时再继续执行当前线程,其他线程也会同时执行。
| 命令 | 作用 |
|---|---|
| info threads | 查看线程列表 |
| print $_thread | 显示当前正在调试的线程编号 |
| set scheduler-locking on | 调试一个线程时,其他线程暂停执行 |
| set scheduler-locking off | 调试一个线程时,其他线程同步执行 |
| set scheduler-locking step | 仅用step调试线程时其他线程不执行,用其他命令如next调试时仍执行 |
如果只关心当前线程,建议临时设置 scheduler-locking 为 on,避免其他线程同时运行,导致命中其他断点分散注意力。
打印输出
通常情况下,在调试的过程中,我们需要查看某个变量的值,以分析其是否符合预期,这个时候就需要打印输出变量值。
| 命令 | 作用 |
|---|---|
| whatis variable | 查看变量的类型 |
| ptype variable | 查看变量详细的类型信息 |
| info variables var | 查看定义该变量的文件,不支持局部变量 |
打印字符串
使用x/s命令打印ASCII字符串,如果是宽字符字符串,需要先看宽字符的长度 print sizeof(str)。
如果长度为2,则使用x/hs打印;如果长度为4,则使用x/ws打印。
| 命令 | 作用 |
|---|---|
| x/s str | 打印字符串 |
| set print elements 0 | 打印不限制字符串长度/或不限制数组长度 |
| call printf(“%s\n”,xxx) | 这时打印出的字符串不会含有多余的转义符 |
| printf “%s\n”,xxx | 同上 |
打印数组
| 命令 | 作用 |
|---|---|
| print *array@10 | 打印从数组开头连续10个元素的值 |
| print array[60]@10 | 打印array数组下标从60开始的10个元素,即第60~69个元素 |
| set print array-indexes on | 打印数组元素时,同时打印数组的下标 |
打印指针
| 命令 | 作用 |
|---|---|
| print ptr | 查看该指针指向的类型及指针地址 |
| print (struct xxx )ptr | 查看指向的结构体的内容 |
打印指定内存地址的值
使用x命令来打印内存的值,格式为x/nfu addr,以f格式打印从addr开始的n个长度单元为u的内存值。
- n:输出单元的个数
- f:输出格式,如x表示以16进制输出,o表示以8进制输出,默认为x
- u:一个单元的长度,b表示1个byte,h表示2个byte(half word),w表示4个byte,g表示8个byte(giant word) | 命令 | 作用 | | —- | —- | | x/8xb array | 以16进制打印数组array的前8个byte的值 | | x/8xw array | 以16进制打印数组array的前16个word的值 |
打印局部变量
| 命令 | 作用 |
|---|---|
| info locals | 打印当前函数局部变量的值 |
| backtrace full | 打印当前栈帧各个函数的局部变量值,命令可缩写为bt |
| bt full n | 从内到外显示n个栈帧及其局部变量 |
| bt full -n | 从外向内显示n个栈帧及其局部变量 |
打印结构体
| 命令 | 作用 |
|---|---|
| set print pretty on | 每行只显示结构体的一名成员 |
| set print null-stop | 不显示’\000’这种 |
函数跳转
| 命令 | 作用 |
|---|---|
| set step-mode on | 不跳过不含调试信息的函数,可以显示和调试汇编代码 |
| finish | 执行完当前函数并打印返回值,然后触发中断 |
| return 0 | 不再执行后面的指令,直接返回,可以指定返回值 |
| call printf(“%s\n”, str) | 调用printf函数,打印字符串(可以使用call或者print调用函数) |
| print func() | 调用func函数(可以使用call或者print调用函数) |
| set var variable=xxx | 设置变量variable的值为xxx |
| set {type}address = xxx | 给存储地址为address,类型为type的变量赋值 |
| info frame | 显示函数堆栈的信息(堆栈帧地址、指令寄存器的值等) |
其它
图形化
tui为terminal user interface的缩写,在启动时候指定-tui参数,或者调试时使用ctrl+x+a组合键,可进入或退出图形化界面。
| 命令 | 含义 |
|---|---|
| layout src | 显示源码窗口 |
| layout asm | 显示汇编窗口 |
| layout split | 显示源码 + 汇编窗口 |
| layout regs | 显示寄存器 + 源码或汇编窗口 |
| winheight src +5 | 源码窗口高度增加5行 |
| winheight asm -5 | 汇编窗口高度减小5行 |
| winheight cmd +5 | 控制台窗口高度增加5行 |
| winheight regs -5 | 寄存器窗口高度减小5行 |
汇编
| 命令 | 含义 |
|---|---|
| disassemble function | 查看函数的汇编代码 |
| disassemble /mr function | 同时比较函数源代码和汇编代码 |
调试和保存core文件
| 命令 | 含义 |
|---|---|
| file exec_file # | 加载可执行文件的符号表信息 |
| core core_file | 加载core-dump文件 |
| gcore core_file | 生成core-dump文件,记录当前进程的状态 |
启动方式
使用gdb调试,一般有以下几种启动方式:
- gdb filename: 调试可执行程序
- gdb attach pid: 通过”绑定“进程ID来调试正在运行的进程
- gdb filename -c coredump_file: 调试可执行文件
在下面的几节中,将分别对上述几种调试方式进行讲解,从例子的角度出发,使得大家能够更好的掌握调试技巧。
调试
可执行文件
单线程
首先,我们先看一段代码:
#include<stdio.h>void print(int xx, int *xxptr) {printf("In print():\n");printf(" xx is %d and is stored at %p.\n", xx, &xx);printf(" ptr points to %p which holds %d.\n", xxptr, *xxptr);}int main(void) {int x = 10;int *ptr = &x;printf("In main():\n");printf(" x is %d and is stored at %p.\n", x, &x);printf(" ptr points to %p which holds %d.\n", ptr, *ptr);print(x, ptr);return 0;}
~/GDB/single thread$ gcc main.cningliu@KS-SHA-LP210095:~/GDB/single thread$ ./a.outIn main():x is 10 and is stored at 0x7ffcdae1ba3c.ptr points to 0x7ffcdae1ba3c which holds 10.In print():xx is 10 and is stored at 0x7ffcdae1ba1c.ptr points to 0x7ffcdae1ba3c which holds 10.
这个代码比较简单,下面我们开始进入调试:
~/GDB/single thread$ gdb a.outGNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2Copyright (C) 2020 Free Software Foundation, Inc.License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>This is free software: you are free to change and redistribute it.There is NO WARRANTY, to the extent permitted by law.Type "show copying" and "show warranty" for details.This GDB was configured as "x86_64-linux-gnu".Type "show configuration" for configuration details.For bug reporting instructions, please see:<http://www.gnu.org/software/gdb/bugs/>.Find the GDB manual and other documentation resources online at:<http://www.gnu.org/software/gdb/documentation/>.For help, type "help".Type "apropos word" to search for commands related to "word"...Reading symbols from a.out...(No debugging symbols found in a.out)(gdb)
(gdb) rStarting program: /home/ningliu/GDB/single thread/a.outIn main():x is 10 and is stored at 0x7fffffffda0c.ptr points to 0x7fffffffda0c which holds 10.In print():xx is 10 and is stored at 0x7fffffffd9ec.ptr points to 0x7fffffffda0c which holds 10.[Inferior 1 (process 456) exited normally]
在上述命令中,我们通过gdb a.out命令启动调试,然后通过执行r(run命令的缩写)执行程序,直至退出,换句话说,上述命令是一个完整的使用gdb运行可执行程序的完整过程(只使用了r命令),接下来,我们将以此为例子,介绍几种比较常见的命令。
不带
-g, 没有调试信息
断点
~/GDB/single thread$ gcc -g main.c -o main~/GDB/single thread$ gdb mainGNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2Copyright (C) 2020 Free Software Foundation, Inc.License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>This is free software: you are free to change and redistribute it.There is NO WARRANTY, to the extent permitted by law.Type "show copying" and "show warranty" for details.This GDB was configured as "x86_64-linux-gnu".Type "show configuration" for configuration details.For bug reporting instructions, please see:<http://www.gnu.org/software/gdb/bugs/>.Find the GDB manual and other documentation resources online at:<http://www.gnu.org/software/gdb/documentation/>.For help, type "help".Type "apropos word" to search for commands related to "word"...Reading symbols from main...(gdb) b 15Breakpoint 1 at 0x1251: file main.c, line 15.(gdb) info bNum Type Disp Enb Address What1 breakpoint keep y 0x0000000000001251 in main at main.c:15(gdb) rStarting program: /home/ningliu/GDB/single thread/mainIn main():x is 10 and is stored at 0x7fffffffda0c.ptr points to 0x7fffffffda0c which holds 10.Breakpoint 1, main () at main.c:1515 print(x, ptr);(gdb)
backtrace
(gdb) backtrace#0 main () at main.c:15(gdb)
backtrace命令是列出当前堆栈中的所有帧。在上面的例子中,栈上只有一帧,编号为0,属于main函数。
(gdb) stepprint (xx=32767, xxptr=0x5555555550a0 <_start>) at main.c:33 void print(int xx, int *xxptr) {(gdb)
接着,我们执行了step命令,即进入函数(print)内。下面我们继续通过backtrace命令来查看栈帧信息
(gdb) backtrace#0 print (xx=32767, xxptr=0x5555555550a0 <_start>) at main.c:3#1 0x0000555555555262 in main () at main.c:15(gdb)
从上面输出结果,我们能够看出,有两个栈帧,第1帧属于main函数,第0帧属于print函数。
每个栈帧都列出了该函数的参数列表。从上面我们可以看出,main函数没有参数,而print函数有参数,并且显示了其参数的值。
有一点我们可能比较迷惑,在第一次执行backtrace的时候,main函数所在的栈帧编号为0,而第二次执行的时候,main函数的栈帧为1,而print函数的栈帧为0,这是因为与栈的向下增长规律一致,我们只需要记住编号最小帧号就是最近一次调用的函数。
frame
栈帧用来存储函数的变量值等信息,默认情况下,GDB总是位于当前正在执行函数对应栈帧的上下文中。
在前面的例子中,由于当前正在print()函数中执行,GDB位于第0帧的上下文中。可以通过frame命令来获取当前正在执行的上下文所在的帧。
(gdb) frame#0 print (xx=32767, xxptr=0x5555555550a0 <_start>) at main.c:33 void print(int xx, int *xxptr) {(gdb)
下面,我们尝试使用print命令打印下当前栈帧的值,如下:
(gdb) print xx$1 = 32767(gdb) print xxptr$2 = (int *) 0x5555555550a0 <_start>(gdb)
如果我们想看其他栈帧的内容呢?比如main函数中x和ptr的信息呢?假如直接打印这俩值的话,那么就会得到如下:
(gdb) print xNo symbol "x" in current context.(gdb) print xxptrNo symbol "ptr" in current context.(gdb)
在此,我们可以通过frame num来切换栈帧,如下:
(gdb) frame 1#1 0x0000555555555262 in main () at main.c:1515 print(x, ptr);(gdb) print x$3 = 10(gdb) print ptr$4 = (int *) 0x7fffffffda0c(gdb)
多线程
为了方便进行演示,我们创建一个简单的例子,代码如下:
#include <chrono>#include <iostream>#include <string>#include <thread>#include <vector>int fun_int(int n) {std::this_thread::sleep_for(std::chrono::seconds(10));std::cout << "in fun_int n = " << n << std::endl;return 0;}int fun_string(const std::string &s) {std::this_thread::sleep_for(std::chrono::seconds(10));std::cout << "in fun_string s = " << s << std::endl;return 0;}int main() {std::vector<int> v;v.emplace_back(1);v.emplace_back(2);v.emplace_back(3);std::cout << v.size() << std::endl;std::thread t1(fun_int, 1);std::thread t2(fun_string, "test");std::cout << "after thread create" << std::endl;t1.join();t2.join();return 0;}
上述代码比较简单:
- 函数fun_int的功能是休眠10s,然后打印其参数
- 函数fun_string功能是休眠10s,然后打印其参数
- main函数中,创建两个线程,分别执行上述两个函数
~/GDB/multi thread$ ./main3after thread createin fun_int n = 1in fun_string s = test
多进程
同上面一样,我们仍然以一个例子进行模拟多进程调试,代码如下:
#include <stdio.h>#include <unistd.h>int main(){pid_t pid = fork();if (pid == -1){perror("fork error\n");return -1;}if (pid == 0){ // 子进程int num = 1;while (num == 1){sleep(10);}printf("this is child,pid = %d\n", getpid());}else{ // 父进程printf("this is parent,pid = %d\n", getpid());wait(NULL); // 等待子进程退出}return 0;}
在上面代码中,包含两个进程,一个是父进程(也就是main进程),另外一个是由fork()函数创建的子进程。
在默认情况下,在多进程程序中,GDB只调试main进程,也就是说无论程序调用了多少次fork()函数创建了多少个子进程,GDB在默认情况下,只调试父进程。为了支持多进程调试,从GDB版本7.0开始支持单独调试(调试父进程或者子进程)和同时调试多个进程。
那么,我们该如何调试子进程呢?我们可以使用如下几种方式进行子进程调试。
attach
首先,无论是父进程还是子进程,都可以通过attach命令启动gdb进行调试。我们都知道,对于每个正在运行的程序,操作系统都会为其分配一个唯一ID号,也就是进程ID。如果我们知道了进程ID,就可以使用attach命令对其进行调试了。
在上面代码中,fork()函数创建的子进程内部,首先会进入while循环sleep,然后在while循环之后调用printf函数。这样做的目的有如下:
- 帮助attach捕获要调试的进程id
- 在使用gdb进行调试的时候,真正的代码(即print函数)没有被执行,这样就可以从头开始对子进程进行调试
可能会有疑惑,上面代码以及进入while循环,无论如何是不会执行到下面printf函数。其实,这就是gdb的厉害之处,可以通过gdb命令修改num的值,以便其跳出while循环
使用如下命令编译生成可执行文件test_process
gcc -g test_process.c -o test_process
现在,我们开始尝试启动调试。
ingliu@KS-SHA-LP210095:~/GDB/multi process$ gdb -q ./test_processReading symbols from ./test_process...(gdb)
这里需要说明下,之所以加-q选项,是想去掉其他不必要的输出,q为quite的缩写。
指定进程
默认情况下,GDB调试多进程程序时候,只调试父进程。GDB提供了两个命令,可以通过follow-fork-mode和detach-on-fork来指定调试父进程还是子进程。

