4张图搞清楚gdb工作原理

4张图搞清楚gdb工作原理

目录

大家好,这里是物联网心球。 gdb是Linux开发者经常会用到的调试工具,相信大部分读者都会使用gdb命令来调试程序。那么大家是否好奇:gdb为什么能够调试我们的程序呢?本文我们来一探究竟。

1.gdb是什么?

gdb是用于调试C、C++等语言编写的程序。它可以控制程序的执行,设置断点,查看和修改变量,查看堆栈信息,以及进行反汇编等操作。它还支持多线程和远程调试,帮助开发者快速定位和修复程序中的错误。

2.gdb工作原理

gdb是基于ptrace系统调用实现的调试器,通过ptrace系统调用来监控和控制目标进程的执行流程。我们把gdb程序称为调试器,把待调试的程序称为目标程序。gdb和目标程序为父子关系,即gdb进程为目标进程的父进程。我们通过一张图来直观了解,如图1所示。

4张图搞清楚gdb工作原理 图1

图1 gdb工作原理

当用户执行gdb <可执行文件>命令时,gdb程序会调用fork和execl系统调用创建目标进程(子进程)。目标进程创建成功后,目标进程需要调用ptrace函数请求父进程(gdb程序)接管,如下:

ptrace(PTRACE_TRACEME, 0, NULL, NULL);

gdb程序接管目标进程后,gdb就能够通过ptrace系统调用来控制目标进程。所以理解gdb工作原理的关键在于理解ptrace系统调用。 ptrace是Linux系统中一个功能强大的系统调用,主要用于进程跟踪和调试。它允许一个进程(称为跟踪者或调试器)监视和控制另一个进程(称为被跟踪者或目标进程)的执行,读取和修改其寄存器和内存,以及拦截系统调用。 ptrace函数原型如下:

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request op, pid_t pid,
         void *addr, void *data);

参数说明:

  • request:指定要执行的操作,常见操作见表1:

4张图搞清楚gdb工作原理 图2

表1 request参数

  • pid:目标进程的进程 ID。
  • addr:目标进程的内存地址,用于读取或写入操作。
  • data:根据不同的request操作,data的用途不同。

返回值:long类型,具体含义根据不同的request 操作而变化:

  • PTRACE_PEEKTEXT和PTRACE_PEEKDATA:返回值是读取到的内存内容。

  • 其他操作:0 表示成功,-1表示失败。

关于ptrace的使用,我们会通过实际案例来讲解,这里先跳过,我们先来讲解另外一个比较重要的知识点:SIGTRAP信号。

3.SIGTRAP信号

SIGTRAP信号编号为5。当程序执行到调试器设置的断点时,操作系统会向程序发送SIGTRAP信号。调试器通过捕获这个信号来暂停程序的执行,程序暂停执行后,开发者就能够查看程序的状态,如变量值、调用堆栈等。

当目标进程没有被调试器追踪,内核对SIGTAP信号的默认处理方式为程序退出。反之,内核会暂停目标进程,调试器可通过waitpid系统调用捕捉目标进程的当前状态。

4张图搞清楚gdb工作原理 图3

图2 SIGTRAP信号处理过程

如图2所示,目标进程执行至断点指令或目标进程单步执行时,Linux内核都会发送一个SIGTRAP信号至目标进程。SIGTRAP信号会插入目标进程的未决信号队列。SIGTRAP信号的处理方式和其他信号一样,当目标程序从内核态切换至用户态时,返回的过程中会调用do_signal信号处理函数,该函数将会轮询目标进程未决信号队列,从队列中取出未决信号并执行信号处理函数。SIGTRAP信号处理函数会暂停目标进程,调试器通过waitpid函数能够捕捉到目标进程停止运行。 SIGTRAP信号的作用是暂停目标进程运行,并通知调试器。这样调试器就能够基于SIGTRAP信号实现断点和单步执行等功能。

4.给目标程序打断点

打断点和单步执行是调试器的两个核心功能。为了让大家进一步了解gdb调试器,我们基于ptrace系统调用来实现这两个功能。简单来说,打断点就是将断点指令(0xCC)插入目标进程代码段指定位置。当目标进程执行至断点指令时,内核会发送SIGTRAP信号至目标进程未决信号队列。SIGTRAP信号会使目标进程暂停运行。此时,用户会观察到程序运行至断点处就停止运行。打断点实现原理如图3所示。

4张图搞清楚gdb工作原理 图4

图3 ptrace给目标程序打断点

打断点的函数调用方式如下:

ptrace(PTRACE_POKETEXT, pid, /*代码地址*/, 0xCC)

request请求为PTRACE_POKETEXT,pid设置目标进程pid,代码地址为目标进程代码段指定地址,断点指令为0xCC。代码地址不能随便填写,需要指定一个有效的地址,通常可以直接使用函数名,或者通过PTRACE_PEEKTEXT先获取代码地址,再在当前位置插入断点指令。调试器调用ptrace函数打断点,内核会先获取目标进程task_struct结构,然后通过tast_struct结构找到目标进行虚拟地址空间代码段,再将断点指令写入代码段指定位置。打断点示例代码如下:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/ptrace.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<sys/user.h>
#include<elf.h>

#define INT3 0xCC // 断点指令

void breakpoint_func(){ //函数地址为断点地址
    printf("Child continue\n");
}

int breakpoint_test(){
    pid_t child = fork();
    if (child == 0) { //子进程(目标进程)
        //目标进程被调试器跟踪
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        //目标进程停止运行
        raise(SIGSTOP);
        printf("before breakpoint\n");
        //执行至断点指令
        breakpoint_func();
        printf("after breakpoint\n");
        return 0;
    } else {
        wait(NULL); //等待目标进程暂停
        //获取断点地址
        void *breakpoint_addr = (void *)breakpoint_func;
        printf("sizeof(long)=%lu\n", sizeof(long));
        //获取目标进程断点地址代码指令,64位系统一次获取8个字节
        long orig = ptrace(PTRACE_PEEKTEXT, child, breakpoint_addr, NULL);
        if (orig == -1) {
            perror("ptrace PEEKTEXT failed");
            return -1;
        }
        //采用或运算方式插入断点指令(0xCC)
        if (ptrace(PTRACE_POKETEXT, child, breakpoint_addr, (orig & ~0xFF) | INT3) == -1) {
            perror("ptrace POKETEXT failed");
            return -1;
        }
        //目标进程继续运行
        ptrace(PTRACE_CONT, child, NULL, NULL);
        //处理SIGTRAP信号,等待断点指令执行
        int status;
        pid_t ret = waitpid(child, &status, WUNTRACED | WCONTINUED);
        if (ret == -1) {
            perror("waitpid failed");
            return -1;
        }

        if (WIFSTOPPED(status)) {
            printf("Child stopped by signal %d\n", WSTOPSIG(status));
        }
        //休眠2秒,便于观察断点是否生效
        sleep(2);
        //目标进程继续运行
        ptrace(PTRACE_CONT, child, NULL, NULL);
        wait(NULL);
        printf("Child process exited\n");
    }

    return 0;
}

int main(int argc, char **argv){
    breakpoint_test();
    return 0;
}

5.目标程序单步执行

所谓单步执行,就是目标程序每执行一条指令就暂停运行,内核会发送SIGTRAP信号至目标进程未决信号队列。SIGTRAP信号的处理方式和打断点是一样的。单步执行实现原理如图4所示。

4张图搞清楚gdb工作原理 图5

图4 ptrace单步执行

单步执行的函数调用方式如下:

ptrace(PTRACE_SINGLESTEP, child, 0, 0);

request请求为PTRACE_SINGLESTEP,pid设置目标进程pid。单步执行需要硬件支持。调试器调用ptrace函数控制目标进程单步执行,内核会设置目标进程对应的寄存器单步执行标志,如X86设置的是EFLAGS寄存器的X86_EFLAGS_TF标志。单步执行标志设置完后,目标进程执行一条指令就会暂停运行,内核会发送SIGTRAP信号至目标进程未决信号队列。单步执行示例代码如下:

int singlestep_test(){
    pid_t child = fork();
    if (child == 0) { //子进程(目标进程)
        printf("Child process, PID: %d\n", getpid());
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        raise(SIGSTOP);
        printf("1111111111111111111\n");
        printf("2222222222222222222\n");
        printf("3333333333333333333\n");
        printf("4444444444444444444\n");
    } else {
        while (1) { //循环控制目标进程单步执行
            //每次单步执行间隔100毫秒,便于观察
            usleep(100 * 1000);
            //目标进程单步执行
            ptrace(PTRACE_SINGLESTEP, child, 0, 0);
            int status;
            //处理SIGTRAP信号
            waitpid(child, &status, WUNTRACED);
            if (WIFEXITED(status)) {
                printf("Child exited with status %d\n", WEXITSTATUS(status));
                break;
            }
        }
    }
}

int main(int argc, char **argv){
    singlestep_test();
    return 0;
}
← 返回文章列表