本文分为两个大模块,第一部分记录下本人常用到的GDB的调试方法和技巧,第二部分则尝试分析GDB调试的底层原理。
一、GDB调试
要让程序能被调试,首先得编译成debug版本,当然release版本的也能通过导入符号表来实现调试,目前没试过。
GDB打断点用break命令,一般简写b,断点有多种形式。
1.1 行断点
可以在指定的文件的指定行里打断点,形式是:break 源文件名字 : 行号,比如:
b source.cpp:22
1.2 函数断点
感觉更常用的是函数断点,因为我们在定位问题的时候,往往定位到某个关键函数,该函数可能被多次调用,被调用的位置也很多,那么用行断点就不太方便了,GDB可以给一个函数打上断点,打上断点后,用continue,简写c,程序执行
b source.cpp:22 if i == 12
到函数被调用处就会阻塞,而不需要关注它在哪个文件哪一行被调用了。用法如下:
b func1
当进程阻塞在这之后,我们就可以用step命令,简写s,来进入该函数内部,然后进一步用next或step来跟踪函数里面的代码
1.3 条件断点
在调试一些循环语句中,我们有时候需要观察某自增变量达到一个特定值的时候,代码的行为,这个时候就需要条件变量,比如for循环语句里,我们只想在i == 12的时候观察程序的运行,那么就可以在断点位置后面加上一个触发条件,比如:
b source.cpp:22 if i == 12
那么程序只会在i==12的时候阻塞,在i取其它值的时候,程序可以正常运行
1.4 多线程调试
当程序有多个线程的情况时,某个函数可能会被多个线程调用我们可以先用info threads查看线程编号,然后再限定下哪个线程指定到这里需要阻塞,比如我们指定编号为3的线程:
break source.cpp : 22 thread 3
或
break func1 thread 3
或者我们指定仅运行当前线程,如下:
set scheduler-locking on
on就是打开,off关闭后就是运行所有线程。
注意:一般是用step进入到函数里面,只想跟踪该函数内部的执行时才使用该命令,否则其余情况线程不能切换,可能对调试会造成麻烦。
从语句就可以看出,它的意思就是设置(线程)调度关闭/开启。
因为在大型工程里面,一个函数被多个线程调用,而那些线程调用我们这个目标函数具体做什么事情我们并不关心,我们只需要在当前的线程里,(该函数也可能在一个循环里多次调用,服务器进程经常有这种情况),当前面几次函数执行完还没有达到我们想要的结果时,如果发生了线程切换,那将很麻烦,而限定程序不切换线程,那么一直执行当前这个线程,那就更好定位问题了。
比如调试某基于PG内核的数据库的SQL入口函数的时候,该函数会被十多个线程调用,而我的问题出现在主线程上,所以我需要设置线程不切换。
另外,在多进程情况下(有fork()时),GDB默认模式下,只能调试这个父进程不会跟踪子进程,不过可以设置,命令:set follow-fork-child,这样就会跟踪子进程了
1.5 删除断点和忽略断点
用info break查看断点信息,每个断点都有个编号,当某些断点不需要时,我们可以用delete删除它,,比如删除断点3:
delete 3
也可以将某行代码上的所有断点都清除,clear:
clear source.cpp : 22
如果只是暂时忽略某个断点,还可以设置忽略次数,比如忽略断点3一共12次,ignore:
ignore 3 12
2. next和step
next简写成n,当执行到某一行我们想要继续往下一行代码走时就可以用该命令;
step简写成s,它也是单步执行,与next不同的是1,如果当前代码行是调用了某个函数,那么step会进入该被调用的函数里面,一般比较接近我们的问题相关的代码时,就可以用step进入函数内部,再单步调试。
3. 查看栈帧
在多线程环境下,因为每个线程都有一个栈,所以首先得切换线程,info threads查看线程编号,加入要切换到的线程是3号,那么thread 3即可切换到3号线程。如果前面设置了关闭线程切换,那就不用管。
查看栈帧的命令是backtrace,简写bt。它会依次从栈顶往栈底列出当前线程的栈帧,如下所示,#0即是栈顶,也就是说,当前线程正在执行exec_simple_query()函数,而且我们可以看到该函数被传入的参数的值
3.1回退栈帧
使用up n和down n可以对栈帧进行回退和前进,想改变当前调试的函数时很好用。如当前在栈帧0处,那么 up 5就会切换到栈帧#5处(up叫up但却是往栈底走的,为了不记忆错乱,记成它会走到栈帧序号更大的栈帧),再down 4,那么就到了栈帧#1的地方
需要C/C++ Linux服务器架构师学习资料加qun579733396获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
4. attach和detach
我们经常需要调试一个已经在运行的进程,一般先用top命令查看其进程号,或者ps -ef | grep 进程名字查看,其中-ef可以把前台、后台的进程都展示出来。
查询到PID之后,就用gdb attach PID调试该进程;注意,调试完该进程后,用detach命令分离被调试进程和gdb,这样该程序将不再受gdb的控制,而gdb也可以继续去attach其它进程。
如果没有detach,那么当我们杀死gdb进程的时候,被调试的进程也会被杀死。
看看GDB的官方文档对detach的描述:
detach When you have finished debugging the attached process, you can use the detach command to release it from GDB control. Detaching the process continues its execution. After the detach command, that process and GDB become completely independent once more, and you are ready to attach another process or start one with run. detach does not repeat if you press RET again after executing the command. If you exit GDB or use the run command while you have an attached process, you kill that process. By default, GDB asks for confirmation if you try to do either of these things; you can control whether or not you need to confirm by using the set confirm command (see section Optional warnings and messages).
5. handle信号处理
GDB在调试进程的时候,可能会受到来自进程的各种信号,这个时候我们需要定义下GDB遇到某种信号时,做某种处理,其语法格式为:
handle 信号类型 处理方式
比如我调试PG内核的时候,就会收到SIGUSR2,这是用户自定义信号,某个进程收到该信号时,默认的处理方式是进程终止,因此当没有在gdb调试前设置针对该信号的处理方式时,输入c后,调试并没有正常进行,而是停了下来,并且打印了一些信息,这个时候就需要使用handle来处理SIGUSR2信号,如下:
handle SIGUSR2 nostop noprint
然后再输入c去continue,就能正常进行调试了。
6. 查看代码
gdb attach 进程之后,执行layout src会出现两个窗口,上方窗口用于看代码,开了两个窗口不能上下切换查看历史命令。
可以切换两个窗口间焦点,用fs next,这样就可以使用上下键查看历史命令了。
7. 查看函数汇编代码
disassemble funcName
8. 内存泄漏
像数据库内核这种代码量庞大的项目,可以用静态代码检测工具去检测内存泄漏。
如果要在中小型项目中用GDB调试的时候去帮助判断是否发生内存泄漏的话,可以给malloc/free或者自己封装的内存申请/释放函数打上断点,并且打印对应的指针的值,可以设置跟踪变量,比如malloc返回的指针p进行跟踪:watch p,因为它如果被释放并且被置空的话,最后是可以看到该变量为0x0的。
还可以在GDB中call一下glibc库函数:malloc_stats()函数可以统计本进程具体的内存使用情况,精确到字节,观察in use bytes 的数值变化。
二、GDB调试原理
GDB能够对程序进行调试,源自于一个系统调用:ptrace
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
第一个参数request 参数指定了我们要使用 ptrace 的什么功能。
2.1 调试一个可执行程序test
用GDB去运行一个程序,比如gdb ./test,或者是先进入gdb,再执行./test运行程序test,第一个参数就是PTRACE_TRACEME,顾名思义,就是“跟踪我”。
参数 pid 表示的是要跟踪进程的 pid, addr 表示要监控的被跟踪子进程的地址。
这个时候,原理就是开启一个GDB进程,然后GDB进程fork出一个子进程,让子进程执行PTRACE_TRACEME,然后子进程再调用execve(),如下图
此时,GDB进程及其子进程就可以读写test进程的指令空间、数据空间、堆栈和寄存器的值。而且gdb进程接管了test进程的所有信号,也就是说系统向test进程发送的所有信号,都被gdb进程接收到。
(其实应该是内核给gdb的子进程发信号,然后该进程给其父进程即GDB进程发信号,父子进程间通信很容易)
2.2 GDB调试一个已经存在的程序即gdb attach原理
我们用gdb attach PID的时候,ptrace第一个参数传入的就是PTRACE_ATTACH,这是父进程调用 attach 到已经运行的子进程中;这个命令会有权限的检查, 普通用户进程不能 attach 到 root 进程中,但一般调试的都是普通用户进程,所以也没遇到过问题。
这个过程就是:运行一个GDB进程,他调用ptrace()尝试去attach附着目标进程test,此时GDB需要给test进程发送一个信号SIGSTOP,要求test停止,这个信号是不能忽略的,然后test进程就进入TASK_STOPED状态,(用top -u 用户名可以看到被gdb attach的进程如果没有continue的话,其进程状态是t,这个就是暂停或被跟踪),然后之后状态是被跟踪状态TASK_TRACED,这个不重要,反正状态都是t,而不是Run。
这个过程的示意图如下
2.3 GDB断点原理
在某行代码处打一个断点,其实就是将该行代码的汇编 (是指令级别!!!)用INT 3中断指令代替,原来的代码被保存到“断点链表”中。
这个是软中断,硬中断是外设给CPU中断,让CPU停下,这个是内核在CPU待执行指令中插入的中断指令 (勘误,CPU执行到int 3中断指令才不会停下,CPU只是个执行指令的机器它不会自己停下,只不过此时执行中断指令,然后CPU被操作系统内核代码占据,也就是进入所谓的CPU的内核态,然后内核会进行补不同进程的调度),所以是软中断。(都是让CPU收到中断指令,只是看是硬件发的还是软件发的)
INT n这种中断指令,CPU执行到这里时,内核调用相应的中断处理程序,对于INT 3,那就是当前进程test停止运行,将CPU交给GDB进程用。
INT 3 是x86系列处理器提供的专门用来支持调试的指令。简单地说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析
这里还有个细节,就是运行到中断指令的话,这句指令不是执行完了吗,那我们到断点处,是怎么继续运行该断点处的代码的?
实际上,CPU轮到GDB进程后,GDB会去断点链表里找到原先的汇编指令(源代码也一样),将断点那一行的INT 3又替换回原先的代码,而且让PC指针回退回该行。
所以我们想执行断点处的代码的话,输入指令n,就行了,而不是直接执行断点的下一行。
PC:Program Counter,是通用寄存器,但是有特殊用途,用来指向当前运行指令的下一条指令