原址
GDB调试-从入门实践到原理.pdf
GDB调试-从入门实践到原理 - 图1

背景

作为C/C++开发人员,保证程序正常运行是最基本也是最主要的目的。而为了保证程序正常运行,调试则是最基本的手段,熟悉这些调试方式,可以方便我们更快的定位程序问题所在,提高开发效率。

在开发过程,如果程序的运行结果不符合预期,

  • 第一时间就是打开GDB进行调试,在对应的地方设置断点,然后分析原因

当线上服务出了问题

  • 第一时间查看进程在不在,如果不在的话,是否生成了coredump文件,如果有,则使用gdb调试coredump文件,否则通过dmesg来分析内核日志来查找原因。

    概念

    GDB是一个由GNU开源组织发布的、UNIX/LINUX操作系统下的、「基于命令行的、功能强大的程序调试工具」

GDB支持断点、单步执行、打印变量、观察变量、查看寄存器、查看堆栈等调试手段。在Linux环境软件开发中,GDB是主要的调试工具,用来调试C和 C++程序(也支持go等其他语言)。

常用命令

断点

断点是我们在调试中经常用的一个功能,我们在指定位置设置断点之后,程序运行到该位置将会暂停,这个时候我们就可以对程序进行更多的操作,比如查看变量内容,堆栈情况等等,以帮助我们调试程序。

以设置断点的命令分为以下几类:

  • breakpoint
  • watchpoint
  • catchpoint

    breakpoint

    可以根据行号、函数、条件生成断点,下面是相关命令以及对应的作用说明:
命令 作用
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: 调试可执行文件

在下面的几节中,将分别对上述几种调试方式进行讲解,从例子的角度出发,使得大家能够更好的掌握调试技巧。

调试

可执行文件

单线程

首先,我们先看一段代码:

  1. #include<stdio.h>
  2. void print(int xx, int *xxptr) {
  3. printf("In print():\n");
  4. printf(" xx is %d and is stored at %p.\n", xx, &xx);
  5. printf(" ptr points to %p which holds %d.\n", xxptr, *xxptr);
  6. }
  7. int main(void) {
  8. int x = 10;
  9. int *ptr = &x;
  10. printf("In main():\n");
  11. printf(" x is %d and is stored at %p.\n", x, &x);
  12. printf(" ptr points to %p which holds %d.\n", ptr, *ptr);
  13. print(x, ptr);
  14. return 0;
  15. }
  1. ~/GDB/single thread$ gcc main.c
  2. ningliu@KS-SHA-LP210095:~/GDB/single thread$ ./a.out
  3. In main():
  4. x is 10 and is stored at 0x7ffcdae1ba3c.
  5. ptr points to 0x7ffcdae1ba3c which holds 10.
  6. In print():
  7. xx is 10 and is stored at 0x7ffcdae1ba1c.
  8. ptr points to 0x7ffcdae1ba3c which holds 10.

这个代码比较简单,下面我们开始进入调试:

  1. ~/GDB/single thread$ gdb a.out
  2. GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2
  3. Copyright (C) 2020 Free Software Foundation, Inc.
  4. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
  5. This is free software: you are free to change and redistribute it.
  6. There is NO WARRANTY, to the extent permitted by law.
  7. Type "show copying" and "show warranty" for details.
  8. This GDB was configured as "x86_64-linux-gnu".
  9. Type "show configuration" for configuration details.
  10. For bug reporting instructions, please see:
  11. <http://www.gnu.org/software/gdb/bugs/>.
  12. Find the GDB manual and other documentation resources online at:
  13. <http://www.gnu.org/software/gdb/documentation/>.
  14. For help, type "help".
  15. Type "apropos word" to search for commands related to "word"...
  16. Reading symbols from a.out...
  17. (No debugging symbols found in a.out)
  18. (gdb)
  1. (gdb) r
  2. Starting program: /home/ningliu/GDB/single thread/a.out
  3. In main():
  4. x is 10 and is stored at 0x7fffffffda0c.
  5. ptr points to 0x7fffffffda0c which holds 10.
  6. In print():
  7. xx is 10 and is stored at 0x7fffffffd9ec.
  8. ptr points to 0x7fffffffda0c which holds 10.
  9. [Inferior 1 (process 456) exited normally]

在上述命令中,我们通过gdb a.out命令启动调试,然后通过执行r(run命令的缩写)执行程序,直至退出,换句话说,上述命令是一个完整的使用gdb运行可执行程序的完整过程(只使用了r命令),接下来,我们将以此为例子,介绍几种比较常见的命令。

不带-g, 没有调试信息

断点

  1. ~/GDB/single thread$ gcc -g main.c -o main
  2. ~/GDB/single thread$ gdb main
  3. GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2
  4. Copyright (C) 2020 Free Software Foundation, Inc.
  5. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
  6. This is free software: you are free to change and redistribute it.
  7. There is NO WARRANTY, to the extent permitted by law.
  8. Type "show copying" and "show warranty" for details.
  9. This GDB was configured as "x86_64-linux-gnu".
  10. Type "show configuration" for configuration details.
  11. For bug reporting instructions, please see:
  12. <http://www.gnu.org/software/gdb/bugs/>.
  13. Find the GDB manual and other documentation resources online at:
  14. <http://www.gnu.org/software/gdb/documentation/>.
  15. For help, type "help".
  16. Type "apropos word" to search for commands related to "word"...
  17. Reading symbols from main...
  18. (gdb) b 15
  19. Breakpoint 1 at 0x1251: file main.c, line 15.
  20. (gdb) info b
  21. Num Type Disp Enb Address What
  22. 1 breakpoint keep y 0x0000000000001251 in main at main.c:15
  23. (gdb) r
  24. Starting program: /home/ningliu/GDB/single thread/main
  25. In main():
  26. x is 10 and is stored at 0x7fffffffda0c.
  27. ptr points to 0x7fffffffda0c which holds 10.
  28. Breakpoint 1, main () at main.c:15
  29. 15 print(x, ptr);
  30. (gdb)

backtrace

  1. (gdb) backtrace
  2. #0 main () at main.c:15
  3. (gdb)

backtrace命令是列出当前堆栈中的所有帧。在上面的例子中,栈上只有一帧,编号为0,属于main函数。

  1. (gdb) step
  2. print (xx=32767, xxptr=0x5555555550a0 <_start>) at main.c:3
  3. 3 void print(int xx, int *xxptr) {
  4. (gdb)

接着,我们执行了step命令,即进入函数(print)内。下面我们继续通过backtrace命令来查看栈帧信息

  1. (gdb) backtrace
  2. #0 print (xx=32767, xxptr=0x5555555550a0 <_start>) at main.c:3
  3. #1 0x0000555555555262 in main () at main.c:15
  4. (gdb)

从上面输出结果,我们能够看出,有两个栈帧,第1帧属于main函数,第0帧属于print函数。
每个栈帧都列出了该函数的参数列表。从上面我们可以看出,main函数没有参数,而print函数有参数,并且显示了其参数的值。
有一点我们可能比较迷惑,在第一次执行backtrace的时候,main函数所在的栈帧编号为0,而第二次执行的时候,main函数的栈帧为1,而print函数的栈帧为0,这是因为与栈的向下增长规律一致,我们只需要记住编号最小帧号就是最近一次调用的函数

frame

栈帧用来存储函数的变量值等信息,默认情况下,GDB总是位于当前正在执行函数对应栈帧的上下文中。
在前面的例子中,由于当前正在print()函数中执行,GDB位于第0帧的上下文中。可以通过frame命令来获取当前正在执行的上下文所在的帧

  1. (gdb) frame
  2. #0 print (xx=32767, xxptr=0x5555555550a0 <_start>) at main.c:3
  3. 3 void print(int xx, int *xxptr) {
  4. (gdb)

下面,我们尝试使用print命令打印下当前栈帧的值,如下:

  1. (gdb) print xx
  2. $1 = 32767
  3. (gdb) print xxptr
  4. $2 = (int *) 0x5555555550a0 <_start>
  5. (gdb)

如果我们想看其他栈帧的内容呢?比如main函数中x和ptr的信息呢?假如直接打印这俩值的话,那么就会得到如下:

  1. (gdb) print x
  2. No symbol "x" in current context.
  3. (gdb) print xxptr
  4. No symbol "ptr" in current context.
  5. (gdb)

在此,我们可以通过frame num来切换栈帧,如下:

  1. (gdb) frame 1
  2. #1 0x0000555555555262 in main () at main.c:15
  3. 15 print(x, ptr);
  4. (gdb) print x
  5. $3 = 10
  6. (gdb) print ptr
  7. $4 = (int *) 0x7fffffffda0c
  8. (gdb)

多线程

为了方便进行演示,我们创建一个简单的例子,代码如下:

  1. #include <chrono>
  2. #include <iostream>
  3. #include <string>
  4. #include <thread>
  5. #include <vector>
  6. int fun_int(int n) {
  7. std::this_thread::sleep_for(std::chrono::seconds(10));
  8. std::cout << "in fun_int n = " << n << std::endl;
  9. return 0;
  10. }
  11. int fun_string(const std::string &s) {
  12. std::this_thread::sleep_for(std::chrono::seconds(10));
  13. std::cout << "in fun_string s = " << s << std::endl;
  14. return 0;
  15. }
  16. int main() {
  17. std::vector<int> v;
  18. v.emplace_back(1);
  19. v.emplace_back(2);
  20. v.emplace_back(3);
  21. std::cout << v.size() << std::endl;
  22. std::thread t1(fun_int, 1);
  23. std::thread t2(fun_string, "test");
  24. std::cout << "after thread create" << std::endl;
  25. t1.join();
  26. t2.join();
  27. return 0;
  28. }

上述代码比较简单:

  • 函数fun_int的功能是休眠10s,然后打印其参数
  • 函数fun_string功能是休眠10s,然后打印其参数
  • main函数中,创建两个线程,分别执行上述两个函数
    1. ~/GDB/multi thread$ ./main
    2. 3
    3. after thread create
    4. in fun_int n = 1
    5. in fun_string s = test

多进程

同上面一样,我们仍然以一个例子进行模拟多进程调试,代码如下:

  1. #include <stdio.h>
  2. #include <unistd.h>
  3. int main()
  4. {
  5. pid_t pid = fork();
  6. if (pid == -1)
  7. {
  8. perror("fork error\n");
  9. return -1;
  10. }
  11. if (pid == 0)
  12. { // 子进程
  13. int num = 1;
  14. while (num == 1)
  15. {
  16. sleep(10);
  17. }
  18. printf("this is child,pid = %d\n", getpid());
  19. }
  20. else
  21. { // 父进程
  22. printf("this is parent,pid = %d\n", getpid());
  23. wait(NULL); // 等待子进程退出
  24. }
  25. return 0;
  26. }

在上面代码中,包含两个进程,一个是父进程(也就是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

  1. gcc -g test_process.c -o test_process

现在,我们开始尝试启动调试。

  1. ingliu@KS-SHA-LP210095:~/GDB/multi process$ gdb -q ./test_process
  2. Reading symbols from ./test_process...
  3. (gdb)

这里需要说明下,之所以加-q选项,是想去掉其他不必要的输出,q为quite的缩写。

指定进程

默认情况下,GDB调试多进程程序时候,只调试父进程。GDB提供了两个命令,可以通过follow-fork-mode和detach-on-fork来指定调试父进程还是子进程。

follow-fork-mode

detach-on-fork

coredump

配置

临时

永久

调试

原理

调试原理

运行并调试新进程

attach运行的进程

断点原理

实现原理

设置原理

命中判断

条件判断

单步原理

其它

pstack

ldd

c++filt

结语