GDB调试指南:C++中如何调试生产环境的程序?

3,630 阅读10分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

引言

对于后端开发而言,不管是java还是c++/go等程序员,都会遇到线上程序崩溃的问题,此时,我们有2种方式进行排查:

  • 通过log确定问题范围,比如有没有error级别的log,或者根据最后一行log大概确定出问题的模块
  • 如果启用了linux的coredump功能,则在程序崩溃时,会把当时的所有堆栈信息按照指定格式转存到文件中,我们可以通过gdb加载这个文件,还原崩溃时程序运行的情况,找到错误具体原因。

除了生产环境中出现问题,平时的调试中我们可能也会使用GDB,但是目前因为VS Code、Clion等IDE集成了gdb调试,故实际开发环境中用的还相对偏少一些。

本文主要从线上程序故障,该如何使用GDB排查来介绍GDB的调试功能。其他方面,推荐 《软件调试的艺术》 和 GDB官方的手册《Debugging with GDB》深入理解GDB。

针对生产环境(线上),我们使用gdb主要有2个场景:

  1. GDB With CoreDump:程序崩溃了。事后我们需要排查崩溃原因,主要是对操作系统生成的CoreDump文件进行分析,还原现场,调查问题。

  2. GDB附加调试:程序正在运行,但是假死了。在线上环境中,我们通常会快速重启已确保业务不受影响,然后在测试环境中自己模拟重现,复现后,通过GDB附加上去,打断点观察(当然通过日志也是一种手段)。

下面来介绍一下这2种使用场景下GDB的使用。

PS:平常编码调试不建议直接使用GDB,不管是在Linux(centos/ubnuntu)还是macOS,都推荐jetbrain开发的Clion跨平台IDE,简直不要太好用,打断点和IDEA类似,也和Windows下面的VS2017相似,强烈推荐。

GDB With CoreDump

先来介绍coredump如何调试。

编译选项增加-g

为了能使gdb看到具体的堆栈源码行数,在g++编译时,我们需要增加-g选项,下面是一个CMAKE定义的编译参数示例:

# -g:添加gdb调试选项。
ADD_DEFINITIONS(-g -W -Wall -D_REENTRANT -D_FILE_OFFSET_BITS=64 -DAC_HAS_INFO
        -DAC_HAS_WARNING -DAC_HAS_ERROR -DAC_HAS_CRITICAL -DTIXML_USE_STL
        -DAC_HAS_DEBUG -DLINUX_DAEMON -std=c++11)

启用Linux CoreDump转储Core文件功能

请跳转到末尾,附录:启用和关闭coredump功能

拷贝程序和core文件到编译机器上

为了防止出现堆栈不正确(很少或者带有?号),我们需要把程序和core文件都拷贝到带有源码的编译机器上,可以是任意位置,比如 /data/ 下面。

这里为了方面演示,故可以省略该步骤。

测试程序

如果没有core文件,我们也可以自己构造一个。为了方便介绍,我们拿以下代码来讲解:

  1. 创建一个test.cpp程序文件
$ vim test.cpp
//#include <stdio.h>
// 这段代码会造成数组越界,使用gdb调试的时候,全部是?号,看不到具体的代码行数,请注意不要使用
//int main( int argc, char * argv[] ) { 
//  char a[1]; 
//  scanf( "%s", a );
//  return 0;
//}

#include <string>
#include <stdio.h>

struct Student {
    std::string name;
};

void printStudent(Student *s) {
    printf(s->name.c_str());
}

int main(){
    Student *s ; // = new Student(); 这里故意不初始化,好让printInfo报空指针错误
    printStudent(s);

    return 0;
}
$ ls # 保存之后,我们是这样的
coredump.sh  imcorefile  test.cpp
  1. 编译,注意别忘记 -g 选项(这篇文章里有个对比:gcc编译为什么要加-g选项
$ g++ -g test.cpp -o test # c++程序使用g++编译
$ ls                      # 多了一个test程序文件
coredump.sh  imcorefile  test  test.cpp
  1. 执行程序
$ ./ test
# Segmentation fault             # 情况1:崩溃,这个是没有转储core文件的,说明没启用coredump功能
Segmentation fault (core dumped) # 情况2:这个才是转储了core文件,记住得提前启用linux的coredump功能
  1. 查看下是否生产了core文件
$ pwd
/data
$ ls imcorefile/
core-test-11-0-0-4198-1621495311

为什么是 /data/imcorefile?这个是 附录:启用和关闭coredump功能 配置core文件的存储路径,所有的core文件都会存储在这里。

加载core文件

$ gdb test imcorefile/core-test-11-0-0-4317-1621496766

GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-119.el7
Copyright (C) 2013 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-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /data/test...done.
[New LWP 4317]
Core was generated by `./test'.
Program terminated with signal 11, Segmentation fault.
#0  0x00007f16eda60ef0 in std::string::c_str() const () from /lib64/libstdc++.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-157.el7_3.1.x86_64 libgcc-4.8.5-11.el7.x86_64 libstdc++-4.8.5-11.el7.x86_64

打印堆栈

此时,我们直接输入 bt命令 打印当前线程的堆栈(如果是多线程程序,可以使用 thread apply all bt 打印所有线程的堆栈):

(gdb) bt
#0  0x00007f16eda60ef0 in std::string::c_str() const () from /lib64/libstdc++.so.6
#1  0x0000000000400688 in printStudent (s=0x0) at test.cpp:9
#2  0x00000000004006ab in main () at test.cpp:14

我们看到出错在printStudent()函数,可以通过frame命令,看堆栈详细信息

(gdb) f 2
#2  0x00000000004006ab in main () at test.cpp:14
14          printStudent(s);
(gdb) f 1
#1  0x0000000000400688 in printStudent (s=0x0) at test.cpp:9
9           printf(s->name.c_str());

然后,通过 p 命令,查看 s 变量的值,看看是什么问题:

(gdb) p s
$1 = (Student *) 0x0 # 0x0,这一看就是空指针,这里只是为了演示p命令,可以查看变量的值
(gdb) p s->name      # 不够明显,在打印
Cannot access memory at address 0x0

总结

至此,GDB 调试core文件就结束了,其实比较简单。就记住4个命令即可:

  • gdb
  • bt:打印堆栈
  • f:查看某一个栈的信息
  • p:查看变量的值

GDB附加调试

除了调试coredump文件,分析崩溃原因之外,还有一种情况:程序没崩溃,但是变成了僵尸进程,要怎么找原因?

这个时候,就需要用到gdb附加调试的功能了(好像还可以远程调试,没用过)。

附加

启动附加调试的语法也很简单:

$ gdb attach <pid>

第2个参数是进程ID,使用ps 查看一下即可:

$ ps aux|grep server
root      4362  0.0  0.3 119444 12220 ?        Sl   16:04   0:00 ./msg_server

附加后,程序将暂停运行,如下:

$ gdb attach 4362
# ... 这里会输出一大段信息,省略

查看所有线程的堆栈

通过bt命令可以看到主线程的堆栈:

(gdb) bt
#0  0x00007fc62e6c1d13 in epoll_wait () from /lib64/libc.so.6
#1  0x00000000006372fd in CEventDispatch::StartDispatch (this=0x2938000, wait_timeout=100)
    at /home/online.base.newteamtalk.service/server/src/base/EventDispatch.cpp:535
#2  0x000000000055a2ae in netlib_eventloop (wait_timeout=100) at /home/online.base.newteamtalk.service/server/src/base/netlib.cpp:188
#3  0x000000000053db44 in main (argc=1, argv=0x7ffe7fa10608)
    at /home/online.base.newteamtalk.service/server/src/msg_server/msg_server.cpp:263

看到所有线程的堆栈需要使用:thread apply all bt

(gdb) thread apply all bt

Thread 2 (Thread 0x7fc62a06c700 (LWP 4363)):
#0  0x00007fc62e6b8ba3 in select () from /lib64/libc.so.6
#1  0x00007fc62c5d3625 in apr_sleep () from /usr/lib64/libapr-1.so.0
#2  0x00007fc62e2e62e9 in ?? ()
#3  0x000000000000110b in ?? ()
#4  0x000000000290a100 in ?? ()
#5  0x00007fc62a06bce0 in ?? ()
#6  0x00007fc62e34416e in ?? ()
#7  0x00000000028f4050 in ?? ()
#8  0x0000000000000018 in ?? ()
#9  0x00007fc62a06bd30 in ?? ()
#10 0x0000000000000000 in ?? ()

Thread 1 (Thread 0x7fc630297940 (LWP 4362)):
#0  0x00007fc62e6c1d13 in epoll_wait () from /lib64/libc.so.6
#1  0x00000000006372fd in CEventDispatch::StartDispatch (this=0x2938000, wait_timeout=100)
    at /home/online.base.newteamtalk.service/server/src/base/EventDispatch.cpp:535
#2  0x000000000055a2ae in netlib_eventloop (wait_timeout=100) at /home/online.base.newteamtalk.service/server/src/base/netlib.cpp:188
#3  0x000000000053db44 in main (argc=1, argv=0x7ffe7fa10608)
    at /home/online.base.newteamtalk.service/server/src/msg_server/msg_server.cpp:263

这里示例的程序是tcp网关,基于epoll实现的,所以程序在epoll_wait()处挂起是正常的。

继续

为了使程序能运行,我们需要输入continue(或者简写c):

(gdb) c
Continuing.

查看源代码,下断点

按住 Ctrl + C,进入gdb 命令模式

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

通过 list 命令查看源码,b 命令打断点,然后输入 c 让程序运行,这里就不贴代码了。

单步调试

程序断下后,我们就可以单步来调试,通过p看变量的值来排查问题了。主要通过下面几个命令:

$ next   # 单步跟踪,如果有函数调用,他不会进入该函数
$ step   # 单步跟踪,如果有函数调用,他会进入该函数
$ finish # 运行程序,直到当前函数完成返回

调用函数

最后一块要提一下,p 不仅仅可以打印变量的值,也可以调用函数。比如在上面 查看所有线程的堆栈 一节的例子中,我们打印了线程堆栈。实际上在写这篇文章时,出现的现象是tcp服务不接受新的tcp连接,“卡死”在哪里。此时我们就可以通过 p命令 调用 epoll_ctl() 来判断是不是和I/O复用有关系,语法如下:

c++代码:

strucht epoll_event ev{};
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listenFd;
ret = ::epoll_ctl(epFd, EPOLL_CTL_ADD, listenFd, &ev);

gdb命令:

# 5: epoll的句柄
# 2: EPOLL_CTL_DEL
# 6: socket句柄
$ p (int)(::epoll_ctl(5,2,6,0))

上面的gdb命令的函数参数中,不能是变量甚至常量,只能是右值(存到寄存器),所以上面写死了。

如果参数是结构体,如何模拟?可以使用c++11的一致性初始化特性,比如

struct Point{
    double x;
    double y;
};

Point p3 = {10, 20};

那么在gdb中,通过p调用的时候,如果参数是Point,对应位置只需要写 {10, 20} 即可。

实战踩坑记录

GDB看不到具体源代码或者显示问号

  1. 可能是少了-g指令,在CMakeLists.txt增加一下:
# -g:添加gdb调试选项。
ADD_DEFINITIONS(-g -W -Wall -D_REENTRANT -D_FILE_OFFSET_BITS=64 -DAC_HAS_INFO
        -DAC_HAS_WARNING -DAC_HAS_ERROR -DAC_HAS_CRITICAL -DTIXML_USE_STL
        -DAC_HAS_DEBUG -DLINUX_DAEMON -std=c++11)
  1. 数组越界了也会导致这种问题
$ vim test.c
#include <stdio.h>
int main( int argc, char * argv[] ) { char a[1]; scanf( "%s", a ); return 0; }

$ gcc -g test.c -o test
$ ./test 
Segmentation fault (core dumped)

$ gdb test imcorefile/core-test-11-0-0-4240-1621495946
#0  0x0000000000400066 in ?? ()
#1  0x00007ffef9a48198 in ?? ()
#2  0x0000000100000000 in ?? ()
#3  0x0000000000000000 in ?? ()

GDB看到的堆栈很少,感觉错误位置莫名其妙

解决方法:把程序和core文件拷贝到编译机器,使用gdb调试。

额外说一下,现在很多互联网公司都有自己的CI/CD(持续集成)平台,可以实现一键代码部署和机器分发。其本质上是通过脚本来编译程序,然后使用jenkins来进行程序的分发和部署。假设有几套环境:编译机器 -> DEV(开发) -> TEST(测试) -> PRE(预发) -> PROD(生产),这中间我们的任何代码的改动都需要经过这一串的完整流程。故在生产环境运行的程序都不是本机编译的。

所以,这个问题需要注意一下,调试core文件的时候,需要在编译机器上,否则堆栈信息可能误导你!

附录:启用和关闭coredump功能

PS:本文的方法适用于CentOS6.6 - CentOS 7.2

如果按照下列步骤操作无效,请先手动编辑/etc/security/limits.conf(需手动删除文件内部分内容,见coredump.sh)、/etc/sysctl.conf(可直接删除)

创建coredump.sh

可以直接把这个shell脚本保存下来,注意脚本/data/imcorefile是coredump存储的路径,如果要更改,别改遗漏了。

$ vim coredump.sh

#!/bin/bash

# Filename: coredumpshell.sh
# Description: enable coredump and format the name of core file on centos system

# enable coredump whith unlimited file-size for all users
echo -e "\n# enable coredump whith unlimited file-size for all users\n* soft core unlimited" >> /etc/security/limits.conf

# 创建coredump文件存储路径和赋予权限
cd /data && mkdir imcorefile && chmod 777 imcorefile

# format the name of core file.   
# %% – 符号%
# %p – 进程号
# %u – 进程用户id
# %g – 进程用户组id
# %s – 生成core文件时收到的信号
# %t – 生成core文件的时间戳(seconds since 0:00h, 1 Jan 1970)
# %h – 主机名
# %e – 程序文件名    
# for centos7 system(update 2017.4.2 21:44)

# /data/imcorefile/:是core文件存储的路径,这里比较重要
# 如果需要更改,上面和这里需要同时改,别忘记
echo -e "\nkernel.core_pattern=/data/imcorefile/core-%e-%s-%u-%g-%p-%t" >> /etc/sysctl.conf
echo -e "\nkernel.core_uses_pid = 1" >> /etc/sysctl.conf

sysctl -p /etc/sysctl.conf

永久启用core dump功能

$ chmod 777 coredump.sh
$ ./coredump.sh
# 重新打开终端
$ ulimit -a # 显示 unlimited 代表成功

core file size          (blocks, -c) unlimited  # 生成core转储文件的大小,这里是不限制
data seg size           (kbytes, -d) unlimited  
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 15085
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 65536         # 最大可打开文件句柄数,约等于tcp最大连接数
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 131072
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

验证

$ vim test.c  // 输入如下内容

#include <stdio.h>
int main( int argc, char * argv[] ) { char a[1]; scanf( "%s", a ); return 0; }

$ gcc test.c -o test  # 编译
$ ./test              # 执行test,然后任意输入一串字符后按回车,如zhaogang.com
$ ls /data/imcorefile # 在此目录下如果生成了相应的core文件core-test-*,代表成功

关闭

core文件比较大,有些时候希望关闭这个功能,节省存储空间

$ ulimit -c 				# 查看core dump状态,0代表关闭,unlimited代表打开
$ vim /etc/profile 	# 加入如下一句话

# No core files by default
ulimit -S -c 0 > /dev/null 2>&1 

# 重新打开终端
$ ulimit -c  # 如果输出0,代表关闭成功,如果要重新启用,把上面那句话注释,重新打开终端即可

附录:GDB常用命令

gdb-commands.png

启动调试

从CoreDump文件

# 举例 gdb msg_server ../imcorefile/core-db_proxy_server-11-1999-1999-19388-1597307792
$ gdb [application_file] [core_dump]

附加正在运行的进程

  1. 附加
$ gdb attach pid   # 附加调试
  1. 观察
$ bt                 # 打印当前堆栈
$ thread apply all b # 打印当前所有线程的堆栈

显示源码

$ list <linenum>    # 行号
$ list <function>   # 函数名
$ list <filename:function>  # 文件加函数名
$ list <filename:linenum>   # 文件加行号

断点

添加

$ b src/main.cpp:127            # 普通断点
$ b src/main.cpp:127 if cnt==10 # 条件断点
$ b *0x400522          # 数据断点(当该地址上的内容发生改变时就会触发断点)
$ b &变量名             # 数据断点
$ b funcName           # 函数断点
$ info break           # 断点列表

ps: b 是 break的简写,也可以直接使用break打断点

删除

$ d break <数字>  # 删除某个断点      
$ d              # 删除所有断点

ps: d 是 delete的简写,也可以直接使用delte

单步

$ next   # 单步跟踪,如果有函数调用,他不会进入该函数
$ step   # 单步跟踪,如果有函数调用,他会进入该函数
$ finish # 运行程序,直到当前函数完成返回

继续

$ continue  # 简写c,恢复程序运行,直到程序结束,或是下一个断点到来
$ Command+C # 退出,可以输入其他命令

打印值

$ p 变量

调用函数

利用p打印的功能。

语法为:

$ p function() # 如果有返回值,需要适当转换成gdb能识别的基本类型,否则gdb会报错

举例:删除epoll事件

C++代码

// 5: epoll的句柄
// 6: socket句柄
::epoll_ctl(5,EPOLL_CTL_DEL,6,nullptr);

翻译成gdb 命令如下:

# 5: epoll的句柄
# 2: EPOLL_CTL_DEL
# 6: socket句柄
$ p (int)(::epoll_ctl(5,2,6,0))

举例:添加epoll事件

C++代码

strucht epoll_event ev{};
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listenFd;
ret = ::epoll_ctl(epFd, EPOLL_CTL_ADD, listenFd, &ev);

翻译成gdb命令如下:

$ p (int)(::epoll_ctl(5, 1, 6, (epoll_event *) ({(char)01, (char)00, (char)00, (char)0x80, (char)06, (char)00, (char)00, (char)00, (char)00, (char)00, (char)00, (char)00})))

PS: epoll_event*后面的是C++11的写法,可以在内存中构建一个字节数组,按照epoll_event结构依次存放,这样就可以传递一个结构体给epoll_ctl()函数。

参考