wenmo8 发布的文章

由于软件调试是软件质量鉴定工 作必须 具备的前提条件 , 而且软件调试 过程关系到 软件质量的优 劣 , 所以 , 专门讨论软件调试技未的有关内容。

目前 , 软件设计人员中存在着一种错误 的认识 , 即认为软件调试只 是为了证明 自己 设计的软件或怪序的正 确性。 在这种思想指 导下 , 软件设计人 员往往会选择简单的调试 方法 和简单的数据情况 , 往往仅完成主 要 功 能的调试 , 这就造成了调试过程不 全面、 不 完整 , 使软件在投 入实际运行 后无法长期可 靠的工 作。

正确的软件调试作用为: 软件调试是为 了发现 错误而执行软件的过程 。 结果通 过软 件调试发现 了错误 , 并不 是证 明了软件设计 的 失败 , 而 恰好是增加了软件的可靠性和应 用 价值。 所以 , 软件调试是软件开发 中地 位 十 分重要 的一 个工 作阶段。

软件调试工作应遵循的原则

  • 根据软件所具有的功能 、 结构和 数据 类 型来选择调试方案和调试的重 点。
  • 从心理学和调试有效性而论 , 软件设 计者不应调 试 自己 的软件。 如 果没有条件做 到这 一点 , 可以采用 一些集体工作的措施来 弥补 。
  • 在软件调试 中, 不 但 要 对 那些合法的、 正常的情况进 行调试 , 而且要对那些 非 法的 、 异 常的情况进行调试。 调试应 对软件 具有一定的破坏力。
  • 在软件调试 中, 不但要 检查软件是否 完成了应完成的功能 , 而且要 检查软件是否 完成了不 应有的功 能。
  • 任何调试数据、 调试结果和错误 内容 都是极有价值的资料 , 应 认 真 地 分析、 保 存 。 某些 情况可能需要反复调试 、 纠错。
  • 国 外的有关统计资料表明: 已发现较 多错误 的软件与已发现较少错误 的软件相比 较 , 前者仍存在错误 的概率较大 。
  • 国外的 研究资料表明: 要结束一个软 件的调试过程是有条件的 , 是根据诸多因素( 如已发现的错误率 、 查错效率、 调试覆盖 率等 ) 综合分 沂后做出的 。

软件调试的几种工作方式

软件调 试中常见的 几种工作方式 和工作 内容见表 l 。 在适用 范围 一栏中, 根据调试 的规模分了三个等级: 模块级 、 功 能级、 系 统级 。 各等级的任 务如下:

  • 模块调试: 对相互独立的各个模块 在 各种条件下进行运行 , 检查模块内部的运算 和逻 辑关系 、 控制关系的正确性。
  • 程序功 能调试: 对于组成某一功能 的 各个模块进行联调 , 检查 在各种条件下的执 行过 程 和执行结果 , 特别是模块之 间的层次 关系和信息 交换 的正确性 。
  • 系统调试: 将各个功能的程序汇合 , 在实际或模拟的工作 环 境中, 检查输入 、 输 出是否符合要求 , 测定并评价软件的各项性 能 , 提出改进意见。

由于三个等级的调 试内容与规模不同 , 所采用的工 作 方式和工 作方法也不相同。 一 般 地说 , 由数人 组成的 软件调 试小组 可以 达 到相互 启发 , 集思 广议 , 相互检查 , 认真高 效的工 作目的 。 但这要求 软件调 试小组的每 个成员在软件设计与调试技术方面 , 在微机 使用 方面 , 在对整个系统功 能与性能的理解 方面 , 具有较高的水平与深刻的认识 。

软件调试方法

软件调 试中经常采用 的方法见表 2 。 逐 步增长和非增长式两种调 试方法 , 各有优缺 点 , 列举如下:

  • 非增长式调试需 要 完 成 更大的 工作量。
  • 逐步增长式调试 中 , 由于 及时地完成 了模块的组合调试, 模块之间的接 口错误和 由多个模块运行产生的功能错误 , 能及早地发现。
  • 利用逐步增长式调试 , 寻错纠错比较容易。
  • 逐步增长式调试可以 更 彻底 地对软件 进行调试 。
  • 非增长式调试需用的机器时间较少 。
  • 用非增长式调试 , 在整个调试阶段有 可 能并行工作。
  • 在逐步增长式调试法中 , 自顶向下调 试的缺点是很难产生调试条件, 调试的 相当 时间内无法考察结果输出。 而自底 向上调试 的缺点是必须给 出调 用模块 , 在加人最后一个模块之前 , 程序不能作为一个整体存在。
  • 在非增长式调试法中, 黑盒 法的缺点 是寻错纠错极困难 , 白盒法的缺点是工作量 太大 。 综合比较起来 , 逐步增长式调试法是一 种比较好的方法 , 适用于工程应用类 软件 , 建议推广实施。 如果数人同时采用逐步增长 法对同一个软件进行调试 , 就可以 达到相互 补充 、 完整调试的 目的 。

调试工 作中应注意的几点

  • 调 试前应认真地查阅有关设计资料 , 避免 出现对软件设计任务 书的理解不准确 、 不完整而造成的软件设计中的缺陷。
  • 调 试中应认真地检查程序结构 ( 是否 采用模块结构 ) 和指令使用 ( 如运算指令使 用不 当 , 转移指令使用 太多等 ) 。
  • 调试中一定要 对 程 序 进行破坏性试 验 , 考核程序的可 靠性。
  • 调试结束前 , 应 把以 前完成的各个调 试情况复现一遍 , 避免由于 纠正某个程序错 误而出现 了其他的错误 或缺陷。
  • 调试完 成后 , 应尽 可能的对程序进行 优化 , 提高程序质量。

软件调试技术中几 个研究专题

为了更有效 、 更经济地开 展软件调试工 作 , 有必要 在以 下两 个方面进行研究 :

  • 应重视软件调试数据与结果的收集、 分析工作。 目的 有两 个: 第一 , 统计软件出 错类型和产生错误 的原因 , 以 便于 在今后的 软件开发 中加以克服 , 第二 , 分析各种调试 措施或数据条件对各类错误的有效性 , 建立 起一套高效的调试原则。
  • 利用 积累的调试经验 , 建立软件可靠 性分析 , 软件错误预测的模型 , 为寻找结束 调试的最佳时机提供参考或依据。

软件调试工作是软件开 发 中最 复杂、 最 具有技术性和技巧性的工作 , 所以 , 应大力地并展研究和提高工 作 , 保 证软件具有优 良 的质量与性能。

随软件向大型化和复杂化方向发展 . 软件调试的难度 也在不断增大。 对于一 些小的软件 我们可 以不讲究什么方法 . 只要通过插入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指 令的简单调 用。

  1. 在 windbg 中启动本地内核调试 (参见 w in d b g 帮助文档 ) 然后使用u命令进行反汇编。 提示符 Ikd> 的含义是 “ Lo c a l ke r n e l d “ 。 本地内核调试需要Wind o w s XP或 以上操作系统才支持。
  2. 用来对齐的,没有实际意义。3 2 位 C户 U 通常需要 内存和可执行文件 以 4 字节对齐。
  3. DbgBreakPointWithStatus()允许向调试器 传递一个整型 参数 。

那么C PU是如何从被调试程序调到调试器 的呢 , 这一机制的全部工作过程因操作系统和被调试程序的执行模式 (用户模式还是 内核模式 ) 的不同而有所不同 。目前我们可 以作出如下简单理解 :
C p U 把 INT 3 指令处理 为一种软件异常 . 当执行INT 3指令 时 C PU 会把当时的程 序指针 ( C S 和 EIP) 压入堆栈保存起来,然后通过 中断向量表调 用 lNT 3 所对应 的中断例程。 当我们在调试器 中运行程序时 . 调试器会直接 ( DOS 时代 ) 或间接(通过操作系统的 A P I ) 注册这个中断服务 因此 当 INT 3 中断发生时 . 调试器的代码会被调用而执行 。在实模式下CPU 的执行逻辑如下  :

 

  1. 这是针对实模式的情况 保护模式下会更复杂 . 但 原理 ’类似。
  2. 对于INT 3指令 ,v e c t o r _ n um b e r 为 3.这个操作过程本适用于所有软件 中断和异常。
  3. # G P 即 Ge n e r a l  Protection Exception , 常规保护性错误。也就是说 当中断向量表 的长度 (Lim i t ) 不足 以包 含本向量时,C P U便会产生常规保护异常。
  4. IF语句的结束语句
  5. 当堆栈不足以容纳接下来要压入的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检查).

从38 6 开始 , IA 一3 2 处理器 内部都含有 8 个 3 2 位的调试 寄存器 DR0一 DR7 (如 图 1 所 示 )。 除了 DR4 和 DR5 保留 外 , 其它 6 个寄存器 分别是 :

  • 四 个 3 2 位的调试地址 寄存器 (DR0~DR3)
  • 一个 3 2 位的调试控制寄存器 (DR7) 
  • 一个 3 2 位的调试状态寄存器 (DR6) 

通过 以上寄存器可 以最多设置 4 个断点, 其基本分工是DR0一DR3 用来指定断点的内存 (线性地址) 或l/ O 地址 。 DR7 用 来进一步定义断点的中断条件。 DR6的作用是当调试事件发生向调试器 《d e b g u g , ) 报告事件的类别和属性 . 以供调试器判断发生 的是何种事 件 (哪个断点 、 单步跟踪 、 断点属性等等 )。 下面分 别详细介绍 D R 7 和DR6 的用法 。

3 2 位的DR7寄存器 中 , 有2 4 位是被划分成 4 组分 别与 4 个调试地址寄存器相对应的.比如 L0 . G0 . R/ W0 和 LEN0这 六位都是 与DR0相对应的 .L1 . G1. R/ W1 和 LEN1 这六位都是与DR1相对应的 依此类推。表 1 列出了DR7 中各个位的具体含义。

调试状态寄存器 DR6 是 当 CPU 检测到匹配 断点 条件的断点时 . 用来向调试器的断点异常处理程序传递该断点异常的具体情况 . 以使调试器可 以很容易 的识别 出发 生的是什么断点 。 比如如果 BO被设置为1, 那么就说 明满足 DR0、 L E N 0、和 R/ W0 所定义条件 的断点发 生了 。 下面的表分 别列出了 DR6 中各个位的具体含义。

通过表 1 的定义可 以看出 . 调试控制寄存器的各个位域提 供了很灵活 的方式 .允许我们通过不同的组合定义出各种复杂的断点条件。 下面我们先进一步介绍一下读写域R/ Wn , 通过 对它的设里 , 我们可 以指定要定义的断点的访问类型 (数据 、代码还是 l/ 0 ) . 即断点的访问条件 :
1、读写内存中的数据时中断 , 这种断点又被称为数据访 问断点 (data breakpointer )。 利用数据访问 断点 可 以监 控对全局变量 , 或局部变量的读写操作。 例如 . 在进行某些复杂的系统级调试 . 或者调 试多线程程序时 , 我们不知道哪个线程在何时修改了某一变量,这时我们就可 以设置一个数据访问断 点。 现代调试器大多还 都支持复杂的条件断点, 比如 当某个变 量等于某个确定的值时中断 . 这其实也是利用数据访问断点实 现的 . 其基本思路是设置 一个数据访问断点来监视这个变量 , 每次这个变量改变时 CPU 都会调用调试器 的中断处理程序 ,调试器检查这个变量的值 . 如 果不 满足规定的条件就立刻返回让 CPU 继续执行 。 如果满足 , 就中断到调 试环境。
2、执行 内存中的代 码时 中断. 这 种断点 又被称为代码访问断点 (Code Br e a k Po in t 》或指令断点 (i n s tr u e t io n b r e a k po in t ) 。 代码访问断点从实现 的功 能上 看与软件断点类似 , 都是 当 C PU 执行到某个地址 的指令时 中断。但是通 过寄存器 实现的代码访问断 点具有一个软件断点无法 实现 的优点 , 就是不 需要 软 件断点那样向 目标代码处插入指令 。 这个优点在某些 情况 下非 常重要 。 例如 . 当我们调 试位于 RO M (只读存储器 ) 上的 代码 《比 如 BIO S 中的 PO S T 程序 ) 时 根本没 有办法向那里 插入软件断点 ( lNT 3 ) 指令 . 因为 目标内存是只 读的 。 另外软件断点的另 一个局限是 只有 当 目标代码被加载进内存后才可 以 向该区域设置软件断点。而调 试寄存器 断点没有这 些限 制. 因为只 要把需 要 中断的内存地址 放入调试地址 寄存器 (DR0一DR3 ) . 并设置好调试控制寄存器 ( DR7 ) 的相应位就可 以 了。

3、读写 I/O(输入输出 ) 端口时中断 .这种断点又被称为I/O访问断点 ( I  /O   b r e a kp oi n t ) 。I/ O 访问断点对于调试设计使用输入输出端口的设备驱动程序非常有用。 也可以利用 I/O 访问断点来监视对I/O空间的非法读写操作 . 提 高系统的安全性 。 因为某些恶意程序在实现破坏动作时 . 需要对某些 I/O 端 口进行读写操作 。

读写域定义 了要监视的访问类型 . 地址 寄存器(DR0一DR3 ) 定义 了要监视的起始地址 。 那么要监视的区域长度呢 , 这便是长度域 L E N n (n= 0, 1, 2,3 . 位于 D R 7 中 ) 的任务。L EN n 位段可 以指定 1,2,4 或 8 字节长的范围。 需要说明的是 :
1、对于代码访 问断点 . 长度域应该为00,代表一字节长 度 。 另外地址寄存器应该指向指令的起始字节 。 也就是 CPU 只会用指令的起始字节来检查代码 断点 匹配 。
2、对于数据和 I/O访问断点 . 有两点需要注意 :
第一 只 要 断点区域中的任一字节在被访问的范围 内 . 都会触发该断点。
第二 , 边界对齐要求 . 两字节区域必 须按字 (w o r d ) 边界对齐 : 四字节区域必须按双 宇 (d o u b l e w o r d ) 边界对齐 : 八字 节区城必 须按四字 (qu a d w o r d ) 边界对齐。 也就是说 . CP U 在 检查断点匹配时会 自动去除相应数量的低位 。 因此如果地址没 有按耍求对齐可能无法实现预期的结果。 例如 假设希望通过将 DR0 设为0 x A 003.L E N 0 设为 1 1 (代表 4 字节长)实现任何对0 x A00 3 ~0 x A c 0 6 内存区的写操作都会触发断点: 那么只有当0 x A 00 3 被访问时会触发断点 . 对0x A 00 4、0x A 00 s 和0x A006 处的内存访问都不会触发断点 。 因为长度域指定的是4 字节 所以CPU 在检查地址匹配时 . 会自动将起始地址0x A003 的低4 位屏蔽掉 , 也就是会被看作是0x A00 )。 表3 给出了更多的例子 用来说明断点的触发条件 。

因为以上介绍的断点不需要像软件断点那样向代码中插入 软件指令 , 依靠处理器本身的功能便可 以实现 . 所以人们习惯 上把这些使用调试寄存器 (DR0~DR7 ) 设置的断点叫硬件断点 ,以与软件断点区别开来 。
l A 一 32 处理器专门分配了两个中断向量来处理调试异常 向量1 和向量3。 向量3 用来处理 INT 3 指令产生的断点异常。 向量1 用来处理调试异常(de b u g e x c e pt io n . 简称 # DB ) 。 硬件断点产生的是调试异常 .所以硬件断点发生时C U P 会执行 1 号向量对应的处理例程 。

硬件断点其 有很多优点 , 但是也有不足 , 最明显的就是数量限制因为只用 4 个调试地址寄存器 . 所以 lA 一3 2 Cpu 允许 最多设置 4 个硬件断点。 这基本可以满足大多数情况下的调试 需要 。

另一点祷要说明的是 . 只有在实模式或保护模式的内核优先级( ring0 ) 下 才能访问调试寄存器 . 否则便会导致保护性 异常。 这是 出于安全性的考虑. 那么像 v i s u a . s t u d i o 这样的用 户态调试器是如何设一硬件断点 (VC 6 支持数据访问断点。 没 必 要也不支持 l/ O 访问断点 , 因为从 Win d o w s 9 8 开始用户态 下不允许进行直接 l/ O 读写 ) 的呢 ? 答案是通过访问线程 的C ON E T X T 数据 (每个线程被挂起时 . 寄存器等上下文信息都被 保存起来 . 当该线程恢复执行时 . 寄存器会被恢复回来) 来间 接访问调试寄存器 。

下面给出一个C + + 例子演示一下如何手工设置 数据访问断点.

 

 

两期我们分别介绍了通过 INT3指令设置的软件断点 和通过调试寄存器设置 的硬件断点。 除了断点 . 还有一类常用的方法使C P U 中断到调试器 . 这便是调试陷阱标志 (debug trap  f l a g ) 。 也就 是 当这些陷阱标志被设置时, 一旦有符合陷阱条件的事件发生,就会陷入到调试器 。 IA 一3 2 处理器所支持的调试陷阱标志可以概括如下

  • 80866就支持的单步执行标志 (标志寄存器E F a g I s 的一位)
  • 3 8 6 引入的任务状态陷阱标志(任务状态段TSS 的T标志)
  • 奔腾Pro引入 的分支到分支单步执行标志 (D e b u gC t !寄存 器 中的BT F 标志)

下面分别详细介绍 。

单步执行标志 (T F )

从 8 0 8 6 C pU 开始 , x 86 系列处理器的标志(EF Ia g s ) 寄存器 中便有一个陷阱标志位 ( b i t 8 )名为Trap Flag 简称T F。当 T F 位为 1 时 CPU 每执行完一条指令便会产生一个调 试异常 《# DB ) . 中断到调试器的调试异常处理 程序。 这就是 通 常所说 的单步执行 。 调试异常的向量号是 1 因此 T F 标志引发的是 1 号异常服例程 。在 80 86 和 2 8 6 时代 , 这个服务 例程是专门用来处理 单步事件的。 从 3 8 6 开始 . 硬 件断点发.生时也会产生调试异常 . 调 用 1 号服务例程 . 但可利用调试 状态寄存器(DR66 ) 来识别发生的是何种事件 。 为了表达方便我们把因 T F标志 触发的软件异常称 为单步异 常。

单步异常属于陷阱类异常。 也 就是 C P U 总是 在执 行完导致此类异常的指令后 才产生该异常。 这意味 着 当因单步异常中断到调 试器 中时, 导致 该异 常的指令已经执行 完毕 了 。 软件 断点 异常(#BP) 和硬 件断点中的数据及I/O 断点 也是 陷阱类异常. 但是硬 件断点 中的指令访 问异 常是错误类异常 . 也就是当由于此异常而 中断到调试器 时 . 相应调试地址寄存器 DRn中所指地址 处的 指令还 没“执行 。 这是 因为CPU是 在取指操作时进行此类断点 匹配的 。

CPU是何 时检查 TF标志的呢?’IA-32手册的原 文是 "while an instruction is begin executed" 也就 是在执行一个指令的过程 中。 尽管没有说过程 中的哪个阶段 (开 始 、 中间还 是末尾 ) . 可以推测应 该是 一条指令即将执行完毕的时 候 。 也就是说 当CPU在 将执行完一条指令的时候检测TF位 ,如果该位为 1那么 CPU会先清除此位 , 然后准备产生异常。 但 是 这里 有个例外 , 对于 那 些可 以设置 TF 位的指令 (例如 POPF) CPU 不会在执行这些指令期间做以上 检查。 也就是说这些 ,旨令不会立刻产生单步异常 . 而是其后的下一 条指令将 产生单步异常。
因 为 C P U 在进入 异 常 处理 例 程前会 自动 清除 T F标 ,因此 当 CPU中断到调 试 器 中后 再观 察 TF标 志 . 它总是0。

既然调试异常的向量号是 1 ,可 不可 以像 INT 3 那样通过在代码 中插入INT 1 这样的指令来实现手工断点呢 , 对于应用 程序答案是否定的。 INT 3尽 管具有INTn的形式 . 但是它具有独特的单字节机器码 . 而且 其作用就是产生一 个断点异常 一(#BP ) 。 因此 系统对其有特别的对待 .允 许其在用户模式下执行。 而 INT 1则不然, 它属于普通的 INTn 指令 机器码为 CD01。 保护模式下如果执行 INTn指令时,当前的 CPL大于引用的门描述符的DPL。那 么便会导致通 用保 护异常 (# GP) 。 在 W id n o w s 20 0 0 和 x p 这样的操作系统下,INT1对用的中断门描述符的DPL 为 0 . 这要求只有内核模式的代码 才能执 行 IN T 1指令,访 问该中断门。 也就是说 , 用户模 式下 的应用程序没有权利使用INT 1指令 。 一 旦使用就会导致一个一般保护性异常 ( # GP) 。W id n o w s会将其封装为一个访 `问违例错误 (如图2一 2 1 所示 )。 在内核模式 下 可 以在代码 (驱 动程序)中写入INT 1指令。CP U 执行到该指令时会转去执行 1号向量对应的处理例程 , 如果在使用 W in D g b 进行内核级调试. 那 么会中断到W i n o b g 中 . W i n o b g 会以为是发生了一个单 步异常 如 图 1 所示 。

下面谈谈调试高级语言时的单步机制 。 由于高级语言的一条语句通常都对应多条汇编指令 例如 , 表 1 中C+ 十的一条语句= i a 十b* “ + d / e + f / g 十 h对应于 1 5 条汇编语句 。 因此容易想到单 步执行这条C+ + 语句有几种可能方法 。 一种是也用一标志一步 步的走过每条汇编指令 , 这种方 法意味着会产生 1 5 次调试异常 . CPU 中断到调试器 1 5 次.不过中间的 1 4 次都是简单的重 新设 皿起 下F 标志 便让 CPU返 回执行 。 第二种方法是在 C + + 语 句对应 的最后一 条汇编指令处动态插入一 条lN 下 3指令 . 让C P U 一下子 跑到那里 然后 再单步执行一次将被替换的那条 指令执行完 . 这种方 法需要 CPU中断到调试器 两次 。 第三种方法是 在这条 C++ 语句的下一条语 句的第一 条汇编指令处( 即行 18 ) 替换入一 个 N I 下 3 . 这样 C PU 中断到调试器一次就可 以了 。
表 1 : 高级语 言的单步执行

 

 

 

 

 

后两种方法较第一种方法速度会快很多 . 但是不幸的是并不总能正确的预测 出最后高级语言对应的最后一条指令和下一行语句开始指令 (要替换为INT 3 的那一条指令)。比如 2 8 行的 e l s e if (b ) 语句就很难判断出它对应 的最后一条汇编语句 和 下一 条高级 语言语 句的起始指令 。 因此 今天 的大 多数调试器在进行高级语言调试时都是使 用第一种方法来实现 单步跟踪的。关于 TF标志还有一点值得注意: . INTn 和 INTO 指令会清除TF标志 , 因此调试器在单步跟踪这些指令时 . 必须做特别处理。

任务状态陷阱标志

除了标志寄存器中的陷阱标志 (TF ) 位.38 6 引入 了一种新 的调试陷阱标志 任务状态段 (TSS ) 中的T标志。任务状态段 (Ta s k一S t a te S e g m e n t ) 用来记录 一个任务 (CPU 可以独立调度和执行 的程序段 ) 的状态 . 包括通用寄存器的值 . 段寄存器 的值和其它重要信息 。 在任务切换时 , 当前任务的状态会被保存到这个内存段里。当要恢复执行这个任务时 . 会先根据这个 记录来把寄存器 的值恢复回来。 T S S 中 , 在宇节偏移为 10 0 的 1 6 位字 (w o r d ) 的最低位是 调试陷阱标志位 . 简称 T标志 。 T 标志被设置为 1, 那么 当CPU切 换到这个任务时 , 便会产生调试异常。 准确的说CPU 是在程序控制权已经转移到新的任务 , 但还没有开始执行新任务的第一 条指令前产 生异常的。 调试 中断处理程序可 以通过调试状态 寄存器 (DR6 ) 的 BT来识别出发生的是否是任务切换异 常。 值得注意的是 . 如果调试器接管了调试异常处理 . 而且该处理例程 属于一个独立 的任务 , 那 么一定不要设置该任务的TSS段中的 T位。否则便会导致死循环。

分支到分支单步执行标志 ( B T F )

在 lA 一 3 2 处理器 家族中 , 所有的 P e n i t u m Pr o  Pe n t l u m II和 Pe n t . u m III处理器 (包括相应的 C e .e r o n 《赛扬 ) 和 X e o n (至 强 ) 版本) 因为都是基于相 同的 6 P 内核 《C o r e ) . 因此经常被 统称为P6处理器 。 P6处理器引入 了一项新 的对调试非 常有用 的功能 : 记录 分支 、 中断和异 常事件 . 和 针对分 支单步执行(s in g le 一s z e p o n b r a n e h ) 的功能。 奔腾 4 处理器对这一功能又 做了很大增强 。 下面具体介绍一下按分支单步执行的功能和使 用方法 。
首先解释一下分支到分支单步执行的含义 。 前面我们介绍过 , 当 ElF a g s 寄存器的T F 位为 1 时 C PU 每执行完一条指 令便会中断到调试器 . 也就是以指令为单位单步执行 。 顾名思义, 针对分支单步执行就是以分支为单位单步执行 。换句 话说 , 每单步 ( s t ep ) 一次 C P U 会一直执行到有分支 、 中 断或异常发生 。 下面 结合表 2 中的代码进行说明。
那么如何启用按分支单步执行呢? 简单来说 . 就是要 同时设置TF和BTF 标志 。TF 标志位干 E F Ia g s寄存器, 大家已经很 熟悉。 BTF 标志位于名为DebugCtlMSR 寄存器中 。

 

 

 

在V C6 的ID E 环境下(系统的 C PU 应该是 P6 或更高 ),先在第 2 2 行设置一个断点 . 然后按F5 运 行到这个断点位置 。 行 1 9是用来启用按分支单步执行功能的 . 也就是设置BTF 标志. 细节我们等一下再讲。 接下来 . 我们按 F10单步执行 , 会发现一下子会执行到行 3 1 . 从第 2 2 行单步一次执行到第 3 1 行 这便 是按分支单步执行的效果。 那么为什么会执行到第 3 1 行呢? 按照分支到分支单步执行的定义 , CP U 会在执行到下一次分支、 中 断或异常发生时停止。 对于我们的例子 . CP U 在执行第 2 0 行对 应的第一条汇编指令时 , CP U 会检测到 TF 标志 (因为我们是按F10单步执行 . 所以 VC 6 会帮助我们设置TF 标志)。

此外 ,P6及以后的 lA 一 3 2 CP U 还会检变 BTF 标志 . 当发现BTF标志也被设置起时 , C P U会认为当前是在按分支单步执行 . 所 以会判断当前指令是否是分支指令 , 如果不是 ,CP U 便会继续执 行。 因为第 2 2 行的第一条汇编指令不是分支指令 . 所以 CP U 会 继续执行。 依次类推 . CP U 会连续执行到第 2 4 行的 i f 语句对应的最后一条汇编指令jne (参见表 3 )。 因为这条语句是分支语句 ,所以 CPU会认为已经满足停止执行的条件 , 在清除 , TF 和BTF标志后,产生单步异常中断到调试器 。 中断到调试器时 . 分支语句已经执行完毕 , 但是跳转到的那条语句 (即表 2 中的行 3 1 } 还 没有执行 。

 

对以上过程还有有几 点需要说明:

第一 如果在从第 2 2 行执行到 2 4 行过程中,有中断或异 常发生 , 那么 C P U 也会认为停止单步执行的条件已经满足. 因 此 按分支单步执行的全称是按分支 、 异常和中断单步执行。

第二 , CPU 认为有分支发生的条件是执行以下分支指令 :JM P (无条件跳转), JCC (包括条件跳转指令 , 如 J A 、 J A E、 JNE等等)、L OO P (循环 ) 和 Call( 函数 / 过程调用 ) 。

第三 , 由于只有内核代码 才能访问MS R 寄存器 (通过RDMSR 和四WRMSR 指令) 所以上面的例子 中 , 在 WriteMSR()函数中 我们使用一个未公开 的 A p l  ZwSystemDebugControl()来设置 BTF 标志。

有很多因素决定了调试在编程乃至整个计算机世界中的无比重要性。从根本上来说,是计算机系统的设计理念决定了调试的重要地位。现代计算机系统的一个重要设计原则是让硬件在软件的指挥下工作,把灵活和智能留在软件中实现,这同时也把计算机系统的控制权交给了软件。让软件控制强大的计算机硬件是聪明的,运行不同的软件就可以让同一台机器做完全不同的事情也是冒险的,一条错误的指令就可能让系统崩溃甚至导致灾难。 降低风 险的方法是提高软件的质量和我们对软件的控 制力。 但对人 类而言 , 无形 的软件比有形 的硬件更加难以驾驭 ! 软件瑕疵 总是存在但却难以寻找 ; 病毒和恶意软件不请 自来 , 而且挥之不去 ; C P U 不堪重负 , 用 户下达的命令却还得不 到执行。 相对于淳朴的硬件 , 软件更 加复杂 、 多变、 柒鹜、 事故和狡黯 ! 何以应对? 为了控制软件 , 计算机先 驱在一开始设计计算机系统时就设计了各种调试设施 , 包括单步执行和 中 断执行等。 今天我们将这些 功能纳入 到调试器工具中。 一旦进 了调 试器 , 再狂野的软件都会变得服服帖帖 , 所有的宏观结构和微观 细节任由我们审 阅 , 大到整个地址空 间和每个文件 , 小到每一个内存单元和每一条指令 。 如果静态的分析还不够 , 那么我们可 以让 它根据我们的要求来执行 , 执行 一 条指令 , 执行到下一个分支 , 执行到上一级 函数 , 等等。 总之 , 以调试 器 为核心的调 试技术是征服软件和计算机世界的最强大工具 , 其用途很广。

具 体地讲 , 首先 , 调试是 定位软件瑕疵的最直接和最 有效的方法 。 没 有哪个程序员能一下子 写出没有错误 的代码。 而使用 以调试器 为主的调试 工具进行调试是定位瑕疵的最直接方法 , 可以从 问题 的症状入手 , 正 向跟 踪或者反 向追 溯。 对于大多数瑕疵 , 使用合适的调试方法可 以大大提高定 位到问题根源 的效率 。 今天的软件环境在不断向着大型化 、 并行化、 复杂 化 方向发展 , 定位瑕疵的难度也在随之不 断提高。 完全靠读源代码来寻找 b u g 的方法已经很难适应今天的软件发展形势 。 另外 , 枚举和排除法通常 也会因系统中的软硬件模块数量太 多而难 以实施 , 有时候 , 花了几天时间 来做替换仍然找不 到怀疑对 象。

第二 , 调 试可 以帮助程序员提高编写代码 的能力。 因 为调试可 以让程 序员彻底了解程 序的实际执行过程 , 检查与自己设计时的预想是 否一致 , 如果 不一致 , 那 么很可能预示 着有问题存在 , 应该引起重视 。 另外 , 调试 过程可 以让 程序员更好的认识到 提高代码可调试性和代码质量 的重要性 。 从此 , 自觉的改进编码方式 , 合理 添加用来支持调 试的代码 。 编码 和调 试 是程序员 日常工作中的两个最 主要任务 , 这两个任务是相辅相成的 , 编写 具有可 调试性的高质量代码 , 可 以明显提高调试效率 , 节约调试时间。 另 一方面 , 调试可 以让程 序员真切感受程序的实际执行过程 , 反思编码和 设 计中的问题 , 加深对软件和 系统的理解 , 提高对代码的感知力和控制力。

第三 , 调试工具是学习计算机系统和其它软硬件知识 的好帮手 。 通过 软件调试技术的强大观察能力和 断点、 栈回溯 、 跟踪等功能可 以快速的了 解一个软件和系统的模块 、 架构 、 和工 作流程 , 因此是学习 其它软硬件技 术的一 个快速而有效的方 法 。