英文名:Software Debugging 作者:张银奎
第1编 绪论
第1章 软件调试基础
一个完整的软件调试过程:
1)重现故障
2)定位根源(最困难也是最关键的步骤)
3)探索和实现解决方案
4)验证方案(又称回归测试)
导致软件调试复杂性的几个因素:
1)定位软件是一个很复杂的搜索问题,必须通过大量分析才能逐步接近真正的内在原因
2)很多时候必须深入到被调试模块或系统底层,研究内部的数据和代码
3)调试着必须有丰富的知识,熟悉问题域内的各个软硬件模块,以及他们的协作模式
4)每个软件调试任务都有特殊性,即很难找到两个调试任务是一样的
5)软件的大型化、层次的增多、多核和多处理器系统的普及都在增加软件调试的难度
第2编 CPU的调试支持
第2章 CPU的调试支持
很多软件工程师的一个弱点是对硬件了解的太少,甚至不愿意去学习硬件知识。事实上,了解必要的硬件知识对理解软件经常会有事半功倍的效果,扎实的硬件基础对于软件工程师来说也是非常重要的
第3章 中断和异常
中断:由CPU外部的输入输出设备(硬件)所触发的,供外部设备通知CPU“有事情要处理”,目的是使CPU暂时停止执行当前正在执行的程序,转去执行中断请求所对应的中断处理例程(ISR)
异常:CPU在执行指令时因为检测到预先定义的某个(或多个)条件而产生的同步事件,来源主要是三种;程序错误、特殊指令(如INT 3)和机器检查异常
异常来源于CPU本身,是CPU主动产生的;而中断来自于外部设备,是中断源发起的,CPU是被动的。
异常的分类:
1)错误(Fault)(开始执行导致异常的指令时,保存导致异常的指令)
2)陷阱(Trap)(执行完导致异常的指令时,保存导致异常的指令的下一条指令)
3)终止(Abort)(发生状态不确定,同时不可以恢复执行)
实模式下IA-32 CPU响应中断和异常的全过程:
1)将代码段寄存器CS和指令寄存器EIP的低16位压入堆栈
2)将标志寄存器EFLAGS的低16位压入堆栈
3)清除标志寄存器的IF标志,以禁止其他中断
4)清除标志寄存器的TF(Trap Flag)、RF(Resume Flag)、AC(Alignment Check)
5)使用向量号n作为索引,在IVT中找到对应的表项(n*4+IVT表基地址)
6)将表项中的段地址和偏移地址分别装入CS和EIP寄存器中,并开始执行对应的代码
7)中断例程总是以IRET指令结束。IRET指令会从对战中弹出前面保存的CS、EIP和标志寄存器值,然后返回执行被中断的程序
第4章 断点和单步执行
软件断点:
当CPU执行INT 3指令时,它会跳转到异常处理例程,让当前的程序接受调试,调试结束后,异常处理例程使用中断返回机制让CPU再继续执行原来的程序。
在Windows系统中,操作系统的断点异常处理函数(KiTrap03)对于x86 CPU的断点异常会有一个特殊处理,会将EIP减1。出于这个原因,我们在调试器看到的程序指针指向的依然是INT 3指令的位置,而不是它的下一条指令。主要是出于以下两个目的:
1)调试器在落实断点时,不管所在位置的指令是几个字节,它都只替换一个字节。因此,如果程序指针指向下一个指令位置,那么只想的可能是原来的多字节指令的第二个字节,不是一条完整的指令。
2)因为有断点在,所以被调试程序在断点位置的那条指令还没有执行。按照程序指针总是指向即将执行的那条指令的原则,应该把程序指针指向这条要执行的指令,也就是倒退回一个字节,指向本来指令的起始地址。
软件断点的局限性:
1)属于代码类断点,即可以让CPU执行到代码段内的某个地址时停下来,不适用于数据段和I/O空间
2)对于在ROM(如BIOS或其他固件程序)中执行的程序,由于目标是只读,无法动态写入断点指令
3)在中断向量表或中断描述表(IDT)没有准备好或遭到破坏的情况下,这类断点是无法或不能正常工作的,只能使用硬件级调试工具
硬件断点:
在保护模式下,我们不能使用调试寄存器来针对一个物理内存地址设置断点
三种调试断点的情况
1)读/写内存中的数据时中断
2)执行内存中的代码时中断
3)读写I/O端口时中断
高级语言的单步执行在大多数调试器下使用TF标志一步步地走过每条汇编指令,如果一条语句对应N条汇编指令,那就是产生N次调试异常,中间的N-1次都是简单地重新设置起TF标志,便恢复被调试程序执行,不中断给用户
第5章 分支记录和性能监视
使用寄存器的分支记录
LBR栈是一个环形堆栈,由数个用来记录分支地址的MSR寄存器(称为LBR MSR)和一个表示栈顶(Top Of Stack)指针的MSR寄存器(称为MSR_LASTBRANCH_TOS)构成。CPU在把新的分支记录放入这个堆栈前会先把TOS加1,当TOS达到最大值时,会自动归0
使用内存的分支记录
分支踪迹存储BTS(Branch Trace Store)机制,允许把分支记录保存在一个特定的被称为BTS的缓冲区的内存内。BTS缓冲区与用于记录性能监控信息的PEBS(Precise Event-Based Sampling,即精确的基于时间采样)缓冲区是使用类似的机制来管理的,这种机制被称为调试存储区(Debug Store),简称为DS存储区
DS存储区由管理信息区、BTS缓冲区、PEBS缓冲区组成,具有以下特点
1)全部在非分页内存中
2)包含DS缓冲区的内存页必须被映射到相同的物理地址
3)不与代码位于统一内存页面中,以防止CPU写分支记录时会触发防止保护代码页的动作
4)DS位于活动状态时,要防止进入A20M模式
5)DS应该仅用在启用了APIC的系统中
使用硬件和系统提供的性能监视工具,比使用调用系统时间计算差的方法要准确很多,对后续工作如性能监视和软件调优都有很重要的意义
第6章 机器检查架构(MCA)
IA-32处理器的机器检查(Machine Check)机制基本原理:CPU先收集好要记录的信息,并把它们存储到特定的寄存器或内存区域中,然后通过产生异常的方式把控制权交给软件,接下来,软件将这些信息懈怠外部存储器(如硬盘)上永久记录下来。
EDX寄存器的MCA(位14)和MCE(位7)分别表示处理器是否实现了机器检查架构和机器检查异常
Windows NT 6以后的系统设计了更完善的体制,称为WHEA(Windows Hardware Error Architecture)
第7章 JTAG调试
JTAG调试,即基于JTAG(Joint Test Action Group)技术的硬件调试工具。硬件调试工具的最大优点就是不需要在目标系统上运行任何软件,可以再目标系统还不具备基本的软件环境时进行调试,因此,JTAG调试非常适合调试BIOS、操作系统的加载程序,以及使用软件调试器南一条是的特殊软件。
JTAG的核心思想就是将测试点和测试设施集成在芯片内部(build test facilities/test points into chip),并通过一组标准的信号(借口)向外输出测试结果,这些标准信号被称为TAP(Test Access Port)信号。有了标准的TAP信号,那么基于JTAG技术的调试工具就可以与这个芯片通信,而不必关心它内部的实现细节。这样,一个JTAG调试工具就可以比较容易地调试很多种芯片。芯片内部通常需要实现一个边界扫描链路和一个TAP控制器。
第2编 操作系统的调试支持
第8章 Windows概要
每个WIndows进程除了虚拟地址空间,还有以下资源:
1)一个全局的文艺的进程ID(Client ID),简称为PID
2)一个可执行映像(image),也就是该进程的程序文件(可执行文件)在内存中的表示
3)一个或多个线程
4)一个位于内核空间中的名为EPROCESS(executive process block,即进程执行块)的数据架构,用以记录该进程的关键信息,包括进程的创建时间、映像文件名称等
5)一个位于内核空间中的对象句柄表,用以记录和索引该进程所创建/打开的内核对象。操作系统根据该表格将用户模式下的句柄翻译为志向内核对象的指针。
6)一个用于描述内存目录表起始位置的基地址,简称页目录基地址(DirBase),当CPU切换到该进程/任务时,会将该地址加载到CR3寄存器,这样当前进程的虚拟地址才会被翻译为正确的物理地址
7)一个位于用户空间中的进程环境块(Process Environment Block,简称PEB)
8)一个访问权限令牌(access token),用于表示该进程的用户、安全组,以及优先级别
第9章 用户态调试模型
Windows下进行用户态调试时,参与的角色有调试器进程(Debugger Process)、被调试进程(Debuggee Process)、调试子系统,调试API,以及位于NTDLL和内核中的支持函数
Debugger Process是调试过程的主导者,它负责发起调试对话,读取和处理调试事件,并通过用户界面接受调试人员下达的指令,然后执行。调试器进程通过调试API与系统的调试支持函数和调试子系统交互。
Debuggee Process是调试的目标。为降低海森伯效应,应尽可能少地向被调试进程中加入支持调试的设施,以免影响问题的重现和分析。
调试子系统是沟通被Debugger Process和Debuggee Process的桥梁,它的指责是帮助调试器完成各种调试功能,比如控制和访问被调试进程,管理和分发调试事件,接收和处理调试器的服务请求。
XP以后的系统是以内核对象DebugObject为核心,结构如下:
typedef struct _DEBUG_OBJECT
{
KEVENT EventsPresent; //+0x00 用于指示有调试事件发生的事件对象(WaitForDebugEvent所等待的对象)
FAST_MUTEX Mutex; //+0x10 用于同步的互斥对象(用于锁定对这个数据结构的访问)
LIST_ENTRY StateEventListEntry; //+0x30 保存调试时间的链表(调试消息队列)
ULONG Flags; //+0x30 标志
} DEBUG_OBJECT, *PDEBUG_OBJECT;
建立程序调试对话的两周情况:
1)在调试器中启动被调试程序。系统在创建进程时,会把调试器线程的TEB结构的DbgSsReserved[1]字段中保存的调试对象句柄传递给创建进程的内核服务,然后内核中的进程穿件函数会将这个句柄所对应的对象指针赋给新创建进程的EPROCESS结构的DebugPort字段
2)把调试器附加到一个已经运行的进程中。系统调用内核中的DbgkpSetProcessDebugObject函数来将一个创建好的调试对象附加到其参数所指定的被调试进程中。除此之外还会调用DbgkpMarkProcessPeb函数设置进程环境块(PEB)的BeingDebugged字段。
第10章 用户态调试过程
调试器的主要功能分成如下两个方面:
1)人机接口(UI线程):以某种界面的形式将调试功能呈现给用户,并坚挺和接受用户的输入(命令),在收到用户收入后,进行解析和执行,然后把执行结果显示给用户。
2)与被调试进程交互(Debugger's Worker Thread线程,简称DWT):包括与被调试进程建立调试关系,然后坚挺和处理调试事件,根据需要将被调试进程中断到调试器,读取和修改被调试进程的数据,或者操纵它的其他行为
处于被调试状态的Windows进程与普通进程相比,有如下差异:
1)EPB的DebugPort字段不为空(志向DebugObject对象)
2)PEB的BeingDebugged字段不等于0
3)可能会存在一个由调试器远程启动的线程,这个线程的作用是将被调试进程中断到调试器,称之为RemoteBreakin线程
4)响应调试热键(F12),按调试热键可以将处于被调试状态的进程中断到调试器
系统在创建进程时,会检查创建报纸是否包含DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS标志,如果包含,那么系统会把调用进程当做Debugger进程,吧新创建的进程当做Debuggee,为二者建立起调试关系,主要执行以下3个动作。
1)在进程创建的早期(执行内核服务NtCreatProcess/NtCreatProcessEx)之前,调用DbgUiConnectToDbg()使调用线程与调试子系统建立连接。在Windows XP以后,DbgUiConnectToDbg()内部会调用ZwCreateDebugEvent创建DEBUG_OBJECT内核对象,并将其保存在TEB的DbgSsReserved[1]字段中。
完成这一步以后,调用线程便由普通线程晋级为DWT了。
2)当调用进程创建内核服务NtCreatProcess或NtCreatProcessEx时,将DbgSsReserved[1]字段中记录的对象句柄以参数(第7个参数)形式传递给内核中的进程管理器。接下来,内核中的进程创建函数(PspCreateProcess)会检查这个句柄是否为空,如果不为空,会取得它的对象指针,然后设置到EPROCESS的DebugPort字段中。
完成这一步以后,新创建进程便由普通进程晋升为调试自启动里的Debuggee了
3)当PspCreateProcess调用MmCreatePeb函数创建新进程的PEB时,MmCreatPeb函数内部会根据EPROCESS结构的DebugPort字段设置BeingDebugged字段。如果DebugPort不为空,那么BeingDebugged会被设置为1(为真)。
当新进程的初始线程在自己上下文中初始化时,作为进程初始化的一个步骤,NTDLL.DLL中的LdrpInitializeProcess函数会见车正在初始化的进程是否处于被调试状态(查询进程环境块的BeingDebugged字段),如果是,它会调用DbgBreakPoint()触发一个断点异常,目的是中断到调试器。这相当于系统在新进程中为我们设置了一个断点,这个断点通常被称为初始断点。
另外一种Attach到已经运行的进程的调试方法,主要是通过DebugActiveProcess()API来完成的,其内部工作过程如下:
1)通过DbgUiConnectToDbg()使调用进程与调试子系统建立连接,实质上就是活的一个调试通信对象并存放在当前线程TEB结构的DbgSsReserved数组中。
这与调试一个新进程的第一步相同。
2)调用ProcessIdToHandle函数,获得指定进程ID的进程句柄,这个函数内部会调用OpenProcess()API,进而调用NtOpenProcess内核服务。
这一步需要调用进程与目标进程有同样或更高的权限,否则会出现“Access is denied”错误
3)调用NTDLL中的DbgUiDebugActiveProcess。这个函数内部主要调用NtDebugActiveProcess内核服务,并将要调试进程的句柄(参数1)和调试对象的句柄(参数2)作为参数传递给这个内核服务。
NtDebugActiveProcess内部主要执行以下三个动作:
1、根据参数中指定的句柄去的被调试进程的EPROCESS结构和调试对象的指针
2、向调试对象发送杜撰的调试事件
3、调用DbgkpSetProcessDebugObject函数,这个函数内部会将调试对象设置到被调试进程的调试端口(DebugPort字段),并调用DbgkpMarkProcessPeb来设置BeingDebugged字段
以上操作都成功后DebugActiveProcess()会返回真,通知调用进程已经成功建立调试对话。接下来调试器便进入调试事件循环开始接收和处理调试事件了。它首先会受到一系列杜撰的调试事件,包括进程创建、模块加载等。最后受到远程中断线程产生的断电事件,接收器受到这一事件后,通常会停下来报告给用户。
中断到调试器的几种方法:
1)初始断点DbgBreakPoint
2)编程时加入断点DebugBreak(x86下,等价于INT 3)
3)通过调试器设置断点(动态插入)
4)通过远程线程触发异常DebugBreakProcess
5)在线程当前执行位置设置断点
6)动态调用远程函数
7)挂起中断
8)调试热键(F12)SrvActiveDebugger
9)窗口更新
Windows下,使用OutputDebugString()API来输出调试字符串。这个函数利用RaiseException()API产生一个调试打印异常(DBG_PRINTEXCEPTION_C),RaiseException()被调用以后,会产生一个标准的异常结构EXCEPTION_RECORD,然后调用内核中的异常分发函数KiDispatchException,此函数会调用支持用户态调试的内核例程DbgForwardException向调试子系统同胞异常。
调试器的DWT通过WaitForDebugEvent调用NTDLL中的DbgUiWaitStateChange来等待调试事件。在接收到异常事件后,如果异常代码等于DBG_RPINTEXCEPTION_C,那么它们都会将事件代码字段(dwDebugEventCode)设置为OUTPUT_DEBUG_STRING_EVENT,而不是EXCEPTION_DEBUG_EVENT,并将异常参数中的调试信息填写到DEBUG_EVENT结构的DebugString子结构中。
调试器收到OUTPUT_DEBUG_STRING_EVENT事件后,会从其参数中得到调试信息字符串的地址和长度,然后使用内存访问函数从被调试进程的空间中读取调试信息字符串,然后显示出来。显示后,调试器默认会立即调用ContinueDebugEvent回复此事件,表明自己处理了该异常,于是KiDispathException函数结束异常分发,被调试程序便继续正常运行了。
终止调试回话的4种方式
1)被调试进程退出:PspExitThread来退出线程,PspProcessDelete做最后的进程清理和删除工作
2)调试器进程退出:执行以下7个步骤
1、[WinDBG]调用系统服务NtTerminateProcess,开始终止WinDBG进程
2、[WinDBG]执行PspExitThread函数,WinDBG的DWT退出
3、[WinDBG]执行PspExitThread函数,WinDBG的UI线程退出
4、[WinDBG]执行ObKillProcess函数,开始清理WinDBG进程的句柄表
5、[WinDBG]执行DbgkpCloseObject函数,关闭调试对象,将被调试进程的DebugPort设为空,并调用DbgkpMarkProcessPeb设置PEB的BeingDebugged字段,并引发终止Debuggee
6、[Debuggee]执行PspExitThread,Debuggee退出
7、执行PspProcessDelete和MmDeleteProcessAddressSpace函数,删除Debuggee和Debugger的内核对象和进程空间
每一步骤前的方括号内是函数执行的进程上下文,最后一步可能是在系统服务进程(svchost.exe)环境内执行的,也可能是在系统进程内执行的。
3)分离(detach)被调试进程,使用DebugActiveProcessStop,内部是调用NTDLL中的DbgUiStopDebugging函数,此函数调用内核服务NtRemoveProcessDebug。NtRemoveProcessDebug内部调用DbgkClearProcessDebugObject。后者就是去除Debugger的DebugPort字段对调试对象的引用,并将其设置为NULL。将DebugPort设为空后,DbgkClearProcessDebugObject会便利调试对象的调试事件队列,并删除有关这个Debuggee的事件。
4)退出时分离:在调用终止进程的函数前,DbgkpCloseObject会检查调试对象的Flags是否包含KillOnExit标志,如果清除了这个标志,那么便不会退出Debuggee。使用DebugSetProcessKillOnExit来设置这个标志。
第11章 中断和异常管理
在保护模式下,当有中断或一场发生时,CPU是通过中断描述表(Interrupt Descriptor Table,简称IDT)来寻找处理函数。IDT表是一张位于物理内存的线性表,共有256个表项,在32位模式下,每个IDT表项长度是8个字节。其位置和长度是由CPU的IDTR寄存器来描述的。IDTR共有48位,高32位是IDT表的基地址,低16位是IDT表的长度(limit)。
IDT表的每一个表项是一个所谓的门描述符(Gate Descriptor)结构。一共有三种门描述符:任务门、中断门和陷阱门。其高字节的6~12位来区分描述符的种类
大多数中断和异常都是利用中断门或陷阱门来处理的,大致流程如些:
首先,CPU会根据门描述符中的段选择子定位到段描述符,然后惊醒一系列检查,如果检查通过后,CPU就判断是否需要切换栈。如果目标代码段的特权级别比当前特权级别高(级别数值小),那么CPU需要切换栈,其方法是从当前任务的任务状态段(TSS)中读取新堆栈的段选择子(SS)和堆栈指针(ESP),并将其加载到SS和ESP寄存器。然后,CPU会把被中断过程(旧的)的堆栈段选择子(SS)和堆栈指针(ESP)压入堆栈。接下来,CPU执行以下两项操作:
1)把EFLAGS、CS和EIP的指针压入堆栈。CS和EIP指针代表了转到处理例程钱CPU正在执行代码的位置
2)如果发生一场,而且该异常有错误代码,那么把该错误代码也压入堆栈
在Windows中TSS个数只与CPU个数有关。在启动期间,Windows会为每个CPU创建3~4个TSS,一共用于处理NMI,一个用于处理#DF一场,一个处理机器检查异常,另一个供所有Windows线程共享。当Windows切换线程时,它把当前线程的状态复制到共享的TSS中。所以,普通的线程切换并不会切换TSS,只有当NMI或#DF异常发生时才会切换TSS。这也就是所谓的以软件方式切换线程(任务)
Windows使用EXCEPTION_RECORD结构来描述异常,其结构如下:
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode; //异常代码
DWORD ExceptionFlags; //异常标志
struct _EXCEPTION_RECORD* ExceptionRecord; //相关的另一个异常
PVOID ExceptionAddress; //异常发生地址
DWORD NumberParameters; //参数数组中的元素个数
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; //参数数组
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
登记CPU异常:CPU会通过IDT表寻找异常处理函数入口KiTrapXX,该例程在完成针对本异常的特别动作后,通常会调用CommonDispathException函数,并通过寄存器将如下信息传递给这个函数
1)将唯一标识该异常的一个异常代码放入EAX
2)将导致一场的指令地址放入EBX
3)将其他信息作为附带参数(最多3个)分别放入EDX(参数1),ESI(参数2)和EDI(参数3)寄存器,并将参数个数放入ECX
CommonDispathException被调用后,它会在栈中分配一个EXCEPTION_RECORD架构,并把以上异常信息存储到该结构中。
登记软件异常:软件中最后都是通过调用KiRaiseException函数来完成,此函数内部会通过KeContextToKframes例程把ContextRecord结构中的信息复制到当前线程的内核栈,然后把ExceptionRecord中的异常代码的最高位清0,一边把软件产生的异常与CPU异常区分开。
对于Visual C++程序抛出的异常,ExceptionCode参数固定为0xe06d7363(对应ASCII码为.msc)
对于.NET程序抛出的异常(CLR)异常,ExceptionCode参数固定为0xe0434f4d(对应ASCII码为.COM)
无论是来自用户态的异常,还是内核态的异常,(如果需要分发)系统都会使用KiDispathException函数来分发异常。对于每个异常系统最多给它两轮被处理机会,对于每轮机会,KiDispathException有都是尝试最先让调试器来处理,如果调试器没有处理,那么KiDispathException会寻找代码中的异常处理块来处理该异常。对于来自内核态的异常,KiDispathException会直接调用RtlDispathException来寻找异常处理块;对于用户态的异常,KiDispathException会通过设置TrapFrame让KiUserExceptionDispatcher来寻找用户空间中的异常处理块。
VEH的基本思想是注册VectoredHandler函数来接受和处理异常,只能用于用户态程序中。使用AddVectoredExceptionHandler和RemoveVectoredExceptionHandler来分别注册和注销回调函数
关于VEH和SEH的区别和联系:
1)从应用范围来说,SEH可用于Ring3和Ring0,但是VEH只能用于Ring3,同时只能在NT5.0以上才能使用
2)从优先级角度看,VEH比SEH先得到处理权
3)从登记方式来看,SEH的注册信息是以固定结构存储在线程栈,不同层次的各个SEH的注册信息一次被压入到栈中,分布在不同的位置上,依靠结构体内指针相联系,因为人们经常将一个函数所对应的栈区称之为栈帧(Stack Frame),所以SEH的异常处理器又被称为基于帧的异常处理去(frame-based exception handler);
VEH的注册信息是存储在进程的内存堆中
4)从作用域的角度来看,VEH处理器相对于整个进程都有效,具有全局性;SEH处理器是动态建立在所在函数的栈帧上,会锁着函数的反悔而被注销,因此SEH只对当前函数或这个函数所调用的子函数有效
5)从编译角度说,SEH的登记和注销是依赖编译器编译时所生成的数据结构和代码的,VEH的注册和注销都是通过调用系统API显式完成的,不需要编译器的特别处理
第12章 未处理异常和JIT调试
对于用户态的未处理异常,Windows的策略是使用系统登记的默认异常处理器来处理。事实上,Windows为应用程序的每个线程都设置了默认的SEH异常处理器。当应用程序内的代码没有处理异常时,系统会使用这些默认的异常处理器来处理异常。
对于内核态的未处理异常,如果有内核调试期存在,则系统(KiExceptionDispatch)会给调试器第二轮处理机会,如果调试器没有处理该异常,或者根本没有内核调试期,则系统会调用KeBugCheckEx发起蓝屏机制,报告错误并停止整个系统,其停止代码为KMODE_EXCEPTION_NOT_HANDLED
对于典型的Windows程序,系统会为它的每个线程登记默认的结构化异常处理器(SEH)。此外,编译器在编译时插入的启动函数通常也会注册一个SEH处理器。对于使用C运行库的程序,C运行库包含了基于信号的异常处理器机制(主要是为了兼容来自UNIX的软件,实际上在现在基本没什么作用了)。这些SEH异常处理器的过滤表达式都直接或间接调用了UnhandledExceptionFilter函数。
Window创建进程(启动一个程序)的大体过程:
1)打开要执行的程序映像文件,创建section对象用于将文件映射到内存中
2)建立进程运行所需的各种数据结构(EPROCESS、KPROCESS及PEB)和地址空间
3)调用NtCreateThread,创建出于挂起状态的初始线程,将用户态的起始地址存储在ETHREAD结构中
4)通知Windows子系统注册新的进程
5)开始执行初始线程
6)在新进程的上下文(context)中对进程做最后的初始化工作
在创建Windows子系统进程时,系统通常并不把PE文件的入口地址用作新线程的起始地址,而是把起始地址指向kernel32.dll中的进程启动函数BaseProcessStart。这样做的一个原因是要注册一个默认的SEH处理器(最后得到处理机会)。
在执行这些函数前,需要做大量的初始化工作,编译器通常会把自身提供的一个启动函数登记为程序的入口,让系统先执行这个启动函数(XXXmainCRTStartup),这个启动函数内部再调用用户编写的main或WinMain函数(源代码叫crt0.cpp),这个入口函数也包含一个SEH处理器,它的保护范围包含了对main或WinMain函数的调用(倒数第二个得到处理机会)。
对于初始线程以外的其他线程,其用户态的起始地址是系统提供一个位于kernel32.dll中的函数BaseThreadStart,系统在该函数也提供了一个SEH处理器。
系统弹出“应用程序错误”对话框(Application Fault Dailog,或者交General Protection Fault)的目的有二:
1)告知用户,该应用程序已经发生严重错误(未处理异常)而无法继续运行即将被终止
2)征求用户的处理意见:是立即终止、启动JIT调试器调试该程序,还是先发送错误报告然后再终止(Windows XP 引入)
“应用程序错误”对话框由UnhandledExceptionFilter函数触发,由操作系统的其他程序弹出并维护(与用户交互),系统本身使用其他的进程来现实“应用程序错误”对话框。不同系统使用不同的办法(Windows 2000:HardError,Windows XP:ReportFault API(使用DWWIN,增加Send Error Report),Windows Vista:WER 2.0 API(使用WerFault)),新办法不能用时就会调用HardError。
JIT调试(Just-In-Time Debugging),就是指在应用程序出现严重错误后面启动的紧急调试。因为JIT调试建立时,被调试的应用程序内已经发生了严重的错粗,通常都无法再恢复正常运行,所以JIT调试又被称为事后调试(Postmortem Debugging),其主要目的是分析和定位错误原因,或者手机和记录错误发生时的县城数据供事后分析。DrWatson是Windows系统中默认的JIT调试器。
顶层过滤函数(Top Level Exception Filter):如果应用程序不希望使用这些默认逻辑来处理未处理异常,那么可以注册一个自己的未处理异常的过滤函数,并通过SetUnhandledExceptionFilter API进行注册,这就使顶层过滤函数,只有在有未处理异常发生时才可能被调用。(例如C运行库将当前进程的顶层过滤器设置为msvcrt!_CxxUnhandledExceptionFilter)。顶层过滤函数被调用的条件是有未处理异常发生,同事所在程序不再被调试。
注意系统是使用一个全局变量而不是一个链表来记录顶层过滤函数的,所以系统(UnhandledExceptionFilter函数)只会调用最后注册成功的那个顶层过滤函数。同事在Windows XP SP2以后的版本中,SetUnhandledExceptionFilter函数会先将要设置的顶层过滤函数地址进行编码,然后再保存到BaseCurrentTopLevelFilter变量中,UnhandledExceptionFilter函数在调用顶层过滤函数时会对其进行解码。
第13章 硬错误和蓝屏
任何软件都可能因为自身设计缺欠或外部环境变化等而发生错误。既然错误是不可避免的,那么制定完善的错误处理机制显然要比试图发生错误更明智。
一套好的错误处理方案通常应该考虑以下3个方面:
1)即时提示(instant notification):即当错误情况需要立刻提示给用户时,将错误情况以可靠的方式、以用户可以理解的语言及时提示给用户
2)永久记录(persistent recording):即当错误情况需要永久记录的条件时,将错误描述永久记录在文件或数据库中供事后分析
3)自动报告(automatic reporting):即自动收集错误现场的详细情况并生成错误报告,让用户可以通过简单的方式(如网络)发送到专门用来收集错误报告的服务器。(Windows Error Reporting)
利用系统提供的MessageBox API,弹出一个图形化的消息框。这个优点是简单易用。但是存在3个局限:
1)MessageBox 是一个用户态的API,内核代码无法直接使用
2)MessageBox是工作在调用者(通常是错误发生地)的进程和线程上下文中的,如果当前进程/线程的数据结构(比如消息队列)已经由于严重错误而遭到破坏,那么MessageBox可能无法工作
3)对于系统启动关闭等待特殊情况,MessageBox是无法工作的
HardError:使用核心函数ExpRaiseHardError,系统中默认的硬错误提示进程就是CSRSS
BSOD:蓝屏机制的设计思想是将系统终止在导致错误的第一现场,并且把这个现场的信息显示给用户或永远保存下来,比如保存到转储文件,这样有利于更快地发现问题根源,去除软件错误(Bug)使用的核心函数是BugCheck2(具体有11个步骤,详见P368)
采用蓝屏方式提示错误的几种常见情况:
1)系统捕捉到内核代码中的未处理异常,或者检测到违反操作系统规则的情况
2)系统监测到操作系统的数据结构、模块或进程遭到破坏
3)在系统安装、启动或退出等边缘状态时发生错误,这时由于还不具备以其他方式提示错误的能力,所以只能以蓝屏提示
蓝屏包含以下几部分内容:
1)错误信息。用以描述错误情况和错误原因的文字信息
2)解决错误的建议。对于同一版本的Windows,这段信息只要出现就是相同的
3)技术信息。其格式为“STOP:停止码(参数1,参数2,参数3,参数4)”
4)现实内存转储(Dump)的过程和结果。
对于蓝屏错误,可以通过如下步骤逐步分析其原因:
1)根据蓝屏的停止码和蓝屏参数做初步判断(WinDBG的帮助文件中以停止码为顺序列出了系统定义的所有蓝屏错误,包括错误码和每个参数的含义,以及解决问题的建议,这个建议是针对停止码的,要不蓝屏画面中的建议有用的多)
2)可以在我iruande知识库(supports.microsoft.com)中或使用其他搜索引擎搜索蓝屏的停止码和参数,以了解更多信息
3)分析转储文件。系统会默认为蓝屏产生小型的转储文件(Small memory dump),默认位置为Windows系统目录的MiniDump文件夹,文件名是MiniMMDDYY-XX.dmp的格式,MMDDYY是发生蓝屏的日期(月日年),XX是序号,从01开始,依次递增。
4)如果经过以上的步骤还没有找到问题的原因,那么应该考虑通过内核调试做进一步的调试和分析。使用内核调试,可以设置断点,跟踪内核代码的执行过程,这样更容易准确地定位到错误根源。
System Mode Dump(与User Mode Dump不同,User Mode Dump也被称为MiniDump,位于Data目录中,可以通过MiniDumpReadDump API来读取,或者使用任何的调试工具,而System Mode Dump文件格式未公开,只能通过WinDBG查看)
Windows定义了3种类型的系统转储文件:完整转储(Complete memory dump)、内核转储(Kernel memory dump)、小型内存转储(Small memory dump)。其大小和信息聊依次递减。由于其是以内存页面(4KB)为单位来组织数据的,因此它的大小是内存页大小(4KB)的整数倍。
当有异常发生时,系统会将当时的状态保存到一个KTRAP_FRAME结构中,称为陷阱帧。因为很多崩溃与异常有关,所以转储文件中经常包含着陷阱帧数据(通过kv可以查看当前栈回溯序列中是否有关联的陷阱帧)。
可以通过Windbg的!analyze -v命令来自动分析Dump文件
辅助调试是错误提示机制的目的之一,但绝不能用错误提示机制取代其他正常的调试手段。应该采用合适的调试机制来帮助调试,比如使用ASSERT语句进行参数检查,使用TRACE语句或调用OutputDebugString之类的调试信息打印函数来追踪变量和执行路径。实际上,在开发期间应该把调试器作为最主要的调试手段。在调试器中运行,可以大大缩短我们熟悉代码执行情况和发现问题的时间。
设计优秀的错误提示机制应该具有可控性,可以按照严重程度、错误类别等属性定制错误提示的方式和次数。
第14章 错误报告
错误报告(reporting)和错误提示(notification)的区别:
1)从时间角度来看。错误提示是即时的,其目的是让用户立刻知晓所提示的信息;错误报告往往没有如此强的时间要求。
2)从目的性来看。错误提示的是让用户得知错误情况,选择处理方法;错误报告是记录错误详情以便找到错误原因
3)从信息(数据)量角度来看。错误报告往往包含更全面、更多的信息
WER采用C/S结构,客户端负责收集、生成和发送错误报告;服务端负责接收、存储、分类和自动寻找解决方案等任务
WER 1.0客户端包括以下几个部分:
1)FAULTREP.DLL,导出ReportFault和AddERExcludedApplication等函数
2)DWWIN.EXE,WER的客户端主程序,负责显示WER风格的“应用程序错误”对话框和发送错误报告,包括应用程序崩溃后的错误报告和系统崩溃后的错误报告。
3)DW.EXE和DW20.EXE,是DWWIN.EXE的两个遍体,分别用于报告Visual Studio与Office的应用程序错误(通常是通过SetUnhandledExceptionHandler注册自己的顶层未处理异常过滤器来启动的)
4)DUMPREP.EXE,用于检查是否有等待发送的错误报告。如果有,则会通过动态加载FAULTREP.DLL中的ReportFault函数启动DWWIN.EXE程序发送报告
5)修改了的KERNEL32.DLL,WER修改了KERNEL32.DLL中的UnhandledExceptionFilter函数。当应用程序出现未处理异常时,调用FAULTREP.DLL中的ReportFault。这时WER与系统的一个重要接入点
6)配置界面
WER的报告模式(Reporting Modes):共享内存模式(Shared memory mode,也称之为异常模式)、清单模式(Manifest mode)、排队模式(Queued mode,也称之为Headless mode)
WER的传输模式:互联网模式(Internet mode,默认方式)和企业方式(Corporate mode)
WER 2.0引新模块,均位于system32目录下:Wer.dll、Wercon.exe、Wercplusupport.dll、Werdiagcontroller.dll、WerFault.exe、WerFaultSecure.exe、Wemgr.exe、Wersvc.dll
WER 2.0比1.0更加灵活强大,允许程序员做更多定制,以用于我们自己开发的软件
第15章 日志
一条日志记录通常包含如下几个要素:
1)时间:所记录事件发生的时间,通常至少精确到分钟级别
2)地点:用来定位所记录事件发生的“位置信息”,通常包括机器名,进程ID、线程ID等
3)主体(来源):即该事件的实施者,根据需要可以是服务名称、模块名称或类名和函数名
4)事件:对所发生事件的描述
5)类型(严重程度):该事件的严重程度,可以分为信息(information)、警告(warning)和错误(error)三种,也可以根据需要分得更细
Windows的日志机制:
1)ELF(Event Log File):使用磁盘文件记录,每一类事件放在一个文件中。XP下就是AppEvent.Evt、SecEvent.Evt和SysEvent.Evt。均位于config目录中,运行在Services.exe中,自动启动且不可停止。内部使用RPC实现
2)CLFS(Common Log File System,Vista以后引用):每一个CLFS Log由一个BLF和多个Container文件组成,用LSN(Log Sequence No)来唯一标识一条日志记录,增加了HardwareEvents和DFS Replication等类别,并且为所有的日志文件建立了一个单独的目录,即winevtLogs目录,日志扩展名也改成.Evtx。内部使用CLFS.SYS和CLFSW32.DLL实现
CLFS的使用方法:创建日志文件——>添加CLFS 容器(Cantainer)——>创建编组区(Marshalling Area)——>添加日志记录
第16章 事件追踪
事件追踪(Event Tracing):记录软件运行的动态轨迹,包括代码执行轨迹和变量的变化轨迹。事件追踪机制更关心软件的“变化和运动过程”,通常以二进制方式而不是文本来传输和记录信息。包含更多的技术细节,比如函数名称、变量取值等。在软件正常运行时通常是关闭的,只在观察和分析时才会开启。
事件追踪通常在性能分析、产品期调试和单元测试或自动测试等任务(领域)中有重要作用
事件追踪机制应满足的要求:高效性、动态性、灵活性、选择性、易用性
Windows提供了一套完整的时间追踪机制(包括内部实现、API和辅助工具),称为ETW(Event Tracing for Windows)。使用ETW,程序员可以非常简单地将这一强大的时间追踪机制插入到自己的软件应用中。
1)ETW将追踪消息先输出到由系统来管理和维护缓冲区中,然后异步写入到追踪文件或送给观察器。如果系统崩溃,崩溃前没有写入到文件中的信息会记录到转储文件(Dump)中
2)ETW的追踪信息是以二进制形式传输和存储的,所有格式信息存放在私有的消息文件中,这样可以防止软件本身的保密技术泄露
3)ETW机制支持动态开启,没有开启时,开销几乎可以忽略(只需判断一个标志)
ETW使用经典的Provider/Consumer/Controller设计模式
ETW提供3种方式来描述格式信息,1)MOF(Managed Object Format),2)WPP(Windows Software Trace Preprocessor),3)Manifest(XML)和TDH API(Vista以后)
第17章 WHEA(Windows Hardware Error Architecture)
WHEA(Windows硬件错误架构)是Windows操作系统中用于处理硬件错误的基本框架,它定义了系统中的硬件、固件、以及软件应该如何相互写作来报告、处理和记录各种硬件错误,并且提供了一系列基础设施和机制来实现以上目标
蓝屏(BSOD)崩溃的2个不足:
1)对于可以纠正的错误,BSOD显然处置过度,导致不必要的系统中止
2)BSOD通常只能记录崩溃前的CPU和内存状态,无法记录与硬件错误直接相关的硬件状态,这让发现硬件的错误根源和修正错误很困难
WHEA框架所定义的基础设施包括:
1)通用的错误来源(Error Source)发现机制
2)统一的硬件错误记录格式
3)统一的硬件错误处理流程
4)可靠的错误记录持久化机制
5)基于ETW的硬件错误事件模型,管理程序可以通过这种模型接收到硬件错误事件并采取进一步措施
WHEA要实现的目标:
1)借助WHEA的错误记录机制所记录下的错误信息,可以更快地发现错误根源,缩短系统从硬件错误中恢复运行的平均时间
2)通过纠正可纠正错误和健康状况监视(Health Monitoring),可减少因为硬件错误而导致的系统崩溃
3)为应用软件开发者提供支持,以便可以开发出强大的硬件错误报告和管理软件
4)更好地利用硬件已经提供的和将来可能提供的错误报告机制,比如CPU的MCA机制,以及PCI Express总线标准中定义的AER(Advance Error Reporting)机制
第18章 内核调试引擎
目前主要有3中方法来进行内核调试:
1)使用硬件调试器,它通过特定的接口(如JTAG)与CPU建立连接并读取它的状态,比如ITP调试器,相对于后两种方法,这种方法能够调试引擎初始化之前的状态。比如加在NTLDR或WinLoad程序夹在内核的过程,或者调试内核开始工作的最初过程。
2)在内核中插入专门用于调试的终端处理函数和驱动程序,当操作系统被中断时,这些终端处理函数和驱动程序接管系统的硬件,营造一个供调试器可以运行的简单环境,这个环境使用自己的驱动程序来接受用户输入,显示输出(窗口)。SoftICE和Syser调试器使用的就是这种方法。
3)在系统内核中加入调试支持,当需要中断到调试器中时,只保留这部分支持调试的代码还在运行,内核的其它部分都停止了,包括任务调度、用户输入和显示输出部分。因为正常的内核服务都已经停止,所以调试器程序是不可能运行在同一个系统中的。、因此这种方法需要调试器运行在另一个系统中,二者通过通信电缆交流信息。使用串行口(native通信方式)、1394和USB 2.0来建立连接。
Windows操作系统推荐的内核调试方式使用的是第3种方法。内建在操作系统内核中负责调试的那个部分通常被称为内核调试引擎(Kernel Debug Engine)。
从访问内核的角度来看,内核调试引擎为内核调试期提供了一套特殊的API,我们将其称为内核调试API,简称KdAPI。通过它调试器可以以一种类似远程调用的方式访问到内核,这与应用程序通过Win32 API访问内核(服务)类似。
目前流行使用虚拟机管道技术来实现内核调试,优点是简单方便,只需要一台主机,缺点如下:
1)难以调试硬件相关的驱动程序
2)当对某些设计底层操作(中断、异常或者I/O)的函数或指令设置断点时,可能导致虚拟机以外重新启动
3)当将目标系统终端到调试器中时,目前的虚拟机管理软件会占用很高的CPU(超过90%,在多核系统上问题不那么严重)
内核调试引擎的第一次调试机会,是在内核的入口函数KiSystemStartup调用HalInitialzeProcessor初始化CPU后,在电泳KiInitializeKernel之前。也就是KdInitSystem函数第一次被调用的时候。
第19章 Windows的验证机制
软件调试的主要任务就是寻找软件瑕疵(defect)的根源,其前提是通常已经知道了有瑕疵存在。
发现软件下次的最普通方法就是测试。常见的测试手段有以下几种:
1)黑盒测试(Black box testing),是指测试人员根据软件需求规约和测试文档对软件的运行行为进行检查。其基本思想是将被测试软件当作一个不透明的黑盒子,给其一个输入,看其输出是否符合要求,只要输出结果正确,便认为测试通过,不检查盒子内部的变化过程。
2)白盒测试(White box testing),是指根据程序的结构和代码逻辑来编写测试用例并进行测试。比黑盒测试更有针对性,但是对测试人员的要求更高。
3)内建自检,又称为BIST(Built-In Self-Test),是指在软件代码内部构建一些测试功能,这些功能(函数)可以在某些情况下执行,或者被自动测试工具所调用以发现问题。
4)压力测试(Stress testing),用于测试目标程序在高负载(入频繁访问和大量要处理的任务)和低资源(如低可用内存和低硬件配置)情况下的工作情况。
虽然以上每种测试都有它的优势和侧重点,但即使使用了以上所有测试手段,也不能保证会发现所有问题。主要原因就是测试时的运行环境和条件不足以将错误触发并暴露出来。所以当测试时,我们通常希望系统做严格的检查,发现问题就立刻报告,并且最好能模拟极端的和苛刻的运行环境,以便让错误更容易暴露出来。Windows操作系统的验证机制(Verifier)就是为了满足这个需求而设计的。验证机制的主要目标是检查被测试软件,或者说是为被测试软件提供一个验证器(Verifier)。
驱动程序验证器(Driver Verifier)的主体实现在内核文件(NOTSKRNL.EXE)中的一系列内核函数和全局变量,其名字中大多都包含Verifier字样,或者是以Vi和Vf开头。其基本设计思想是在驱动程序调用设备驱动接口(DDI)函数时,对驱动程序执行各种检查,看其是否符合系统定义的设计要求,特别是DDK文档所定义的调用条件和规范。
一个驱动程序如果想取得Windows徽标和签名,则一定要通过驱动验证器的验证(WHQL,Windows Hardware Quality Labs)。这种强制性有利于提高内核模块的质量和整个系统的稳定性。
Windows采用的是通过修改被验证驱动程序的输入地址表(Import Address Table,IAT)来挂接(HOOK)驱动程序DDI调用,即通常所说的IAT HOOK方法。简单的说,系统会将被验证驱动程序IAT表中的DDI函数地址替换为验证函数的地址。这样,当这个驱动程序调用DDI函数时,便会调用对应的验证函数。验证函数与原来的函数具有完全一致的函数原型,所以不会影响被验证程序的执行。
在验证函数得到调用后,它执行的典型操作如下:
1)更新计数器,或者全局变量
2)检测调用参数,或者做其他检查,如果检测到异常情况,那么调用KeBugCheckEx(DRIVER_VERIFIER_DETECTED_VIOLATION,...)函数,即通过蓝屏(Bug Check)机制来报告验证失败
3)如果没有发现问题,那么验证函数会调用原来的函数,并返回原函数的返回值(如果有)
驱动验证器的主要验证项目:自动检查(唯一一个不可以单独禁止和取消的项目)、特殊内存池、强制IRQL检查、低资源模拟、内存池跟踪、I/O验证、死锁检测、增强I/O验证、SCSI验证、IRP记录、驱动滞留探测(Driver Hand Detection)、安全检查、强制I/O请求等待解决、零散检查等
应用程序验证机制的实现分为连个部分,一部分是是现在NTDLL.DLL中的一系列函数,这些函数都是以AVrf开头的;另一部分是一个名为应用程序验证器(Microsoft Application Verifier)的工具包,其设计思想是通过监视应用程序与操作系统之间的交互来发现应用程序中隐藏的设计问题(如内存分配、内核对象使用和API调用等)。其设计原理也是通过IAT HOOK的方式
第4编 编译器的调试支持
第20章 编译和编译期检查
编译器的调试支持主要为以下几个方面:编译期检查、运行期检查、调试符号、内存分配和释放、异常处理、映射(MAP)文件
构建并执行一个C语言程序的典型过程:源程序经过编译器(Compiler)被编译为等价的汇编语言模块,再经过汇编器(Assembler)产生出于目标平台CPU一致的机器码模块。尽管机器码模块中包含的指令已经可以被目标CPU所执行,但其中可能还包含没有解决(unresolved)的名称和地址引用,因此需要链接器(Linker)解决这些问题,并产生出符合目标平台操作系统所要求格式的可执行模块。当用户执行程序时,操作系统的加载器(Loader)会解读链接器记录在可执行模块中的格式信息,将程序中的代码和数据“布置”在内存中,成为真正可以运行的内存映像。
链接器的主要职责是将编译期产生的多个目标文件和成为一个可以在目标平台下执行的执行映像。
在Windows系统下,链接器应该根据Windows操作系统定义的可执行文件格式来产生可执行文件,也就是产生PE(Portable Exectuable)格式的执行映像文件。要产生一个PE格式的可执行文件,链接器要完成的典型任务如下:
1)解决目标文件中的外部符号,包括函数调用和变量引用。如果调用的函数是Windows API或其他位于DLL模块中的函数,那么须要为这些调用建立输入目录表(Import Directorty Table,简称IDT)和输入地址表(Import Address Table,简称IAT),IDT用来描述被引用的文件,IAT用来记录或重定位被引用函数的地址,链接器会把IDT和IAT放在PE文件的输入数据段(.idata)中
2)生成代码段(.text),放入已经解决了外部引用的目标代码
3)生成包含只读数据的数据段(.data)
4)生成包含资源数据的资源段(.rsrc)
5)如果定义了输出函数和变量,则产生包含输出表的.edata段。输出表通常表现在DLL文件中,EXE文件一般不包含.edata段,但NTOSKRNL.EXE是个例外
6)生成PE文件头,文件头描述了文件的构成和程序的基本信息
加载器(Loader)是操作系统的一个部分,它负责将可执行程序从外部存储器(如硬盘)加载到内存中,并做好执行准备,包括遍历输入目录表加载依赖模块,遍历IAT表绑定动态调用函数,对基地址发生冲突的模块执行调整工作等。NTDLL中包含了一系列以Ldr开头的函数,用于完成以上任务。
编译器的基本功能就是将使用一种语言编写的程序(源程序)翻译成用另一种语言表示的等价程序(目标)。现实中,通常是从高层次的语言翻译到底层次的语言。基本结构都是由前端和后端两个部分组成。前端主要是负责理解源代码的含义,即分析(analysis)功能,后端负责产生等价的目标程序,即合成(synthesis)功能。前端和后端之间的媒介是中间代码,又称为中间表示(Intermediate Representation),即IR。编译器前端对于源程序进行词法分析(Lexical Analysis)、语法分析(Syntax Analysis)、语义分析(Semantic Analysis),并将其映射到中间表示,后端对中间表示进行优化处理,再将其映射到机器码表示的目标程序中。而后,链接器再将目标程序经链接成为可以执行的执行映像。
编译器的前端(Front End)负责扫描和分析源程序并产生中间表示(IR),它主要完成如下几项任务:
1)词法分析(Lexical Analysis):读入并小苗源程序文件的字符流,剔除其中的空格和注释内容,并根据构成词规则识别出单词,将源代码中的字节流转换成记号流(token stream)。实现该功能的部分通常被称为扫描器(Scanner)
2)语法分析(Syntax Analysis):对词法分析产生的记号流进行层次分析,根据语法规则(syntax rules)把单词序列组成语法词语,并表示为语法树(syntax tree)或推导树(derivation tree)的形式
3)语义分析(Semantic Analysis):对语法树中的语句进行语义处理,审查数据类型的正确性,以及运算符使用是否符合语言规范。因为编译阶段的语义分析无法分析程序运行时才确定的动态语义,所以编译时的语义分析又被称为静态语义分析(Static Semantic Analysis),简称SSA
4)中间代码生成:产生编译器和后端交流所使用的中间表示(IR),有时也被称为中间代码。中间表示的具体形式因编译期的不同而不同,常见的有三元式(three-address code)、四元式(4-tuple code)等
编译器的后端(Back End)负责对前端产生的中间代码进行优化处理,并阐释使用目标代码表示的目标程序。优化后的代码仍然是使用中间代码表示的。目标程序经过联建最终成为可以在特定平台上运行的可执行程序。
编译器前端-后端设计使它们分工明确,前端与被编译的语言相关,不必关心目标平台(CPU),而后端与目标平台相关,不必关心源程序语言。这样的好处是编译器的前端和后端松耦合,更容易支持新的语言和新的目标平台。
编译器编译过程的8个主要步骤:词法分析、语法分析、语义分析、中间代码生成、优化代码生成、目标代码生成、符号表管理、错误处理
编译期检查可以发现代码中的词法,语法以及少量语义方面的问题,主要有以下5个方法:
1)未初始化的局部变量
2)类型不匹配
3)使用编译器指令(compiler directives)手工加入检查
4)标准标注语言(Standard Annotation Language,SAL)向代码中加入标注(仅限于VC8以上)
5)使用静态分析工具,如驱动程序静态验证器(Static Driver Verifier,SDV)、PREfast或FxCop
第21章 运行库和运行期检查
由于编译期检查分析的主要是程序的静态特征,所以对于程序运行过程中才体现出的错误编译期检查通常是难以发现的。为了发现只有在运行期才显露出的问题,编译器通常还涉及了运行期检查功能。编译期的运行库(Run-Time Library)是支持运行期检查的载体。
当编译器在将高级语言编译到低级语言的过程中时,因为高级语言中的某些比较复杂的运算符要对应比较多的低级语言指令,为了防止这样的指令段多次重复出现在目标代码中,编译期通常将这些指令封装为函数,然后将高级语言的某些操作翻译为函数调用(比如把new和delete操作符编译为对malloc和free函数的调用)。
同时,为了增强编程语言的能力,加快软件开发速度,几乎所有编程语言都定义了相配套的函数库或类库,比如C标准定义的标准C函数,C++标准定义的C++标准类库,这些库通常被称为支持库(support library)。支持库是编程语言和编译器的不可分割的部分,实现支持库是实现编译器的一项重要任务。对于使用了某一支持库编译的程序来说,支持库是它们运行的必要条件,这些程序在运行时必须可以以某种方式找到支持库。出于这个原因,支持库有时也被称为运行库(Run-Time Library)。
为了提高使用C语言的开发软件的效率,C语言标准(ANSI/ISO C)定义了一系列常用的函数,称为标准C库函数,简称C库函数。C标准定义了C库函数的原型和功能,但没有提供实现,把这个任务留给了编译期。每个编译器实现的通常是标准C函数库的一个超集,一般称为C支持库或C运行库(C Run-Time Library),简称CRT。
与C标准类似,C++的国际标准(ISO C++)也是既包含度C++语言本身的定义,又规定了C++标准库的内容。C++标准库是为了方便使用C++语言编程而设计的一套函数、常量、类和对象库,包括标准输入输出、字符串、容器(列表、对象、队列、map等)、排序和搜索算法、数学运算等。
C++标准库由三大部分组成:
1)C标准库
2)IO流(iostream)
3)标准模板库(STL,Standard Template Library)
为了使编译好的程序可以顺利运行,使用运行库的程序在运行时必须可以找到库中的函数。实现这一目的的有两种方法:一种是静态链接;另一种是动态链接。
静态链接就是将程序所使用的支持库中的函数复制到程序文件中。这样一来,这些支持库函数的实现就位于程序模块中,称为本模块中的代码。
动态链接是利用动态连接库技术,在程序运行时再动态地加载包含支持函数的动态链接库(DLL),并更新程序的IAT(Import Address Table)表,使程序可以顺利地调用DLL中的支持函数。
编译器会为每个模块自动插入一个“编译器编写”的入口函数,在这个入口函数中进行好各种初始化工作后再调用用户的入口函数,在用户的入口函数返回后再运行自己的清理函数。
运行期检查(Run-Time Check)就是在程序运行期间对其进行的各种检查。更多时候,运行期检查是与编译期检查相对的,二者都是与编译器密切相关的概念。运行期检查的目的是发现程序在运行时所暴露出的各种错误,即运行期错误(Run-Time Errors)。因为程序运行是编译过程结束后才发生的事情,所以要实现运行期检查,编译期通常采取如下3种措施:
1)使用调试版本的支持库和库函数,调试版本的库函数包含了更多的调试支持和检查功能。比如调试版本的内存分配和释放函数会插入额外的信息来支持各种内存检查功能。
2)在编译时插入额外代码对栈指针、局部变量等进行检查。
3)提供断言(ASSERT)、报告(_RPT)等机制让程序员在编写程序时加入检查代码和报告运行期错误。
断言(ASSERT)是程序员手工插入运行期检查的一种常用方法。通常用来检查某一条件是否成立。要断言的条件以参数的形式传递给断言宏,如果该参数表达式的结果为真,那么断言成功并直接返回,否则断言失败,弹出断言消息框(assert message box)。
第22章 栈和函数调用
栈(stack):从数据结构角度看,栈是一种用来存储数据的容器(container)。放入数据的操作被称为压入(push),从栈中取出数据的操作被称为弹出(pop)。存取数据的一条基本规则是后进先出(last-in-fist-out,LIFO)。
从线程角度看,栈是每个Windows线程的必备设施。在Windows系统中,每个线程至少有一个栈,系统线程之外的每个线程都有两个栈,一个供该线程在用户态下执行使用,称为用户态栈;另一个供该线程在内核态下执行使用,称为内核态栈。
每个任务的任务状态端(TSS)记录了不同优先级所使用的栈的基本信息。在TSS中,偏移4到偏移28的24个字节是用来记录栈的段信息(SS)和栈指针(ESP)值的。内核态栈基本信息记录在_KTHREAD结构中,用户态栈基本信息记录在_TEB结构中。
栈的创建过程:
1)内核态栈创建,使用PspCreateThread函数,主要是创建重要的ETHREAD结构以及创建内核态栈(调用MmCreateKernelStack函数)。当一个线程被转化为GUI线程时,系统的PsConvertToGuiThread函数会为该线程重新创建一个栈,然后用KeSwitchKernelStack切换到新的栈。新的栈是可以改变大小的,被称为大内核态栈(Large Kernel Stack)。其最大值记录在名为MmLargeStackSize的全局变量中。
2)用户态栈的创建,由KERNEL32.DLL中的BaseCreateStack创建的。此函数在hProcess所指定的进程空间中根据dwReservedStackSize参数(指定要创建栈的保留内存区大小)保留一段内存区域,并在这个区域中按照dwCommitStackSize参数(已经提交的内存区大小)所指定的大小提交出一部分作为栈的初始空间。BaseCreateStack函数将所保留和提交内存区域的参数保存到pInitialTeb指向的结构中。而后这些参数会传递给NtCreateThread内核服务,最终被保存到线程的环境块(TEB)结构中。
初始线程的栈大小,可由PE文件头的IMAGE_OPTIONAL_HEADER中的SizeOfStackReserve和SizeOfStackCommit(均为DWORD类型)来指定栈的默认保留大小和提交大小。当我们调用CreateThread时用0做dwStackSize参数时,系统也会使用IMAGE_OPTIONAL_HEADER中指定的大小。
跨特权级调用:不同特权级的代码段中的代码相互调用。通常是通过调用门(Call Gate)(在CPU处理中断或者异常时,Windows操作系统使用INT 2E或专门的快速调用指令来实现从用户态(低特权)到内核态(高特权)的系统调用,不使用调用门)
CPU执行跨特权级调用的过程:
1)进行访问权限检查,如果检查失败,则产生保护性异常
2)将SS、ESP、CS、EIP寄存器值临时保存到CPU内部
3)从任务状态段(TSS)中找到目标代码所处特权级的栈信息,并将段选择子和栈指针加载到SS和ESP寄存器中。这一步进行的动作经常被称为栈切换。
4)将第二步保存的SS和ESP寄存器值依次压入新的栈。这一步的目的是将发起调用的代码的栈信息压入到被调用的代码的栈信息压入到被调用代码所使用的栈。
5)将参数从发起调用的栈。调用门中的Param Count(参数个数)字段描述了要复制的参数个数,这里是DWORD为单位的,最多可以复制32个DWORD。
6)将第二步报复你的CS和EIP值压入到新的栈中。
7)将要调用的代码段的段选择子和函数偏移分别加载到CS和EIP寄存器中。
8)开始执行被调用的代码。
当被调用的代码执行完毕时,可使用RET指令返回到发起调用的函数。
局部变量(Local Variables):指作用域和生命期都局限于所在函数或过程范围内的变量,它是相对全局可见的全局变量(Global Variable)而言的。编译期在为局部变量分配空间时通常有两种做法:使用寄存器(最快)和使用栈(主要做法)
编译器在产生栈和函数调用有关代码时所加入的支持软件调试的机制:
1)建立栈指针:产生栈回溯信息的基础
2)使用0xCC填充局部变量区:如果CPU因为缓冲区溢出等原因意外执行到这些区域,可以立即中断(debug ONLY)
3)为局部变量分配和建立屏障字段,并在运行期进行变量检查(RTCu):检测缓冲区溢出,可以报告发生溢出的局部变量名称(debug ONLY)
4)栈指针检查(RTCs):检测不匹配的函数调用,及时发现栈指针异常(debug ONLY)
5)对可能发生缓冲区溢出的函数自动加入安全Cookie的检查机制(GS Switch):及时发现缓冲区溢出和栈受损(debug & release)
第23章 堆和堆检查
栈的优点:分配局部变量和存储函数调用参数及返回位置的主要场所,编译器在编译阶段会生成合适的代码来从栈上分配和释放空间,不需要程序员编写任何额外代码
栈的缺点:
1)栈空间(尤其内核态栈)的容量是相对较小的,为了防止栈溢出,不适合在栈上分配特别大的内存区
2)由于栈帧通常是随着函数的调用和返回而创建和消除的,因此分配在栈上的变量只是在函数内有效,这使栈只适合分配局部变量,不适合分配需要较长生存期的全局变量和对象
3)尽管也可以用__alloca()这样的函数来从栈上分配可变长度的缓存区,但是这样做会给异常处理(EH)带来麻烦,因此,栈也不适合分配运行期才能决定大小(动态大小)的缓冲区
从操作系统的角度看,堆是系统的内存管理功能向应用软件提供服务的一种方式。通过堆,内存管理器(Memoey Manager)将一块较大的内存空间委托给堆管理器(Heap Manager)来管理。堆管理器将大块的内存分割成不同大小的很多个小块来满足应用程序的需要。
从实现角度来讲,内核态的池管理器和用户态的Win32堆管理器是共享一套基础代码的,它们以运行时库(Run Time Library)的形式分别存在于NTOSKRNL.EXE和NTDLL.DLL模块中。
Windows系统在创建一个新的进程时,在加载器函数执行进程的用户态初始化阶段,会调用RtlCreateHeap函数为新的进程创建第一个堆,称为进程的默认堆,有时简称进程堆(Process Heap)。创建好的堆句柄会被保存到进程环境块(PEB)的ProcessHeap字段中。RtlCreateHeap内部会调用ZwAllocateVirtualMemory系统服务从内存管理器申请内存空间,初始化用于维护堆的数据结构,最后将堆记录到进程的PEB结构(堆列表)中。
堆管理器提供以下功能来辅助调试:
1)堆尾检查(Heap Tail Checking),简称HTC,是在堆块的末尾附加额外的标记信息(通常是8字节),用于检查堆块是否发生溢出
2)释放检查(Heap Free Checking),简称HFC,是在释放堆块时对对进行各种检查,可以防止多次释放同一个堆块
3)参数检查(Heap Parameter Checking),简称HFC,对传递给堆管理器的参数进行更多的检查
4)调用时验证(Heap Validation on Call),简称HVC,即每次调用堆函数时都对整个堆进行验证和检查
5)堆块标记(Heap Tagging),即为堆块增加附加标记(Tag),以记录堆块的使用情况或其他信息
6)用户态栈回溯(User mode Stack Trace),简称UST,即将每次调用堆函数的函数调用信息(Calling Stack)记录到一个内存数据库中
7)专门用于调试的页堆(Debug Page Heap),简称DPH堆
CRT堆的调试功能:
1)内存分配序号断点,针对CRT堆的堆块分配序号设置断点,检查某一次堆块分配的细节
2)内存状态快照(监测点):记录CRT堆的统计状态,检查内存泄漏
3)堆块转储(Dump):转存CRT堆中的堆块
第24章 异常处理代码的编译
根据运行模式和编程语言的不同,Windows系统中的程序可以选择使用不同的异常处理机制,比如驱动程序可以使用结构化异常处理(SEH)机制(是其他机制的基础),C++语言编写的应用程序可以使用向量化异常处理和C++标准定义的异常处理机制
记录和注册异常处理器的方式(也就是系统分发异常时的寻找方式)主要有两种:栈帧(Stack Frame,注册在所在函数的栈帧中,x86使用这种方式)和表格(Table,将注册信息以表格的形式存储在可执行文件(PE)的数据段中,x64使用这种方式)
段寄存器FS总是指向线程TEB/TIB结构,而TIB是TEB的第一个子结构,TIB的第一个字段ExceptionList记录的就是用来登记结构化异常处理链表的表头地址,也就是说FS:[0]总是指向结构化异常处理链表的表头
处理异常主要有2种方法:
1)编写结构化异常处理函数(SehHandler)并手工注册到FS:[0]中
2)使用VC编译器的_try{}_except()结构
两种方法的共同点就是都需要在栈上动态建立一个登记结构并将这个结构注册到FS:[0]链条中,因为这个等级结构是位于所在函数栈帧中,所以这2种异常处理方法经常被称为基于帧的异常处理(Frame Based Exeption Handling)
基于帧的异常处理的不足:如果栈上发生缓冲区溢出,那么登记在栈上的信息可能被破坏,容易被攻击
应对这个安全隐患主要有3种处理方式:
1)Safe Cookie:编译器编译使用try{}catch结构的函数时生成的异常处理函数加入一系列检查安全Cookie指令,如果Cookie被破坏,则报告GS失败(_report_gsfailure)终止程序运行
2)SafeSEH:将模块中合法的异常处理函数登记在一个专用的被称为SafeSEH表中,当有异常发生时,异常分发函数会根据异常处理函数的地址到它所对应的模块中查询这个函数是否在SafeSEH表中,如果在那么说明这是安全的异常处理函数可以支持它,如果不在那么就不去执行它
3)Table Based Exception Handling:将异常处理器的描述和登记信息都以表格的形式存储在可执行文件中,当有异常发生时,系统根据异常发生的位置自动在这些表格中寻找匹配的处理函数,不需要在栈上做任何标志,也不再使用FS:[0]链条。基于表的异常处理机制与基于帧的异常处理机制是不兼容的。只运行在x64位的Windows
第25章 调试符号
从软件编译的角度看,调试符号是编译器在将源文件编译为可执行程序的过程中,为支持调试而摘录的调试信息。这些信息以表格的形式记录在符号表中,是对源程序的概括。调试信息描述的目标主要有变量、类型、函数、标号(Label)和源代码行等。
调试信息是在编译过程中逐步收集和提炼出来的,最后由链接器或专门的工具保存到调试符号文件中。调试符号既可以存储在单独的文件中,也可以与目标代码共享一个文件。Vsual Studio编译器默认是将调试符号保存在单独的文件中,即PDB(Program Database)文件。MS提供两种方式访问调试符号文件中的符号,一种是DbgHelp函数库,另一种是DIA SDK(Debug Interface Access Software Development Kit)
调试信息的存储格式:
1)COFF(Common Object File Format):早期的*nix的映像文件、目标文件(Object File)、库文件(Library File),Windows中的PE格式也基于COFF格式
2)CodeView(CV):CodeView是与MSC编译器一起使用的调试器,它使用的调试符号格式被称为CodeView格式
3)PDB(Program Database):PDB支持Edit and Continue(EnC),CV和COFF是不支持的
4)DWARF(Debugging With Attributed Record Formats):这是一种公开的调试信息格式规范,目前只用于*nix中
PE文件中的调试信息的产生过程分为如下3个阶段:
1)收集阶段:编译器在编译源文件的过程中收集调试信息,然后存放在目标文件中
2)集成阶段:链接器在连接目标代码的过程中,将分布在各个目标文件中的调试信息集成到PE文件中
3)可选的调整压缩阶段:如果生成格式是CodeView,那么VC链接器会在连接的最后阶段对已经集成到PE文件中的调试信息做最后的整理和压缩;如果使用的是COFF格式的调试信息,那么不需要这一步骤。
DBG文件:使用rebase工具可以将集成在PE文件中的她哦是信息提取出来放在一个独立的.DBG文件中。
复合文件(Compound File):使用管理磁盘的方式来组织文件中的不同类型数据,就好象是将包含多个文件的磁盘数据移植到文件中,所以有时被称为包含文件系统的文件。负荷文件中的每个字文件通常被称为一个数据流(stream)。好处如下:
1)可以方便地增加或减小每个数据流的大小,改变某个数据流的大小不会影响其他的数据流
2)可以使用类似读写文件的方式来读写数据流
3)更好地并发支持,不同进程、线程可以访问文件的不同部分,互不干扰
PDB文件是以表格的形式(关系数据库)来存储调试信息的,每条信息占据表格的一行,通过类型字段来区分不同种类的信息。一个典型的PDB文件通常包含很多个数据表,用来存放不同类型的数据。
第5编 可调式性
第26章 可调试性概览
从行业标准角度来看,有关软件可调式性的标准有两个:
1)DMTF(Distributed Management Task Force)组织发起的Common Diagnostic Model,简称CDM,CDM是对DMTF的CIM(WMI的基础)的扩展,旨在指导软件实现标准的诊断支持,以便可以通过统一的方式发现和执行诊断功能,萃取诊断信息。
2)DMTF的Web-Based Enterprise Management标准,简称WBEM,这个标准不是专门针对调试设计的,但是其中的分布式管理方法有利于收集软件的执行状态,提高软件的可调式性。
从工具角度来看,软件领域中还没有像硬件领域中的如示波器和分析仪那样成熟的工具来测量软件。尽管这方面的努力已经持续了很久:
1)Program Instrumentation,即通过向程序加入测量性代码(Instrumental Code)来收集软件的执行路径和状态信息,以实现观察、记录和寻找错误(调试)等目标,插入测量代码的方法有编译期插入和在执行期动态插入等多种方法。
2)Software Profiling,即先通过工具软件为被分析软件产生某一方面的Profile(描述),统计其在某个时间段内的活动资料,比如内存分配和释放及执行轨迹等,然后使用这些资料来发现内存使用方面的问题或寻找运行为调优(Tuning)提供信息。收集Profile时可以使用上面介绍的加入测量代码,也可以使用CPU提供的监视功能,比如分支监视和记录机制。
从工程实践的角度来看,目前最有效的还是在设计软件中规划出支持调试的各种机制,并将其实现在软件代码中。这也是现在重点发展的方向——在软件开发过程中考虑并实现软件的可调试性。
很多软件项目延期与大量Showstopper直接相关,而解决Showstopper的关键是寻找问题的根源。很多问题如果找到了根源,那么解决起来通常非常简单。
高效调试是一项系统工程,除了系统提供好的调试支持外,被调试软件的可调式性也是至关重要的,要实现好的可调式性,应该从软件的设计阶段就开始为软件调试做准备,然后把它贯彻到整个项目的实施过程中。这样就可以在相对比较宽松的项目前期为紧张的调试阶段打下比较好的基础。相反,如果平时不做准备,那么问题出现了就要花更多的时间,并且所需的时间变得难以估算。另外,因为很多问题是在临近产品发布才出现的,所以时间非常紧迫,调试时的压力也很大。这与未雨绸缪的经验恰好吻合,在下雨之前要把基础设施建好,不要等风云来临时叫苦不迭。
提高软件可调试性的宗旨就是要降低软件调试的难度,使软件易于被调试。软件调试中难度最大的部分通常是寻找导致问题的根源(Root Cause)。那么,降低软件调试的难度也就是要让被调试软件可以更容易地被诊断和分析,让其中的问题更容易被发现。
基于这一思想,以下几个原则可以提高软件的可调式性:
1)最短距离原则:使错误检查代码距离失败操作的距离最短
2)最小范围原则(信息唯一原则):使错误报告或调试信息所能定位到的范围尽可能小
3)立即终止原则(报告第一现场原则):当检测到严重错误时,使程序立刻终止并报告第一现场的信息(如Bug Check)
4)可追溯原则:使代码的执行轨迹和数据变化过程可以追溯(Traceable)(目前最有效的技术就是利用栈中的栈帧信息来生成回溯序列,即所谓的Stack Backtrace)
5)可控制原则:通过简单的方式就可以控制程序的执行轨迹(软件功能的可控性Controlability和灵活性Flexibility是软件智能的重要体现,对于软件调试也有重要意义)
6)可重复原则:使程序的行为可以被简单地重复
7)可观察原则:使软件的特征和内部状态可以被方便地观察(直接观察或者借助工具)
8)可辨识原则:可以简单地辨识出每个模块乃至类或函数的版本
迄今为止,还没有非常方便并且开销又低的方法来实现数据的可追溯性。因为要给变量赋一个新的值,便会覆盖掉它以前的值。要想记住旧的值,必须先保存起来,这必然需要额外的时间和空间开销,目前有以下3种解决方案:
1)通过日志(log)的方式将变量的每个值以文件或其他方式记录下来
2)如果允许使用数据库,那么可以利用数据库本身的功能或编写脚本记录一个字段的每次取值
3)编写专门的类,为要追溯的数据定义用于记录其历史取值的环境缓冲区,重载赋值运算符。每次赋值时,先将当前值保存在缓冲区中。
不可调试代码的类型:(不可调式性只是相对调试器而言的,比如软件调试器的不可调试代码可能是可以被硬件调试器所调试的)
1)系统的异常分发函数
2)提供调试功能的函数
3)对调试器敏感的函数(UnhandledExceptionFilter)
4)反跟踪和调试的程序
5)时间敏感的代码
应对措施:
1)使用不同的调试器,特别是硬件调试器
2)动态修改检测调试器是否存在的检测结果(寄存器),调试UnhandledExceptionFilter的方法就是这样做的
3)修改程序指针寄存器(EIP)强制跳转到要调试的程序路径
4)使用调试器的汇编功能动态修改阻碍调试的代码
第27章 可调试性的实现
因为调试的效率直接影响项目的进度,无法解决的调试问题可能导致整个项目陷入停顿,所以实现可调式性应该是软件团队中所有人的共同目标。大家都应该对提高可调式与足够的的重视和支持,就像重视安全、质量和可靠性一样。
架构师:软件架构师是规划和缔造软件系统的核心角色,他们负责规划软件系统的整体框架、模块布局、基础设施和基本的工作方式,参与制定开发策略、方针(Policy)和计划,并指导开发过程。软件架构师在软件团队中的地位好比是建筑团队中的总设计师。作为一个好的架构师,应该充分意识到提高可调式性的重要意义,承担起如下职责:
1)在架构设计中规划统一的支持可调试性策略、机制和设施,包括检查、记录和报告错误的方法;输出调试信息和记录日志的方式;专门用来辅助调试的模块(如模拟器等);简化调试的设施等。
2)设计必要的技术手段,提醒或强制程序员在编写代码时实现可调式性。
3)制定提高可调式性的指导意见和纪律,并写入到软件项目的开发方针中去,以纪律的方式强制这些策略的执行,并检查和监督执行情况。
4)通过培训或其他沟通方式让团队的成员理解可调式性的意义和实现方法。
5)参与调试重大的软件问题,验证调试机制的有效性,并向团队证明这些机制所带来的好处。
程序员:程序员同时是编写代码和调试代码的主要力量。因此,提高可调式性,程序员是主要的执行者,也是主要的受益者。他们应承担的职责包括:
1)提高对错误处理的重视,认真编写错误检查和错误处理代码。不要因为错误情况是小概率事件就草草编写一些代码。要知道很多人造成的重大损失的大问题都是由于编写代码时的小疏忽所埋下的。
2)认真执行架构师所制定的提高可调式性的各项策略和方针。当编写代码时,合理应用各种提高可调式性的原则,努力提高代码的的可调式性。如果发现有不合理的地方,应该及时提出,而不是消极放弃。
3)熟练使用各种调试工具,善于使用调试方法来充分理解程序的执行流程,发现并纠正其中潜在的错误。
4)正确对待分配给自己的软件问题(Bug),不推诿,不敷衍,积极使用调试工具和提高可调试性的机制来地凝望给问题根源,及时跟新问题记录。
5)检查日志文件和其他调试机制所生成的信息,检查是否存在不正常情况,并根据日志信息审查代码中可能存在的问题。
测试人员:提高软件的可调试性会给测试工作和测试人员带来直接的好处。测试人员应该积极支持为提高可调试性所做的各种努力,并承担起如下职责:
1)理解提高软件可调式性的重要意义,支持开发人员实现可调式性,为他们提供测试支持
2)充分利用可调试机制帮助测试工作,提高测试效率。这有利于进一步发挥可调试机制的价值,进一步推动并提高软件的可调式性
产品维护和技术支持工程师:提高软件的可调试性对于产品期调试和技术支持有着重要意义。技术支持工程师应该积极支持并推动软件产品的可调式性,利用支持调试的设施解决问题,并将改善调试设施的意见反馈给架构师和开发人员。
管理者:管理者应充分支持为提高软件可调式性所作的努力,为其分配足够的资源。而不是武断地干预开发计划,压缩项目时间。
可调试架构组成:
1)日志:集中存储;选择并定义一种方法记录日志(利用系统的Event Log、CLFS或者自己定义);封装好以后应该以公共模块的方式发布给所有开发人员
2)输出调试信息:使用print、OS API或macro;输出信息时应该加上context;合理安排输出信息的代码位置,认真选择要输出的内容;不能因为输出调试信息而忽视了记录日志
3)转储(Dump):对象转储、应用程序转储和系统转储
4)基类:通过定义统一的基类来传达设计理念和强化设计规范
调试模型需要注意的地方:
1)对于不能独立运行的库模块,如DLL和静态库,应该设计一个简单的可执行程序(EXE)专门供调试使用,我们把这样的程序成为八字程序。利用八字程序,开发者可以测试和调试不能直接运行的库模块,不必等待项目中真正使用这个模块的程序开发后才能调试;另外,出现Bug时也容易定位和排除。当然集成测试阶段,需要与真实的模块工作在一起。
2)对于被系统或者其他模块自动启动的程序,最好要设计一个特别的命令行开关,使其支持手动启动,因为调试时手动启动更方便。为了期总被调试程序而执行一大多操作必然会影响调试的效率。
3)对于依赖于小概率时间(比如系统崩溃)触发才开始工作的模块,应该设计一个工具软件,使用这个工具可以方便地触发这个事件并开始调试。
4)对于需要硬件配合才能调试的程序,如果这个硬件比较昂贵或稀缺,那么需要很多人共享一台,或者这个硬件开启成本较高,那么最好编写一个模拟器程序,这个模拟器程序可以模拟硬件的行为,输出真实硬件所产生的数据。利用模拟器,程序员不仅可以在没有硬件设备的情况下进行调试,而且可以利用模拟器来模拟硬件设备不支持的功能以辅助调试,比如可以让模拟器停止在某个状态或者慢速工作以配合调试。
5)如果软件的某些功能难以通过普通的手工测试来发现问题,那么应该设计专门的测试工具,这些工具有利于测试,对调试也是有帮助的。
综上所述,应该为每个模块设计一种简便的调试方式,使程序员可以方便地调试他所负责的模块,这有利于提高程序员进行调试的积极性,使用调试方法解决问题和提尕代码质量。
栈回溯的基本算法:
1)取得标识线程状态的上下文结构(CONTEXT)。当有异常发生时,系统会创建这样的结构记录发生异常时的状态。使用RtlCpatureContext和GetThreadContext API可以在没有发生异常时取得线程的CONTEXT结构
2)通过CONTEXT结构或直接访问寄存器,取得程序指针寄存器(EIP)的值,通过它可知道线程的当前执行位置,然后搜索这个位置附近的符号(SymFromAddr),可以知道所在函数的函数名
3)通过CONTEXT结构或者直接访问寄存器取得当前栈帧的基准地址,在x86系统中,如果没有使用FPO,那么EBP寄存器的值就是栈帧的基地址
4)栈帧基地址向上便宜一个指针宽度(32位系统,即4字节)的位置是函数的返回地址。紧接着便是放在栈上的参数,具体个数因为函数原型和调用规范而不同
5)搜索函数返回地址的临近符号,可以找到父函数的函数名对应的源文件名等信息
6)当前栈帧基地址处保存的是前一个栈帧的值,去除这个值便得到上一个栈帧的基地址,回到第4步循环,直到取得的栈帧基地址等于0
第6编 调试器
第28章 调试器概览
调试器主要实现的功能:
1)建立和终止调试会话
2)控制被调试程序执行:中断到调试器、受控执行(如单步执行)、挂起/恢复/冻结/解冻、恢复被调试程序运行、终止被调试进程、重新启动被调试程序和调试会话
3)访问内存:以不同格式显示内存中的数据、编辑制定地址的内存数据、通过变量名来观察和修改它的取值、移动某一范围的内存数据到另一位置、填充某一内存区域、在内存中搜索某一个数据模式、观察用来组织内存的数据结构(如堆和栈)
4)访问寄存器(羁绊都是CONTEXT中的寄存器)
5)断点:指令断点、数据断点、I/O断点
6)跟踪执行:汇编级别、源代码级别、分支级别、TSS级别(大部分调试器尚未支持)、执行到上一级函数(Step Out)
7)观察栈和栈回溯:显示栈的基本信息、显示栈帧的基本信息、栈回溯
8)汇编和反汇编
9)源代码级调试:使用源程序中定义的数据结果来显示和修改数据、在源程序文件上设置断点,跟踪执行源程序和在源程序中显示执行位置
10)EnC(Edit and Continue):在调试的过程中编辑源代码然后自动编译并继续调试
11)文件管理:维护和显示程序模块的信息、寻找和加载模块的符号文件、提供多种方式查找符号、设置文件的搜索路径、从符号服务器寻找模块或符号文件并下载
12)接受和显示调试信息
13)转储(Dump)
分类标准:
1)特权级别:Ring3、Ring0
2)操作系统:Windows(WinDBG)、Linux(GDB)、(Dbx)Unix
3)执行方式:解释执行(Interpreted)、编译后执行(Compiled)
4)处理器架构:x86、x64和Itanium
5)编程语言:C/C++(WinDBG)、Java(JDB)
海森伯错误:调试时无法复现或行为发生改变的错误
波尔错误:调试时可以稳定复现的错误
调试器实现模型:
1)进程内调试模型(Windows基本不使用,海森伯效应很大)
2)进程外调试模型(WIndows使用该模型实现对普通程序的调试)
3)混合调试模型(.NET的CLR调试模型采用的是该模型,此模型很复杂)
4)内核调试模型
第29章 WinDBG及其实现
WinDBG溯源:KD和NTSD诞生->WinDBG诞生
WinDBG调试器引擎架构,一共分为6个逻辑层:
1)公开接口层:这一层定义了调试器引擎对外的6个公开接口,是调试器引擎与应用程序交互的渠道。
2)DebugClient类:包装了几乎所有的调试功能。这个类为公开接口层调用下面的调试服务提供了一种简洁的方法,隐藏了下层的复杂性。
3)中间层:将与被调试程序有关的信息和调试任务封装为很多个C++类。
4)服务层:提供对调试目标的访问和控制。
5)传输层:在内核调试或者远程调试的情况下负责传输信息,将信息组织成适合传输的数据包后交给连接层进行发送,同时也负责接收来自连接层的数据。
6)连接层:负责建立和维护数据连接,以及实际的数据收发工作。
第30章 WinDBG用法详解
工作空间:WinDBG使用工作空间(Workspace)来描述和存储调试项目的属性、参数以及调试器设置等信息,其功能相当于集成开发环境(IDE)中的项目文件
命令:WinDBG一共有3类命令,分别是标准命令、元命令(点命令)和扩展命令(实现在动态加载的扩展模块DLL中的,以感叹号开始)
表达式:
1)WinDBG支持MASM和C++表达式
2)为了支持复杂的调试命令,WinDBG还定义了一些特殊的类似函数的运算符,它们都以$字符开头
3)在C++表达式中,可以使用C和C++语言定义的各种运算符(取地址、引用指针、索引结构中的字段、制定类名、类型转换等),此外,在C++表达式中还可以使用#开始的宏
伪寄存器:为了可以方便地引用被调试程序中的数据和寄存器,WinDBG定义了一系列伪寄存器(Pseudo-Register)。在命令编辑框和命令文件中都可以使用伪寄存器,解析命令的过程中,WinDBG的调试器引擎会自动将伪寄存器替换(展开)为合适的值。
别名:WinDBG支持定义和使用3类别名(Alias)
1)用户命名别名(User-Named Alias),即别名的名称和实体都是用户指定的
2)固定名称别名(Fixed-Name Alias),其名称固定为$u0~$u9
3)自动定义别名(Automatic Alias),如$ntnsym、$ntwsym、$ntsym、$CurrentDumpFile、$CurrentDumpPath、$CurrentDumpArchiveFile、$CurrentDumpArchivePath
非入侵调试
非入侵调试是调试用户态进程的一种特殊方式。使用这种方式,WinDBG与目标进程没有真正建立调试与被调试的关系,不能接收到任何调试事件,因此只可以使用行观目标进程的各种命令,不可以使用控制调试目标执行的各种命令,包括单步跟踪、继续执行等。非入侵式调试的好处是减小调试器与目标进程的干预,最大程度地减少海森伯效应。非入侵调试只适用于附加到已经运行进程的情况。
终止调试会话方式:
1)停止调试
2)分离调试目标
3)抛弃被调试进程
4)杀死被调试进程
5)调试器终止或僵死
6)重新开始调试
Notes
《软件调试》热门书评
-
调试器真是个好东西!
11有用 0无用 dada 2009-08-06
首先要肯定张老师写了一本非常好的书,从您的书中学到了不少东西,尤其是一些调试机理的东西对自己收获很大。曾经的曾经对调试器很感兴趣,觉得他是个很神秘的东西就象以前对操作系统内核的感觉似的。国内TRW的作者刘涛涛先生,Syser的作者吴岩峰先生都做出过自己的debugger,当年特好奇刘涛涛是怎么在以前...
-
评估《软件调试》的六种方法
9有用 0无用 yolanda 2009-08-04
为了便于大家评估,特罗列出一些公开的资源供参考:1)浏览目录可以看上面的,也可以点击下面的链接,查看更详细的三级目录:http://advdbg.org/books/swdbg/toc_3rd.aspx2)读一下简介下面的网页中包含了封底简介和页前简介的详细版本:http://advdbg.org/...
-
门槛很高
8有用 1无用 伊卡洛斯 2009-08-30
对于这样原理性质很强的书,仅仅读这一本效果不会太好倒不是说张先生的书不好,原理和基础这样的东西本身就存在着理解的差异,对于我们这样的读者需要多读读很多其他相关人写的东西比如大名鼎鼎的《深入解析计算机系统》我个人一直认为底层编程既然不考虑用户层面的需要,那么就需要把底层原理搞得很清楚,目标也很简单:写...
-
少见的佳作
7有用 0无用 zeroins 2010-05-02
本书刚出版时我就买了一本,可惜当时自身水平不够,看了不到一半就放下了。现在再来看本书,已经能比较流畅的理解内容,也越发感觉本书的珍贵。本书从调试的角度串起了X86 CPU / Windows操作系统的方方面面。之前几年我的兴趣更多的集中在对OS内核的学习,看到本书才意识到自己忽略了如此重要的一个领域...
-
吹响软件调试的集结号
5有用 1无用 不及格的程序员-八神 2009-11-26
献给所有不及格的程序员们对于刚刚进入此行业的程序员来说,软件调试是一个熟悉又陌生的领域,熟悉是因为经常会听到人说"你调一下程序,看看变量值对不对?",陌生是因为大部分程序员不知道在IDE开发环境中按F5键跟踪程序运行到底发生了什么事儿。通过阅读<<软件调试>&g...