GDB 是C/C++程序的调试器。

编译时需要添加debug信息,添加 -g-g3-ggdb-ggdb3 参数。(带3的会携带宏的信息)

基本使用

启动

gdb <可执行文件>,会进入gdb 交互式环境,可以加-tui参数进入tui环境。此时要调试的程序并没有执行,需要用run命令执行。

gdb attach <pid>,会直接attach到某个进程。也可以先用gdb启动交互式环境再attach <pid>

可以通过tui enable/disable进入或退出tui环境。

断点

  • info b:查看当前设置了哪些断点。
  • b <断点位置> <断点条件>
    • b <函数名称>:在函数入口设置断点。
    • b <行号>:在当前文件的第n行设置断点。
    • b <文件名:行号>
    • b <断点位置> if x == 1:条件断点。
  • delete <断点序号>...:删除断点,序号通过info查看。
  • enable/disable <断点序号>...:启用或禁用断点。

进入断点后:

调试流程控制

  • next/n:下一行,遇到函数调用不会进入
  • step/s:下一步,遇到函数调用会进入
  • finish:完成当前函数,返回到调用当前函数的那行
  • until/u:继续执行到某一行停下。(如果目的点是函数外的话,会先到函数调用处停下)
  • continue/c:跳过此断点。

信息获取

  • print/p: p x打印变量值, p x=1修改变量值。变量可以是寄存器,使用$寄存器名称访问。
  • backtrace/bt:打印调用栈
  • list/l:查看当前行附近代码(l . 列出当前行附近10行;l 列出上次列出最后一行的后10行)
  • info/i: 有许多子命令
    • i args:打印函数参数
    • i thread:查看线程,*代表当前线程

打印内存信息

命令格式: x/<num><type><size> <address>,含义是从起始地址address开始读size大小的内存,以type的类型打印值。重复num次,num>0则每次地址递增,num<0则每次地址递减。

  • num: 可以不指定,默认是1
  • type:
    • a: address
    • t: binary
    • x: 十六进制格式, z: 十六进制,zero padded on the left.
    • d: 十进制格式, ud: unsigned 十进制
    • f: float
    • c: 字符
    • s: 字符串
    • i: instruction 汇编指令
  • size:
    • b: byte
    • h: halfword
    • w: word
    • giant: giant, 8 bytes

汇编

打印汇编指令:disas [/r|/m|/s] (<函数位置>|<开始地址>[, <结束地址>]):

  • 位置格式是:[文件名称::]<函数名>,默认是当前文件。
  • /r代表会展示指令的16进制形式。
  • /m:代表会打印对应的源码,并且指令顺序是按源码行的顺序来的。(regardless of any optimization that is present。This modifier hasn’t proved useful in practice and is deprecated in favor of /s.)
  • /s:代表会打印对应的源码,但是顺序是按指令本身的顺序来的。

参数

可以通过 set <参数名称> <value> 设置参数。使用 show <参数名称>查看参数值。

多线程调试 scheduler-locking

进入断点时这个值才能修改。

  • off:默认值, 在断点处继续往下运行时所有线程均可执行。(注意:当进入断点时所有线程都是不可执行的)
  • on:在断点处继续往下运行时仅当前被调试线程可执行。
  • step:用的较少,这里不展开。

测试用代码(gcc a.c -g -lpthread -o a.out):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
static int a = 0;
static int b = 0;
void *runnable1(void *args) {
    while (1) {
        a++;
        sleep(1);
    }
}

void *runnable2(void *args) {
    while (1) {
        b++;
        sleep(1);
    }
}

int main(void) {
    pthread_t t1, t2;

    pthread_create(&t1, NULL, runnable1, NULL);
    pthread_create(&t2, NULL, runnable2, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

测试过程:

  • a++那行添加断点,运行。
  • 进入断点,查看当前的参数确认是off。打印一下ab的值,假设是a = 0, b = 1。然后继续运行,又进入断点。此时打印值可能会是a = 1, b = 3,说明继续执行时两个线程都在执行。
  • 然后再设置参数为on,继续运行。此时打印值会是a = 2, b = 3,说明这次只有a线程执行了。

多进程调试

  • detach-on-fork: 默认on,当设置为off时,父子进程都会attach(即之前提到的进入断点)
  • follow-fork-mode: 当detach-on-forkon时,执行进程fork时,由这个参数控制attach到parent还是child。默认是 parent
  • i inferiors:查看进程
  • inferior <序号> 切换调试的进程

测试用例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <unistd.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <stdlib.h>

pid_t gettid() {
    return syscall(__NR_gettid);
}
int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        exit(1);
    } else if (pid == 0) {
        printf("in child, pid: %d, tid:%d\n", getpid(), gettid());
        exit(0);
    } else {
        printf("in parent, pid: %d, tid:%d\n", getpid(), gettid());
    }
    return 0;
}
  • 首先在fork的后一行添加断点。运行一次,此时会先打印出in child。说明目前调试的是父进程,c继续运行。
  • 修改follow-fork-mode为child。运行一次,此时会先打印出in parent。说明目前调试的是子进程,c继续运行。
  • 再修改detach-on-forkoff,运行一次。此时会先进入parent,但是child已经创建了并且也是attach的。使用i inferiors查看当前attach的进程,此时会有两个进程。通过inferior <子进程序号> 可以切换到子进程。会发现子进程并不是停留在fork的下一行,而是在fork函数内部。

原理

首先需要了解一下ptrace系统调用。

ptrace系统调用

函数签名:long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data)

  • request:指定执行的动作
    • PTRACE_TRACEME: 让父进程追踪当前进程
    • PTRACE_PEEKTEXT、PTRACE_PEEKDATA:读取进程内存
    • PTRACE_POKETEXT、PTRACE_POKEDATA:修改进程内存
    • PTRACE_CONT:让进程继续执行
    • PTRACE_GETSIGINFO: 获取进程的signal信息
    • PTRACE_SINGLESTEP: 单步执行一条汇编指令
    • PTRACE_ATTACH: 当前进程动态地attach到目标进程
  • pid:被追踪的进程的进程号
  • addr:这个参数一般是地址值,指示要读取或写入的进程内存地址。
  • data:根据 request 的类型,这个参数可能用于提供数据,例如写入目标进程的内存,或者用于接收某些类型的输出。

gdb 通过预先指定elf文件进行调试:

  1. gdb 运行被调试的程序时是通过fork生成子进程,然后子进程调用exec替换运行程序为被调试的程序。
  2. 随后子进程会调用ptrace PTRACE_TRACEME 让gdb能够trace被调试程序。

断点的原理:

  • 当设置一个断点时会将断点所在的汇编指令替换为INT 3指令,当被调试的进程执行到这个命令时,会收到中断信号SIGTRAP被暂停。而父进程先前执行的waitpid会返回,通过PTRACE_GETSIGINFO能够拿到是否是由SIGTRAP。如果是,说明进入了断点,此时响应用户的操作如获取被调试进程的相关信息。并且设置断点的同时gdb会将原指令保存起来,当继续运行时则将这个指令写回。读取指令需要用到PTRACE_PEEKDATA、而写入用到PTRACE_POKEDATA。

DWARF

它是gcc编译时记录在elf文件中的用于调试的信息,调试器能利用这些数据找到指令与源码行的关系。

dwarfdump elf文件 可以查看elf文件中的dwarf信息。

另外dwarf信息可以存放在独立的文件中,调试的时候引入,这样就不用增大elf文件的大小。