程序调试器原理

调试器是一个能够操纵内核数据以控制被调试进程的程序。它通过系统调用劫持来实现对目标进程的控制,利用内核中断处理程序进行单步调试或设置断点。在用户态,调试器通过操作系统对目标进程的内存和寄存器进行读写。源代码调试依赖于调试信息和符号表,使得调试器能解释内存数据的意义并进行对象解析。内核调试则更为复杂,涉及对目标进程内存和寄存器的直接操作,以及通过结构体解析内存内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

调试器原理:

 

   调试器是一个程序,在开发工具中也是调用一个程序,在运行时就是一个进程,这个进程与普通进程没有区别,只是这个进程调用了内核的一些特殊函数(系统调用)来操纵内核数据,这些数据就是被调试进程的内存数据。

 

而对操作系统调试的调试器则不同,因为没有操作系统的支持,调试器本身就不再需要调用操作系统内核的程序来支持,但是此时的调试非常特殊,因为操作系统自己有中断处理程序,调试器对中断服务程序的劫持会让操作系统的操作变得有些不同,调试器首先启动先设置CPU为单步执行状态,然后启动操作系统的代码,每执行一条指令,CPU均产生中断进入调试器程序(中断服务程序)。

通常内核调试紧紧是通过输出信息(如printk)来调试,这其实已经不是调试,而是嵌入代码进行测试,即使是kgdb也是在内核中插入了代码来实现调试,如让windows运行时启动调试模式,那么其内核中的调试代码就起作用了,这些调试是内核设计者预先设计好的。

 

在操作系统下调试程序,只是调试用户态代码,而用户态代码是出于操作系统控制之下的,因此调试就是借助操作系统来操作目标进程。

  无论windows还是unix中,都是通过对系统调用的劫持来实现对目标程序的调试的,如果操作系统内核不实现对程序的调试功能,那么调试器是不可能实现对另一个进程控制的。调试器通过创建子进程,并告诉操作系统(创建进程的状态设置)自己要对子进程进行调试,那么操作系统装载目标程序时,如果发生了规定的事件,就会停止目标进程的执行,此时对于目标进程来说,根本不知道操作系统为什么将自己停了,因为目标进程此时正在处于系统调用中,如线程创建、退出、发生异常等,而这时的内核代码就会检查当前进程是否处于调试状态,如果是,那么就启动调试器进程,这里所说的事件的发生是由操作系统内核实现的,基本是与内核的进程(线程)操作有关。

当目标进程中断后,调试器也必须通过内核才能实现读取目标进程的内存,如果不是这样,进程间可以互相读写进程就会让操作系统的内存保护功能失效了。

内核对于目标进程的内存读写是相当简单的,就是找到目标进程的页表,遍历页表找到虚拟内存对应的实际内存地址(物理地址),然后读写这个内存,对目标进程的寄存器也是一样,内核很容易获得目标进程的进程块,其中保存了目标进程的所有寄存器值。

如果没有对目标进程启动的事件中断,那么目标进程就会一直执行,不会受调试器的控制。正因为内核实现了目标进程一旦创建后就会让其中断,然后等待调试器进程的指令。

调试器在目标进程刚准备好就获得了目标进程的控制权,然后如果调试器直接run目标进程,那么目标进程就会处于失控状态,只有当系统事件(如线程创建、退出、DLL装卸载等)发生时内核才会中断目标进程,启动调试器。

因此调试时,通常在一开始就要实现对目标进程的单步调试或在目标进程中实现断点,这样目标进程才会频繁中断或在目标位置中断,然后交给调试器处理。

设置单步调试比较简单,设置CPU的状态寄存器的TF位,那么CPU每执行一个指令就会产生中断,当然如果目标程序是执行系统调用,那么内核会清除这个状态位,因此一旦进入内核,CPU就不是单步执行了,而是只有等系统调用返回才能执行下一步调试。

 

单步调试可以让我们实现很多功能,如果有源代码,那么我们执行源代码调试时,程序会编译带上调试信息,而在其中会构建符号表,行号等信息,每条指令会对应源程序行号,目标程序每执行一个指令,就产生中断,而内核中断服务程序就调用调试器来处理,调试器启动后,当然还是通过系统调用来读写目标程序的内存,当然首先还是判断当前执行的指令是属于源程序的哪行产生的代码,因此调试器会维护很多关于源程序与目标代码之间的关联信息。这需要编译器的支持,经过优化后,源代码与目标代码之间的对应关系有时也许并不那么明显。

 

当然要设置断点调试,就不是这么单步执行,其实在源代码调试时,也可以通过设置断点来实现调试,调试器也通常这样做。 调试器通过修改断点地址的指令代码来实现中断,修改为中断指令后,目标进程一执行到这里就会产生中断(异常),然后操作系统就会启动调试器来接管。因此必须在以上提到的系统事件发生时,调试器才能设置断点,例如在进程启动时刻,调试器会获得控制权,此时就可以通过修改目标进程的内存来实现断点。而设置断点时,当然调试器会将断点处的内存保存下来,一次可以设置多个断点,那么调试器必然会保存地址与内存值对应的表。当断点中断发生时,调试器等待用户指令操作完,进行run操作时,调试器就通过重新写回目标断点处的指令,目标程序接着执行。

当然要设置断点,就必须对目标程序非常了解,否则,你设置断点时,修改的是一个指令的部分(产生非法指令异常),或者是一个数据(没有反应)。设置断点是有目标的,首先我们要遍历目标程序的内存,或者通过反编译找到要调试的目标代码,然后通过对可执行文件的分析,找到代码加载到内存的位置,这样我们才可以设置断点。

当然对于源代码级别的断点设置,因为有源代码和调试信息的支持,比较容易找到源代码与加载到内存的指令地址的关系,设置断点就比较容易。因此有源代码的调试就比直接的内存进程调试相对简单。

调试器中,经常要用到符号表,符号表是一个调试器用来解释目标内存数据意义的数据表,如获得一个目标进程内存地址的数据后,如果没有符号表,那么我们只能看到一堆二进制数据,如果用符号表解释出来,那么就会知道内存中保存了什么。

调试器可以将内存转换成某个对象,按照某个结构体解析数据,然后我们就可以发现每个对象成员的值。前提是我们知道这个位置保存了某个类型的对象,如果实际上不是,那么解析结果就是无意义的。

还有一种符号表,假定对象在内存中的位置是不变的,那么我们可以通过地址中的数据就可以构建起对象。

对于内核调试中,我们通常通过结构体(符号表)解析某个地址内容的意义。当然调试器通常没有对象的识别能力,但是完全可以设计一个调试器,可以根据预先定义的对象网络(或树)来构建起对目标对象内存的解释。通常在内核调试时,我们也是通过获得一个核心对象后,然后根据其中的地址来获得其引用的其他对象,当然前提是调试者要非常清楚内存中对象的实际关系。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值