GDB在LINUX是一项重要的调试工具,本文介绍它的基本使用方法。

1. 安装GDB

在linux中需要先安装gcc和gdb

# 安装 gcc
yum install gcc
# 安装 g++
yum install gcc-c++
# 安装 gdb
yum install gdb

当要调试某程序时,需要在gcc编译时加上-g,目的是保留编译后的程序中调试符号信息

当输入gdb hello时能看到:

Reading symbols from /root/testclient/hello_server...done.
(gdb)

说明保留调试符号信息成功。

2. 启动GDB调试

启动有三种方式:直接调试目标程序,附加进程,调试core文件

2.1 直接调试目标程序

gdb filename

filename是需要启动的调试文件名,可以直接使用gdb启动一个程序调试,此时并没有完全启动,而是附加了一个可执行文件,需要输入run才能真正运行

2.2 附加进程号

gdb attach ProcessID

有时候程序已经启动,我们并不想重启它(比如服务器),此时需要利用attach指令将进程号添加到GDB调试器。获取进程号的方法如下所示:

[zhangyl@iZ238vnojlyZ flamingoserver]$ ps -ef | grep chatserver
zhangyl  21462 21414  0 18:00 pts/2    00:00:00 grep --color=auto chatserver
zhangyl  26621     1  5 Oct10 ?        2-17:54:42 ./chatserver -d

得到charserver的PID为26621,使用gdb attach 26621将GDB附加到chatserver中,看到attaching to process 26661时表示已经附加成功。

[zhangyl@localhost flamingoserver]$ gdb attach 26621
Attaching to process 26661
Reading symbols from /home/zhangyl/flamingoserver/chatserver...done.
Reading symbols from /usr/lib64/mysql/libmysqlclient.so.18...Reading symbols from /usr/lib64/mysql/libmysqlclient.so.18...(no debugging symbols found)...done.
Reading symbols from /lib64/libpthread.so.0...(no debugging symbols found)...done.
[New LWP 42931]
[New LWP 42930]
[New LWP 42929]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
Loaded symbols for /lib64/libpthread.so.0
Reading symbols from /lib64/libc.so.6...(no debugging symbols found)...done.

调试完成时,可以使用detach将其分离。

2.3 调试core文件

gdb filename corename

有时候,服务器程序运行一段时间后会突然崩溃,只要程序在崩溃的时候有 core 文件产生,就可以使用这个 core 文件来定位崩溃的原因。

默认情况下,Linux关闭了core文件机制,使用ulimit -c命令来查看系统是否启动这一机制。0表示关闭,数字表示启动但限制core文件大小,unlimited表示启动且不限制大小。

[zhangyl@localhost flamingoserver]$ ulimit -c
core file size          (blocks, -c) 0

使用ulimit -c unlimited指令来启动。

[zhangyl@localhost flamingoserver]$ ulimit -c unlimited
[zhangyl@localhost flamingoserver]$ ulimit -c
core file size          (blocks, -c) unlimited

生成的 core 文件的默认命名方式是 core.pid,比如某个程序运行时的进程 ID 是 16663,那么它崩溃产生的 core 文件的名称就是 core.16663,可以通过gdb chatserver core.16663来启动调试。

多个程序同时崩溃,我们根本没法通过 core 文件名称中的 PID 来区分。因此可以再程序中记录一下自己的PID。

void writePid()
{
      uint32_t curPid = (uint32_t) getpid();
      FILE* f = fopen("xxserver.pid", "w");
      assert(f);
      char szPid[32];
      snprintf(szPid, sizeof(szPid), "%d", curPid);
      fwrite(szPid, strlen(szPid), 1, f);
      fclose(f);
}

我们在程序启动时调用上述 writePID函数,将程序当时的 PID 记录到 xxserver.pid 文件中去,这样当程序崩溃时,可以从这个文件中得到进程当时运行的 PID,这样就可以与默认的 core 文件名后面的 PID 做匹配了。

3. 常用命令

首先下载redis开源代码作为示例

Redis是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API

Redis 的最新源码下载地址可以在 Redis 官网获得,使用 wget 命令将 Redis 源码文件下载下来:

[root@localhost gdbtest]# wget http://download.redis.io/releases/redis-4.0.11.tar.gz

然后解压:

[root@localhost gdbtest]# tar zxvf redis-4.0.11.tar.gz

使用Makefile编译,加入MALLOC=libc防止jemalloc问题,make distclean清除上次失败的编译残留文件。

make distclean
make MALLOC=libc

进入src目录,使用gdb redis-server启动程序。

3.1 流控制语句

run

gdb filename 命令只是附加的一个调试文件,并没有启动这个程序,需要输入 run 命令(简写为 r)启动这个程序

redis-server 启动后能看到一个四方堆叠结构,说明启动成功了。假设程序已经启动,再次输入 run 命令则是重启程序。我们在 GDB 界面按 Ctrl + C 快捷键让 GDB 中断下来,再次输入 r 命令,GDB 会询问我们是否重启程序,输入 yes 确认重启。

continue

当 GDB 触发断点或者使用 Ctrl + C 命令中断下来后,想让程序继续运行,只要输入 continue命令即可(简写为 c)

^C
Program received signal SIGINT, Interrupt.
0x00007ffff73ee923 in epoll_wait () from /lib64/libc.so.6
(gdb) c
Continuing.

next、step、until、finish、return 和 jump 命令

next 命令(简写为 n)是让 GDB 调到下一条命令去执行,这里的下一条命令不一定是代码的下一行,而是根据程序逻辑跳转到相应的位置。

在 GDB 命令行界面如果直接按下回车键,默认是将最近一条命令重新执行一遍,因此,当使用 next 命令单步调试时,不必反复输入 n 命令,直接回车就可以。

(gdb) n
3736        spt_init(argc, argv);
(gdb)
3738        setlocale(LC_COLLATE,"");
(gdb)
3739        zmalloc_set_oom_handler(redisOutOfMemoryHandler);

next 命令用调试的术语叫“单步步过”(step over),即遇到函数调用直接跳过,不进入函数体内部。而step 命令(简写为 s)就是“单步步入”(step into),顾名思义,就是遇到函数调用,进入函数内部。


在某个函数中调试一段时间后,不需要再一步步执行到函数返回处,希望直接执行完当前函数并回到上一层调用处,就可以使用 finish 命令。与 finish 命令类似的还有 return 命令,return 命令的作用是结束执行当前函数,还可以指定该函数的返回值

#include <stdio.h>
int func()
  {
     int a = 9;
     printf("a=%d.\n", a);
 }
 int main()
 {
    int c = func();
    printf("c=%d.\n", c);
    return 0;
 }

上述代码在func函数里直接输入 return 命令以后,不会返回任何值,如果输入retrun 9999,那么打印出来结果就是9999。


until 命令(简写为 u)可以指定程序运行到某一行停下来,可以代替break+continue,比如当前运行到338行,想要运行到992行,直接u 992

jump命令也是跳转,但中间的步骤不会执行。比如从338行跳转到992行j 992。从338行到992行之间的所有代码都不会执行(可能会因为没有初始化造成问题),并且会从992行以后持续执行(如果没有碰到断点)

3.2 断点语句

break 命令(简写为 b)即我们添加断点的命令,可以使用以下方式添加断点:

  • break functionname,在函数名为 functionname 的入口处添加一个断点;
  • break LineNo,在当前文件行号为 LineNo 处添加一个断点;
  • break filename:LineNo,在 filename 文件行号为 LineNo 处添加一个断点。

在程序中加了很多断点,而我们想查看加了哪些断点时,可以使用 info break 命令。

如果我们想禁用某个断点,使用“disable 断点编号”就可以禁用这个断点了,被禁用的断点不会再被触发;同理,被禁用的断点也可以使用“enable 断点编号”重新启用。如果 disable 命令和 enable 命令不加断点编号,则分别表示禁用和启用所有断点。

使用“delete 编号”可以删除某个断点,如 delete 2 3 则表示要删除的断点 2 和断点 3。同样的道理,如果输入 delete 不加命令号,则表示删除所有断点。

tbreak 命令也是添加一个断点,第一个字母“t”的意思是 temporarily(临时的),也就是说这个命令加的断点是临时的,所谓临时断点,就是一旦该断点触发一次后就会自动删除。

3.2 堆栈和线程查看语句

list

list 命令(简写为 l)可以查看当前断点处的代码。使用 frame 命令切换到刚才的堆栈 #3 处,然后输入 list 命令看下效果:

(gdb) f 4
#4  0x000000000042fa77 in initServer () at server.c:1852
1852            listenToPort(server.port,server.ipfd,&server.ipfd_count) == C_ERR)
(gdb) l
1847        }
1848        server.db = zmalloc(sizeof(redisDb)*server.dbnum);
1849
1850        /* Open the TCP listening socket for the user commands. */
1851        if (server.port != 0 &&
1852            listenToPort(server.port,server.ipfd,&server.ipfd_count) == C_ERR)
1853            exit(1);
1854
1855        /* Open the listening Unix domain socket. */
1856        if (server.unixsocket != NULL) {
(gdb)

list语句现实的是目标语句处前后附近的代码,再次输入list命令后,代码继续往后显示 10 行。 list 指令还可以往前和往后显示代码,命令分别是list + (加号)和list - (减号)。

backtrace和frame

backtrace 命令(简写为 bt)用来查看当前调用堆栈。若redis-server 现在中断在 anet.c:452 行,可以通过 backtrace 命令来查看当前的调用堆栈:

(gdb) bt
#0  anetListen (err=0x746bb0 <server+560> "", s=10, sa=0x7e34e0, len=16, backlog=511) at anet.c:452
#1  0x0000000000426e35 in _anetTcpServer (err=err@entry=0x746bb0 <server+560> "", port=port@entry=6379, bindaddr=bindaddr@entry=0x0, af=af@entry=10, backlog=511)
    at anet.c:487
#2  0x000000000042793d in anetTcp6Server (err=err@entry=0x746bb0 <server+560> "", port=port@entry=6379, bindaddr=bindaddr@entry=0x0, backlog=511)
    at anet.c:510
#3  0x000000000042b0bf in listenToPort (port=6379, fds=fds@entry=0x746ae4 <server+356>, count=count@entry=0x746b24 <server+420>) at server.c:1728
#4  0x000000000042fa77 in initServer () at server.c:1852
#5  0x0000000000423803 in main (argc=1, argv=0x7fffffffe648) at server.c:3862
(gdb)

这里一共有 6 层堆栈,最顶层是 main() 函数,最底层是断点所在的 anetListen()函数,堆栈编号分别是 #0 ~ #5。比如我们在利用pthread_creat函数创建线程时可以利用bt查看创建过程。

使用bt以后可以看到如下结果,说明creat线程创建过程是clone->start_thread->MainTcpEpollThread

#0  MainTcpEpollThread (lpParam=0x0) at ./Server.cpp:1035
#1  0x00007ffff7bc6dd5 in start_thread () from /lib64/libpthread.so.0
#2  0x00007ffff68f3ead in clone () from /lib64/libc.so.6

如果想切换到其他堆栈处,可以使用 frame 命令(简写为 f),该命令的使用方法是frame 堆栈编号(编号不加 #)

(gdb) f 1
#1  0x0000000000426e35 in _anetTcpServer (err=err@entry=0x746bb0 <server+560> "", port=port@entry=6379, bindaddr=bindaddr@entry=0x0, af=af@entry=10, backlog=511)
    at anet.c:487
487             if (anetListen(err,s,p->ai_addr,p->ai_addrlen,backlog) == ANET_ERR) s = ANET_ERR;
(gdb) f 2
#2  0x000000000042793d in anetTcp6Server (err=err@entry=0x746bb0 <server+560> "", port=port@entry=6379, bindaddr=bindaddr@entry=0x0, backlog=511)
    at anet.c:510
510         return _anetTcpServer(err, port, bindaddr, AF_INET6, backlog);
(gdb)

通过查看上面的各个堆栈,可以得出这里的调用层级关系,即:

  • main() 函数在第 3862 行调用了 initServer() 函数
  • initServer() 在第 1852 行调用了 listenToPort()函数
  • listenToPort() 在第 1728 行调用了 anetTcp6Server() 函数
  • anetTcp6Server() 在第 510 行调用了 _anetTcpServer() 函数
  • _anetTcpServer() 函数在第 487 行调用了 anetListen() 函数
  • 当前断点正好位于 anetListen() 函数中

thread 命令

使用 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 处。

虽然第一栏的名称叫 Id,但第一栏的数值不是线程的 Id,第三栏括号里的内容(如 LWP 53065)中,53065 这样的数值才是当前线程真正的 Id。 LWP 是Light Weight Process(轻量级进程),之前Linux没有线程,用进程代替后来 Linux 系统有了真正的线程实现,这个名字仍然被保留了下来。

可以通过“thread 线程编号”切换到具体的线程上去。例如,想切换到线程 2 上去,只要输入 thread 2 即可,然后输入 bt 就能查看这个线程的调用堆栈了。当把 GDB 当前作用的线程切换到线程 2 上之后,线程 2 前面就被加上了星号。

3.4 变量信息查看语句

print和ptype命令

通过 print 命令(简写为 p)我们可以在调试过程中方便地查看变量的值,也可以修改当前内存中的变量值。切换当前断点到堆栈 #4 ,然后打印以下三个变量:

(gdb) f 4
#4  0x000000000042fa77 in initServer () at server.c:1852
1852            listenToPort(server.port,server.ipfd,&server.ipfd_count) == C_ERR)
(gdb) l
1847        }
1848        server.db = zmalloc(sizeof(redisDb)*server.dbnum);
1849
1850        /* Open the TCP listening socket for the user commands. */
1851        if (server.port != 0 &&
1852            listenToPort(server.port,server.ipfd,&server.ipfd_count) == C_ERR)
1853            exit(1);
1854
1855        /* Open the listening Unix domain socket. */
1856        if (server.unixsocket != NULL) {
(gdb) p server.port
$15 = 6379
(gdb) p server.ipfd
$16 = {0 <repeats 16 times>}
(gdb) p server.ipfd_count
$17 = 0

这里使用 print 命令分别打印出 server.portserver.ipfdserver.ipfd_count 的值,其中 server.ipfd 显示 “{0 …}”,这是 GDB 显示字符串或字符数据特有的方式,当一个字符串变量或者字符数组或者连续的内存值重复若干次,GDB 就会以这种模式来显示以节约空间.

此外我们可以输入 p &server.port 来输出 server.port 的地址值。 func() 是一个可以执行的函数,p func() 命令可以输出该变量的执行结果。举一个最常用的例子,某个时刻,某个系统函数执行失败了,通过系统变量 errno 得到一个错误码,则可以使用 p strerror(errno) 将这个错误码对应的文字信息打印出来,这样就不用费劲地去 man 手册上查找这个错误码对应的错误含义了。


ptype ,顾名思义,其含义是“print type”,就是输出一个变量的类型。例如,我们试着输出 Redis 堆栈 #4 的变量 server 和变量 server.port 的类型:

(gdb) ptype server
type = struct redisServer {
    pid_t pid;
    char *configfile;
    char *executable;
    char **exec_argv;
    int hz;
    redisDb *db;
    ...省略部分字段...
(gdb) ptype server.port
type = int

info args命令

info args可以用来查看当前函数的参数值

(gdb) thread 1
[Switching to thread 1 (Thread 0x7ffff7fec780 (LWP 53062))]
#0  0x00007ffff73ee923 in epoll_wait () from /lib64/libc.so.6
(gdb) bt
#0  0x00007ffff73ee923 in epoll_wait () from /lib64/libc.so.6
#1  0x00000000004265df in aeApiPoll (tvp=0x7fffffffe300, eventLoop=0x7ffff08350a0) at ae_epoll.c:112
#2  aeProcessEvents (eventLoop=eventLoop@entry=0x7ffff08350a0, flags=flags@entry=11) at ae.c:411
#3  0x0000000000426aeb in aeMain (eventLoop=0x7ffff08350a0) at ae.c:501
#4  0x00000000004238ef in main (argc=1, argv=0x7fffffffe648) at server.c:3899
(gdb) f 2
#2  aeProcessEvents (eventLoop=eventLoop@entry=0x7ffff08350a0, flags=flags@entry=11) at ae.c:411
411             numevents = aeApiPoll(eventLoop, tvp);
(gdb) info args
eventLoop = 0x7ffff08350a0
flags = 11
(gdb)

上述代码片段切回至主线程 1,然后切换到堆栈 #2,堆栈 #2 调用处的函数是 aeProcessEvents() ,一共有两个参数,使用 info args 命令可以输出当前两个函数参数的值,参数 eventLoop 是一个指针类型的参数,对于指针类型的参数,GDB 默认会输出该变量的指针地址值,如果想输出该指针指向对象的值,在变量名前面加上解引用即可,这里使用 p* eventLoop 命令:

(gdb) p *eventLoop
$26 = {maxfd = 11, setsize = 10128, timeEventNextId = 1, lastTime = 1536570672, events = 0x7ffff0871480, fired = 0x7ffff08c2e40, timeEventHead = 0x7ffff0822080,
  stop = 0, apidata = 0x7ffff08704a0, beforesleep = 0x429590 <beforeSleep>, aftersleep = 0x4296d0 <afterSleep>}

watch命令

watch 命令可以用来监视一个变量或者一段内存,当这个变量或者该内存处的值发生变化时,GDB 就会中断下来。被监视的某个变量或者某个内存地址会产生一个 watch point(观察点), watch 命令就可以通过添加硬件断点来达到监视数据变化的目的。它有两种形式:

//形式一:整型变量
int i;
watch i
//形式二:指针类型
char *p;
watch p 与 watch *p
//形式三:watch 一个数组或内存区间
char buf[128];
watch buf

注意watch pwatch *p 是有区别的,前者是查看* (&p),是 p 变量本身;后者是 p 所指内存的内容。我们需要查看地址,因为目的是要看某内存地址上的数据是怎样变化的。

display命令

display 命令监视的变量或者内存地址,每次程序中断下来都会自动输出这些变量或内存的值。例如,假设程序有一些全局变量,每次断点停下来我都希望 GDB 可以自动输出这些变量的最新值,那么使用“display 变量名”设置即可

比如输入(gdb) display $ebx,那么每次调试的最后无论有没有print最后都会打印出$ebx的结果。

以使用 info display 查看当前已经自动添加了哪些值,使用 delete display 清除全部需要自动输出的变量,使用 delete diaplay 编号 删除某个自动输出的变量。