1 调试信息和调试原理
一般要调试某个程序,为了能清晰地看到调试的每一行代码、调用的堆栈信息、变量名和函数名等信息,需要调试程序含有调试符号信息。使用 gcc 编译程序时,如果加上 -g 选项即可在编译后的程序中保留调试符号信息。以下命令将生成一个带调试信息的程序 hello_world。
gdb -g -o hello_world hello_world.c
当然我们可以通过gdb来判断程序是否带有调试信息:
gdb hello_world
如果gdb 加载成功以后,会显示如下信息:
Reading symbols from /root/testclient/hello_server...done
我们也可以使用 Linux 的 strip 命令移除掉某个程序中的调试信息。
strip hello_world
调试时建议关闭编译器的程序优化选项,因为程序优化后调试显示的代码和实际代码可能就会有差异了,这会给排查问题带来困难。
gdb -g -O0 hello_world world_world.c
2 启动GDB调试
2.1 直接调试目标程序
gdb hello_world // gdb + 程序名
2.2 附加进程
当一个程序已经启动,我们想调试这个程序,但又不想重启这个程序时,可以通过使用 gdb attach 进程ID 来将gdb调试器附加到想要调试的程序上。
gdb attach 进程ID
当⽤ gdb attach 上⽬标进程后,调试器会暂停下来,此时可以使⽤ continue 命令让程序继续运⾏,或者加上相应的断点再继续运⾏程序。当调试完程序想结束此次调试时,⽽且不对当前进程有任何影响,可以在 GDB 的命令⾏界⾯输⼊ detach 命令 让程序与 GDB 调试器分离。
(gdb) detach
2.3 调试core文件
Linux 系统默认是不开启程序崩溃产⽣ core ⽂件这⼀机制的,我们可以使⽤ ulimit -c 命令来查看系统是否开启了这⼀机制。使用ulimit -c unlimited 直接将core文件的大小修改成不限制大小。然后就可以通过以下命令调试core文件:
gdb filename corename
通过调试core文件可以看到程序崩溃的地方,使用bt命令查看崩溃时的调用堆栈,进一步分析找到崩溃的原因。当有多个程序崩溃时,有时很难通过core文件的名称来判断对应的core文件。我们可以自己修改core文件的名称来解决该问题。通过修改/proc/sys/kernel/core_uses_pid
可以控制产生的 core 文件的文件名,修改方式如下:
echo "/corefile/core-%e-%p-%t" > /proc/sys/kernel/core_pattern
文件名各个参数的说明如下:
参数名称 | 参数含义(中文) |
---|---|
%p | 添加 pid 到 core 文件名中 |
%u | 添加当前 uid 到 core 文件名中 |
%g | 添加当前 gid 到 core 文件名中 |
%s | 添加导致产生 core 的信号到 core 文件名中 |
%t | 添加 core 文件生成时间(UNIX)到 core 文件名中 |
%h | 添加主机名到 core 文件名中 |
%e | 添加程序名到 core 文件名中 |
假设现在的程序叫 test,我们设置该程序崩溃时的 core 文件名如下:
echo "/root/testcore/core-%e-%p-%t" > /proc/sys/kernel/core_pattern
那么最终会在 /root/testcore/ 目录下生成的 test 的 core 文件名格式如下:
-rw-------. 1 root root 409600 Jan 14 13:54 core-test-13154-1547445291
3 GDB常用调试命令
命令名称 | 命令缩写 | 命令说明 |
---|---|---|
run | r | 运行一个程序 |
continue | c | 让暂停的程序继续运行 |
next | n | 运行到下一行 |
step | s | 如果有调用函数,进入调用的函数内部,相当于 step into |
until | u | 运行到指定行停下来 |
finish | fi | 结束当前调用函数,到上一层函数调用处 |
return | return | 结束当前调用函数并返回指定值,到上一层函数调用处 |
jump | j | 将当前程序执行流跳转到指定行或地址 |
p | 打印变量或寄存器值 | |
backtrace | bt | 查看当前线程的调用堆栈 |
frame | f | 切换到当前调用线程的指定堆栈,具体堆栈通过堆栈序号指定 |
thread | thread | 切换到指定线程 |
break | b | 添加断点 |
tbreak | tb | 添加临时断点 |
delete | del | 删除断点 |
enable | enable | 启用某个断点 |
disable | disable | 禁用某个断点 |
watch | watch | 监视某一个变量或内存地址的值是否发生变化 |
list | l | 显示源码 |
info | info | 查看断点 / 线程等信息 |
ptype | ptype | 查看变量类型 |
disassemble | dis | 查看汇编代码 |
set args | 设置程序启动命令行参数 | |
show args | 查看设置的命令行参数 |
3.1 run 命令
前面说的 gdb filename 命令只是附加的一个调试文件,并没有启动这个程序,需要输⼊ run 命令(简写为 r)启动这个程序。
3.2 continue 命令
当 GDB 触发断点或者使⽤ Ctrl + C 命令中断下来后,想让程序继续运⾏,只要输⼊ continue 命令即可(简写为 c)。
3.3 break 命令
break 命令(简写为 b)即我们添加断点的命令,可以使⽤以下⽅式添加断点:
- break functionname,在函数名为 functionname 的⼊⼝处添加⼀个断点;
- break LineNo,在当前⽂件⾏号为 LineNo 处添加⼀个断点;
- break filename:LineNo,在 filename ⽂件⾏号为 LineNo 处添加⼀个断点。
3.4 backtrace 与 frame 命令
backtrace 命令(简写为 bt)⽤来查看当前调⽤堆栈。查看调用的堆栈信息后可以使⽤ frame + 堆栈编号 命令(简写为 f),切换⾄指定堆栈顶部。
3.5 info break、 enable、 disable 和 delete 命令
在程序中加了很多断点,⽽我们想查看加了哪些断点时,可以使⽤ info break 命令(简写为 info b)。
(gdb) info b Num Type Disp Enb Address What 1 breakpoint keep y 0x0000000000423450 in main at server.c:3709 breakpoint already hit 1 time 2 breakpoint keep y 0x000000000049c1f0 in _redisContextConnectTcp at net.c:267
由上面的内容片段可以知道,目前一共增加了2个断点,断点1触发1次,断点2未触发过。我们想禁⽤某个断点时,使⽤“ disable 断点编号 ”就可以禁⽤这个断点了,同理,被禁⽤的断点也可以使⽤“ enable 断点编号 ”重新启⽤。使⽤“delete 编号”可以删除某个断点,如果输⼊ delete 不加命令号,则表示删除所有断点。
3.6 list命令
第⼀次输⼊ list 命令会显示断点处前后的代码,继续输⼊ list 指令会以递增⾏号的形式继续显示剩下的代码⾏,⼀直到⽂件结束为⽌。当然 list 指令还可以往前和往后显示代码,命令分别是“list + (加号) ”和“list - (减号) ”。
3.7 print 和 ptype 命令
通过 print + 变量名 可以打印出指定变量的值,print 命令也可以显示进⾏⼀定运算的表达式计算结果值,甚⾄可以显示⼀些函数的执⾏结果值。举个例子,我们可以使用 p a+b+c 来打印这三个变量的结果值;也可以使用 p func() 命令输出一个可执行函数 func() 的执行结果。
print 命令不仅可以输出表达式结果,同时也可以修改变量的值,我们尝试将端⼝号从 6379 改成 6400 试试:
(gdb) p server.port=6400 $24 = 6400 (gdb) p server.port $25 = 6400 (gdb)
ptype 命令,其含义是“print type”,就是输出⼀个变量的类型。
3.8 info thread 和 info args命令
⽤ info thread命令来查看当前进程有哪些线程,分别中断在何处。
(gdb) info thread Id Target Id Frame 4 Thread 0x7fffef7fd700 (LWP 53065) "redis-server" 0x00007ffff76c4945 in pthread_cond_wait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0 3 Thread 0x7fffefffe700 (LWP 53064) "redis-server" 0x00007ffff76c4945 in pthread_cond_wait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0 2 Thread 0x7ffff07ff700 (LWP 53063) "redis-server" 0x00007ffff76c4945 in pthread_cond_wait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0 * 1 Thread 0x7ffff7fec780 (LWP 53062) "redis-server" 0x00007ffff73ee923 in epoll_wait () from /lib64/libc.so.6
通过 info thread 的输出可以知道 redis-server 正常启动后,⼀共产⽣了 4 个线程,包括⼀个主线程和三个⼯作线程,线程编号(Id 那⼀列)分别是 4、 3、 2、 1。三个⼯作线程(2、 3、 4)分别阻塞在 Linux API pthread_cond_wait 处,⽽主线程(1)阻塞在 epoll_wait 处。当有多个线程时,我们可以使用 backtrace 命令查看调用堆栈,通过过堆栈判断 GDB 作用在哪个线程上面。如何切换到其他线程呢?可以通过“thread 线程编号”切换到具体的线程上去。例如,想切换到线程 2 上去,只要输⼊ thread 2 即可。
info 命令还可以⽤来查看当前函数的参数值,组合命令是 info args。
3.9 next、 step、 util、 finish 和 return 命令
next 命令(简写为n)是让 GDB 调到下⼀条命令去执⾏,这⾥的下⼀条命令不⼀定是代码的下⼀⾏,⽽是根据程序逻辑跳转到相应的位置。这⾥有⼀个⼩技巧,在 GDB 命令⾏界⾯如果直接按下回⻋键,默认是将最近⼀条命令重新执⾏⼀遍,因此,当使⽤ next 命令单步调试时,不必反复输⼊ n 命令,直接回⻋就可以了。
step 命令(简写为 s)就是“单步步⼊”(step into),顾名思义,就是遇到函数调⽤,进⼊函数内部。
finish 命令会执⾏函数到正常退出该函数;⽽ return 命令是⽴即结束执⾏当前函数并返回,也就是说,如果当前函数还有剩余的代码未执⾏完毕,也不会执⾏了。
until 命令(简写为 u)可以指定程序运⾏到某⼀⾏停下来。比如直接输入 u 1888,就可以快速执行完中间的内容,直接跳到1888行。当然也可以使用断点的方式,但是使用until命令会更便捷。
3.10 set args 和 show args 命令
很多程序需要我们传递命令⾏参数。在 GDB 调试中,很多⼈会觉得可以使⽤ gdb filename args 这种形式来给 GDB 调试的程序传递命令⾏参数,这样是不⾏的。正确的做法是在⽤ GDB 附加程序后,在使⽤ run 命令之前,使⽤“ set args 参数内容 ”来设置命令⾏参数
如果单个命令⾏参数之间含有空格,可以使⽤引号将参数包裹起来。
(gdb) set args "999 xx" "hu jj" (gdb) show args Argument list to give program being debugged when it is started is ""999 xx" "hu j j"". (gdb)
如果想清除掉已经设置好的命令⾏参数,使⽤ set args 不加任何参数即可。
(gdb) set args (gdb) show args Argument list to give program being debugged when it is started is "". (gdb)
3.11 tbreak 命令
tbreak 命令也是添加⼀个断点,第⼀个字⺟“t”的意思是 temporarily(临时的),也就是说这个命令加的断点是临时的,所谓临时断点,就是⼀旦该断点触发⼀次后就会⾃动删除。添加断点的⽅法与上⾯介绍的 break命令⼀模⼀样,这⾥不再赘述。
3.12 watch 命令
watch 命令是⼀个强⼤的命令,它可以⽤来监视⼀个变量或者⼀段内存,当这个变量或者该内存处的值发⽣变化时, GDB 就会中断下来。被监视的某个变量或者某个内存地址会产⽣⼀个 watch point(观察点)。
3.13 display 命令
display 命令监视的变量或者内存地址,每次程序中断下来都会⾃动输出这些变量或内存的值。例如,假设程序有⼀些全局变量,每次断点停下来我都希望 GDB 可以⾃动输出这些变量的最新值,那么使⽤“ display变量名 ”设置即可。
4 GDB 调试技巧
4.1 将 print 打印结果显示完整
当使⽤ print 命令打印⼀个字符串或者字符数组时,如果该字符串太⻓, print 命令默认显示不全的,我们可以通过在 GDB 中输⼊ set print element 0 命令设置⼀下,这样再次使⽤ print 命令就能完整地显示该变量的所有字符串了。
4.2 让被 GDB 调试的程序接收信号
void prog_exit(int signo) { std::cout << "program recv signal [" << signo << "] to exit." << std::endl; } int main(int argc, char* argv[]) { //设置信号处理 signal(SIGCHLD, SIG_DFL); signal(SIGPIPE, SIG_IGN); signal(SIGINT, prog_exit); signal(SIGTERM, prog_exit); int ch; bool bdaemon = false; while ((ch = getopt(argc, argv, "d")) != -1) { switch (ch) { case 'd': bdaemon = true; break; } } if (bdaemon) daemon_run(); //省略⽆关代码... }
在这个程序中,我们接收到 Ctrl + C 信号(对应信号 SIGINT)时会简单打印⼀⾏信息,⽽当⽤ GDB 调试这个程序时,由于 Ctrl + C 默认会被 GDB 接收到(让调试器中断下来),导致⽆法模拟程序接收这⼀信号。解决这个问题有两种⽅式:在 GDB 中使⽤ signal 函数⼿动给程序发送信号,这⾥就是 signal SIGINT;改变 GDB 信号处理的设置,通过 handle SIGINT nostop print 告诉 GDB 在接收到 SIGINT 时不要停⽌,并把该信号传递给调试⽬标程序 。
(gdb) handle SIGINT nostop print pass SIGINT is used by the debugger. Are you sure you want to change it? (y or n) y Signal Stop Print Pass to program Description SIGINT No Yes Yes Interrupt (gdb)
4.3 多线程下禁⽌线程切换
假设现在有 5 个线程,除了主线程,⼯作线程都是下⾯这样的⼀个函数:
void thread_proc(void* arg) { //代码⾏1 //代码⾏2 //代码⾏3 //代码⾏4 //代码⾏5 //代码⾏6 //代码⾏7 //代码⾏8 //代码⾏9 //代码⾏10 //代码⾏11 //代码⾏12 //代码⾏13 //代码⾏14 //代码⾏15 }
为了能说清楚这个问题,我们把四个⼯作线程分别叫做 A、 B、 C、 D。假设 GDB 当前正在处于线程 A 的代码⾏ 3 处,此时输⼊ next 命令,我们期望的是调试器跳到代码⾏ 4 处;或者使⽤“u 代码⾏10”,那么我们期望输⼊ u 命令后调试器可以跳转到代码⾏ 10 处。但是在实际情况下, GDB 可能会跳转到代码⾏ 1 或者代码⾏ 2 处,甚⾄代码⾏ 13、代码⾏ 14 这样的地⽅也是有可能的,这不是调试器 bug,这是多线程程序的特点,当我们从代码⾏ 4 处让程序 continue 时,线程A 虽然会继续往下执⾏,但是如果此时系统的线程调度将 CPU 时间⽚切换到线程 B、 C 或者 D 呢?那么程序最终停下来的时候,处于代码⾏ 1 或者代码⾏ 2 或者其他地⽅就不奇怪了,⽽此时打印相关的变量值,可能就不是我们需要的线程 A 的相关值。
为了解决调试多线程程序时出现的这种问题, GDB 提供了⼀个在调试时将程序执⾏流锁定在当前调试线程的命令: set scheduler-locking on。当然也可以关闭这⼀选项,使⽤ set scheduler-locking off。
4.4 条件断点
所谓条件断点,就是满⾜某个条件才会触发的断点,这⾥先举⼀个直观的例⼦
void do_something_func(int i) { i ++; i = 100 * i; } int main() { for(int i = 0; i < 10000; ++i) { do_something_func(i); } return 0; }
在上述代码中,假如我们希望当变量 i=5000 时,进⼊ do_something_func() 函数追踪⼀下这个函数的执⾏细节。添加条件断点的命令是 break [lineNo] if [condition],其中 lineNo 是程序触发断点后需要停下的位置, condition 是断点触发的条件。这⾥可以写成 break 11 if i==5000,其中, 11 就是调⽤ do_something_fun() 函数所在的⾏号。当然这⾥的⾏号必须是合理⾏号,如果⾏号⾮法或者⾏号位置不合理也不会触发这个断点。
4.5 使⽤ GDB 调试多进程程序
在实际的应⽤中,如有这样⼀类程序,如 Nginx,对于客户端的连接是采⽤多进程模型,当 Nginx 接受客户端连接后,创建⼀个新的进程来处理这⼀路连接上的信息来往,新产⽣的进程与原进程互为⽗⼦关系,那么如何⽤ GDB 调试这样的⽗⼦进程呢?⼀般有两种⽅法:⽤ GDB 先调试⽗进程,等⼦进程 fork 出来后,使⽤ gdb attach 到⼦进程上去,当然这需要重新开启⼀个 session 窗⼝⽤于调试, gdb attach 的⽤法在前⾯已经介绍过了;GDB 调试器提供了⼀个选项叫 follow-fork,可以使⽤ show follow-fork mode 查看当前值,也可以通过set follow-fork mode 来设置是当⼀个进程 fork 出新的⼦进程时, GDB 是继续调试⽗进程还是⼦进程取值是 child),默认是⽗进程( 取值是 parent)。