0%

【工具】gdb入门笔记

GDB 全称 GNU Project debugger,是一个通用的 C / C++ 程序调试器,可以用来深入分析程序的运行过程,或者排查程序崩溃的原因。本文面向的读者是 C / C++ 程序员,主要内容包括 GDB 的基本命令、进阶用法和实践案例。目标是使读者掌握 GDB 的常见使用方法,满足日常开发所需。

GDB 主要有以下几个功能:

  1. 运行程序,随心所欲地查看程序内部状态 (如变量值、寄存器值)、控制程序的行为 (如逐行执行、反向执行等)
  2. 使程序在特定位置中断,或者满足条件时才中断
  3. 当程序崩溃时,查看完整现场,分析发生了什么
  4. 改变程序状态 (如临时修改某个变量值),以测试程序在不同情况下的行为

GDB 和 Vim 一样,只需要学会几个简单的命令,就能解决大部分问题。但它们就像一把瑞士军刀,有丰富的功能和技巧,只有深入掌握,才能成为效率提升利器。

本文面向的读者是 C / C++ 程序员,主要内容包括 GDB 的基本命令、进阶用法和实践案例。目标是使读者掌握 GDB 的常见使用方法,满足日常开发所需。读者也可以将本文作为 GDB 命令的速查手册,随时查阅。

Hello World

安装GDB

本文在 Linux (CentOS) 环境下运行 GDB,读者也可以使用网页版 GDB

Linux 系统可以使用包管理器安装:

1
2
$ sudo apt-get update
$ sudo apt-get install gdb

使用 GDB

下面是一个使用 GDB 设置断点、逐行运行程序的示例。

  1. 编写 C++ 程序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// main.cpp
#include <iostream>
using namespace std;

void print_foo(int v) {
int i = v + 5;
i = i + 3;
cout << "i == " << i << endl;
}

int main() {
int a = 0;
a += 1;
a += 2;
print_foo(a);
return 0;
}
  1. 编译程序,添加 -g 选项,保留 debug info:

    1
    $ g++ -g main.cpp -o example
  2. 进入 gdb,加载二进制程序,最后一行表示符号表加载成功:

    1
    2
    3
    4
    5
    6
    7
    8
    $ gdb example
    GNU gdb (GDB) 12.1
    Copyright ...
    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 example...
  3. main() 函数第一行设置一个断点,运行程序:

    1
    2
    3
    4
    5
    6
    (gdb) b main.cpp:12
    Breakpoint 1 at 0x55555555522c: file main.cpp, line 12.
    (gdb) r
    Starting program: /home/a.out
    Breakpoint 1, main () at main.cpp:12
    12 int a = 0;
  4. 逐行执行程序,打印变量 a 的值:

    next 命令输出的是下一行要执行的代码。如果下一行是函数,next 命令会执行完整个函数,停在函数的下一行 (step over)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    (gdb) next
    13 a += 1;
    (gdb) p a
    $3 = 1
    (gdb) next
    14 a += 2;
    (gdb) next
    15 print_foo(a);
    (gdb) p a
    $4 = 3
  5. step 命令会进入函数,停在函数的第一行 (step into):

    1
    2
    3
    (gdb) step    
    print_foo (v=21845) at main.cpp:5
    5 void print_foo(int v) {
  6. backtrack 命令可以查看当前程序的调用栈:

    1
    2
    3
    (gdb) backtrace
    #0 print_foo (v=21845) at main.cpp:5
    #1 0x0000555555555245 in main () at main.cpp:15
  7. continue 命令会执行程序,直到遇到下一个断点。这里没有下一个断点了,整个程序正常退出:

    1
    2
    3
    (gdb) continue
    Continuing.
    [Inferior 1 (process 1308) exited normally]

命令的简写形式

大部分 GDB 命令都有一个简写形式,一般是命令的首字母,比如:

  • backtracebt
  • breakb
  • continuec
  • nextn
  • infoi

某些命令有相同的前缀,只需要写出前几个能区分的字符,GDB 就可以识别:

1
2
3
4
(gdb) i w    // 无法判断
Ambiguous info command "w": w32, warranty, watchpoints, win.
(gdb) i wat // 可以识别,等于 info watchpoints
No watchpoints.

此外,在 GDB 中如果什么都不输入,直接回车,会重复执行上一条命令。

命令的适用场景

当应用程序异常退出时,操作系统会生成一个 coredump 文件,记录了程序退出时的所有内存状态。GDB 可以读取这个文件,查看程序退出时的变量值或者寄存器值,但是无法执行程序。即只能使用静态命令,如 pbti

Core Dump

当进程崩溃时,操作系统会把进程当前的所有内存和寄存器状态信息保存到 core dump 文件中。Core dump file 是一个二进制文件,需要配合 debug info 来赋予其含义。GDB 可以读取 core dump 文件,协助分析进程崩溃的瞬间发生了什么。

可能会产生 core dump 文件的场景:

  • 段错误 Segmentation Fault
    • Null Pointer Dereference (NPD)
    • Stack Overflow / Buffer Overflow
    • Use After Free (UAF)
    • Double Free
    • Out Of Memory (OOM)
  • 其他一些会引起 core dump 的 signal

Core Dump Settings

为了使能 core dump,首先要检查最大的 core dump 限制

1
$ ulimit -c

如果结果是 0,那么需要设置限制为 unlimited

1
$ ulimit -c unlimited

现在,将生成一个 core dump 并将其放置在 /proc/sys/kernel/core_pattern 指定的位置 ,通过下面的命令查看 core dump 生成的位置:

1
$ cat /proc/sys/kernel/core_pattern

大部分系统的默认输出是:

1
core

这意味着任何核心转储都将放置在当前目录中名为 core 的文件中。
您可以使用以下方式更改此位置:

1
2
$ echo "<desired-file-path>/<desired-file-name>" > /proc/sys/kernel/core_pattern

通过下面的命令可以恢复 core dump 生成时的程序运行状态:

1
$ gdb <binary-file> <core-dump-file>

此时,这些命令: where, up, down, print, info locals, info args, info registers and list 通常非常有帮助

GDB 也可以直接加载一个二进制程序并执行。在这种情况下,GDB 不仅可以随时查看程序当前的变量值或其他内存状态,还可以控制程序的运行,如设置断点、单步执行、反向执行等。即不仅可以使用静态命令,还可以使用 rbc动态命令。

帮助和术语

在 GDB 内使用 apropos {keyword} 可以模糊查找某条命令:

image-20240721222903936

使用 help {command} 可以查看某个具体命令的帮助文档:

image-20240721223140044

基本命令

选择线程: t

info thread 可以查看当前进程的所有线程。示例程序是单线程的:

1
2
3
(gdb) info threads
Id Target Id Frame
* 1 process 1537 "example" main () at main.cpp:15

thread / t 可以查看当前位于哪个线程:

1
2
(gdb) t
[Current thread is 1 (process 3496)]

在多线程程序里,可以通过 t {id} 切换线程,每个线程有独立的调用栈。

查看堆栈: bt

backtrace / bt 可以查看调用栈。调用栈展示了从 main() 入口到当前断点或进程退出时刻的所有函数调用路径:

1
2
3
4
5
(gdb) bt
#0 0x0 in (unknown) at :0
#1 0x1a796e7c in foo() at main.cpp:13
#2 0x6259058 in bar() at main.cpp:17
#3 0x6bb7580 in main() at main.cpp:83

选择栈帧: f

每次函数调用,会创建一个独立的栈帧,对应上面的 #0#1#2。默认在 #0

frame / f 可以跳转到指定栈帧:

1
2
3
(gdb) f 2
#2 bar() at main.cpp:17
17 int a = foo();

up / down 可以向上层或下层跳转,对应编号增大或减小。

打印各种信息: i

  • info locals:打印当前栈帧的所有局部变量
  • info args:打印所有函数参数
  • info threads: 打印进程的线程信息
  • info registers: 打印当前线程的寄存器信息
  • info sharedlibrary:打印当前加载的动态连接库
  • info proc mappings:打印地址空间中的内存 map,用来确定某个地址的类型
  • help info:所有 info 支持的命令

打印变量: p

基本使用

print / p 可以打印一个变量的值,支持数字、字符串、结构体、指针等变量类型:

1
2
(gdb) p a // int a = 3;
$1 = 3

打印出来的值会存在名为 $1$2、… 的变量里,后续可以直接复用:

1
2
(gdb) p $1 // 等价于 p a
$2 = 3

p 有一些可选参数:

  • -elements:限制字符串或者数组打印的元素数量
  • -max-depth:限制嵌套结构体的最大打印层数
  • …,help p 查看所有参数

打印指针

指针变量

p 后面跟一个指针类型的变量,打印的是指针的值,即指针所指向的地址:

1
2
(gdb) p b // int* b = &a;
$1 = (int *) 0x7ffd3dcfa27c

可以用解引用运算符,打印指针指向的值:

1
2
(gdb) p *b
$2 = 1

如果是字符串指针,p 会同时输出指针指向的地址字符串的内容

1
2
p str
$3 = (char*) 0x7ffc734ff250 "hello,world"

如果希望只打印地址,可以使用说明符 /a

1
2
(gdb) p/a str
$4 = 0x7ffc734ff250

/a 表示 address,即把变量的值以地址的形式打印。

地址字面量

p 默认会把十六进制的字面量看成是数字,输出一个十进制的整数:

1
2
3
4
(gdb) p 0x7ffd3dcfa27c
$1 = 140725640471164
(gdb) p 140725640471164 == 0x7ffd3dcfa27c
$2 = true

如果想把数字解释为地址、打印地址上的内容,需要先指定变量类型,然后解引用:

1
2
(gdb) p *(int*)0x7ffd3dcfa27c
$3 = 1

更简单的语法是 {TYPE}ADDRESS:

1
2
(gdb) p {int}0x7ffd3dcfa27c
$4 = 1

也可以用 x 命令打印地址。

转换指针类型

指针的类型可以转换,以不同方式解释其指向的内存区域:

1
2
3
4
5
6
7
// char* c = "hello, world";
(gdb) p c
$1 = (char *) 0x7ffc734ff250 "hello, world";
(gdb) p *(int*)c
$2 = 1819043176
(gdb) p {int}c
$3 = 1819043176

打印内存可以发现,1819043176 就是把 h e l l 四个字符解释成了一个整数:

1
2
(gdb) x/w 0x7ffc734ff250    // 以 word 形式打印,4 个字节
0x7ffc734ff250: 1819043176 // 上述 4 个字符的 ASCII 码转成整数

1819043176 对应的十六进制是 0x6C6C6568,恰好依次是 l , l , eh 的 ASCII 码。

打印结构体的字段

如果指针 p 指向某个结构体,可以用 p ptr->field 打印字段的值。

在 GDB 里,.-> 是一样的,所以无论 ptr 是否是指针,都可以用 p.field 打印字段的值。

打印数组

语法:p ELEMENT@LEN。从 ELEMENT 的地址开始向后解释 LEN 大小的内存单元,内存单元的大小是 sizeof(T)

栈上数组

如果 array 是栈上数组,可以直接 p array,会打印数组的所有元素:

1
2
3
// int array[] = {1, 2, 3, 4};
(gdb) p array
$1 = {1, 2, 3, 4}

也可以 p array[INDEX]@LEN,从某个下标开始打印指定的长度:

1
2
(gdb) p array[1]@[3] // array[1] 的类型是 int
$2 = {1, 2, 3}

但不能 p array@LEN,因为栈上数组 array 的类型是 int[4] 而不是 int

1
2
(gdb) p array@3
$3 = {{1, 2, 3, 4}, {-693741568, 32764, 1033857024, -1536906435}, {0, 0, -793505661, 32580}}
堆上数组

如果 array 是堆上数组,可以 p *array@LEN

1
2
3
// int* array = (int*)malloc(3 * sizeof(int));
(gdb) p *array@3 // *array 是数组的第一个元素,类型是 int
$1 = {1, 2, 3}

或者 p array[INDEX]@LEN,从某个下标开始打印:

1
2
(gdb) p array[1]@3 // array[1] 的类型是 int
$2 = {2, 3, 4}

但不能 p array ,因为堆上数组 array 的类型是 int* 指针,值是一个地址:

1
2
(gdb) p array
$3 = 0x55669a743eb0

也不能 p array@LEN,理由同上。array 是一个 int* 指针,保存在栈上,这里会输出栈上相邻内存的值,没有任何意义:

1
2
(gdb) p array@3
$4 = {0x55669a743eb0, 0x55669a255330, 0x200000001}

如果只有一个地址字面量,可以把它强制转换为指针类型,然后用同样的语法打印:

1
2
(gdb) p ((int*)0x55669a743eb0))[2]
$5 = 3

格式化输出

可以在 p 后面添加说明符 (specifier),把一个变量解释为给定的类型:

1
2
3
4
(gdb) p foo // int foo = 98;
$1 = 98
(gdb) p/c foo // 将 98 解释为字符
$2 = 98 'b'

所有说明符:

  • p/a:将变量解释为指针 address,使用十六进制打印

  • p/c:将变量解释为字符 char,打印为字符

  • p/o:使用八进制打印变量

  • p/x:使用十六进制打印变量

  • p/u:将变量解释为无符号整数 unsigned,使用十进制打印

  • p/s:将变量解释为字符串,打印输出

  • help x 查看全部:

    1
    2
    3
    o(octal), x(hex), d(decimal), u(unsigned decimal),
    t(binary), f(float), a(address), i(instruction),
    c(char), s(string) and z(hex, zero padded on the left)

查看历史变量

通过 p 打印出来的值会存在名为 $1$2、… 的变量里 (value history),后续可以直接复用:

1
2
3
4
(gdb) p a
$1 = 123
(gdb) p $1 // 等价于 p a
$2 = 123

一些特殊的变量:

  • $:最近打印的变量
  • $$$ 之前的变量,倒数第二个
  • $$n:最后一个变量往前的第 n 个变量,比如 $$0 就是 $$$1 就是 $$

可以批量打印历史变量:

  • show values:打印最后 10 个历史变量
  • show values +:打印刚才打印过的历史变量的后 10 个历史变量

打印内存: x

x 可以查看一个内存地址的值,以指定的格式打印。

1
2
(gdb) x/s 0x7ffc734ff250  // 以字符串形式打印
0x7ffc734ff250: "hello,world"

x 支持的格式化说明符

  • x/c:将地址解释为字符 char,打印为字符

  • x/o:使用八进制打印变量

  • x/x:使用十六进制打印变量

  • x/u:将地址解释为无符号整数 unsigned,使用十进制打印

  • x/s:将地址解释为字符串

  • help x 查看全部:

    1
    2
    3
    o(octal), x(hex), d(decimal), u(unsigned decimal),
    t(binary), f(float), a(address), i(instruction),
    c(char), s(string) and z(hex, zero padded on the left)

xp 的区别:

  • 传入一个数字,p 会当作一个数字字面量,输出原始值的十进制;而 x 会当作一个地址,输出对应内存区域的值。比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    (gdb) p 0x10    // 字面量
    $1 = 16 // 输出十进制值
    (gdb) p/x 0x10 // 以十六进制形式输出
    $2 = 0x10

    (gdb) x/s 0x10 // 这个内存地址解释为字符串
    0x10 "hello, world"
    (gdb) x/c 0x10 // 把这个地址上的内容解释为单个字符
    0x10: 'h'
    (gdb) x/d 0x10 // 把这个地址上的内容解释为整数
    0x10: 104
  • 传入一个指针,p 会输出指针的值,即一个十六进制地址;而 x 会输出指针指向的内存区域的值:

    1
    2
    3
    4
    5
    (gdb) p str_pointer;
    $1 = 0x7ffc

    (gdb) x/s 0x7ffc
    0x7ffc "hello world"

x 的完整语法:x/FMT ADDRESSF / M / T 是可选的参数。

  • F:一个数字,表示输出几个内存单元,默认是 1
  • M:格式化说明符,o / x / d / u / s
  • T:一个内存单元的字节数,默认是 4 个字节,可选的是 b(byte), h(halfword), w(word), g(giant, 8 bytes)
  • ADDRESS:一个内存地址,可以是一个字面量,也可以是一个指针类型的变量

例如,x/3uh 0x1234 表示从内存地址 0x1234 开始,以双字节为单位,输出 3 个无符号整数。

打印类型: ptype

1
2
(gdb) ptype foo
type = int

存储变量 / 修改变量的值: set

set 可以保存一个变量 ,方便后续使用:

1
(gdb) set $foo = *object_ptr

查看所有存储的变量:

1
2
(gdb) show convenience
(gdb) show conv // 简写形式

set 命令也可以用于在运行时修改某个变量的值:

1
(gdb) set foo.bar = true

如果没有调试符号,上述命令将无法查找到变量的地址。可以手动修改变量所在的内存位置:

1
set (char)0x7e864a2b = 1

修改变量值的使用场景:

  • 临时修复某个 bug,使程序可以继续运行
  • 给变量设置不同的值,测试不同的 case

断点调试: b

设置 / 清除断点

设置断点:break POINT,简写是 b

1
(gdb) b foo.cpp:14

设置断点的方式有多种:

  • 在当前执行位置设断点:b,没有任何参数
  • 函数名:b function
  • 文件名 + 函数名:b filename:function
  • 行号:b linenum,在当前文件设置断点
  • 文件名 + 行号:b filename:linenum,在特定文件设置断点
  • 偏移量:b +offset / b -offset,在当前栈帧执行位置的前后设置断点
  • 给汇编命令打断点:略

删除断点:clear

1
(gdb) clear foo.cpp:14

clear 的语法和 break 相同,需要指定要删除的断点的位置:

  • clear:删除当前执行位置上的所有断点
  • clear functionclear filename:function
  • clear linenumclear filename:linenum
  • delete:删除所有断点,简写是 d

设置临时断点:tbreak。参数同 break,命中一次后就会自动删除。

GDB - Setting breakpoints

GDB - Deleting breakpoints

停用 / 启用断点

停用断点:disable

1
2
(gdb) disable      // 停用所有断点
(gdb) disable NUM // 停用编号为 n 的断点

停用断点后,断点将暂时不被触发。可以通过 enable 命令启用断点,语法同 disable

继续运行: cont

命中断点后程序会停止运行,此时可以输入 continue 命令,继续运行程序。简写是 cont

查看所有断点:i b

1
2
(gdb) i b
(gdb) info breakpoints

这会以表格的形式展示断点编号、是否是临时断点、是否 enable、断点位置等信息。

在函数返回前中断

有时候希望在函数返回前中断,从而检查函数的返回值,或者检查函数是在哪一个 return 语句返回的。

有两种方式。一种是反向调试,先正向执行,直到函数返回,然后再反向执行,设置断点:

1
2
3
(gdb) record
(gdb) fin
(gdb) reverse-step

另一种方式更通用。所有的函数无论有多少条 return 语句,在编译成汇编指令后,一定是只有一条 retq 指令。因此可以在汇编指令里找到 retq 所在位置打断点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main() {
return foo(0);
}

(gdb) disas foo // 查看汇编
Dump of assembler code for function foo:
0x0000000000400448 <+0>: push %rbp
0x0000000000400449 <+1>: mov %rsp,%rbp
...
0x0000000000400473 <+43>: jmp 0x40047c <foo+52>
0x0000000000400480 <+56>: retq // 这里就是函数的返回指令
End of assembler dump.

(gdb) b *0x0000000000400480 // 在 retq 指令打断点
Breakpoint 1 at 0x400480

(gdb) r // 运行程序,直到命中断点
Breakpoint 1, 0x0000000000400480 in foo ()

(gdb) p var
$1 = 42

监控断点: watch

GDB 可以监控一个变量,直到它被修改时才触发断点:

1
(gdb) watch foo
1
(gdb) watch bar.var

如果想在变量被读取时中断,可以使用 rwatchawatch

  • rwatch:仅当变量被读取时终端
  • awatch:当变量被读取或写入时中断

查看所有 watchpoints:

1
(gdb) info watchpoints

禁用 / 删除 watchpoints 的命令同 break

GDB - Setting watchpoints

条件断点: b ... if

常规断点 (breakpoints) 和监控断点 (watchpoints) 都可以绑定一个条件,只在满足条件时才触发断点。

“条件”是一个布尔表达式:

1
2
(gdb) b foo.cpp:123 if bar == 1
(gdb) b foo.cpp:123 if bar == 1 && foo < 2

如果要判断两个字符串是否相等,可以使用 gdb 的内置函数 $_streq

1
(gdb) b foo.cpp:123 if $_streq(some_str, "hello_world")

GDB - Break conditions

断点命令列表: commands

可以通过 commands 命令给断点绑定一组自定义命令,当命中断点后会自动执行,如打印变量的值,或者设置另一个断点。

语法:先指定要绑定的断点编号,然后输入自定义命令,最后以 end 结束。例如:

1
2
3
(gdb) commands 1
(gdb) p foo
(gdb) end

断点编号可以通过 i bi wat 获取。如果不给 commands 传入任何编号,则默认绑定到最近触发的断点上。

commands 的应用场景之一是收集信息。比如在某行代码后面插入一行 debug 日志,打印变量或调用栈。由于每次命中断点后,必须输入 cond 命令才会继续运行程序,因此可以在 end 前面加一个 cont 命令,这样程序便可以无需干预、自动运行:

1
2
3
4
5
(gdb) b foo.cpp:123
(gdb) commands
(gdb) p bar
(gdb) cont
(gdb) end

commands 的另一个应用场景是临时修复一个 bug,以便让程序正常运行。比如在某一行错误代码后面,给变量设置正确的值。同样要以 continue 命令结尾:

1
2
3
4
5
6
(gdb) b foo.cpp:123
(gdb) commands
(gdb) silent // 这个命令后面的命令不会有任何输出
(gdb) set x = y + 4
(gdb) cont
(gdb) end

GDB - Breakpoint command lists

运行程序: n / s / c / fin / u

  • run / r:运行程序,直到遇到第一个断点或者运行结束
  • start:启动程序,临时停在 main() 的第一行
  • next / n:逐行执行,如果某一行是函数,不会进入到函数里,而是会执行完整个函数 (step over)
  • step / s:逐行执行,如果某一行是函数,会进入到函数的第一行 (step into)
  • continue / c:从断点位置继续执行,直到遇到下一个断点或者运行结束
  • finish / fin:执行到函数结束,停在 return 后的下一条语句
  • until / u :
    • 不加任何参数:执行直到当前语句结束,比如在 for loop 里 until 会跳到 for 循环体的下一行
    • 加参数:执行直到特定位置,参数的语法同 break,等价于 tbreak + continue
  • quit / q:退出 GDB

直接回车会重复上一次执行的命令,所以在单步跟踪的时候,无论是 s 还是 n 都可以连续敲回车继续执行。

输出日志: set logging

可以把 GDB 的所有输出打印到日志里,作进一步分析。

需要执行这两个命令:

1
2
3
(gdb) set logging file gdb.txt
(gdb) set logging on
copying output to gdb.txt

这样任何命令的输出便会写到 gdb.txt,前提是 shell 拥有该文件的写入权限。

配合以下命令,确保输出完整内容:

1
2
3
4
set print repeats 0       // 否则相同的连续字符会被合并
set print elements 0 // 否则过长的数组会被省略
set height 0 // 否则如果一页显示不完,会停下来要求 continue
set width 0

总结

掌握上面这些命令,就能够满足日常的使用了,在代码调试的过程一点要习惯使用 GDB 而不是打印输出,因为 GDB 能够极大的提高效率,更容易发现隐藏的 bug。