Skip to main content

浅入理解断点和调试器

· 13 min read
ayanami

浅入理解断点和调试器

主要参考1,2两篇文章

在写知识之前,不如先问自己几个问题:

  • debugger 的实现原理是什么?
  • 断点(breakpoint)和监视点(watchpoint)的区别?
  • 断点有哪些实现方法?具体到 gdb 之中,它是怎么实现的?

debugger 的最基本原理,就是这样的代码

int main(int argc, char** argv)
<!--truncate-->{
pid_t child_pid;

if (argc < 2) {
fprintf(stderr, "Expected a program name as argument\n");
return -1;
}

child_pid = fork();
if (child_pid == 0)
run_target(argv[1]);
else if (child_pid > 0)
run_debugger(child_pid);
else {
perror("fork");
return -1;
}

return 0;
}

debugger 作为父进程,使用 fork 调出了需要 debug 的子进程,然后通过某种方法和子进程交互(操控子进程)

那么就引出了这样的问题:

  1. 怎么操控的(允许操控的机制)
  2. 操控之后的控制流
  3. 已经控制了,debug 信息从哪来(如果不做处理,高级语言编译之后是没有原来的代码行数、变量符号等信息的)

又可以进一步总结出以下几点,

debug 需要信息:

  1. 在高级代码--->汇编的过程之中,我们需要代码的一一对应关系,比如第几行的高级代码对应第几行(到第几行)的汇编代码

  2. 在汇编代码--->可执行文件的过程之中,我们需要将有用的信息保存在可执行文件内

debug 需要断点:

  1. 需要有一种方法在 gdb 或者在可执行文件之中达成中断
  2. 进一步地,需要让 gdb(父进程)能够控制、监视、改变子进程(被调试的进程)

解决了这几个问题,理论上就能产生 debugger

问题 1,很遗憾,由于现代编译器的优化和现代编程语言的复杂性,源程序语言和编译完成的汇编,又或者说最后执行的机器指令很难完美对应,编译器开启对应的编译指令之后也只能做到近似(不能完全相信 back trace!)但大体上还是对的。(也可以强制让编译器不优化)

下图转载: gdb br的失败例子

问题 2,这就是为什么需要 gcc -g 的理由,也是为什么可以不止于对内存地址设断点,还能对一个函数,一行源代码设断点的原因

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

那么,当您要求调试器在某个函数的入口处中断时,调试器如何知道在哪里停止呢?当你向它询问变量的值时,它是如何找到要显示的内容的?答案是 - 调试信息。 **调试信息由编译器与机器代码一起生成。它是可执行程序和原始源代码之间关系的表示。这些信息被编码为预定义的格式,并与机器代码一起存储。多年来,为不同的平台和可执行文件发明了许多这样的格式。由于本文的目的不是调查这些格式的历史,而是展示它们的工作原理,因此我们必须确定一些事情。这将是 DWARF,它今天几乎无处不在地用作 Linux 和其他 Unix-y 平台上 ELF 可执行文件的调试信息格式。

对应 elf 文件中的.debug_**段

如何进一步阅读?

objdump --dwarf=info

info 可以换成别的

问题 3、4

debug 需要断点,需要某种可恢复的中断,怎么做?

  • 软件支持
  • 硬件支持

先讲硬件支持是怎么实现的。如果设备有实现硬件 debug,它会在内存之中占据一段特殊的位置,使得这个硬件支持对 cpu 是可见的

(cpu 也实现了 debug 的控制寄存器和控制单元)。而 gdb 在 debug 时,会先确定本机的架构和硬件信息,之后根据硬件信息去寻找相关的 debug 是否有硬件支持。

而这个硬件支持表现在能够硬件上单步执行,通过比较器设置断点,etc

Gateway between re-purposed JTAG bit protocol and debug logic

Debug hardware often visible in a special memory address space

E.g. (gdb) stop requires writing 0x1 (Halt Request) to address 0x090 (Debugger Run Control Register) of the CPU debug unit.

  • Shift 4 bits into IR

  • Shift 34 bits into DR

  • Shift 4 bits into IR

  • Shift 34 bits into DR

  • Shift 34 bits into DR

另一种就是软件支持,

首先是中断,当程序运行到断点的时候,它应该向 gdb 发出一个中断信号(比如 SIGTRAP),之后 gdb 程序(父进程)接收到中断信号后,辨别出这个是断点产生的中断还是程序正常运行的中断,并加以处理

然后是 ptrace 系统调用,这个系统调用允许一个进程去得到另一个进程的控制权,包括监视、改变、发送命令等

void run_target(const char* programname)
{
procmsg("target started. will run '%s'\n", programname);

/* Allow tracing of this process */
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
perror("ptrace");
return;
}

/* Replace this process's image with the given program */
execl(programname, programname, 0);
}

而有了中断之后,一种简单的实现方法可能是这样的:

在指定的地址上设一个监视点 w

首先,每次运行一条指令之后我都切换到 debugger,比对一下 pc 和监视地址是否相同,如果相同,那么我就停止

这种切换的机制来源于:

  1. wait() 不止在子进程 exit 时才会退出,在子进程触发中断时也会返回,并保存一些中断信息
  2. ptrace 调用定义了一个特殊的 request PTRACE_SINGLESTEP,会告诉 OS 启动被监控进程,但是一条指令后停止(pc+4),并产生中断通知父进程
void run_debugger(pid_t child_pid)
{
int wait_status;
unsigned icounter = 0;
procmsg("debugger started\n");

/* Wait for child to stop on its first instruction */
wait(&wait_status);

while (WIFSTOPPED(wait_status)) {
icounter++;
/* Make the child execute another instruction */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
perror("ptrace");
return;
}

/* Wait for child to stop on its next instruction */
wait(&wait_status);
}

procmsg("the child executed %u instructions\n", icounter);
}

我们当然可以用这种单步执行比对 pc 的“监视点”方法实现断点,但这个方法的问题是,每执行一条指令都要不断切换进程,效率太低了

jyy 介绍, gdb 用了一种很神奇的方法: int 3(注意这个 int 不是 integer 的 int)偷龙转凤

int 3 是一个单字节 x86 指令,作用就是简单地发出一个中断 SIGTRAP

单字节使得它能够替换到任何一条指令的开头,并且不会覆盖两条及以上的指令

在设置断点的时候,gdb 可以将断点处的指令保存起来,之后替换它的首个字节为 int 3

Instruction at the given address is read, saved and replaced with a breakpoint:

- either a special instruction, // SIGTRAP, int 3

- or an undefined encoding. // SIGILL

之后程序正常运行,执行到 int 3 时产生中断(SIGTRAP,int 3 就是第三号 TRAP,x86 的调试器中断),而 gdb 程序作为父进程收到这个信号,达成中断(通过 ptrace 设置了中断的 handler),之后也可以将原来的指令替换回来继续执行。

继续执行的细节:

实际上在有了 ptrace 的调用之后,每当被监控程序执行 exec 的时候,就会发出一个中断

Indicates that this process is to be traced by its parent. Any signal (except SIGKILL) delivered to this process will cause it to stop and its parent to be notified via wait(). Also, all subsequent calls to exec() by this process will cause a SIGTRAP to be sent to it, giving the parent a chance to gain control before the new program begins execution. A process probably shouldn't make this request if its parent isn't expecting to trace it. (pid, addr, and data are ignored.)
指示此过程将由其父级跟踪。传递给此进程的任何信号(SIGKILL 除外)都会导致它停止,并通过 wait() 通知其父级。此外,此进程对 exec() 的所有后续调用都将导致向它发送 SIGTRAP,从而使父级有机会在新程序开始执行之前获得控制权。如果进程的父进程不希望跟踪它,则进程可能不应发出此请求。(PID、ADDR 和 DATA 将被忽略。

小的自问自答环节:

Q:为什么 gdb 不是特权指令?它读寄存器值、内存值等是怎么实现的?

A:但 install 是特权指令(笑),Linux 之中,二进制文件是自动具备可执行的默认权限的(file mode),而不是像 bash 脚本那样需要 sudo chmod +x 给予权限。

并且咨询 gpt 还得到一个有趣的事情:ptrace 调用确实可以修改其他的进程,所以 ptrace 调用是需要 root 权限,但是,用户权限下,可以使用 setuid 或者 setcap 机制,是 gdb 能对特定的文件,具有特定系统调用的权限

读寄存器值是使用了特定的中断,读内存是读的虚拟内存。

Loading Comments...