CPU对软件调试的支持(一)
随软件向大型化和复杂化方向发展 . 软件调试的难度 也在不断增大。 对于一 些小的软件 我们可 以不讲究什么方法 . 只要通过插入print语句等简单手段就可 以解决问题 但是如果是要调试一个比较大的系统 . 不讲究必要的调试 技巧就会多花费很多时间甚至根本行不通了。
那么如何掌握调试技巧 , 提高调试效率呢 ?学习基本的调试原理是第一步 . 试想如果我们不 了解调试工具的工 作机制 , 那么怎么可能最大限度地发挥其功能呢。 如果我们根本没听说过硬件断点 . 那么我们怎么能利用它解决普通软件断点无法完成的任务呢 ?
从 宏观来看 . 软件调试是调试工具 、 系统软件 (操作系统)和C P U 这三者密切 配合、 相互协作的一个复杂过程。 简单来说CP U 为软件调试提供了硬件一级的支持 , 是很多调试功能的根 本基础: 操作系统负资协调管理 CPU 所提供的硬件支持 ,并为各种调试工具提供服务;调试工具与调试人 员直接交互 ,使操作系统和CPU所提供的调试支持真正 可用。
下面 , 便以 IA 一3 2 处理器 《CP U ) 为例介绍 CP U 对软件调 试的支持。 IA 一 3 2 处理器是指英特尔3 2 位架构 ( l n t e l ? rA c h i一tc e t u r e 3 2一b it ) 处理器 . 即从 38 6 开始的 x 8 6 处理器 . 包括i3 86 、i4 86、奔腾、p 6 系列和奔腾 4 系列处理器。
可以将 lA 一 3 2 处理器 的调试支持简单概括如下:
- INT3 指令— 又叫断点指令 . 是软件断点的实现基础 。
- 标志寄存器 F L A G S 的 TF 标志— 陷阱标志位 . 是单 步执行的实现基础
- 断点地址寄存器 D R0一 D R 3— 用于设置断点地址 (线性 内存地址或 l /O 地址 ), 是硬件断点的实现基础 。
- 断点控制寄存器 DR 7— 用来控制和进一步描述四个调 试地址寄存器 (D R O一D R 3 ) 的断点条件
- 断点状态寄存器 DR 6— 当断点发生 时 . 向调试器报告该断点的具体情况, 以便调试器区分发生的是哪个断点。
- 断点异常 (# BP) 一 当 INT3 指令执行时 , 会导致此异常.CPU 转到该异常的处理 程序 。
- 调试异常 (# DB ) 一 当除 INT 3 指令以外的调试事件 发生时 会导致此异常。
- 任务状态段 (T S )S 的T 标志 任务陷阱标志 , 当切换到设置了 T 标志的任务时 , 中断到调试器 。
- 分支记录机制 用来记录上一个分支 、 中断和异常的地址等信息 。
下面我们分几块对以上 内容做进一步讨论:
软件断点
X8 6 系列处理器从其第一代 产品英特尔 8 0 8 6 开始就提供 了一条专门用来支持调试的指令INT 3。 简单来说 , 这条指令的目的就是使 CP U 中断 (陷入 ) 到调试器 . 以供调试者对执行 现场进行各种分析 。
下面通过一 个小实验来感受一下INT 3 指令的工作原理 。
在 V is u a l C + + S tu d io 6.0 ( 以下简称 v C 6 ) 中创建一个简单的He l l o w o r l d 控制台程序HIn t 3 然后在m a i n () 函数的开头通过嵌 入式汇编插入 对INT3指令的调用 :
当在 V C 环境中执行以上程序时 . 会得到以下对话框 , 点O K 按钮后程序便 会停在 N I T 3 指令所在的位t 。 由此看来我们 刚刚插入的一行 (asm INT 3 ) 相当于在那里设 了一个断点 .
这正是通过注入代码手工 设盆断点的方法 , 这种方法在调试某些特殊的程序时还 非常有用。
w id n o w s 操作系统还提供了相应的 A P I 用于手 工断点 . 例 如用户模式T 的De bu g B re a k ( ) 和内核模式下的DbgBreakPoint(),DbgBreakPointWithStatus()。 把刚才的小程序中的对 INT 3 的直接调 用改 为调用 Win do w s A PI De bug B r e a k ( ) (需要在开头 Include< w id n o w s.h> ) . 然后执行可 以看到产生 的效果是一样的。 通过反汇编很容易看出这些 AIP 在x 8 6 平台上其实都只是对INT 3指 令的简单调 用。
- 在 windbg 中启动本地内核调试 (参见 w in d b g 帮助文档 ) 然后使用u命令进行反汇编。 提示符 Ikd> 的含义是 “ Lo c a l ke r n e l d “ 。 本地内核调试需要Wind o w s XP或 以上操作系统才支持。
- 用来对齐的,没有实际意义。3 2 位 C户 U 通常需要 内存和可执行文件 以 4 字节对齐。
- DbgBreakPointWithStatus()允许向调试器 传递一个整型 参数 。
那么C PU是如何从被调试程序调到调试器 的呢 , 这一机制的全部工作过程因操作系统和被调试程序的执行模式 (用户模式还是 内核模式 ) 的不同而有所不同 。目前我们可 以作出如下简单理解 :
C p U 把 INT 3 指令处理 为一种软件异常 . 当执行INT 3指令 时 C PU 会把当时的程 序指针 ( C S 和 EIP) 压入堆栈保存起来,然后通过 中断向量表调 用 lNT 3 所对应 的中断例程。 当我们在调试器 中运行程序时 . 调试器会直接 ( DOS 时代 ) 或间接(通过操作系统的 A P I ) 注册这个中断服务 因此 当 INT 3 中断发生时 . 调试器的代码会被调用而执行 。在实模式下CPU 的执行逻辑如下 :
- 这是针对实模式的情况 保护模式下会更复杂 . 但 原理 ’类似。
- 对于INT 3指令 ,v e c t o r _ n um b e r 为 3.这个操作过程本适用于所有软件 中断和异常。
- # G P 即 Ge n e r a l Protection Exception , 常规保护性错误。也就是说 当中断向量表 的长度 (Lim i t ) 不足 以包 含本向量时,C P U便会产生常规保护异常。
- IF语句的结束语句
- 当堆栈不足以容纳接下来要压入的6字节内容时,便产生堆栈异常
下面考虑一下调试器是如何设置断点的。当我们在调试器中对代码的某一行设断点时,调试器会先把这里的本来的指令的第一个字节保存起来。然后写入一条INT 3指令。因为INT 3指令的机器码为0xCC,仅有一个字节,所以设置和取消断点时也只需要保存和恢复一个字节。这是设计这条指令时便考虑好的。顺便说一下,虽然VC6是把断点的设置信息(断点所在的文件和行位置)保存在和项目文件相同位置且相同主名称的一个.opt文件中。但注意,该文件并不保存每个断点处应该被INT 3指令替换掉的那个字节。因为这种替换是在启动调试和调试过程中动态进行的。这可以解释有时我们在VC6中,在非调试状态下,我们甚至可以在注释行设置缎带你。当开始调试时,会得到一个图2所示的警告信息。这是因为当用户在非调试状态下设置断点时,VC6只是简单的记录下该断点的设置信息。当开始调试时,VC会一个一个的取出OPT文件中的断点记录 . 并真正将这些断点设置到目标代码的内存映像中。 也就 是要将断点位置对应的指令的第一个字节先保存起来 , 再替换为C C . 即 INT 3 指令 . 这是如果 VC 6 发现某个断点的位置根本 对应不到目标映像的代码段 , 那么便会发出图 2 所示的警告 。
下面说说INT 3 断点被触发时的悄形 . 我们仍以V C 6 为例 .也就是使用 VC 6 调试一 个普通的 3 2 位 W in d o w s 应用程序 。 当Cp U 执行到 INT 3 指令时 . 由于 INT 3指令的设计目的就是 中 断到调试器 . 因此CPU 执行该指令的过程也就是准备产生断点异常 (Breakpoint exception简称# B P)并转去执行异常处理例 程的过程。 W in d o w s下所有异常和中断都是先由内核例程处理的. 因此应用程序中的 INT 3会导致 C U P 从用户模式转入内核模式并执行nt!KiTrap03例程。 接下来经过几个内核函数的处理 .因为这个异常是来自内核模式的. 而且该异常的拥有进程正在 被调试 (内核函数可以得到这些信息 ) . 所以内核例程会把这 个异常分发给用户模式的调试器 . 这里也就是VC 6 。 接下来V C 6会根据异常的发生位置 (记录在每个异常的附属数据结构中) 试图寻 找一个与其匹配 的断点记录。 如果找不到 . 那么就说明
导致这个异常的INT 3 指令不是 v C6 动态替换进去的 , 因此会 显示一个图 1 所示的对话框. 意思是说一个 “ 用户 “ 插入的断 点被触发了。 另外值得说明的是 . V C 6 在每次中断到调试器 时 .会先将所有断点处替换为 INT 3的指令恢复成原来的指令 , 然 后再把控制权交给用户 。 所以在调试器下 . 我们是看不到动态插入的 INT 3指令的.
还想介绍一个有趣现象 。当我们用 VC 6 进行调试时 , 常常会观察到一块刚分配 的内存或字符串数组里 面被坟充满了CC。如果是在中文环境下 . 因为x o C CC C 恰好是汉字 ` 烫 ` 字 的简码 . 所以会观察到很多 ` 烫烫烫烫烫烫… ’ . CC 正好是 INT 3 指令的机器码 . 这是偶然的么? 答案是否定的 . 因为这是有意为之 . 为了辅助试 调试版本的运行库会用0xCC 来填充 刚刚分配的缓冲区 . 这样如果 因为缓冲区或堆栈溢出时程序指针意外指向了这些 区域 . 那么便会因 为遇到这些 自动填充的 INT 3指令而马上 中断到调试器 。 另一方面 . 编译器也经常用 INT 3指令来填充函数或代码段末尾的空 闲区域。 这也可以解释 为什么有时我们没有手工插入任何对 INT 3的调用 . 但是也会遇到图 1 所示的对话框。因为使用 INT 3 指令产生 的断点是依靠插入指令和软件中 断机制工作的 . 因此人们习惯把这类断点成为软件断点 . 软件断点具有如下局限性 :
- 属于代码类断点 , 即可 以让 C PU 执行到代码段内的某个地址时停下来 . 不适用于数据段和 1 / 0 空 间。
- 对于在RO M ( 只读存储器 ) 中执行的程序 ( 比如 B I O S 或其它固件程序) . 无法动态增加软件断点 。 因为目标内存是 只读的 . 不能动态写入断点指令 。 这时就要使用我们后面介绍 的硬件断点。
- 当中断向量表或中断描述表 (IDT) 没有准备好或遭到破 坏的情况下这类断点无法或不能正常工作的。 比如系统刚刚启动时或者IDT被病毒窜改后 。 这时只能使用硬件级的调试工具。
虽然软件断点存在以上不足 . 但因为它使用方便 , 而且没 有数量限制 (硬 件断点需要寄存器记录断点地址 . 有数量限制 ),所以目前仍被广泛应用。
关于 INT 3指令还有一点要说明的是 . IN T 3 指令与当n=3时的 INT n 指令 (通常所说的软件中断) 并不同. INT n 指令对 应的机器码是CD后跟 1字节n 值 . 比如INT 23H 会被编译为CD23 。 与此不同 INT 3 指令具有独特的单字节机器码 CC 。 也就是当编译器看见 IN T 3 时会特别的将其编译为 CC . 而不是 CD 0 3。尽 管没有那个编译器会将 INT 3 编译成 CD 0 3. 但是可以通过某些方 法直接在程序中插入 CD 0 3 。 但是这样做会失去IN T 3 指令所具有的 特殊待遇 (例如在虚拟 8086模式下免受IOPL检查).