作者:京东科技 孙晓军

1. GDB介绍

GDB是GNU Debugger的简称,其作用是能够在程序运转时,检测程序正在做些什么。GDB程序自身是运用C和C++程序编写的,但能够支持除C和C++之外许多编程言语的调试。GDB原生支持调试的言语包括:

•C

•C++

•D

•Go

•Object-C

•OpenCL C

•Fortran

•Pascal

•Rust

•Modula-2

•Ada

此外,经过扩展GDB,也能够用来调试Python言语。

运用GDB,咱们能够方便地进行如下使命:

•假如程序崩溃后产生了core dump文件,gdb能够经过剖析core dump文件,找出程序crash的方位,调用仓库等用于找出问题原因的关键信息

•在程序运转时,GDB能够检测当时程序正在做什么事情

•在程序运转时,修正变量的值

•能够使程序在特定条件下中止

•监视内存地址变动

•剖析程序Crash后的core文件

GDB是了解三方中间件,无源码程序,处理程序疑难杂症的利器。运用GDB,能够了解程序在运转时的方方面面。尤其关于在测验(Test),集成(SIT),检验(UAT),预发布(Staging)等环境下的问题查询和处理,GDB有着日志无法比拟的优势。此外,GDB还十分适合对多种开发言语混合的程序进行调试。

GDB不适合用来做什么:

•GDB能够用来辅佐调试内存走漏问题,但GDB不能用于内存走漏检测

•GDB能够用来辅佐程序性能调优,但GDB不能用于程序性能问题剖析

•GDB不是编译器,不能运转有编译问题的程序,也不能用来调试编译问题

2. 装置GDB

2.1. 从已发布的二进制包装置

在根据Debian的Linux体系,能够运用apt-get指令方便地装置GDB

apt-get update
apt-get install gdb

2.2. 从源代码装置

前置条件

# 装置必要的编译东西
apt-get install build-essential

首要,咱们需求下载GDB的源码。官网下载源码的地址是:
ftp.gnu.org/gnu/gdb/

# 下载源代码
wget http://ftp.gnu.org/gnu/gdb/gdb-9.2.tar.gz
# 解压装置包
tar -xvzf gdb-9.2.tar.gz
# 编译GDB
cd gdb-7.11
mkdir build
cd build
../configure
make
# 装置GDB
make install
# 检查装置成果
gdb --version //输出

3. 预备运用GDB

3.1. 在docker容器内运用GDB

GDB需求运用ptrace 办法发送PTRACE_ATTACH恳求给被调试进程,用来监视和操控另一个进程。

Linux 体系运用
/proc/sys/kernel/yama/ptrace_scope设置来对ptrace施加安全操控。默许ptrace_scope的设置的值是1。默许设置下,进程只能经过PTRACE_ATTACH恳求,附加到子进程。当设置为0时,进程能够经过PTRACE_ATTACH恳求附加到任何其它进程。

在docker容器内,即使是root用户,仍有或许没有修正这个文件的权限。使得在运用GDB调试程序时会产生“ptrace: Operation not permitted “错误。

为了处理docker容器内运用GDB的问题,咱们需求运用特权形式运转docker容器,以便取得修正
/proc/sys/kernel/yama/ptrace_scope文件的权限。

# 以特权形式运转docker容器
docker run --privileged xxx
# 进入容器,输入如下指令改变PTRACE_ATTACH恳求的约束
echo 0 > /proc/sys/kernel/yama/ptrace_scope

3.2. 启用生成core文件

默许状况下,程序Crash是不生成core文件的,由于默许答应的core文件巨细为0。

为了在程序Crash时,能够生成core文件来协助排查Crash的原因,咱们需求修正答应的core文件巨细设置

# 检查当时core文件巨细设置
ulimit -a 
# 设置core文件巨细为不约束
ulimit -c unlimited
# 封闭core文件生成功能
ulimit -c 0

修正core文件设置后,再次检查core文件的设置时,会看到下面的成果

程序调试利器——GDB使用指南

这样,当程序Crash时,会在程序地点的目录,生成名称为core.xxx的core文件。

当程序运转在Docker容器内时,在容器内进行上述设置后,程序Crash时依然无法生成core文件。这时需求咱们在Docker容器的宿主机上,清晰指定core文件的生成方位。

# 当程序Crash时,在/tmp目录下生成core文件
echo '/tmp/core.%t.%e.%p' > /proc/sys/kernel/core_pattern

设置中的字段的含义如下:

•/tmp 寄存core文件的目录

•core 文件名前缀

•%t 体系时间戳

•%e 进程名称

•%p 进程ID

3.3. 生成调试符号表

调试符号表是二进制程序和源代码的变量,函数,代码行,源文件的一个映射。一套符号表对应特定的一套二进制程序,假如程序产生了改变,那么就需求一套新的符号表。

假如没有调试符号表,包括代码方位,变量信息等许多调试相关的能力和信息将无法运用。在编译时参加-ggdb编译选项,就会在生成的二进制程序中参加符号表,此刻生成的二进制程序的巨细会有显著的添加。

-ggdb 用来生成针对gdb的调试信息,也能够运用-g来代替

另外,只需条件答应,建议运用-O0来封闭编译优化,用来防止调试时,源代码和符号表对应不上的古怪问题。

-O0 封闭编译优化

3.4. 运用screen来康复会话

GDB调试依赖于GDB操控台来和进程进行交互,假如咱们的衔接终端封闭,那么本来的操控台就没有办法再运用了。此刻咱们能够经过敞开另一个终端,封闭之前的GDB进程,并从头attach到被调试进程,但此刻的断点,监视和捕获都要从头设置。另一种办法就是运用screen。运用screen运转的程序,能够完全康复之前的会话,包括GDB操控台。

# 装置screen
apt install screen
# 检查装置成果
screen -v //output: Screen version 4.08.00 (GNU) 05-Feb-20
# 运用screen发动调试
screen gdb xxx
# 检查screen会话列表
screen -ls
# 康复screen会话
screen -D -r [screen session id]

4. 发动GDB的几种办法

4.1. 运用GDB加载程序,在GDB指令行发动运转

这是经典的运用GDB的办法。程序能够经过GDB指令的参数来加载,也能够在进入GDB操控台后,经过file指令来加载。

# 运用GDB加载可履行程序
gdb [program]
# 运用GDB加载可履行程序并传递指令行参数
gdb --args [program] [arguments]
# 开端调试程序
(gdb) run
# 传递指令行参数并开端调试程序
(gdb) run arg1 arg2
# 开端调试程序并在main函数进口中止
(gdb) start
# 传递指令行参数,开端调试程序并在main函数进口中止
(gdb) start arg1 arg2

4.2. 附加GDB到运转中的进程

GDB能够直接经过参数的办法,附加到一个运转中的进程。也能够在进入GDB操控台后,经过attach指令附加到进程。

需求留意的是一个进程只答应附加一个调试进程,假如被调试的进程当时现已出于被调试状况,那么要么经过detach指令来免除另一个GDB进程的附加状况,要么强行完毕当时附加到进程的GDB进程,否则不能经过GDB附加另一个调试进程。

# 经过GDB指令附加到进程
gdb --pid [pid]
# 在GDB操控台内,经过attach指令附加的进程
gdb
(gdb) attach [pid]

4.3. 调试core文件

在程序Crash后,假如生成了core文件,咱们能够经过GDB加载core文件,调试产生反常时的程序信息。core文件是没有约束当时机器相关信息的,咱们能够拷贝core文件到另一台机器进行core剖析,但前提是产生core文件的程序的符号表,需求和剖析core文件时加载的程序的符号表保持一致。

运用GDB调试core文件

# 运用GDB加载core文件进行反常调试
gdb --core [core file] [program]

4.4. 运用GDB加载程序并自动运转

在自动化测验场景中,需求程序能够以非中止的办法流畅地运转,一起又期望附加GDB,以便随时能够了解程序的状况。这时咱们能够运用–ex参数,指定GDB完成程序加载后,自动运转的指令。

# 运用GDB加载程序,并在加载完成后自动运转run指令
gdb --ex r --args [program] [arguments]

5. 运用GDB

5.1. 你好,GDB

咱们先从一个Hello world的比方,经过GDB设置断点来调试程序,近距离接触下GDB。

首要运用记事本或其它东西编写下面的main.cc代码:

#include <iostream>
#include <string>
int main(int argc, char *argv[]) {
  std::string text = “Hello world”;
  std::cout << text << std::endl;
  return 0;
}

接下来咱们运用g++编译器编译源码,并设置-ggdb -O0编译选项。

g++ -ggdb -O0 -std=c++17 main.cc -o main

生成可履行程序后,咱们运用GDB加载可履行程序,并设置断点。

# 运用gdb加载main
gdb main
# 在main.cc源文件的第六行设置断点
(gdb) b main.cc:6
# 运转程序
(gdb) run

之后,程序会运转到断点方位并停下来,接下来咱们运用一些常用的GDB指令来检查程序的当时状况

# 输出text变量数据 “Hello world“
(gdb) p text
# 输出局部变量列表,当时断点方位只需一个text局部变量
(gdb) info locals
# 输出当时栈帧的办法参数,当时栈帧函数是main,参数包括了argc和argv 
(gdb) info args
# 检查仓库信息,当时只需一个栈帧 
(gdb) bt
# 检查当时栈帧附近的源码
(gdb) list
# 持续运转程序
(gdb) c
# 退出GDB
(gdb) q

程序调试利器——GDB使用指南

5.2. Segmentation Fault问题排查

Segmentation Fault是进程拜访了由操作体系内存保护机制规定的受限的内存区域触发的。当产生Segmentation Fault反常时,操作体系经过建议一个“SIGSEGV”信号来停止进程。此外,Segmentation Fault不能被反常捕捉代码捕获,是导致程序Crash的常见诱因。

关于C&C++等贴近操作体系的开发言语,由于供给了灵敏的内存拜访机制,所以自然成为了Segmentation Fault反常的重灾区,由于默许的Segmentation Fault反常几乎没有具体的错误信息,使得开发人员处理此类反常时变得更为棘手。

在实践开发中,运用了未初始化的指针,空指针,现已被回收了内存的指针,栈溢出,堆溢出等办法,都会引发Segmentation Fault。

假如启用了core文件生成,那么当程序Crash时,会在指定方位生成一个core文件。经过运用GDB对core文件的剖析,能够协助咱们定位引发Segmentation Fault的原因。

为了模仿Segmentation Fau咱们首要在main.cc中添加一个自定义类Employee

class Employee{
public:
    std::string name;
};

然后编写代码,模仿运用已回收的指针,从而引发的Segmentation Fault反常

void simulateSegmentationFault(const std::string& name) {
    try {
        Employee *employee = new Employee();
        employee->name = name;
        std::cout << "Employee name = " << employee->name << std::endl;
        delete employee;
        std::cout << "After deletion, employee name = " << employee->name << std::endl;
    } catch (...) {
        std::cout << "Error occurred!" << std::endl;
    }
}

终究,在main办法中,添加对simulateSegmentationFault办法的调用

在main办法中,添加对simulateSegmentationFault办法的调用

int main(int argc, char *argv[]) {
  std::string text = "Hello world";
  std::cout << text << std::endl;
  simulateSegmentationFault(text);
  return 0;
}

编译并履行程序,咱们会得到如下的运转成果

$ ./main
Hello world
Employee name = Hello world
Segmentation fault (core dumped)

从成果上来看,首要咱们的反常捕获代码关于Segmentation Fault力不从心。其次,产生反常时没有打印任何对咱们有协助的提示信息。

由于代码十分简略,从日志上很简略了解到问题产生在”std::cout << “After deletion, employee name = ” << employee->name << std::endl;” 这一行。在实践运用中,代码和调用都十分杂乱,许多时分仅经过日志没有办法准确定位反常产生的方位。这时,就轮到GDB进场了

# 运用GDB加载core文件
gdb --core [core文件路径] main
//关于没有生成core文件的状况,请参阅3.2. 启用生成core文件

程序调试利器——GDB使用指南

留意其间的”Reading symbols from main..”,假如接下来打印了找不到符号表的信息,阐明main程序中没有嵌入调试符号表,此刻变量,行号,等信息均无法获取。若要生成调试符号表,能够参阅 “3.3. 生成调试符号表”。

成功加载core文件后,咱们首要运用bt指令来检查Crash方位的错误仓库。从仓库信息中,能够看到__GI__IO_fwrite办法的buf参数的值是0x0,这显然不是一个合法的数值。序号为5的栈帧,是产生反常前,咱们自己的代码压入的终究一个栈帧,信息中甚至给出了产生问题时的调用方位在main.cc文件的第15行(main.cc:15),咱们运用up 5 指令向前移动5个栈帧,使得当时处理的栈帧移动到编码为5的栈帧。

# 显现反常仓库
(gdb) bt
#向上移动5个栈帧
(gdb) up 5

程序调试利器——GDB使用指南

此刻能够看到传入的参数name是没有问题的,运用list指令检查下问题调用部分的上下文,再运用info locals指令检查调用时的局部变量的状况。终究运用 p *employe指令,检查employee指针指向的数据

# 显现一切的参数
(gdb) info args
# 显现栈帧地点方位的上下文代码
(gdb) list
# 显现一切的局部变量
(gdb) info locals
# 打印employee指针的数据
(gdb) p *employee

程序调试利器——GDB使用指南

此刻能够看到在main.cc代码的第15行,运用std::cout输出Employee的name特点时,employee指针指向的地址的name特点现已不再是一个有用的内存地址(0x0)。

5.3. 程序堵塞问题排查

程序堵塞在程序运转中是十分常见的现象。并不是一切的堵塞都是程序产生了问题,堵塞是否是一个要处理的问题,在于咱们关于程序堵塞的预期。比方一个服务端程序,当完成了必要的初始化后,需求堵塞主线程的持续履行,防止服务端程序履行完main办法后退出。就是正常的符合预期的堵塞。可是假如是一个客户端程序,履行完了一切的使命后在需求退出的时分,还处于堵塞状况无法封闭进程,就是咱们要处理的程序堵塞问题。除了上面说到的程序退出堵塞,程序堵塞问题一般还包括:

•并发程序中产生了死锁,线程无法获取到锁目标

•长途调用长期堵塞无法回来

•程序长期等候某个事情告诉

•程序产生了死循环

•拜访了受限的资源和IO,出于排队堵塞状况

关于大多数堵塞来说,被堵塞的线程会处于休眠状况,放置于等候队列,并不会占用体系的CPU时间。但假如这种行为不符合程序的预期,那么咱们就需求查明程序当时在等候哪个锁目标,程序堵塞在哪个办法,程序在拜访哪个资源时卡住了等问题.

下面咱们经过一个等候锁开释的堵塞,运用GDB来剖析程序堵塞的原因。首要引入线程和互斥锁头文件

#include <thread>
#include <mutex>

接下来咱们运用两个线程,一个线程负责加锁,另一个线程负责解锁

std::mutex my_mu;
void thread1_func() {
    for (int i = 0; i < 5; ++i) {
        my_mu.lock();
        std::cout << "thread1 lock mutex succeed!" << std::endl;
        std::this_thread::yield();
    }
}
void thread2_func() {
    for (int i = 0; i < 5; ++i) {
        my_mu.unlock();
        std::cout << "thread2 unlock mutex succeed!" << std::endl;
        std::this_thread::yield();
    }
}
void simulateBlocking() {
    std::thread thread1(thread1_func);
    std::thread thread2(thread2_func);
    thread1.join();
    thread2.join();
}

终究,从头编译main程序,并在g++编译时,参加lpthread链接参数,用来链接pthread库

g++ -ggdb -O0 -std=c++17  main.cc -o main -lpthread

直接运转main程序,此刻程序大概率会堵塞,并打印出类似于如下的信息

程序调试利器——GDB使用指南

为了查询程序堵塞的原因,咱们运用指令把gdb关联到运转中的进程

gdb --pid xxx

进入GDB操控台后,依旧是先运用bt打印当时的仓库信息

# 打印仓库信息
(gdb) bt
# 直接跳转到咱们的代码地点的编号为2的栈帧
(gdb) f 2
# 检查代码
(gdb) list

程序调试利器——GDB使用指南

此刻咱们经过检查仓库信息,知道堵塞的方位是在main.cc的45行,即thread1.join()没有完成。但这并不是引发堵塞的直接原因。咱们还需求持续查询为什么thread1没有完毕

# 检查一切运转的线程
(gdb) info threads
# 检查编号为2的线程的仓库
(gdb) thread apply 2 bt
# 切换到线程2
(gdb) thread 2

程序调试利器——GDB使用指南

由于示例程序比较简略,一切运转的线程只需两个,咱们能够很简略地找到咱们需求具体查询的thread1地点的线程。

当进程当时运转较多线程时,想找到咱们程序中的特定线程并不简略。info threads中给出的线程ID,是GDB的thread id,和thread1线程的id并不相同。而LWP中的线程ID,则是体系赋予线程的唯一ID,相同和咱们在进程内部直接获取的线程ID不相同。这儿咱们经过thread apply指令,直接查询编号为2的线程的仓库信息,确认了其进口函数是thread1_func,正是咱们要找到thread1线程。咱们也能够经过thread apply all bt指令,检查一切线程的仓库信息,用来查找咱们需求的线程。更简略的办法是调用gettid函数,获取操作体系为线程分配的轻量进程ID(LWP)。

接下来,咱们查询thread1的仓库,找到堵塞的方位并查询堵塞的互斥锁my_mu的信息,找到当时持有该锁的线程id(Linux体系线程ID),再次经过info threads查到持有锁的线程。终究发现是由于当时线程持有了互斥锁,当再次恳求获取锁目标my_mu时,由于my_mu不行重入,导致当时线程堵塞,构成死锁。

# 检查thread1的仓库
(gdb) bt
# 直接跳转到咱们的代码地点的栈帧
(gdb) f 4
# 检查锁目标my_mu
(gdb) p my_mu
# 确认持有锁的线程
(gdb) info threads

程序调试利器——GDB使用指南

5.4. 数据篡改问题排查

数据篡改不一定会引发反常,但很或许会导致事务成果不符合预期。关于很多运用了三方库的项目来说,想知道数据在哪里被修正成了什么,并不是一件简略的事。关于C&C++来说,还存在着指针被修正后,导致指针本来指向的目标或许无法回收的问题。单纯运用日志,想要发现一个变量在何时被哪个程序修正成了什么,几乎是不行能的事,经过运用GDB的监控断点,咱们能够方便地查询这类问题。

咱们依然运用多线程形式,一个线程模仿读取数据,当发现数据被修正后,打印一条犯错信息。另一个线程用来模仿修正数据。

这儿咱们运用的Employee目标的原始的name和修正后的name都大于15个字符,假如长度小于这个数值,你将会观察到不一样的成果。

void check_func(Employee& employee) {
    auto tid = gettid();
    std::cout << "thread1 " << tid << " started" << std::endl;
    while (true) {
        if (employee.name.compare("origin employee name") != 0) {
            std::cout << "Error occurred, Employee name changed, new value is:" << employee.name << std::endl;
            break;
        }
        std::this_thread::yield();
    }
}
void modify_func(Employee& employee) {
    std::this_thread::sleep_for(std::chrono::milliseconds(0));
    employee.name = std::string("employee name changed");
}
void simulateDataChanged() {
    Employee employee("origin employee name");
    std::thread thread1(check_func, std::ref(employee));
    std::thread thread2(modify_func, std::ref(employee));
    thread1.join();
    thread2.join();
}

在main办法中,参加simulateDataChanged办法的调用,之后编译并运转程序,会得到如下的成果:

程序调试利器——GDB使用指南

现在,咱们假定修正了name特点的modify_func在一个三方库中,咱们对其内部实现不了解。咱们需求要经过GDB,找到谁动了employee目标的name特点

# 运用gdb加载main
(gdb) gdb main
# 在进入gdb操控台后,在simulateDataChanged办法上添加断点
(gdb) b main.cc:simulateDataChanged
# 运转程序
(gdb) r
# 接连履行两次下一步,使程序履行到employee目标创建完成后
(gdb) n
(gdb) n

程序调试利器——GDB使用指南

之后,咱们对employee.name特点进行监控,只需name特点的值产生了改变,就会触发GDB中止

# 监视employee.name变量对应的地址数据
(gdb) watch -location employee.name
# 持续履行
(gdb) c
# 在触发watch中止后,检查中止地点方位的仓库
(gdb) bt
#直接跳转到咱们的代码地点的栈帧
(gdb) f 1

程序调试利器——GDB使用指南

在触发中止后,咱们发现是中止方位是在modify_func办法中。正是这个办法,在内部修正了employee的name特点。至此查询完毕。

5.5. 堆内存重复开释问题排查

堆内存的重复开释,会导致内存走漏,被损坏的内存能够被攻击者利用,从而产生更为严重的安全问题。目标盛行的C函数库(比方libc),会在内存重复开释时,抛出“double free or corruption (fasttop)”错误,并停止程序运转。为了修复堆内存重复开释问题,咱们需求找到一切开释对应堆内存的代码方位,用来判断哪一个开释堆内存的操作是不正确的。

运用GDB能够处理咱们知道哪一个变量产生了内存重复开释,但咱们不知道都在哪里对此变量开释了内存空间的问题。假如咱们对产生内存重复开释问题的变量一无所知,那么还需求凭借其它的东西来辅佐定位。

下面咱们运用两个线程,在其间开释同一块堆内存,用来模仿堆内存重复开释问题

void free1_func(Employee* employee) {
    auto tid = gettid();
    std::cout << "thread " << tid << " started" << std::endl;
    employee->name = "new employee name1";
    delete employee;
}
void free2_func(Employee* employee) {
    auto tid = gettid();
    std::cout << "thread " << tid << " started" << std::endl;
    employee->name = "new employee name2";
    delete employee;
}
void simulateDoubleFree() {
    Employee *employee = new Employee("origin employee name");
    std::thread thread1(free1_func, employee);
    std::thread thread2(free2_func, employee);
    thread1.join();
    thread2.join();
}

编译程序并运转,程序会由于employee变量的double free问题而停止

程序调试利器——GDB使用指南

现在咱们运用GDB来找到一切开释employee变量堆内存的代码的方位,以便决定那个开释操作是不需求的

# 运用GDB加载程序
gdb main
# 在employee变量创建完成后的方位设置断点
(gdb) b main.cc:101
# 运转程序
(gdb) r

在程序中止后,咱们打印employee变量的堆内存地址,并在一切开释此内存地址的方位添加条件断点之后持续履行程序

# 检查employee变量
(gdb) p employee //$1 = (Employee *) 0x5555555712e0
# 在开释employee变量时,添加条件断点
(gdb) b  __GI___libc_free if mem == 0x5555555712e0
# 持续运转程序
(gdb) c

在程序中止时,咱们找到了开释employee变量堆内存的第一个方位,位于main.cc文件89行的delete employee操作。持续履行程序,咱们会找到另一处开释了employee堆内存的代码的方位。至此,咱们现已能够调整代码来修复此double free问题

程序调试利器——GDB使用指南

6. 常用的GDB指令

程序调试利器——GDB使用指南
程序调试利器——GDB使用指南

总结

GDB是探查查询运转中各种疑难问题的利器。在实践运用中,问题产生的原因一般要杂乱得多。程序或许在标准库中产生了Crash,整个仓库或许都是标准库代码;程序或许由于咱们的代码的操作,终究在三方中间件中产生了问题;整个反常仓库或许都不包括咱们自己开发的代码;面临被三方库不知以何种办法运用的变量。咱们除了需求了解GDB的运用之外,在这些杂乱的实践问题上,咱们还需求尽或许多地了解咱们运用的其它库的机制和原理。