天天看点

看懂GDB调试核心:剖析ptrace原理及其应用场景!

作者:嵌入式Linux内核

前言:在程序出现bug的时候,最好的解决办法就是通过 GDB 调试程序,然后找到程序出现问题的地方。比如程序出现 段错误(内存地址不合法)时,就可以通过 GDB 找到程序哪里访问了不合法的内存地址而导致的。

本文不是介绍 GDB 的使用方式,而是大概介绍 GDB 的实现原理,当然 GDB 是一个庞大而复杂的项目,不可能只通过一篇文章就能解释清楚,所以本文主要是介绍 GDB 使用的核心的技术 - ptrace。

一、ptrace系统调用

ptrace() 系统调用是Linux提供的一个调试进程的工具,ptrace() 系统调用非常强大,它提供非常多的调试方式让我们去调试某一个进程,下面是 ptrace() 系统调用的定义:

long ptrace(enum __ptrace_request request,  pid_t pid, void *addr,  void *data);           

下面解释一下 ptrace() 各个参数的作用:

  • request:指定调试的指令,指令的类型很多,如:PTRACE_TRACEME、PTRACE_PEEKUSER、PTRACE_CONT、PTRACE_GETREGS等等,下面会介绍不同指令的作用。
  • pid:进程的ID(这个不用解释了)。
  • addr:进程的某个地址空间,可以通过这个参数对进程的某个地址进行读或写操作。
  • data:根据不同的指令,有不同的用途,下面会介绍。

要自己动手写 strace 的第一步就是了解 ptrace() 系统调用的使用,我们来看看 ptrace() 系统调用的定义:

int ptrace(long request, long pid, long addr, long data);           

ptrace() 系统调用用于跟踪进程的运行情况,下面介绍一下其各个参数的含义:

  • request:指定跟踪的动作。也就是说,通过传入不同的 request 参数可以对进程进行不同的跟踪操作。其可选值有:
  • PTRACE_TRACEME
  • PTRACE_PEEKTEXT
  • PTRACE_POKETEXT
  • PTRACE_CONT
  • PTRACE_SINGLESTEP
  • pid:指定要跟踪的进程PID。
  • addr:指定要读取或者修改的内存地址。
  • data:对于不同的 request 操作,data 有不同的作用,下面会介绍。

前面介绍过,使用 strace 跟踪进程有两种方式,一种是通过 strace 命令启动进程,另外一种是通过 -p 指定要跟踪的进程。

ptrace() 系统调用也提供了两种 request 来实现上面两种方式:

  • 第一种通过 PTRACE_TRACEME 来实现
  • 第二种通过 PTRACE_ATTACH 来实现

本文我们主要介绍使用第一种方式。由于第一种方式使用跟踪程序来启动被跟踪的程序,所以需要启动两个进程。通常要创建新进程可以使用 fork() 系统调用,所以自然而然地我们也使用 fork() 系统调用。

我们新建一个文件 strace.c,输入代码如下:

int main(int argc, char *argv[])
{
    pid_t child;
 
    child = fork();
    if (child == 0) {
        // 子进程...
    } else {
        // 父进程...
    }
 
    return 0;
}           

上面的代码通过调用 fork() 来创建一个子进程,但是没有做任何事情。之后,我们就会在 子进程 中运行被跟踪的程序,而在 父进程 中运行跟踪进程的代码。

运行被跟踪程序

前面说过,被跟踪的程序需要在子进程中运行,而要运行一个程序,可以通过调用 execl() 系统调用。所以可以通过下面的代码,在子进程中运行 ls 命令:

#include <unistd.h>
#include <stdlib.h>
 
int main(int argc, char *argv[])
{
    pid_t child;
 
    child = fork();
    if (child == 0) {
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        // 父进程...
    }
 
    return 0;
}           

execl() 用于执行指定的程序,如果执行成功就不会返回,所以 execl(...) 的下一行代码 exit(0) 不会被执行到。

由于我们需要跟踪 ls 命令,所以在执行 ls 命令前,必须调用 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 来告诉系统需要跟踪这个进程,代码如下:

#include <sys/ptrace.h>
#include <unistd.h>
#include <stdlib.h>
 
int main(int argc, char *argv[])
{
    pid_t child;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        // 父进程...
    }
 
    return 0;
}           

这样,被跟踪进程部分的代码就完成了,接下来开始编写跟踪进程部分代码。

编写跟踪进程代码

如果编译运行上面的代码,会发现什么效果也没有。这是因为当在子进程调用 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 后,并且调用 execl() 系统调用,那么子进程会发送一个 SIGCHLD 信号给父进程(跟踪进程)并且自己停止运行,直到父进程发送调试命令,才会继续运行。

由于上面的代码中,父进程(跟踪进程)并没有发送任何调试命令就退出运行,所以子进程(被跟踪进程)在没有运行的情况下就跟着父进程一起退出了,那么就不会看到任何效果。

现在我们开始编写跟踪进程的代码。

由于被跟踪进程会发送一个 SIGCHLD 信息给跟踪进程,所以我们先要在跟踪进程的代码中接收 SIGCHLD 信号,接收信号通过使用 wait() 系统调用完成,代码如下:

#include <sys/ptrace.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
int main(int argc, char *argv[])
{
    pid_t child;
    int status;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
    }
 
    return 0;
}           

上面的代码通过调用 wait() 系统调用来接收被跟踪进程发送过来的 SIGCHLD 信号,接下来需要开始向被跟踪进程发送调试命令,来对被跟踪进程进行调试。

由于本文介绍怎么跟踪进程调用了哪些 系统调用,所以我们需要使用 ptrace() 的 PTRACE_SYSCALL 命令,代码如下:

#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
int main(int argc, char *argv[])
{
    pid_t child;
    int status;
    struct user_regs_struct regs;
    int orig_rax;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        // 1. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用前,可以获取系统调用的参数)
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        // 2. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用后,可以获取系统调用的返回值)
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
    }
 
    return 0;
}           

从上面的代码可以发现,我们调用了两次 ptrace(PTRACE_SYSCALL, child, NULL, NULL),这是因为跟踪系统调用时,需要跟踪系统调用前的环境(比如获取系统调用的参数)和系统调用后的环境(比如获取系统调用的返回值),所以就需要调用两次 ptrace(PTRACE_SYSCALL, child, NULL, NULL)

后台私信【内核】免费领取
看懂GDB调试核心:剖析ptrace原理及其应用场景!

获取进程寄存器的值

Linux系统调用是通过 CPU寄存器 来传递参数的,所以要想获取调用了哪个系统调用,必须获取进程寄存器的值。获取进程寄存器的值,可以通过 ptrace() 系统调用的 PTRACE_GETREGS 命令来实现,代码如下:

#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
int main(int argc, char *argv[])
{
    pid_t child;
    int status;
    struct user_regs_struct regs;
    int orig_rax;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        // 1. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用前,可以获取系统调用的参数)
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        ptrace(PTRACE_GETREGS, child, 0, ®s); // 获取被跟踪进程寄存器的值
 
        orig_rax = regs.orig_rax; // 获取rax寄存器的值
 
        printf("orig_rax: %d\n", orig_rax); // 打印rax寄存器的值
 
        // 2. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用后,可以获取系统调用的返回值)
        ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
    }
 
    return 0;
}           

上面的代码通过调用 ptrace(PTRACE_GETREGS, child, 0, ®s) 来获取进程寄存器的值,PTRACE_GETREGS 命令需要在 data 参数传入类型为 user_regs_struct 结构的指针,user_regs_struct 结构定义如下(在文件 sys/user.h 中):

struct user_regs_struct {
    unsigned long r15,r14,r13,r12,rbp,rbx,r11,r10;
    unsigned long r9,r8,rax,rcx,rdx,rsi,rdi,orig_rax;
    unsigned long rip,cs,eflags;
    unsigned long rsp,ss;
    unsigned long fs_base, gs_base;
    unsigned long ds,es,fs,gs;
};           

其中 user_regs_struct 结构的 orig_rax 保存了系统调用号,所以我们可以通过 orig_rax 的值来知道调用了哪个系统调用。

编译运行上面的代码,会输出结果:orig_rax: 12,就是说当前调用的是编号为 12 的系统调用。那么编号为 12 的系统调用是哪个系统调用呢?

通过查阅系统调用表,可以知道编号 12 的系统调用为 brk(),如下:

系统调用号     函数名     入口点     源码
...
12            brk       sys_brk    mm/mmap.c
...           

上面的程序只跟踪了一个系统调用,那么怎么跟踪所有的系统调用呢?很简单,只需要把跟踪的代码放到一个无限循环中即可。代码如下:

#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
int main(int argc, char *argv[])
{
    pid_t child;
    int status;
    struct user_regs_struct regs;
    int orig_rax;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        while (1) {
            // 1. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用前,可以获取系统调用的参数)
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
            wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
            if (WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪
                break;
            }
 
            ptrace(PTRACE_GETREGS, child, 0, ®s); // 获取被跟踪进程寄存器的值
 
            orig_rax = regs.orig_rax; // 获取rax寄存器的值
 
            printf("orig_rax: %d\n", orig_rax); // 打印rax寄存器的值
 
            // 2. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用后,可以获取系统调用的返回值)
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
            wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
            if (WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪
                break;
            }
        }
    }
 
    return 0;
}           

从执行结果来看,只是打印系统调用号不太直观,那么我们怎么优化呢?

我们可以定义一个系统调用号与系统调用名的对应表来实现更清晰的输出结果,如下:

#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
struct syscall {
    int  code;
    char *name;
} syscall_table[] = {
    {0, "read"},
    {1, "write"},
    {2, "open"},
    {3, "close"},
    {4, "stat"},
    {5, "fstat"},
    {6, "lstat"},
    {7, "poll"},
    {8, "lseek"},
    ...
    {-1, NULL},
}
 
char *find_syscall_symbol(int code) {
    struct syscall *sc;
 
    for (sc = syscall_table; sc->code >= 0; sc++) {
        if (sc->code == code) {
            return sc->name;
        }
    }
 
    return NULL;
}
 
int main(int argc, char *argv[])
{
    pid_t child;
    int status;
    struct user_regs_struct regs;
    int orig_rax;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        while (1) {
            // 1. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用前,可以获取系统调用的参数)
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
            wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
            if(WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪
                break;
            }
 
            ptrace(PTRACE_GETREGS, child, 0, ®s); // 获取被跟踪进程寄存器的值
 
            orig_rax = regs.orig_rax; // 获取rax寄存器的值
 
            printf("syscall: %s()\n", find_syscall_symbol(orig_rax)); // 打印系统调用
 
            // 2. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用后,可以获取系统调用的返回值)
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
            wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
            if(WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪
                break;
            }
        }
    }
 
    return 0;
}           

上面例子添加了一个函数 find_syscall_symbol() 来获取系统调用号对应的系统调用名,实现也比较简单。编译运行后输出结果如下:

[root@localhost liexusong]$ ./strace
syscall: brk()
syscall: mmap()
syscall: access()
syscall: open()
syscall: fstat()
syscall: mmap()
syscall: close()
syscall: open()
syscall: read()
syscall: fstat()
syscall: mmap()
syscall: mprotect()
syscall: mmap()
syscall: mmap()
syscall: close()
...           

从执行结果来看,现在可以打印系统调用的名字了,但我们知道 strace 命令还会打印系统调用参数的值,我们可以通过 ptrace() 系统调用的 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 来获取参数的值,所以有兴趣的就自己实现这个效果了。

#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
 
struct syscall {
    int  code;
    char *name;
} syscall_table[] = {
    {0, "read"},
    {1, "write"},
    {2, "open"},
    {3, "close"},
    {4, "stat"},
    {5, "fstat"},
    {6, "lstat"},
    {7, "poll"},
    {8, "lseek"},
    {9, "mmap"},
    {10, "mprotect"},
    {11, "munmap"},
    {12, "brk"},
    {13, "rt_sigaction"},
    {14, "rt_sigprocmask"},
    {15, "rt_sigreturn"},
    {16, "ioctl"},
    {17, "pread64"},
    {18, "pwrite64"},
    {19, "readv"},
    {20, "writev"},
    {21, "access"},
    {22, "pipe"},
    {23, "select"},
    {24, "sched_yield"},
    {25, "mremap"},
    {26, "msync"},
    {27, "mincore"},
    {28, "madvise"},
    {29, "shmget"},
    {30, "shmat"},
    {31, "shmctl"},
    {32, "dup"},
    {33, "dup2"},
    {34, "pause"},
    {35, "nanosleep"},
    {36, "getitimer"},
    {37, "alarm"},
    {38, "setitimer"},
    {39, "getpid"},
    {40, "sendfile"},
    {41, "socket"},
    {42, "connect"},
    {43, "accept"},
    {44, "sendto"},
    {45, "recvfrom"},
    {46, "sendmsg"},
    {47, "recvmsg"},
    {48, "shutdown"},
    {49, "bind"},
    {50, "listen"},
    {51, "getsockname"},
    {52, "getpeername"},
    {53, "socketpair"},
    {54, "setsockopt"},
    {55, "getsockopt"},
    {56, "clone"},
    {57, "fork"},
    {58, "vfork"},
    {59, "execve"},
    {60, "exit"},
    {61, "wait4"},
    {62, "kill"},
    {63, "uname"},
    {64, "semget"},
    {65, "semop"},
    {66, "semctl"},
    {67, "shmdt"},
    {68, "msgget"},
    {69, "msgsnd"},
    {70, "msgrcv"},
    {71, "msgctl"},
    {72, "fcntl"},
    {73, "flock"},
    {74, "fsync"},
    {75, "fdatasync"},
    {76, "truncate"},
    {77, "ftruncate"},
    {78, "getdents"},
    {79, "getcwd"},
    {80, "chdir"},
    {81, "fchdir"},
    {82, "rename"},
    {83, "mkdir"},
    {84, "rmdir"},
    {85, "creat"},
    {86, "link"},
    {87, "unlink"},
    {88, "symlink"},
    {89, "readlink"},
    {90, "chmod"},
    {91, "fchmod"},
    {92, "chown"},
    {93, "fchown"},
    {94, "lchown"},
    {95, "umask"},
    {96, "gettimeofday"},
    {97, "getrlimit"},
    {98, "getrusage"},
    {99, "sysinfo"},
    {100, "times"},
    {101, "ptrace"},
    {102, "getuid"},
    {103, "syslog"},
    {104, "getgid"},
    {105, "setuid"},
    {106, "setgid"},
    {107, "geteuid"},
    {108, "getegid"},
    {109, "setpgid"},
    {110, "getppid"},
    {111, "getpgrp"},
    {112, "setsid"},
    {113, "setreuid"},
    {114, "setregid"},
    {115, "getgroups"},
    {116, "setgroups"},
    {117, "setresuid"},
    {118, "getresuid"},
    {119, "setresgid"},
    {120, "getresgid"},
    {121, "getpgid"},
    {122, "setfsuid"},
    {123, "setfsgid"},
    {124, "getsid"},
    {125, "capget"},
    {126, "capset"},
    {127, "rt_sigpending"},
    {128, "rt_sigtimedwait"},
    {129, "rt_sigqueueinfo"},
    {130, "rt_sigsuspend"},
    {131, "sigaltstack"},
    {132, "utime"},
    {133, "mknod"},
    {134, "uselib"},
    {135, "personality"},
    {136, "ustat"},
    {137, "statfs"},
    {138, "fstatfs"},
    {139, "sysfs"},
    {140, "getpriority"},
    {141, "setpriority"},
    {142, "sched_setparam"},
    {143, "sched_getparam"},
    {144, "sched_setscheduler"},
    {145, "sched_getscheduler"},
    {146, "sched_get_priority_max"},
    {147, "sched_get_priority_min"},
    {148, "sched_rr_get_interval"},
    {149, "mlock"},
    {150, "munlock"},
    {151, "mlockall"},
    {152, "munlockall"},
    {153, "vhangup"},
    {154, "modify_ldt"},
    {155, "pivot_root"},
    {156, "_sysctl"},
    {157, "prctl"},
    {158, "arch_prctl"},
    {159, "adjtimex"},
    {160, "setrlimit"},
    {161, "chroot"},
    {162, "sync"},
    {163, "acct"},
    {164, "settimeofday"},
    {165, "mount"},
    {166, "umount2"},
    {167, "swapon"},
    {168, "swapoff"},
    {169, "reboot"},
    {170, "sethostname"},
    {171, "setdomainname"},
    {172, "iopl"},
    {173, "ioperm"},
    {174, "create_module"},
    {175, "init_module"},
    {176, "delete_module"},
    {177, "get_kernel_syms"},
    {178, "query_module"},
    {179, "quotactl"},
    {180, "nfsservctl"},
    {181, "getpmsg"},
    {182, "putpmsg"},
    {183, "afs_syscall"},
    {184, "tuxcall"},
    {185, "security"},
    {186, "gettid"},
    {187, "readahead"},
    {188, "setxattr"},
    {189, "lsetxattr"},
    {190, "fsetxattr"},
    {191, "getxattr"},
    {192, "lgetxattr"},
    {193, "fgetxattr"},
    {194, "listxattr"},
    {195, "llistxattr"},
    {196, "flistxattr"},
    {197, "removexattr"},
    {198, "lremovexattr"},
    {199, "fremovexattr"},
    {200, "tkill"},
    {201, "time"},
    {202, "futex"},
    {203, "sched_setaffinity"},
    {204, "sched_getaffinity"},
    {205, "set_thread_area"},
    {206, "io_setup"},
    {207, "io_destroy"},
    {208, "io_getevents"},
    {209, "io_submit"},
    {210, "io_cancel"},
    {211, "get_thread_area"},
    {212, "lookup_dcookie"},
    {213, "epoll_create"},
    {214, "epoll_ctl_old"},
    {215, "epoll_wait_old"},
    {216, "remap_file_pages"},
    {217, "getdents64"},
    {218, "set_tid_address"},
    {219, "restart_syscall"},
    {220, "semtimedop"},
    {221, "fadvise64"},
    {222, "timer_create"},
    {223, "timer_settime"},
    {224, "timer_gettime"},
    {225, "timer_getoverrun"},
    {226, "timer_delete"},
    {227, "clock_settime"},
    {228, "clock_gettime"},
    {229, "clock_getres"},
    {230, "clock_nanosleep"},
    {231, "exit_group"},
    {232, "epoll_wait"},
    {233, "epoll_ctl"},
    {234, "tgkill"},
    {235, "utimes"},
    {236, "vserver"},
    {237, "mbind"},
    {238, "set_mempolicy"},
    {239, "get_mempolicy"},
    {240, "mq_open"},
    {241, "mq_unlink"},
    {242, "mq_timedsend"},
    {243, "mq_timedreceive"},
    {244, "mq_notify"},
    {245, "mq_getsetattr"},
    {246, "kexec_load"},
    {247, "waitid"},
    {248, "add_key"},
    {249, "request_key"},
    {250, "keyctl"},
    {251, "ioprio_set"},
    {252, "ioprio_get"},
    {253, "inotify_init"},
    {254, "inotify_add_watch"},
    {255, "inotify_rm_watch"},
    {256, "migrate_pages"},
    {257, "openat"},
    {258, "mkdirat"},
    {259, "mknodat"},
    {260, "fchownat"},
    {261, "futimesat"},
    {262, "newfstatat"},
    {263, "unlinkat"},
    {264, "renameat"},
    {265, "linkat"},
    {266, "symlinkat"},
    {267, "readlinkat"},
    {268, "fchmodat"},
    {269, "faccessat"},
    {270, "pselect6"},
    {271, "ppoll"},
    {272, "unshare"},
    {273, "set_robust_list"},
    {274, "get_robust_list"},
    {275, "splice"},
    {276, "tee"},
    {277, "sync_file_range"},
    {278, "vmsplice"},
    {279, "move_pages"},
    {280, "utimensat"},
    {281, "epoll_pwait"},
    {282, "signalfd"},
    {283, "timerfd_create"},
    {284, "eventfd"},
    {285, "fallocate"},
    {286, "timerfd_settime"},
    {287, "timerfd_gettime"},
    {288, "accept4"},
    {289, "signalfd4"},
    {290, "eventfd2"},
    {291, "epoll_create1"},
    {292, "dup3"},
    {293, "pipe2"},
    {294, "inotify_init1"},
    {295, "preadv"},
    {296, "pwritev"},
    {297, "rt_tgsigqueueinfo"},
    {298, "perf_event_open"},
    {299, "recvmmsg"},
    {300, "fanotify_init"},
    {301, "fanotify_mark"},
    {302, "prlimit64"},
    {303, "name_to_handle_at"},
    {304, "open_by_handle_at"},
    {305, "clock_adjtime"},
    {306, "syncfs"},
    {307, "sendmmsg"},
    {308, "setns"},
    {309, "getcpu"},
    {310, "process_vm_readv"},
    {311, "process_vm_writev"},
    {312, "kcmp"},
    {313, "finit_module"},
    {314, "sched_setattr"},
    {315, "sched_getattr"},
    {316, "renameat2"},
    {317, "seccomp"},
    {318, "getrandom"},
    {319, "memfd_create"},
    {320, "kexec_file_load"},
    {321, "bpf"},
    {322, "execveat"},
    {323, "userfaultfd"},
    {324, "membarrier"},
    {325, "mlock2"},
    {326, "copy_file_range"},
    {327, "preadv2"},
    {328, "pwritev2"},
    {329, "pkey_mprotect"},
    {330, "pkey_alloc"},
    {331, "pkey_free"},
    {332, "statx"},
    {333, "io_pgetevents"},
    {334, "rseq"},
    {424, "pidfd_send_signal"},
    {425, "io_uring_setup"},
    {426, "io_uring_enter"},
    {427, "io_uring_register"},
    {428, "open_tree"},
    {429, "move_mount"},
    {430, "fsopen"},
    {431, "fsconfig"},
    {432, "fsmount"},
    {433, "fspick"},
    {434, "pidfd_open"},
    {435, "clone3"},
    {436, "close_range"},
    {437, "openat2"},
    {438, "pidfd_getfd"},
    {439, "faccessat2"},
    {440, "process_madvise"},
    {512, "rt_sigaction"},
    {513, "rt_sigreturn"},
    {514, "ioctl"},
    {515, "readv"},
    {516, "writev"},
    {517, "recvfrom"},
    {518, "sendmsg"},
    {519, "recvmsg"},
    {520, "execve"},
    {521, "ptrace"},
    {522, "rt_sigpending"},
    {523, "rt_sigtimedwait"},
    {524, "rt_sigqueueinfo"},
    {525, "sigaltstack"},
    {526, "timer_create"},
    {527, "mq_notify"},
    {528, "kexec_load"},
    {529, "waitid"},
    {530, "set_robust_list"},
    {531, "get_robust_list"},
    {532, "vmsplice"},
    {533, "move_pages"},
    {534, "preadv"},
    {535, "pwritev"},
    {536, "rt_tgsigqueueinfo"},
    {537, "recvmmsg"},
    {538, "sendmmsg"},
    {539, "process_vm_readv"},
    {540, "process_vm_writev"},
    {541, "setsockopt"},
    {542, "getsockopt"},
    {543, "io_setup"},
    {544, "io_submit"},
    {545, "execveat"},
    {546, "preadv2"},
    {547, "pwritev2"},
    {-1, NULL},
};
 
char *find_syscall_symbol(int code) {
    struct syscall *sc;
 
    for (sc = syscall_table; sc->code >= 0; sc++) {
        if (sc->code == code) {
            return sc->name;
        }
    }
 
    return NULL;
}
 
int main(int argc, char *argv[])
{
    pid_t child;
    int status;
    struct user_regs_struct regs;
    int orig_rax;
 
    child = fork();
    if (child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execl("/bin/ls", "/bin/ls", NULL);
        exit(0);
    } else {
        wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
 
        while (1) {
            // 1. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用前,可以获取系统调用的参数)
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
            wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
            if(WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪
                break;
            }
 
            ptrace(PTRACE_GETREGS, child, 0, ®s); // 获取被跟踪进程寄存器的值
 
            orig_rax = regs.orig_rax; // 获取rax寄存器的值
 
            printf("syscall: %s()\n", find_syscall_symbol(orig_rax)); // 打印rax寄存器的值
 
            // 2. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用后,可以获取系统调用的返回值)
            ptrace(PTRACE_SYSCALL, child, NULL, NULL);
 
            wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号
            if(WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪
                break;
            }
        }
    }
 
    return 0;
}           

二、ptrace使用示例

下面通过一个简单例子来说明 ptrace() 系统调用的使用,这个例子主要介绍怎么使用 ptrace() 系统调用获取当前被调试(追踪)进程的各个寄存器的值,代码如下(ptrace.c):

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <stdio.h>
 
int main()
{   pid_t child;
    struct user_regs_struct regs;
 
    child = fork();  // 创建一个子进程
    if(child == 0) { // 子进程
        ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示当前进程进入被追踪状态
        execl("/bin/ls", "ls", NULL);          // 执行 `/bin/ls` 程序
    } 
    else { // 父进程
        wait(NULL); // 等待子进程发送一个 SIGCHLD 信号
        ptrace(PTRACE_GETREGS, child, NULL, ®s); // 获取子进程的各个寄存器的值
        printf("Register: rdi[%ld], rsi[%ld], rdx[%ld], rax[%ld], orig_rax[%ld]\n",
                regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax); // 打印寄存器的值
        ptrace(PTRACE_CONT, child, NULL, NULL); // 继续运行子进程
        sleep(1);
    }
    return 0;
}           

通过命令 gcc ptrace.c -o ptrace 编译并运行上面的程序会输出如下结果:

Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59]
ptrace  ptrace.c           

上面结果的第一行是由父进程输出的,主要是打印了子进程执行 /bin/ls 程序后各个寄存器的值。而第二行是由子进程输出的,主要是打印了执行 /bin/ls 程序后输出的结果。

下面解释一下上面程序的执行流程:

  • 主进程调用 fork() 系统调用创建一个子进程。
  • 子进程调用 ptrace(PTRACE_TRACEME,...) 把自己设置为被追踪状态,并且调用 execl() 执行 /bin/ls 程序。
  • 被设置为追踪(TRACE)状态的子进程执行 execl() 的程序后,会向父进程发送 SIGCHLD 信号,并且暂停自身的执行。
  • 父进程通过调用 wait() 接收子进程发送过来的信号,并且开始追踪子进程。
  • 父进程通过调用 ptrace(PTRACE_GETREGS, child, ...) 来获取到子进程各个寄存器的值,并且打印寄存器的值。
  • 父进程通过调用 ptrace(PTRACE_CONT, child, ...) 让子进程继续执行下去。

从上面的例子可以知道,通过向 ptrace() 函数的 request 参数传入不同的值时,就有不同的效果。比如传入 PTRACE_TRACEME 就可以让进程进入被追踪状态,而传入 PTRACE_GETREGS 时,就可以获取被追踪的子进程各个寄存器的值等。

三、调试工具

3.1基础知识

我将介绍 Linux 上调试器实现的主要构建块 -ptrace系统调用。本文中的所有代码都是在32位Ubuntu机器上开发的。请注意,该代码在很大程度上是特定于平台的,尽管将其移植到其他平台应该不会太困难。

动机

要了解我们要做什么,请尝试想象调试器需要什么才能完成其工作。调试器可以启动某个进程并对其进行调试,或者将其自身附加到现有进程。它可以单步执行代码、设置断点并运行它们、检查变量值和堆栈跟踪。许多调试器具有高级功能,例如在调试进程的地址空间中执行表达式和调用函数,甚至动态更改进程的代码并观察效果。

尽管现代调试器是复杂的野兽,但令人惊讶的是它们的构建基础却如此简单。调试器一开始只提供操作系统和编译器/链接器提供的一些基本服务,其余的只是简单的编程问题。

Linux调试——ptrace

Linux 调试器的瑞士军刀是ptrace系统调用。它是一种多功能且相当复杂的工具,允许一个进程控制另一个进程的执行并窥探其内部结构。ptrace需要一本中等大小的书才能完整解释,这就是为什么我只在示例中重点介绍它的一些实际用途。

单步执行流程的代码

我现在将开发一个在“跟踪”模式下运行进程的示例,其中我们将单步执行其代码 - 由 CPU 执行的机器代码(汇编指令)。我将分部分展示示例代码,逐一进行解释,在文章末尾,您将找到一个下载完整 C 文件的链接,您可以编译、执行和使用该文件。高级计划是编写代码,将其分为一个执行用户提供的命令的子进程和一个跟踪子进程的父进程。

主要功能:

int  main ( int argc, char ** argv)
{
    pid_t 子进程pid;

    if (argc < 2 ) {
        fprintf(stderr, "需要一个程序名称作为参数\n" );
        返回- 1;
    }

    child_pid = fork();
    如果(child_pid == 0)
        run_target(argv[ 1 ]);
    否则 如果(child_pid > 0 )
        run_debugger(child_pid);
    否则{
        perror( “分叉” );
        返回- 1;
    }

    返回 0;
}           

非常简单:我们使用fork 启动一个新的子进程。后续条件的if分支运行子进程(此处称为“目标”),else if分支运行父进程(此处称为“调试器”)。

这是目标进程:

void  run_target ( const  char * 程序名)
{
    procmsg( "目标已启动。将运行 '%s'\n" , 程序名);

    /* 允许跟踪该进程 */ 
    if (ptrace(PTRACE_TRACEME, 0 , 0 , 0 ) < 0 ) {
        perror( “ptrace” );
        返回;
    }

    /* 用给定的程序替换该进程的映像 */ 
    execl(programname, programname, 0 );
}           

这里最有趣的一行是ptrace调用。ptrace是这样声明的(在sys/ptrace.h中):

long ptrace( enum __ptrace_request 请求, pid_t pid,
                  void *addr, void *data);           

第一个参数是request ,它可能是许多预定义的PTRACE_*常量之一。第二个参数指定某些请求的进程 ID。第三个和第四个参数是地址和数据指针,用于内存操作。上面代码片段中的 ptrace 调用发出PTRACE_TRACEME请求,这意味着该子进程请求操作系统内核让其父进程跟踪它。手册页中的请求描述非常清楚:

表示该进程将被其父进程跟踪。传递给该进程的任何信号(SIGKILL 除外)都会导致该进程停止,并通过 wait() 通知其父进程。此外,此进程对 exec() 的所有后续调用都会导致向其发送 SIGTRAP,从而使父进程有机会在新程序开始执行之前获得控制权。如果进程的父进程不希望跟踪它,则它可能不应该发出此请求。 (pid、addr 和 data 被忽略。)

我在这个例子中强调了我们感兴趣的部分。请注意, run_target在ptrace之后执行的下一件事是使用execl调用作为参数提供给它的程序。正如突出显示的部分所解释的,这会导致操作系统内核在开始执行execl中的程序并向父进程发送信号之前停止该进程。

因此,时机成熟了,看看父母会做什么:

无效 run_debugger(pid_t child_pid)
{
    int wait_status;
    无符号icounter = 0;
    procmsg( “调试器已启动\n” );

    /* 等待子进程停止执行第一个指令 */
    等待(&等待状态);

    while (WIFSTOPPED(wait_status)) {
        icounter++;
        /* 让子进程执行另一条指令 */ 
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) {
            perror( “ptrace” );
            返回;
        }

        /* 等待子进程停止执行下一条指令 */
        等待(&等待状态);
    }

    procmsg( "子进程执行了 %u 条指令\n" , icounter);
}           

想一下上面的内容,一旦子进程开始执行exec调用,它将停止并发送SIGTRAP信号。这里的父级在第一个等待调用中等待这种情况发生。一旦发生有趣的事情, wait将返回,并且父进程检查是否是因为子进程被停止(如果子进程通过传递信号而停止,则WIFSTOPPED返回 true)。

父母接下来要做的事情是本文最有趣的部分。它通过PTRACE_SINGLESTEP请求调用ptrace,并为其提供子进程 ID。它的作用是告诉操作系统 -请重新启动子进程,但在执行下一条指令后停止它。同样,父进程等待子进程停止并继续循环。当wait调用发出的信号不是关于子进程停止时,循环将终止。在跟踪器正常运行期间,这将是告诉父进程子进程已退出的信号(WIFEXITED将返回 true)。

请注意,icounter计算子进程执行的指令数量。因此,我们的简单示例实际上做了一些有用的事情 - 在命令行上给定程序名称,它会执行该程序并报告从开始运行到结束所需的 CPU 指令量。让我们看看它的实际效果。

试运行

我编译了以下简单程序并在跟踪器下运行它:

#include <stdio.h>


int 主函数()
{
    printf( "你好,世界!\n" );
    返回 0;
}           

令我惊讶的是,跟踪器运行了很长时间,并报告执行了超过 100,000 条指令。对于一个简单的printf调用?是什么赋予了?答案很有趣。默认情况下, Linux 上的gcc动态地将程序链接到 C 运行时库。这意味着,执行任何程序时首先运行的事情之一就是查找所需共享库的动态库加载器。这是相当多的代码 - 请记住,我们的基本跟踪器会查看每条指令,不仅仅是主函数,而是整个过程。

因此,当我使用-static标志链接测试程序时(并验证可执行文件的重量增加了约 500KB,这对于 C 运行时的静态链接来说是合乎逻辑的),跟踪仅报告了 7,000 条左右的指令。这仍然很多,但如果您还记得libc初始化仍然必须在main之前运行,并且清理必须在main之后运行,那就完全有意义了。此外,printf是一个复杂的函数。

仍然不满意,我想看到一些可测试的东西- 即我可以解释执行的每条指令的整个运行。当然,这可以通过汇编代码来完成。所以我拍了这个版本的《你好,世界!》并组装它:

节.文本
    ;必须为链接器声明 _start 符号 (ld)
    全局_start

_开始:

    ;为 sys_write 系统调用准备参数:
    ; - eax:系统调用号(sys_write)
    ; - ebx:文件描述符(标准输出)
    ; - ecx:指向字符串的指针
    ; - edx:字符串长度
    mov edx,仅
    mov ecx, 消息
    移动 ebx, 1
    移动 eax, 4

    ;执行sys_write系统调用
    整数0x80

    ;执行sys_exit
    移动eax, 1
    整数0x80

.data 节
msg db '你好,世界!',0xa
len equ $ - 味精           

果然。现在跟踪器报告执行了 7 条指令,这是我可以轻松验证的。

深入指令流

通过汇编编写的程序,我可以向您介绍ptrace的另一个强大用途- 仔细检查所跟踪进程的状态。这是run_debugger函数的另一个版本:

无效 run_debugger(pid_t child_pid)
{
    int wait_status;
    无符号icounter = 0;
    procmsg( “调试器已启动\n” );

    /* 等待子进程停止执行第一个指令 */
    等待(&等待状态);

    while (WIFSTOPPED(wait_status)) {
        icounter++;
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, child_pid, 0 , ®s);
        无符号指令 = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0 );

        procmsg( "icounter = %u.EIP = 0x%08x.instr = 0x%08x\n" ,
                    icounter、regs.eip、instr);

        /* 让子进程执行另一条指令 */ 
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) {
            perror( “ptrace” );
            返回;
        }

        /* 等待子进程停止执行下一条指令 */
        等待(&等待状态);
    }

    procmsg( "子进程执行了 %u 条指令\n" , icounter);
}           

唯一的区别在于while循环的前几行。有两个新的ptrace调用。第一个将进程寄存器的值读入结构中。user_regs_struct在sys/user.h中定义。现在这是有趣的部分 - 如果你查看这个头文件,靠近顶部的评论说:

/* 这个文件的全部目的是为了 GDB 和 GDB 而已。
   不要过度解读它。除非知道自己在做什么,否则不要将其用于
   GDB 以外的任何用途
   。 */           

现在,我不了解你的情况,但这让我觉得我们走在正确的轨道上:-)无论如何,回到这个例子。一旦我们在regs中拥有了所有寄存器,我们就可以通过使用PTRACE_PEEKTEXT调用ptrace来查看进程的当前指令,并将regs.eip(x86 上的扩展指令指针)作为地址传递给它。我们返回的是指令。让我们看看这个新的跟踪器在我们的汇编代码片段上运行:

$ simple_tracer 追踪_helloworld
[5700]调试器已启动
[5701]目标开始了。将运行“traced_helloworld”
[5700] icounter = 1。EIP = 0x08048080。指令 = 0x00000eba
[5700] icounter = 2。EIP = 0x08048085。指令 = 0x0490a0b9
[5700] icounter = 3。EIP = 0x0804808a。指令 = 0x000001bb
[5700] icounter = 4。EIP = 0x0804808f。指令 = 0x000004b8
[5700] icounter = 5。EIP = 0x08048094。指令 = 0x01b880cd
你好世界!
[5700] icounter = 6。EIP = 0x08048096。指令 = 0x000001b8
[5700] icounter = 7。EIP = 0x0804809b。指令 = 0x000080cd
【5700】孩子执行了7条指令           

好的,现在除了icounter之外,我们还可以看到指令指针以及它在每一步指向的指令。如何验证这是否正确?通过在可执行文件上使用objdump-d :

$ objdump -d traced_helloworld

traced_helloworld:文件格式 elf32-i386


.text 节的反汇编:

08048080 <.文本>:
 8048080: ba 0e 00 00 00 mov $0xe,%edx
 8048085:b9 a0 90 04 08 mov $0x80490a0,%ecx
 804808a: bb 01 00 00 00 mov $0x1,%ebx
 804808f: b8 04 00 00 00 mov $0x4,%eax
 8048094:cd 80 int $0x80
 8048096:b8 01 00 00 00 mov $0x1,%eax
 804809b:cd 80 int $0x80           

这和我们的跟踪输出之间的对应关系很容易观察到。

附加到正在运行的进程

如您所知,调试器还可以附加到已经运行的进程。现在您不会惊讶地发现这也是通过ptrace完成的,它可以获取PTRACE_ATTACH请求。我不会在这里展示代码示例,因为考虑到我们已经完成的代码,它应该很容易实现。出于教育目的,这里采用的方法更方便(因为我们可以在子进程开始时停止它)。

代码

本文中介绍的简单跟踪器的完整 C 源代码(更高级的指令打印版本)可在此处获取。它可以在gcc 4.4 版本上使用-Wall -pedantic --std=c99进行干净地编译。这部分内容并没有涉及太多内容——我们距离拥有真正的调试器还很远。然而,我希望它已经让调试过程至少不再那么神秘了。ptrace确实是一个多功能的系统调用,具有多种功能,到目前为止我们只采样了其中的一些。

单步执行代码很有用,但仅限于一定程度。采取 C “你好,世界!”我上面演示的示例。要进入main,可能需要执行数千条 C 运行时初始化代码指令。这不太方便。理想情况下,我们希望能够在main 的入口处放置一个断点,然后从那里开始执行。很公平,在本系列的下一部分中,我打算展示断点是如何实现的。

3.2断点

断点是调试的两个主要支柱之一,另一个支柱能够检查被调试进程内存中的值。我们已经在该系列的第 1 部分中看到了另一个支柱的预览,但断点仍然很神秘。到本文结束时,他们将不再是这样。

软件中断

为了在 x86 架构上实现断点,需要使用软件中断(也称为“陷阱”)。在深入讨论细节之前,我想先解释一下中断和陷阱的一般概念。

CPU 具有单个执行流,一条一条地执行指令[1]。为了处理 IO 和硬件定时器等异步事件,CPU 使用中断。硬件中断通常是附有特殊“响应电路”的专用电信号。该电路注意到中断的激活,并使CPU停止当前的执行,保存其状态,并跳转到中断处理程序例程所在的预定义地址。当处理程序完成其工作时,CPU 从停止处恢复执行。

软件中断在原理上类似,但在实践中有些不同。 CPU 支持允许软件模拟中断的特殊指令。当执行这样的指令时,CPU 将其视为中断 - 停止其正常执行流程,保存其状态并跳转到处理程序例程。这些“陷阱”使得现代操作系统的许多奇迹(任务调度、虚拟内存、内存保护、调试)得以有效实现。

一些编程错误(例如除以 0)也会被 CPU 视为陷阱,并且通常称为“异常”。这里硬件和软件之间的界限变得模糊,因为很难说这种异常是真正的硬件中断还是软件中断。但我已经离主题太远了,所以是时候回到断点了。

理论上int 3

写完上一节后,我现在可以简单地说,断点是通过一个名为int 3的特殊陷阱在 CPU 上实现的。int是 x86 术语,意为“陷阱指令”——调用预定义的中断处理程序。 x86支持int指令,其8位操作数指定发生的中断编号,因此理论上支持256个陷阱。前 32 个由 CPU 为其自身保留,而第 3 个是我们在这里感兴趣的 - 它称为“调试器陷阱”。

话不多说,我将引用圣经本身:

INT 3 指令生成一个特殊的单字节操作码 (CC),用于调用调试异常处理程序。 (这个单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而无需覆盖其他代码)。

括号中的部分很重要,但现在解释还为时过早。我们将在本文后面讨论这个问题。

实践中的 int 3

是的,了解事物背后的理论固然很好,但这到底意味着什么呢?我们如何使用int 3来实现断点呢?或者解释一下常见的编程问答术语 -请告诉我代码!

实际上,这确实非常简单。一旦您的进程执行int 3指令,操作系统就会停止它。在 Linux 上(这是我们在本文中关注的内容),它会向进程发送一个信号 - SIGTRAP。

这就是全部内容——诚实!现在回想一下本系列的第一部分,跟踪(调试器)进程会收到其子进程(或其附加的用于调试的进程)获得的所有信号的通知,并且您可以开始了解我们要去的地方。

就这样,不再有计算机体系结构 101 jabber。现在是示例和代码的时候了。

手动设置断点

我现在将展示在程序中设置断点的代码。我将用于此演示的目标程序如下:

节.文本
    ;必须为链接器声明 _start 符号 (ld)
    全局_start

_开始:

    ;为 sys_write 系统调用准备参数:
    ; - eax:系统调用号(sys_write)
    ; - ebx:文件描述符(标准输出)
    ; - ecx:指向字符串的指针
    ; - edx:字符串长度
    mov edx,只有 1
    mov ecx, 消息1
    移动 ebx, 1
    移动 eax, 4

    ;执行sys_write系统调用
    整数0x80

    ;现在打印另一条消息
    移动edx,len2
    mov ecx, 消息2
    移动 ebx, 1
    移动 eax, 4
    整数0x80

    ;执行sys_exit
    移动eax, 1
    整数0x80

.data 节

msg1 db '你好,',0xa
len1 equ $ - msg1
msg2 db '世界!', 0xa
len2 equ $ - msg2           

我现在使用汇编语言,是为了避免我们进入 C 代码时出现的编译问题和符号。上面列出的程序所做的只是在一行上打印“Hello”,然后打印“world!”在下一行。它与上一篇文章中演示的程序非常相似。

我想在第一个打印输出之后、第二个打印输出之前设置一个断点。假设就在mov edx, len2指令上的第一个int 0x80 [4]之后。首先,我们需要知道该指令映射到什么地址。运行objdump -d:

Traced_printer2:文件格式 elf32-i386

部分:
Algn 中的 Idx 名称大小 VMA LMA 文件
  0.文本00000033 08048080 08048080 00000080 2**4
                  内容、分配、加载、只读、代码
  1.数据0000000e 080490b4 080490b4 000000b4 2**2
                  内容、分配、加载、数据

.text 节的反汇编:

08048080 <.文本>:
 8048080: ba 07 00 00 00 mov $0x7,%edx
 8048085:b9 b4 90 04 08 mov $0x80490b4,%ecx
 804808a: bb 01 00 00 00 mov $0x1,%ebx
 804808f: b8 04 00 00 00 mov $0x4,%eax
 8048094:cd 80 int $0x80
 8048096: ba 07 00 00 00 mov $0x7,%edx
 804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx
 80480a0: bb 01 00 00 00 移动 $0x1,%ebx
 80480a5: b8 04 00 00 00 mov $0x4,%eax
 80480aa:cd 80 int $0x80
 80480ac: b8 01 00 00 00 mov $0x1,%eax
 80480b1:cd 80 int $0x80           

所以,我们要设置断点的地址是0x8048096。等等,这不是真正的调试器的工作方式,对吗?真正的调试器在代码行和函数上设置断点,而不是在某些裸内存地址上设置断点?非常正确。但我们距离目标还很远 - 要像真正的调试器一样设置断点,我们仍然必须首先介绍符号和调试信息,并且需要本系列中的另一部分或两部分来讨论这些主题。现在,我们必须处理裸内存地址。

说到这里我真的很想再跑题了,所以你有两个选择。如果您确实有兴趣了解为什么地址是 0x8048096 以及它的含义,请阅读下一节。如果没有,并且您只想继续处理断点,则可以安全地跳过它。

使用 int 3 在调试器中设置断点

要在跟踪进程中的某个目标地址处设置断点,调试器将执行以下操作:

  1. 记住目标地址存储的数据
  2. 将目标地址的第一个字节替换为 int 3 指令

然后,当调试器要求操作系统运行该进程(如我们在上一篇文章中看到的PTRACE_CONT)时,该进程将运行并最终遇到 int 3 ,在那里它将停止,操作系统将向其发送一个信号。这是调试器再次介入的地方,接收到其子进程(或跟踪进程)已停止的信号。然后它可以:

  1. 将目标地址处的int 3指令替换为原指令
  2. 将跟踪进程的指令指针回滚 1。这是必需的,因为指令指针现在指向int 3之后,并且已经执行了它。
  3. 允许用户以某种方式与进程交互,因为进程仍然在所需的目标地址处停止。这是调试器允许您查看变量值、调用堆栈等的部分。
  4. 当用户想要继续运行时,调试器将负责将断点放回目标地址(因为它在步骤 1 中被删除),除非用户要求取消断点。

让我们看看其中一些步骤如何转换为实际代码。我们将使用第 1 部分中介绍的调试器“模板”(分叉子进程并跟踪它)。无论如何,本文末尾有一个指向此示例的完整源代码的链接。

/* 获取并显示子进程的指令指针 */ 
ptrace(PTRACE_GETREGS, child_pid, 0 , ®s);
procmsg( "子进程已启动。EIP = 0x%08x\n" , regs.eip);

/* 查看我们感兴趣的地址处的字 */ 
unsigned addr = 0x8048096 ;
无符号数据 = ptrace(PTRACE_PEEKTEXT, child_pid, ( void *)addr, 0 );
procmsg( "原始数据位于 0x%08x: 0x%08x\n" , addr, data);           

此处,调试器从跟踪的进程中获取指令指针,并检查当前位于 0x8048096 处的字。当运行跟踪本文开头列出的汇编程序时,将打印:

[13028] 孩子开始了。电子IP = 0x08048080
[13028] 0x08048096处的原始数据:0x000007ba           

到目前为止,一切都很好。下一个:

/* 将陷阱指令 'int 3' 写入地址 */ 
unsigned data_with_trap = (data & 0xFFFFFF00 ) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, ( void *)addr, ( void *)data_with_trap);

/* 再看看那里有什么... */ 
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, ( void *)addr, 0 );
procmsg( "陷阱后,数据位于 0x%08x: 0x%08x\n" , addr, readback_data);           

注意int 3是如何插入到目标地址的。这打印:

[13028] 陷阱后,数据位于 0x08048096:0x000007cc           

再次,正如预期的那样 - 0xba被替换为0xcc。调试器现在运行子进程并等待它在断点处停止:

/* 让子进程运行到断点并等待它
** 到达它
*/ 
ptrace(PTRACE_CONT, child_pid, 0 , 0 );

等待(&等待状态);
如果(WIFSTOPPED(等待状态)){
    procmsg( "孩子收到一个信号:%s\n" , strsignal(WSTOPSIG(wait_status)));
}
否则{
    perror( “等待” );
    返回;
}

/* 查看子进程现在在哪里 */ 
ptrace(PTRACE_GETREGS, child_pid, 0 , ®s);
procmsg( "子进程停在 EIP = 0x%08x\n" , regs.eip);           

这打印:

你好,
[13028] 孩子收到信号:跟踪/断点陷阱
[13028] 子进程停止在 EIP = 0x08048097           

请注意在断点之前打印的“Hello”——与我们计划的完全一样。另请注意子进程停止的位置 - 就在单字节陷阱指令之后。

最后,正如前面所解释的,为了让孩子继续奔跑,我们必须做一些工作。我们用原始指令替换陷阱,并让进程继续从它运行。

/* 通过恢复目标地址处之前的数据来移除断点
,并将 EIP 回退 1,以
** 让 CPU 执行那里的原始指令
。
*/ 
ptrace(PTRACE_POKETEXT, child_pid, ( void *)addr, ( void *)data);
regs.eip -= 1 ;
ptrace(PTRACE_SETREGS, child_pid, 0 , ®s);

/* 子进程现在可以继续运行 */ 
ptrace(PTRACE_CONT, child_pid, 0 , 0 );           

这使得子打印出“世界!”并按计划退出。

请注意,我们在这里不恢复断点。这可以通过以单步模式执行原始指令,然后放回陷阱,然后才执行PTRACE_CONT来完成。本文后面演示的调试库实现了这一点。

有关 int 3 的更多信息

现在是回来检查int 3和英特尔手册中那个奇怪的注释的好时机。又是这样:

这种单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而无需覆盖其他代码

x86 上的int指令占用两个字节 - 0xcd后跟中断号[6]。 int 3可以被编码为cd 03,但是有一个为其保留的特殊单字节指令 - 0xcc。

为什么这样?因为这允许我们插入断点而无需覆盖多个指令。这很重要。考虑这个示例代码:

..一些代码..
    富杰
    十进制
富:
    呼叫栏
    ..一些代码..           

假设我们想在dec eax上放置一个断点。这恰好是一条单字节指令(操作码为0x48)。如果替换断点指令的长度超过 1 个字节,我们将被迫覆盖下一条指令 ( call ) 的一部分,这会使其出现乱码,并可能产生完全无效的结果。但是jz foo 的分支是什么?然后, CPU不会在dec eax处停止,而是直接执行其后的无效指令。

对int 3使用特殊的 1 字节编码可以解决这个问题。由于 1 字节是 x86 上一条指令可以得到的最短指令,因此我们保证只有我们想要中断的指令才会改变。

封装一些血淋淋的细节

上一节的代码示例中显示的许多低级细节可以轻松封装在方便的 API 后面。我已经将一些封装到一个名为debuglib的小型实用程序库中- 它的代码可以在文章末尾下载。在这里,我只想演示一个其用法的示例,但有所不同。我们将跟踪用 C 编写的程序。

跟踪 C 程序

到目前为止,为了简单起见,我主要关注汇编语言目标。现在是时候更上一层楼,看看我们如何跟踪用 C 编写的程序了。

事实证明,情况并没有太大不同 - 只是找到放置断点的位置有点困难。考虑这个简单的程序:

#include <stdio.h>


无效 do_stuff ()
{
    printf( “你好,” );
}


int 主函数()
{
    for ( int i = 0 ; i < 4 ; ++i)
        做东西();
    printf( "世界!\n" );
    返回 0;
}           

假设我想在do_stuff的入口处放置一个断点。我将使用老朋友objdump来反汇编可执行文件,但其中有很多内容。特别是,查看文本部分有点无用,因为它包含很多我目前不感兴趣的 C 运行时初始化代码。因此,让我们在转储中查找do_stuff :

080483e4 <do_stuff>:
 80483e4: 55 推 %ebp
 80483e5: 89 e5 mov %esp,%ebp
 80483e7: 83 ec 18 子 $0x18,%esp
 80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp)
 80483f1: e8 22 ff ff ff 呼叫 8048318 <puts@plt>
 80483f6:c9离开
 80483f7:c3 ret           

好吧,我们将断点放置在 0x080483e4 处,这是do_stuff的第一条指令。此外,由于该函数是在循环中调用的,因此我们希望一直在断点处停止,直到循环结束。我们将使用debuglib库来简化此操作。这是完整的调试器功能:

无效 run_debugger(pid_t child_pid)
{
    procmsg( “调试器已启动\n” );

    /* 等待子进程在执行第一条指令时停止 */ 
    wait( 0 );
    procmsg( "子进程现在的 EIP = 0x%08x\n" , get_child_eip(child_pid));

    /* 创建断点并运行到它*/ 
    debug_breakpoint* bp = create_breakpoint(child_pid, ( void *) 0x080483e4 );
    procmsg( "已创建断点\n" );
    ptrace(PTRACE_CONT, child_pid, 0 , 0 );
    等待(0);

    /* 只要子进程没有退出就循环 */ 
    while ( 1 ) {
         /* 子进程在断点处停止。恢复其
        ** 执行,直到退出或
        再次遇到 ** 断点。
        */ 
        procmsg( "子进程在断点处停止。EIP = 0x%08X\n" , get_child_eip(child_pid));
        procmsg( “正在恢复\n” );
         int rc =resume_from_breakpoint(child_pid, bp);

        如果(rc== 0){
            procmsg( "子进程退出\n" );
            打破;
        }
        否则 if (rc == 1 ) {
            继续;
        }
        否则{
            procmsg( “意外:%d\n”,rc);
            打破;
        }
    }


    cleanup_breakpoint(bp);
}           

我们不必亲自修改 EIP 和目标进程的内存空间,而只需使用create_breakpoint、resume_from_breakpoint和cleanup_breakpoint。让我们看看跟踪上面显示的简单 C 代码时会打印什么:

$ bp_use_lib traced_c_loop
[13363] 调试器已启动
[13364] 目标开始。将运行“traced_c_loop”
[13363] 孩子现在在 EIP = 0x00a37850
[13363] 断点已创建
[13363] 孩子停在断点处。电子IP = 0x080483E5
[13363] 恢复
你好,
[13363] 孩子停在断点处。电子IP = 0x080483E5
[13363] 恢复
你好,
[13363] 孩子停在断点处。电子IP = 0x080483E5
[13363] 恢复
你好,
[13363] 孩子停在断点处。电子IP = 0x080483E5
[13363] 恢复
你好,
世界!
[13363] 孩子退出了           

如预期的那样!

代码

这是这部分的完整源代码文件。在档案中您会发现:

  • debuglib.h 和 debuglib.c - 用于封装调试器的一些内部工作的简单库
  • bp_manual.c - 本文首先介绍的设置断点的“手动”方式。将debuglib库用于某些样板代码。
  • bp_use_lib.c - 将debuglib用于其大部分代码,如用于跟踪 C 程序中的循环的第二个代码示例中所示。

我们已经介绍了如何在调试器中实现断点。虽然不同操作系统的实现细节有所不同,但当您使用 x86 时,它基本上都是同一主题的变体 - 将int 3替换为我们希望进程停止的指令。

也就是说,我确信有些读者,就像我一样,对于指定要中断的原始内存地址不会感到兴奋。我们想说“在do_stuff上中断”,甚至“在do_stuff中的这一行上中断”并让调试器执行此操作。

3.3调试信息

现代编译器可以很好地将高级代码(具有良好的缩进和嵌套控制结构以及任意类型的变量)转换为一大堆称为机器代码的位,其唯一目的是在计算机上尽可能快地运行目标CPU。大多数 C 代码行都会被转换成多个机器代码指令。变量被塞到各处——堆栈中、寄存器中,或者完全优化掉。结构和对象甚至不存在于生成的代码中——它们只是一个抽象,被转换为硬编码的偏移量到内存缓冲区中。

那么,当您要求调试器在某个函数的入口处中断时,调试器如何知道在哪里停止呢?当您向它询问变量的值时,它如何设法找到要显示的内容?答案是——调试信息。

调试信息由编译器与机器代码一起生成。它是可执行程序与原始源代码之间关系的表示。该信息被编码为预定义的格式并与机器代码一起存储。多年来,针对不同平台和可执行文件发明了许多此类格式。由于本文的目的不是调查这些格式的历史,而是展示它们的工作原理,因此我们必须做出一些决定。这个东西就是 DWARF,它现在几乎普遍用作 Linux 和其他 Unix-y 平台上的 ELF 可执行文件的调试信息格式。

ELF中的矮人

根据其维基百科页面,DWARF 是与 ELF 一起设计的,尽管理论上它也可以嵌入其他目标文件格式中[1]。

DWARF 是一种复杂的格式,建立在针对各种体系结构和操作系统的先前格式的多年经验之上。它必须很复杂,因为它解决了一个非常棘手的问题 - 将任何高级语言的调试信息呈现给调试器,提供对任意平台和 ABI 的支持。要充分解释它,需要的不仅仅是这篇不起眼的文章,而且说实话,我对它所有的阴暗角落都没有足够的了解,无论如何也无法参与这样的努力。在本文中,我将采取更实际的方法,展示足够多的 DWARF 来解释调试信息在实际中是如何工作的。

ELF 文件中的调试部分

首先让我们看一下 DWARF 信息在 ELF 文件中的位置。 ELF 定义了每个目标文件中可能存在的任意部分。节头表定义存在哪些节及其名称。不同的工具以特殊的方式处理不同的部分 - 例如链接器正在寻找某些部分,调试器则寻找其他部分。

我们将使用从此 C 源代码构建的可执行文件进行本文中的实验,并将其编译为tracedprog2:

#include <stdio.h>


无效 do_stuff ( int my_arg)
{
    int my_local = my_arg + 2 ;
    整数我;

    for (i = 0 ; i < my_local; ++i)
        printf( "i = %d\n" , i);
}


int 主函数()
{
    do_stuff( 2 );
    返回 0;
}           

使用objdump-h从 ELF 可执行文件中转储节头,我们会注意到几个名称以.debug_开头的节- 这些是 DWARF 调试节:

26.debug_aranges 00000020 00000000 00000000 00001037
                 内容,只读,调试
27.debug_pubnames 00000028 00000000 00000000 00001057
                 内容,只读,调试
28.debug_info 000000cc 00000000 00000000 0000107f
                 内容,只读,调试
29.debug_abbrev 0000008a 00000000 00000000 0000114b
                 内容,只读,调试
30.debug_line 0000006b 00000000 00000000 000011d5
                 内容,只读,调试
31.debug_frame 00000044 00000000 00000000 00001240
                 内容,只读,调试
32.debug_str 000000ae 00000000 00000000 00001284
                 内容,只读,调试
33.debug_loc 00000058 00000000 00000000 00001332
                 内容,只读,调试           

这里每个部分看到的第一个数字是它的大小,最后一个数字是它在 ELF 文件中开始的偏移量。调试器使用此信息从可执行文件中读取该部分。现在让我们看一些在 DWARF 中查找有用调试信息的实际示例。

寻找函数

调试时我们要做的最基本的事情之一就是在某个函数处放置断点,期望调试器在其入口处中断。为了能够执行此功能,调试器必须在高级代码中的函数名称与机器代码中该函数的指令开始的地址之间具有某种映射。

可以通过查看.debug_info部分从 DWARF 获取此信息。在我们进一步讨论之前,先介绍一些背景知识。 DWARF 中的基本描述实体称为调试信息条目 (DIE)。每个 DIE 都有一个标签 - 它的类型和一组属性。 DIE 通过兄弟链接和子链接相互链接,并且属性值可以指向其他 DIE。

让我们运行:

objdump --dwarf=info tracedprog2           

输出相当长,对于这个例子,我们只关注这些行:

<1><71>:缩写编号:5(DW_TAG_子程序)
    <72> DW_AT_外部:1
    <73> DW_AT_name : (...): do_stuff
    <77> DW_AT_decl_file:1
    <78> DW_AT_decl_line:4
    <79> DW_AT_原型:1
    <7a> DW_AT_low_pc:0x8048604
    <7e> DW_AT_high_pc:0x804863e
    <82> DW_AT_frame_base : 0x0(位置列表)
    <86> DW_AT_sibling:<0xb3>

<1><b3>:缩写编号:9(DW_TAG_子程序)
    <b4> DW_AT_external:1
    <b5> DW_AT_name : (...): 主要
    <b9> DW_AT_decl_file:1
    <ba> DW_AT_decl_line : 14
    <bb> DW_AT_type : <0x4b>
    <bf> DW_AT_low_pc:0x804863e
    <c3> DW_AT_high_pc:0x804865a
    <c7> DW_AT_frame_base : 0x2c(位置列表)           

有两个条目(DIE)标记为DW_TAG_subprogram,这是 DWARF 行话中的一个函数。请注意,有一个do_stuff条目和一个main条目。有几个有趣的属性,但我们感兴趣的是DW_AT_low_pc。这是函数开始处的程序计数器( x86 中的EIP)值。请注意,do_stuff的值为0x8048604。现在让我们通过运行objdump-d来看看该地址在可执行文件的反汇编中是什么:

08048604 <do_stuff>:
 8048604:55推ebp
 8048605: 89 e5 mov ebp,esp
 8048607: 83 ec 28 子 esp,0x28
 804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
 804860d: 83 c0 02 添加 eax,0x2
 8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
 8048613: c7 45 (...) mov DWORD PTR [ebp-0x10],0x0
 804861a: eb 18 jmp 8048634 <do_stuff+0x30>
 804861c: b8 20 (...) mov eax,0x8048720
 8048621:8b 55 f0 mov edx,DWORD PTR [ebp-0x10]
 8048624: 89 54 24 04 mov DWORD PTR [esp+0x4],edx
 8048628: 89 04 24 mov DWORD PTR [esp],eax
 804862b: e8 04 (...) 调用 8048534 <printf@plt>
 8048630: 83 45 f0 01 添加 DWORD PTR [ebp-0x10],0x1
 8048634:8b 45 f0 mov eax,DWORD PTR [ebp-0x10]
 8048637:3b 45 f4 cmp eax,DWORD PTR [ebp-0xc]
 804863a: 7c e0 jl 804861c <do_stuff+0x18>
 804863c:c9 离开
 804863d:c3 右           

事实上,0x8048604是do_stuff的开头,因此调试器可以在函数及其在可执行文件中的位置之间建立映射。

寻找变量

假设我们确实停在do_stuff内的断点处。我们想让调试器向我们显示my_local变量的值。它怎么知道在哪里可以找到它?事实证明,这比查找函数要棘手得多。变量可以位于全局存储中、堆栈中,甚至寄存器中。此外,具有相同名称的变量在不同的词法作用域中可以具有不同的值。调试信息必须能够反映所有这些变化,DWARF 确实做到了。

我不会涵盖所有可能性,但作为示例,我将演示调试器如何在do_stuff中找到my_local。让我们从.debug_info开始,再次查看do_stuff的条目,这次还查看它的几个子条目:

<1><71>:缩写编号:5(DW_TAG_子程序)
    <72> DW_AT_外部:1
    <73> DW_AT_name : (...): do_stuff
    <77> DW_AT_decl_file:1
    <78> DW_AT_decl_line:4
    <79> DW_AT_原型:1
    <7a> DW_AT_low_pc:0x8048604
    <7e> DW_AT_high_pc:0x804863e
    <82> DW_AT_frame_base : 0x0(位置列表)
    <86> DW_AT_sibling:<0xb3>
 <2><8a>:缩写编号:6(DW_TAG_formal_parameter)
    <8b> DW_AT_name : (...): my_arg
    <8f> DW_AT_decl_file:1
    <90> DW_AT_decl_line:4
    <91> DW_AT_类型:<0x4b>
    <95> DW_AT_位置:(...)(DW_OP_fbreg:0)
 <2><98>:缩写编号:7 (DW_TAG_variable)
    <99> DW_AT_name : (...): my_local
    <9d> DW_AT_decl_file:1
    <9e> DW_AT_decl_line : 6
    <9f> DW_AT_type:<0x4b>
    <a3> DW_AT_location : (...) (DW_OP_fbreg: -20)
<2><a6>:缩写编号:8 (DW_TAG_variable)
    <a7> DW_AT_名称:i
    <a9> DW_AT_decl_file:1
    <aa> DW_AT_decl_line : 7
    <ab> DW_AT_type : <0x4b>
    <af> DW_AT_location : (...) (DW_OP_fbreg: -24)           

请注意每个条目中尖括号内的第一个数字。这是嵌套级别 - 在此示例中,带有<2> 的条目是带有<1>的条目的子项。所以我们知道变量my_local(由DW_TAG_variable标签标记)是do_stuff函数的子函数。调试器还对变量的类型感兴趣,以便能够正确显示它。在my_local的情况下,类型指向另一个 DIE - <0x4b>。如果我们在objdump的输出中查找它,我们会看到它是一个带符号的 4 字节整数。

为了在执行进程的内存映像中实际定位变量,调试器将查看DW_AT_location属性。对于my_local,它显示DW_OP_fbreg: -20。这意味着该变量存储在距其包含函数的DW_AT_frame_base属性的偏移量 -20 处 - 这是该函数的框架的基础。

do_stuff的DW_AT_frame_base属性的值为0x0 (位置列表),这意味着该值实际上必须在位置列表部分中查找。我们来看一下:

$ objdump --dwarf=loc Tracedprog2

tracedprog2:文件格式 elf32-i386

.debug_loc 部分的内容:

    偏移开始结束表达式
    00000000 08048604 08048605 (DW_OP_breg4: 4 )
    00000000 08048605 08048607 (DW_OP_breg4: 8 )
    00000000 08048607 0804863e (DW_OP_breg5:8)
    00000000 <列表结束>
    0000002c 0804863e 0804863f (DW_OP_breg4: 4 )
    0000002c 0804863f 08048641(DW_OP_breg4:8)
    0000002c 08048641 0804865a(DW_OP_breg5:8)
    0000002c <列表结束>           

我们感兴趣的位置信息是第一个[4]。对于调试器所在的每个地址,它指定当前帧基址,从该基址计算变量的偏移量作为寄存器的偏移量。对于 x86,bpreg4指esp,bpreg5指ebp。

再次查看do_stuff的前几条指令是有教育意义的:

08048604 <do_stuff>:
 8048604:55推ebp
 8048605: 89 e5 mov ebp,esp
 8048607: 83 ec 28 子 esp,0x28
 804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
 804860d: 83 c0 02 添加 eax,0x2
 8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax           

请注意,ebp仅在执行第二条指令后才变得相关,实际上,对于前两个地址,基址是根据上面列出的位置信息中的esp计算的。一旦ebp有效,就可以方便地计算相对于它的偏移量,因为它保持不变,而esp随着数据从堆栈中压入和弹出而不断移动。

那么my_local给我们带来了什么呢?我们只对0x8048610指令之后的值感兴趣(在eax中计算后,它的值被放置在内存中),因此调试器将使用DW_OP_breg5: 8帧基数来查找它。现在是时候回顾一下my_local的DW_AT_location属性 为DW_OP_fbreg: -20。让我们计算一下:距框架基数 -20,即ebp + 8。我们得到ebp - 12。现在再次查看反汇编并注意数据从eax移至何处 - 事实上,ebp - 12是my_local的存储位置。

查找行号

当我们谈到在调试信息中查找函数时,我有点作弊。当我们调试 C 源代码并在函数中放置断点时,我们通常对第一条机器代码指令不感兴趣[5]。我们真正感兴趣的是该函数的第一行C 代码行。

这就是为什么 DWARF 对 C 源代码中的行和可执行文件中的机器代码地址之间的完整映射进行编码。此信息包含在.debug_line部分中,可以以可读形式提取,如下所示:

$ objdump --dwarf=decodedline tracedprog2

tracedprog2:文件格式 elf32-i386

.debug_line 部分调试内容的解码转储:

CU:/home/eliben/eli/eliben-code/debugger/tracedprog2.c:
文件名 行号 起始地址
跟踪prog2.c 5 0x8048604
跟踪prog2.c 6 0x804860a
跟踪prog2.c 9 0x8048613
跟踪prog2.c 10 0x804861c
跟踪prog2.c 9 0x8048630
追踪prog2.c 11 0x804863c
跟踪prog2.c 15 0x804863e
追踪prog2.c 16 0x8048647
追踪prog2.c 17 0x8048653
追踪prog2.c 18 0x8048658           

不难看出这些信息、C 源代码和反汇编转储之间的对应关系。第 5 行指向do_stuff - 0x8040604的入口点。下一行 6 是调试器在被要求中断do_stuff时真正应该停止的地方,它指向0x804860a,即函数序言后面的位置。此线路信息可以轻松实现线路和地址之间的双向映射:

  • 当要求在某一行放置断点时,调试器将使用它来查找应该将陷阱放置在哪个地址(还记得上一篇文章中我们的朋友int 3吗?)
  • 当指令导致分段错误时,调试器将使用它来查找发生该错误的源代码行。

libdwarf - 以编程方式使用 DWARF

使用命令行工具访问 DWARF 信息虽然有用,但并不完全令人满意。作为程序员,我们想知道如何编写可以读取格式并从中提取我们需要的内容的实际代码。

当然,一种方法是获取 DWARF 规范并开始破解。现在,还记得每个人都说你永远不应该手动解析 HTML 而应该使用库吗?嗯,对于 DWARF 来说情况更糟。 DWARF比 HTML 复杂得多。我在这里展示的只是冰山一角,让事情变得更加困难的是,大部分信息都以非常紧凑和压缩的方式编码在实际的目标文件中[6]。

因此,我们将采取另一条路并使用库来与 DWARF 一起工作。我知道有两个主要的库(加上一些不太完整的库):

  1. BFD ( libbfd ) 由GNU binutils使用,包括在本文中发挥重要作用的objdump 、 ld(GNU 链接器)和as(GNU 汇编器)。
  2. libdwarf - 与其老大哥libelf一起用于 Solaris 和 FreeBSD 操作系统上的工具。

我选择libdwarf而不是 BFD,因为它对我来说似乎不那么神秘,而且它的许可证更自由(LGPL与GPL)。

由于libdwarf本身相当复杂,因此需要大量代码来操作。我不会在这里展示所有这些代码,但您可以自己下载并运行它。要编译此文件,您需要安装libelf和libdwarf,并将-lelf和-ldwarf标志传递给链接器。

演示的程序采用可执行文件并打印其中的函数名称及其入口点。以下是它为我们在本文中使用的 C 程序生成的结果:

$ dwarf_get_func_addr 追踪prog2
DW_TAG_子程序:'do_stuff'
低电脑:0x08048604
高电脑:0x0804863e
DW_TAG_子程序:'主'
低电脑:0x0804863e
高电脑:0x0804865a           

libdwarf的文档(链接在本文的参考资料部分)非常好,并且通过一些努力,您应该可以毫无问题地使用它从 DWARF 部分中提取本文中演示的任何其他信息。

调试信息原则上是一个简单的概念。实现细节可能很复杂,但最终重要的是我们现在知道调试器如何找到它所需的有关编译其跟踪的可执行文件的原始源代码的信息。有了这些信息,调试器就在用户的世界(根据代码行和数据结构进行思考)和可执行文件的世界(只是寄存器和内存中的一堆机器代码指令和数据)之间建立了桥梁。

四、ptrace实现原理

本文使用的 Linux 2.4.16 版本的内核,看懂本文需要的基础:进程调度,内存管理和信号处理相关知识。

调用 ptrace() 系统函数时会触发调用内核的 sys_ptrace() 函数,由于不同的 CPU 架构有着不同的调试方式,所以 Linux 为每种不同的 CPU 架构实现了不同的 sys_ptrace() 函数,而本文主要介绍的是 X86 CPU 的调试方式,所以 sys_ptrace() 函数所在文件是 linux-2.4.16/arch/i386/kernel/ptrace.c。

sys_ptrace() 函数的主体是一个 switch 语句,会传入的 request 参数不同进行不同的操作,如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    struct task_struct *child;
    struct user *dummy = NULL;
    int i, ret;
 
    ...
 
    read_lock(&tasklist_lock);
    child = find_task_by_pid(pid); // 获取 pid 对应的进程 task_struct 对象
    if (child)
        get_task_struct(child);
    read_unlock(&tasklist_lock);
    if (!child)
        goto out;
 
    if (request == PTRACE_ATTACH) {
        ret = ptrace_attach(child);
        goto out_tsk;
    }
 
    ...
 
    switch (request) {
    case PTRACE_PEEKTEXT:
    case PTRACE_PEEKDATA:
        ...
    case PTRACE_PEEKUSR:
        ...
    case PTRACE_POKETEXT:
    case PTRACE_POKEDATA:
        ...
    case PTRACE_POKEUSR:
        ...
    case PTRACE_SYSCALL:
    case PTRACE_CONT:
        ...
    case PTRACE_KILL: 
        ...
    case PTRACE_SINGLESTEP:
        ...
    case PTRACE_DETACH:
        ...
    }
out_tsk:
    free_task_struct(child);
out:
    unlock_kernel();
    return ret;
}           

从上面的代码可以看出,sys_ptrace() 函数首先根据进程的 pid 获取到进程的 task_struct 对象。然后根据传入不同的 request 参数在 switch 语句中进行不同的操作。

ptrace() 支持的所有 request 操作定义在 linux-2.4.16/include/linux/ptrace.h 文件中,如下:

#define PTRACE_TRACEME         0
#define PTRACE_PEEKTEXT        1
#define PTRACE_PEEKDATA        2
#define PTRACE_PEEKUSR         3
#define PTRACE_POKETEXT        4
#define PTRACE_POKEDATA        5
#define PTRACE_POKEUSR         6
#define PTRACE_CONT            7
#define PTRACE_KILL            8
#define PTRACE_SINGLESTEP      9
#define PTRACE_ATTACH       0x10
#define PTRACE_DETACH       0x11
#define PTRACE_SYSCALL        24
#define PTRACE_GETREGS        12
#define PTRACE_SETREGS        13
#define PTRACE_GETFPREGS      14
#define PTRACE_SETFPREGS      15
#define PTRACE_GETFPXREGS     18
#define PTRACE_SETFPXREGS     19
#define PTRACE_SETOPTIONS     21           

由于 ptrace() 提供的操作比较多,所以本文只会挑选一些比较有代表性的操作进行解说,比如 PTRACE_TRACEME、PTRACE_SINGLESTEP、PTRACE_PEEKTEXT、PTRACE_PEEKDATA 和 PTRACE_CONT 等,而其他的操作,有兴趣的朋友可以自己去分析其实现原理。

进入被追踪模式(PTRACE_TRACEME操作)

当要调试一个进程时,需要使进程进入被追踪模式,怎么使进程进入被追踪模式呢?有两个方法:

  • 被调试的进程调用 ptrace(PTRACE_TRACEME, ...) 来使自己进入被追踪模式。
  • 调试进程(如GDB)调用 ptrace(PTRACE_ATTACH, pid, ...) 来使指定的进程进入被追踪模式。

第一种方式是进程自己主动进入被追踪模式,而第二种是进程被动进入被追踪模式。

被调试的进程必须进入被追踪模式才能进行调试,因为 Linux 会对被追踪的进程进行一些特殊的处理。下面我们主要介绍第一种进入被追踪模式的实现,就是 PTRACE_TRACEME 的操作过程,代码如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    ...
    if (request == PTRACE_TRACEME) {
        if (current->ptrace & PT_PTRACED)
            goto out;
        current->ptrace |= PT_PTRACED; // 标志 PTRACE 状态
        ret = 0;
        goto out;
    }
    ...
}           

从上面的代码可以发现,ptrace() 对 PTRACE_TRACEME 的处理就是把当前进程标志为 PTRACE 状态。

当然事情不会这么简单,因为当一个进程被标记为 PTRACE 状态后,当调用 exec() 函数去执行一个外部程序时,将会暂停当前进程的运行,并且发送一个 SIGCHLD 给父进程。父进程接收到 SIGCHLD 信号后就可以对被调试的进程进行调试。

我们来看看 exec() 函数是怎样实现上述功能的,exec() 函数的执行过程为 sys_execve() -> do_execve() -> load_elf_binary():

static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
    ...
    if (current->ptrace & PT_PTRACED)
        send_sig(SIGTRAP, current, 0);
    ...
}           

从上面代码可以看出,当进程被标记为 PTRACE 状态时,执行 exec() 函数后便会发送一个 SIGTRAP 的信号给当前进程。

我们再来看看,进程是怎么处理 SIGTRAP 信号的。信号是通过 do_signal() 函数进行处理的,而对 SIGTRAP 信号的处理逻辑如下:

nt do_signal(struct pt_regs *regs, sigset_t *oldset) 
{
    for (;;) {
        unsigned long signr;
 
        spin_lock_irq(¤t->sigmask_lock);
        signr = dequeue_signal(¤t->blocked, &info);
        spin_unlock_irq(¤t->sigmask_lock);
 
        // 如果进程被标记为 PTRACE 状态
        if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) {
            /* 让调试器运行  */
            current->exit_code = signr;
            current->state = TASK_STOPPED;   // 让自己进入停止运行状态
            notify_parent(current, SIGCHLD); // 发送 SIGCHLD 信号给父进程
            schedule();                      // 让出CPU的执行权限
            ...
        }
    }
}           

里面的代码主要做了3件事:

  1. 如果当前进程被标记为 PTRACE 状态,那么就使自己进入停止运行状态。
  2. 发送 SIGCHLD 信号给父进程。
  3. 让出 CPU 的执行权限,使 CPU 执行其他进程。

执行以上过程后,被追踪进程便进入了调试模式,过程如下图:

看懂GDB调试核心:剖析ptrace原理及其应用场景!

父进程(调试进程)接收到 SIGCHLD 信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了。

获取被调试进程的内存数据(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)

调试进程(如GDB)可以通过调用 ptrace(PTRACE_PEEKDATA, pid, addr, data) 来获取被调试进程 addr 处虚拟内存地址的数据,但每次只能读取一个大小为 4字节的数据。

我们来看看 ptrace() 对 PTRACE_PEEKDATA 操作的处理过程,代码如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    ...
    switch (request) {
    case PTRACE_PEEKTEXT:
    case PTRACE_PEEKDATA: {
        unsigned long tmp;
        int copied;
 
        copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0);
        ret = -EIO;
        if (copied != sizeof(tmp))
            break;
        ret = put_user(tmp, (unsigned long *)data);
        break;
    }
    ...
}           

从上面代码可以看出,对 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 的处理是相同的,主要是通过调用 access_process_vm() 函数来读取被调试进程 addr 处的虚拟内存地址的数据。

access_process_vm() 函数的实现主要涉及到 内存管理 相关的知识,可以参考我以前对内存管理分析的文章,这里主要大概说明一下 access_process_vm() 的原理。

我们知道每个进程都有个 mm_struct 的内存管理对象,而 mm_struct 对象有个表示虚拟内存与物理内存映射关系的页目录的指针 pgd。如下

struct mm_struct {
    ...
    pgd_t *pgd; /* 页目录指针 */
    ...
}           

而 access_process_vm() 函数就是通过进程的页目录来找到 addr 虚拟内存地址映射的物理内存地址,然后把此物理内存地址处的数据复制到 data 变量中。如下图所示:

看懂GDB调试核心:剖析ptrace原理及其应用场景!

access_process_vm() 函数的实现这里就不分析了,有兴趣的读者可以参考我之前对内存管理分析的文章自行进行分析。

单步调试模式(PTRACE_SINGLESTEP)

单步调试是一个比较有趣的功能,当把被调试进程设置为单步调试模式后,被调试进程没执行一条CPU指令都会停止执行,并且向父进程(调试进程)发送一个 SIGCHLD 信号。

我们来看看 ptrace() 函数对 PTRACE_SINGLESTEP 操作的处理过程,代码如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    ...
    switch (request) {
    case PTRACE_SINGLESTEP: {  /* set the trap flag. */
        long tmp;
        ...
        tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
        put_stack_long(child, EFL_OFFSET, tmp);
        child->exit_code = data;
        /* give it a chance to run. */
        wake_up_process(child);
        ret = 0;
        break;
    }
    ...
}           

要把被调试的进程设置为单步调试模式,英特尔的 X86 CPU 提供了一个硬件的机制,就是通过把 eflags 寄存器的 Trap Flag 设置为1即可。

当把 eflags 寄存器的 Trap Flag 设置为1后,CPU 每执行一条指令便会产生一个异常,然后会触发 Linux 的异常处理,Linux 便会发送一个 SIGTRAP 信号给被调试的进程。eflags 寄存器的各个标志如下图:

看懂GDB调试核心:剖析ptrace原理及其应用场景!

从上图可知,eflags 寄存器的第8位就是单步调试模式的标志。

所以 ptrace() 函数的以下2行代码就是设置 eflags 进程的单步调试标志:

tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
put_stack_long(child, EFL_OFFSET, tmp);           

而 get_stack_long(proccess, offset) 函数用于获取进程栈 offset 处的值,而 EFL_OFFSET 偏移量就是 eflags 寄存器的值。所以上面两行代码的意思就是:

  1. 获取进程的 eflags 寄存器的值,并且设置 Trap Flag 标志。
  2. 把新的值设置到进程的 eflags 寄存器中。

设置完 eflags 寄存器的值后,就调用 wake_up_process() 函数把被调试的进程唤醒,让其进入运行状态。单步调试过程如下图:

看懂GDB调试核心:剖析ptrace原理及其应用场景!

处于单步调试模式时,被调试进程每执行一条指令都会触发一次 SIGTRAP 信号,而被调试进程处理 SIGTRAP 信号时会发送一个 SIGCHLD 信号给父进程(调试进程),并且让自己停止执行。

而父进程(调试进程)接收到 SIGCHLD 后,就可以对被调试的进程进行各种操作,比如读取被调试进程内存的数据和寄存器的数据,或者通过调用 ptrace(PTRACE_CONT, child,...) 来让被调试进程进行运行等。

由于 ptrace() 的功能十分强大,所以本文只能抛砖引玉,没能对其所有功能进行分析。另外断点功能并不是通过 ptrace() 函数实现的,而是通过 int3 指令来实现的,在 Eli Bendersky 大神的文章有介绍。而对于 ptrace() 的所有功能,只能读者自己慢慢看代码来体会了。

继续阅读