【第50节】Windows编程必学之从零手写C++调试器上篇(仿ollydbg)

目录

引言

一、调试器实现概览 

1.1 调试器是什么

1.2 调试器的两种类型 

1.3 调试器的核心功能

二、CPU对调试的硬件支持  

2.1 CPU的中断和异常  

2.2 CPU的中断表(IDT)  

2.3 陷阱标志和调试寄存器  

三、异常分发与调试子系统  

3.1 异常分发机制  

3.2 调试子系统 

3.3 调试事件  

四、调试器工作流程  

4.1 调试器与调试子系统的交互

4.2 调试器与用户的交互 


引言

        从零开始手写window平台C++调试器需融合多领域知识:需精通C++语法与x86汇编、内存管理与多线程编程,熟悉Windows API对进程/内存/事件的操控;掌握windows原理、异常分发机制及PE文件格式;理解CPU寄存器、中断/异常原理与调试寄存器功能;还要深入断点(软件/硬件/内存访问)、单步执行及符号解析等调试核心技术。这些知识共同构成从底层机制到上层功能实现的完整链条,是打造高效调试器的必要基础。基础较差的同学请先查看学习前面章节及本人其他专栏。

一、调试器实现概览 

1.1 调试器是什么

        当一个二进制可执行文件被加载器载入内存并开始运行后,如果没有错误,它会一直运行到正常结束。要是运行时出错,而且程序里没有处理异常的逻辑,操作系统就会捕捉到让程序崩溃的异常,还会弹出窗口结束这个进程。 

        想找出程序崩溃的原因,一般办法是查看代码来排查逻辑错误。可要是遇到隐藏的逻辑漏洞,这种办法效率很低。要是能在程序出现异常时,让它在出错的指令那里停下来,用可控的方式一步一步执行程序,同时实时查看内存和寄存器的状态变化,找错就会容易很多。这种通过工具控制程序执行并分析状态的操作叫做调试,实现这些功能的软件就是调试器。

1.2 调试器的两种类型 

(1)汇编级调试器
        在展示被调试进程的指令时,只提供将二进制机器码翻译后的汇编语言代码。进行单步调试时,最小单位是一条汇编指令。  

(2)源码级调试器
        一般在编译可执行文件时,会生成对应的调试符号文件(例如PDB文件),文件中存储着调试行号、函数名、变量名等信息。源码级调试器通过读取调试符号文件,把二进制代码和源代码关联起来,以源代码的形式显示调试信息,单步调试的最小单位是一条源代码语句(一条源代码通常对应多条汇编指令)。

1.3 调试器的核心功能

(1)异常捕获:当被调试进程出现异常时,调试器需及时接收异常信息。  
(2)执行控制:能对被调试进程的运行状态进行操控,如暂停、继续、单步执行等。  
(3)状态获取:可获取被调试进程的内存数据、寄存器状态等信息。  
(4)符号支持(源码级调试器):需解析调试符号文件,建立二进制代码与源代码的对应关系。  

        被调试进程与调试器是独立进程(各有4GB虚拟地址空间),且被调试程序无需预先添加调试支持代码,两者的交互依赖操作系统底层功能。例如,调试器捕获被调试进程异常的过程为:CPU检测到异常→通知操作系统→操作系统将异常转发给调试器。因此,实现调试器需深入理解程序异常产生机制及CPU与操作系统的异常处理流程。

二、CPU对调试的硬件支持  

2.1 CPU的中断和异常  

        在8086 CPU中,中断被分为两类:内部中断和外部中断。而在80386 CPU以及后续的x86架构中,外部中断被称为“中断”,而内部中断则被称为“异常”。这些CPU最多可以支持256种不同的中断或异常类型,并且通常会在两条指令之间响应这些中断或异常。

        外部中断是由外部硬件设备(例如鼠标、键盘)触发的。当发生外部中断时,硬件会通过中断信号向CPU发送一个8位的中断号,然后CPU根据这个中断号调用相应的处理程序来处理中断。

        内部中断(异常)则是由CPU自身产生的,它们可以进一步细分为以下三类:

        (1)故障(Faults):这类异常通常是可纠正的错误。比如,在访问未加载到内存中的页面时(P=0),异常处理程序可以分配内存并恢复状态,使得CPU能够回到故障指令之前的状态,从而重新执行该指令。

        (2)陷阱(Traps):主要用于调试目的,如单步中断`INT 3`或者溢出中断`INTO`等。当陷阱中断发生后,CPU会在栈中保存当前指令的下一条指令地址,以确保在处理完中断后程序能继续从正确的位置开始执行。

        (3)终止(Aborts):这类异常通常表示严重的错误情况,比如硬件故障或者是无效的系统表。在这种情况下,很难确定具体是哪条指令导致了错误,因此程序往往无法从中恢复,操作系统一般会选择终止相关的任务。

CPU内部中断向量表

2.2 CPU的中断表(IDT)  

        80386 CPU处于保护模式时,借助**中断描述表(IDT)**来处理中断和异常。IDT是一个数组,最多能容纳256个函数地址,由操作系统在初始化阶段完成填充。当CPU产生内部中断时,会依据中断向量号从IDT中查找对应的处理程序地址。

        举例来说,若应用程序执行空指针赋值操作,引发内存访问异常,CPU就会调用IDT中对应的异常处理程序。此时,操作系统会先保存现场信息(包括栈帧、寄存器等),若存在调试器,就将异常信息转发给调试器;若没有调试器,则把异常交给程序自身的异常处理机制(如SHE、VEH)来处理。

2.3 陷阱标志和调试寄存器  

        (1)陷阱标志(TF,也就是标志寄存器EFLAGS的第1位):要是TF的值为1,CPU每执行完一条指令就会主动触发异常(也就是单步中断),这个机制可用于实现调试器的“单步步入”功能。

        (2)调试寄存器(DR0到DR7):
- DR0 - DR3:这几个寄存器用于保存断点地址,不过它们是否启用由DR7寄存器来控制。
- DR7(调试控制寄存器):
        - 读写域(RW0 - RW3):用来指定断点触发的条件,比如执行指令时触发、写数据时触发,或者读写数据时都触发。
        - 长度域(LEN0 - LEN3):用于指定断点地址对应的区域长度,可以是1字节、2字节、4字节,也可能是未定义的长度。
        - 局部断点(L0 - L3):只会在当前的任务(也就是进程)中启用断点。
        - 全局断点(G0 - G3):理论上能在所有任务中启用断点,但调试器一般没办法设置出全局效果。
- DR6(调试状态寄存器):会记录触发中断的断点来源,其中B0 - B3分别对应DR0 - DR3。

        (3)DR7 寄存器的不同位域控制着断点的不同属性,具体如下:

- 对于 DR0 对应的断点:
        - 通过 DR7 的 L0 和 G0 来控制该断点地址是局部生效(仅当前任务)还是全局生效。
        - DR7 的 LEN0 用于设置此断点触发中断对应的地址区域长度,可以是 1 字节、2 字节或者 4 字节。
        - DR7 的 RW0 用来指定该断点的中断类型,包括读写操作触发或执行操作触发。
- 对于 DR1 对应的断点:
        - DR7 的 L1 和 G1 决定该断点地址的生效范围是局部还是全局。
        - DR7 的 LEN1 能设置此断点触发中断的地址区域长度为 1 字节、2 字节或 4 字节。
        - DR7 的 RW1 可指定该断点是因读写操作还是执行操作而触发中断。
- 对于 DR2 对应的断点:
        - DR7 的 L2 和 G2 控制该断点地址是局部有效还是全局有效。
        - DR7 的 LEN2 可将此断点触发中断的地址区域长度设置为 1 字节、2 字节或者 4 字节。
        - DR7 的 RW2 用于指定该断点是由读写操作还是执行操作触发中断。
- 对于 DR3 对应的断点:
        - DR7 的 L3 和 G3 确定该断点地址的生效范围是局部还是全局。
        - DR7 的 LEN3 能设置此断点触发中断的地址区域长度为 1 字节、2 字节或 4 字节。
        - DR7 的 RW3 可指定该断点是因读写操作还是执行操作触发中断。

        借助上面说的这些硬件机制,CPU为调试器提供了设置断点、单步执行等核心功能的底层支持,让调试器能够精确控制被调试进程的执行流程,还能捕捉进程的状态变化。

三、异常分发与调试子系统  

3.1 异常分发机制  

        Windows系统把处理异常的函数地址存放在IDT(中断描述表)里,当CPU产生内部中断时,就会调用IDT中对应的函数,Windows从而接管异常处理。异常可能来自3环(用户态)的任何进程,此时操作系统需要定位目标进程:  
        如果目标进程处于调试状态,操作系统会找到调试该目标进程的调试器进程,并通过`WaitForDebugEvent`函数将异常信息发送给调试器进程;要是调试器进程没调用这个函数,就获取不到这些异常信息。  
        如果目标进程没被调试,操作系统会调用进程已经注册过的异常处理程序(比如SHE、VEH等)。  

        操作系统接管异常后,根据不同条件将异常分发给相应处理方的这个流程,叫做异常分发。要是没有Windows操作系统,这套异常分发机制就不存在了。

        这里有两个关键问题:Windows怎样判断进程是否处于调试状态?Windows如何确定调试器进程?

3.2 调试子系统 

Windows 调试子系统由以下三部分构成:  
1. ntdll 中的支持函数
   这类函数用于 3 环(用户态)与 0 环(内核态)之间的通信,是向用户层暴露的 API。  
2. 内核中的支持函数 
   主要功能是采集和传递调试事件,并对被调试进程进行控制。  
3. 调试子系统服务器
   承担调试会话和事件的管理任务,是调试消息的核心集散中心。


调试会话建立流程:  
- 当调用创建进程函数并传入调试标志时,调试子系统会执行以下操作:  
        - 为被调试进程的内核对象 **调试端口** 赋值。  
        - 在调试进程的线程内核对象中添加调试对象句柄。  
        - 完成上述步骤后,创建被调试进程。  
- 被调试进程创建过程中,内核会调用进程创建、线程创建、模块加载等函数。调试子系统在这些函数中预设检查逻辑:若判断进程处于被调试状态(通过调试端口是否为空判断),则向调试器进程发送调试事件(如进程创建、线程创建、模块加载等)。

参考资料:张银奎《软件调试》第 4 章和第 9 章。  

3.3 调试事件  

        Windows把发生在被调试进程里的特定事件叫做**调试事件**,这些事件由调试子系统捕获后发送给调试器。常见的调试事件有以下这些:

四、调试器工作流程  

        调试器一般有两个线程:一个用来等待调试事件,另一个用于和用户交互。不过有些控制台调试器通过单个线程也能实现。下面从两个方面来解析调试器的功能:

4.1 调试器与调试子系统的交互

        调试事件全部由调试子系统产生,调试器需要借助子系统提供的接口,来建立与被调试进程之间的联系。

(1)建立调试会话  

        可以通过以下API将普通进程转变为调试器进程:  
1. `CreateProcess`
        - 在创建进程时传入调试标志,使新创建的进程成为被调试进程,调用该函数的线程成为调试器线程。  
        - 注意:若调试器存在多个线程,只有调用 `CreateProcess` 的线程能等待调试事件,其他线程无法接收事件。  
2. `DebugActiveProcess` 
        - 用于附加到一个已运行的进程,使其成为被调试进程,功能与 `CreateProcess` 类似,但适用于已启动的进程。

(2)接收调试事件  

        调试器与被调试进程建立合法连接后,调试子系统会在调试事件产生时将信息发送到调试进程(类似窗口消息的传递机制)。  
        调试器需要调用 **`WaitForDebugEvent`** 函数来等待调试事件,这个函数会使调用它的线程进入挂起状态,直到调试事件到达才会返回,并带回调试信息(这些信息以结构体的形式存储)。 

调试事件与结构体对应关系:  

        调试器的核心功能主要通过对不同类型事件的处理逻辑来体现。

(3)回复调试子系统  

        调试子系统在发送事件后会暂停被调试进程的运行,直到收到调试器的回复:  
        调试器处理完事件后,需要调用 **`ContinueDebugEvent`** 函数向调试子系统回复处理结果,支持以下两种回复类型:  

  1. `DBG_CONTINUE`:表示事件已处理,调试子系统会让被调试进程从异常发生的位置继续执行(不会调用进程自身的异常处理程序)。  

  2. `DBG_EXCEPTION_NOT_HANDLED`:表示事件未处理,调试子系统会将异常处理权交还给被调试进程的异常处理程序。

4.2 调试器与用户的交互 

        调试器在接收到调试事件后,需要向用户展示相关信息并接收控制命令,主要操作如下:  

(1)等待输入:接收用户指令,例如查看内存、寄存器状态、反汇编代码等操作请求。  

(2)根据命令操作被调试进程:  
        - 通过 `ReadProcessMemory`/`WriteProcessMemory` 读写进程内存。  
        - 使用 `GetThreadContext`/`SetThreadContext` 读写线程环境块(包含寄存器数据)。  
        - 设置断点(软件断点或硬件断点)。  
        - 执行单步调试(通过设置陷阱标志 **TF** 实现)。  

        通过上述流程,调试器形成了从事件捕获、用户交互到进程控制的完整调试闭环。

 

评论 28
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

攻城狮7号

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值