源码级调试 vs 汇编级调试

动态调试是在程序的运行过程中施加干预和观测状态,问题在于,如何干预程序的运行?又该观测程序 的什么状态?以打一个断点为例,应该在什么地方打断点,程序触发断点后,又该检查程序的哪些状 态?动态调试器本身只是为程序员提供了完成上述工作的能力,但如何运用这些能力,仍然需要程序员 本身对于程序的了解。站在程序员的角度,无疑希望能直接在程序执行到某一行源代码时触发断点,触 发断点后,又可以直接检查程序中某个变量的值甚至复杂对象的内容。许多同学此前可能已经接触过各 种IDE自带的调试功能,它们大多都允许程序员在源代码中设置断点,并且可以在触发断点时直接看到各 个变量和对象的内容。这种程序员直接站在源代码的层级,使用源代码级的概念(代码行、变量、对象 等)进行调试的过程称为源码级调试。

然而,从动态调试器的功能而言,要支持源码级调试并非仅有动态调试器即可做到。这是因为,CPU本 身只能执行二进制形式的机器指令,不论是编译执行或是解释执行的高级编程语言,最终在程序运行 时,动态调试器能观察和控制的只是最终的机器指令、寄存器和内存地址。例如,仅使用动态调试器本 身,我们只能指定在某一条指令暂停执行,也不能直接检查变量或者对象的内容,因为在机器指令的层 面并没有变量和对象的概念,只有寄存器和内存。这种只使用机器执行过程中直接可见的概念(指令、 寄存器、内存)进行调试的过程称为汇编级调试/机器级调试。造成上述问题的原因是,在从源代码到可 执行程序的编译或解释过程中,许多信息都丢失了,因为这些信息对于程序的最终执行并无任何帮助: 从程序执行的角度来说,CPU不需要理解某条指令对应源代码中的哪个文件的哪一行,也不需要理解某 个寄存器在某一时刻存储的是哪个变量的值。但是,这些信息的丢失,就给调试带来了较大的困难,因 为高级语言翻译成汇编指令的方式非常多样,并且存在各种复杂的细节,从而使得汇编级调试并不直 观,往往需要远多于源码级调试的时间精力才有可能定位和理解程序存在的问题。

如果想要进行源码级调试,就需要在程序可执行文件中加入一系列的额外信息,来弥补编译/解释过程中 损失的信息,让动态调试器可以把指令地址、寄存器、内存地址“还原”为源代码级的概念如源代码行、 变量、对象等等。但是,嵌入这些信息会使得程序可执行文件的体积增大,所以如果不是在编译时使用 特定的选项,程序往往是没有这些额外信息的。以Linux下最常见的可执行文件格式ELF为例,若要支持 源码级调试,需要ELF文件存在符号表和专门的调试信息。其中符号表可以用于将一些内存地址还原回函 数或全局变量等,除了调试之外,还有许多其他用途,而调试信息则是专门为了将机器级概念还原到源 代码级存在的。如果一个ELF文件只有符号表,没有调试信息,那么绝大多数源码级调试功能也都是不可 用的,但是也可以支持一定程度的源码级调试,例如在函数的入口打断点,检查全局变量的值等等。 对于解释型语言,情况还要更复杂一些。这是因为,gdb等动态调试器,都是把程序视为一个“黑盒”,它 们并不理解一个程序是在完成自身的工作,还是在作为解释器,为一种更高级的语言(如Python)提供 支持。以Python为例,即使Python解释器程序本身有包含调试信息,从gdb的角度来看,也只能看到解 释器本身的工作情况,例如它是如何解析Python字节码的,这个过程中它调用了自身的哪些函数,修改 了自己内部的哪些变量等等。但是从Python程序员的角度来说,往往假设Python解释器本身是正确的, 问题在于自己编写的Python代码,比起理解解释器内部的执行情况,更关注的是Python语言层级的概 念。但gdb等通用动态调试器是无法在Python语言层级进行调试的。对于使用解释执行的语言,需要解 释器本身支持调试功能,往往还需要使用专门的调试器。

在本次实验中,我们提供的炸弹程序是使用非常短的C代码编译而成的,且没有使用过高的优化等级, 汇编指令与原始C代码是高度对应的;程序保留了符号表,但移除了调试信息。这是因为在操作系统中 不可避免地存在无法使用高级语言,必须使用汇编语言编写的部分。因此我们希望通过一个复杂度有限 的汇编程序,提高同学们对于汇编语言以及C语言编译到汇编语言过程的理解,同时增强同学们对gdb的 熟悉程度和调试能力,为后续的实验打下基础。

Last change: 2024-08-17, commit: 5b84259