本章的内容是针对Windows XP的32位版本的,但是绝大多数内容也适用于Windows的其他32位版本(Windows NT、Windows 2000和Windows Vista),并且可以比较容易地推广到64位版本的Windows系统。
11.1 中断描述符表在保护模式下,当有中断或异常发生时,CPU是通过中断描述符表(Interrupt Descriptor Table,IDT)来寻找处理函数的。因此,可以说IDT是CPU(硬件)与操作系统(软件)交接中断和异常的关口(gate)。操作系统在启动早期的一个重要任务就是设置IDT,准备好处理异常和中断的各个函数。
11.1.1 概况简单来说,IDT是一张位于物理内存中的线性表,共有256个表项。在IA-32e(64位)模式下,每个IDT项的长度是16字节,IDT的总长度是4096字节(4KB)。在32位模式下,每个IDT项的长度是8字节,IDT的总长度是2048字节(2KB)。32位与64位的主要差异在于地址长度的变化,因此,下文只讨论32位的情况。
IDT的位置和长度是由CPU的IDTR来描述的。IDTR共有48位,高32位是IDT的基地址,低16位是IDT的长度(limit)。LIDT(Load IDT)指令用于将操作数指定的基地址和长度加载到IDTR中,也就是改写IDTR的内容。SIDT(Store IDT)指令用于将IDTR的内容写到内存变量中,也就是读取IDTR的内容。LIDT和SIDT指令只能在实模式或保护模式的高特权级(Ring 0)下执行。在内核调试时,可以使用rigtr和rigtl命令观察IDTR的内容(卷1中的2.6.2节)。
在Windows操作系统中,IDT的初始化过程大致是这样的。IDT的最初建立和初始化工作是由Windows系统的加载程序(NTLDR或WinLoad)在实模式下完成的。在准备好一个内存块后,加载程序先执行CLI指令关闭中断处理,然后执行LIDT指令将IDT的位置和长度信息加载到CPU中,而后,加载程序将CPU从实模式切换到保护模式,并将执行权移交给NT内核的入口函数KiSystemStartup。接下来,内核中的处理器初始化函数会通过SIDT指令取得IDT的信息,对其进行必要的调整,然后以参数形式传递给KiInitializePcr函数,后者将其记录到描述处理器的基本数据区PCR(Processor Control Region)和Prcb(Processor control block)中。
以上介绍的过程都是发生在0号处理器中的,也就是所谓的Bootstrap Processor,简称BSP。因为即使是多CPU的系统,在把NTLDR或WinLoad及执行权移交给内核的阶段都只有BSP在运行。在BSP完成了内核初始化和执行体的阶段0初始化后,在阶段1初始化时,BSP才会执行KeStartAllProcessors函数来初始化其他CPU。BSP之外的其他CPU一般称为AP(Application Processor)。对于每个AP,KeStartAllProcessors函数会为其建立一个单独的处理器状态区,包括它的IDT,然后调用KiInitProcessor函数,后者会根据启动CPU的IDT为要初始化的AP复制一份,并做必要的修改。
在内核调试会话中,可以使用!pcr命令观察CPU的PCR内容,清单11-1显示了Windows Vista系统中0号CPU的PCR内容。
清单11-1 Windows Vista系统中0号CPU的PCR内容
kd> !pcr
KPCR for Processor 0 at 81969a00: // KPCR结构的线性内存地址
Major 1 Minor 1 // KPCR结构的主版本号和子版本号
NtTib.ExceptionList: 9f1d9644 // 异常处理注册链表
[…] // 省略数行关于NTTIB的信息
SelfPcr: 81969a00 // 本结构的起始地址
Prcb: 81969b20 // KPRCB结构的地址
Irql: 0000001f // CPU的中断请求级别(IRQL)
IRR: 00000000 //
IDR: ffff20f0 //
InterruptMode: 00000000 //
IDT: 834da400 // IDT的基地址
GDT: 834da000 // GDT的基地址
TSS: 8013e000 // 任务状态段(TSS)的地址
CurrentThread: 84af6270 // 当前在执行的线程,ETHREAD地址
NextThread: 00000000 // 下一个准备执行的线程
IdleThread: 8196cdc0 // IDLE线程的ETHREAD地址
内核数据结构KPCR描述了PCR内存区的布局,因此也可以使用dt命令来观察PCR,例如,kd> dt nt!_KPCR 81969a00。
11.1.2 门描述符IDT的每个表项是一个所谓的门描述符(gate descriptor)结构。之所以这样称呼,是因为IDT项的基本用途就是引领CPU从一个空间到另一个空间去执行,每个表项好像是一个从一个空间进入另一个空间的大门(gate)。在穿越这扇门时CPU会做必要的安全检查和准备工作。
IDT中可以包含以下3种门描述符。
(1)任务门(task-gate)描述符:用于任务切换,里面包含用于选择任务状态段(TSS)的段选择子。可以使用JMP或CALL指令通过任务门来切换到任务门所指向的任务,当CPU因为中断或异常转移到任务门时,也会切换到指定的任务。
(2)中断门(interrupt-gate)描述符:用于描述中断处理例程的入口。
(3)陷阱门(trap-gate)描述符:用于描述异常处理例程的入口。
图11-1描述了以上3种门描述符的内容布局。
图11-1 IDT中的3种门描述符的内容布局
从图11-1可以看出,3种门描述符的格式非常相似,有很多共同的字段。其中DPL代表描述符优先级(descriptor previlege level),用于优先级控制,P是段存在标志。段选择子用于选择一个段描述符(位于LDT或GDT中,选择子的格式参见本书卷1的2.6.3节),偏移部分用来指定段中的偏移,二者共同定义一个准确的内存位置。对于中断门和陷阱门,二者指定的就是中断或异常处理例程的地址;对于任务门,它们指定的就是任务状态段的内存地址。
系统通过门描述符的类型字段,即高4字节的6~12位,来区分一个描述符的种类。例如任务门的类型是0b00101(b代表二进制数),中断门的类型是0b0D110,其中D位用来表示描述的是16位门(0)还是32位门(1),陷阱门的类型是0b0D111。
11.1.3 执行中断和异常处理函数下面我们看看当有中断或异常发生时,CPU是如何通过IDT寻找和执行处理函数的。首先,CPU会根据其向量号码和IDTR中的IDT基地址信息找到对应的门描述符。然后判断门描述符的类型,如果是任务描述符,那么CPU会执行硬件方式的任务切换,切换到这个描述符所定义的线程;如果是陷阱描述符或中断描述符,那么CPU会在当前任务上下文中调用描述符所描述的处理例程。下面分别加以讨论。
我们先来看任务门的情况。简单来说,任务门描述的是一个TSS,CPU要做的是切换到这个TSS所代表的线程,然后开始执行这个线程。TSS是用来保存任务信息的一段内存区,其格式是CPU所定义的。图11-2给出了IA-32 CPU的TSS格式。从中我们看到TSS中包含了一个任务的关键上下文信息,如段寄存器、通用寄存器和控制寄存器,其中特别值得注意的是靠下方的SS0~SS2和ESP0~ESP2字段,它们记录着一项任务在不同优先级执行时所应使用的栈,SSx用来选择栈所在的段,ESPx是栈指针值。
CPU在通过任务门的段选择子找到TSS描述符后,会执行一系列的检查动作,比如确保TSS描述符中的存在标志是1,边界值应该大于0x67,B(Busy)标志不为1等。所有检查都通过后,CPU会将当前任务的状态保存到当前任务的TSS中。然后把TSS描述符中的B标志设置为1。接下来,CPU要把新任务的段选择子(与门描述符中的段选择子等值)加载到TR寄存器,然后把新任务的寄存器信息加载到物理寄存器中。最后,CPU开始执行新的任务。
图11-2 32位的任务状态段(TSS)
下面通过一个小实验来加深大家的理解。首先,在一个调试Windows Vista的内核调试会话中,通过ridtr命令得到系统IDT的基地址。
kd> r idtr
idtr=834da400
因为双重错误异常(Double Fault,#DF)通常是使用任务门来处理的,所以我们观察这个异常对应的IDT项。因为#DF异常的向量号是8,每个IDT项的长度是8字节,所以我们可以使用如下命令显示出8号IDT项的内容。
kd> db 834da400 8*8 l8
834da440 00 00 50 00 00 85 00 00 ..P.....
其中第2、3字节(从0起,下同)组成的WORD是段选择子,即0x0050。第5字节(0x85)是P标志(为1)、DPL(0b00)和类型(0b00101)。
接下来使用dg命令显示段选择子所指向的段描述符。
kd> dg 50
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- -----------
0050 81967000 00000068 TSS32 Avl 0 Nb By P Nl 00000089
也就是说,TSS的基地址是0x81967000,长度是0x68字节(Gran位指示By即Byte)。Type字段显示这个段的类型是32位的TSS(TSS32),它的状态为Available,并非Busy。
至此,我们知道了#DF异常对应的门描述符所指向的TSS,是位于内存地址0x81967000开始的0x68字节。使用内存观察命令便可以显示这个TSS的内容了(清单11-2)。
清单11-2 TSS的内容
kd> dd 81967000
81967000 00000000 81964000 00000010 00000000
81967010 00000000 00000000 00000000 00122000
81967020 8193f0a0 00000000 00000000 00000000
81967030 00000000 00000000 81964000 00000000
81967040 00000000 00000000 00000023 00000008
81967050 00000010 00000023 00000030 00000000
81967060 00000000 20ac0000 00000000 81964000
81967070 00000010 00000000 00000000 00000000
参考清单11-2,从上至下,81964000是在优先级0执行时的栈指针,00000010是在优先级0执行时的栈选择子,00122000是这个任务的页目录基地址寄存器(PDBR,即CR3)的值,8193f0a0是程序指针寄存器(EIP)的值,当CPU切换到这个任务时便是从这里开始执行的。接下来,依次是标志寄存器(EFLAGS)和通用寄存器的值。偏移0x48字节处的0x23是ES寄存器的值,相邻的00000008是CS寄存器的值,即这个任务的代码段的选择子。而后是SS寄存器的值,即栈段的选择子,再往后是DS、FS和GS寄存器的值(0x23、0x30和0)。偏移0x64字节处的20ac0000是TSS的最后4字节,它的最低位是T标志(0),即我们在卷1的4.3.3节介绍过的TSS中的陷阱标志。高16字节是用来定位IO映射区基地址的偏移地址,它是相对于TSS的基地址的。
使用ln命令可以观察EIP的值对应的就是内核函数KiTrap08。
kd> ln 8193f0a0
(8193f0a0) nt!KiTrap08 | (8193f118) nt!Dr_kit9_a
Exact matches:
nt!KiTrap08 = <no type information>
也就是说,当有#DF异常发生时,CPU会切换到以上TSS所描述的线程,然后在这个线程环境中执行KiTrap08函数。之所以要切换到一个新的线程,而不是像其他异常那样在原来的线程中处理,是因为#DF异常指的是在处理一个异常时又发生了异常,这可能意味着本来的线程环境已经不可靠了,所以有必要切换到一个新的线程来执行。
类似地,代表紧急任务的不可屏蔽中断(NMI)也是使用任务门机制来处理的。最后要说明的是,因为x64架构不支持硬件方式的任务切换,所以IDT中也不再有任务门了。
大多数中断和异常是利用中断门或陷阱门来处理的,下面我们看看这两种情况。
首先,CPU会根据门描述符中的段选择子定位到段描述符,然后再进行一系列检查,如果检查通过,CPU就判断是否需要切换栈。如果目标代码段的特权级别比当前特权级别高(级别的数值小),那么CPU需要切换栈,其方法是从当前任务的TSS中读取新栈的段选择子(SS)和栈指针(ESP),并将其加载到SS和ESP寄存器。然后,CPU会把被中断过程(旧的)的栈段选择子(SS)和栈指针(ESP)压入新的栈。接下来,CPU会执行如下两项操作。
(1)把EFLAGS、CS和EIP的指针压入栈。CS和EIP的指针代表了转到处理例程前CPU正在执行代码的位置。
(2)如果发生的是异常,而且该异常具有错误代码(参见本书卷1的3.3.2节),那么把该错误代码也压入栈。
如果处理例程所在代码段的特权级别与当前特权级别相同,那么CPU便不需要进行栈切换,但仍要执行上面的两步操作。
TR寄存器中存放着指向当前任务TSS的选择子,使用WinDBG可以观察TSS的内容。
kd> r tr
tr=00000028
kd> dg 28
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- ---------
0028 8013e000 000020ab TSS32 Busy 0 Nb By P Nl 0000008b
经常做内核调试的读者可能会发现,TR寄存器的值大多时候是固定的。也就是说,值并不随着应用程序的线程切换而变化。事实上,Windows系统中的TSS个数并不是与系统中的线程个数相关的,而是与CPU个数相关的。在启动期间,Windows系统会为每个CPU创建3~4个TSS,一个用于处理NMI,一个用于处理#DF异常,一个用于处理机器检查异常(与版本有关,在XP SP1中存在),另一个供所有Windows线程共享。当Windows系统切换线程时,它把当前线程的状态复制到共享的TSS中。也就是说,普通的线程切换并不会切换TSS,只有当NMI或 #DF异常发生时,才会切换TSS,这就是所谓的以软件方式切换线程(任务)。
11.1.4 IDT一览使用WinDBG的!idt扩展命令可以列出IDT中的各个项,不过该命令做了很多翻译,显示出的不是门描述符的原始格式。
lkd> !idt -a
Dumping IDT:
00: 804dbe13 nt!KiTrap00 // 0号异常,即除以0
01: 804dbf6b nt!KiTrap01
02: Task Selector = 0x0058 // NMI的门描述符,显示的是TSS的选择子
03: 804dc2bd nt!KiTrap03
表11-1列出了典型Windows系统的IDT设置,对于不同的Windows版本或硬件配置不同的系统,某些表项可能有所不同,但是大多数表项是一致的。
表11-1 IDT设置一览(略)
在Windows XP系统中,处理机器检查异常(#MC)的18号表项处是一个任务门描述符,指向一个单独的TSS,对应的处理函数是hal模块中的HalpMcaExceptionHandlerWrapper。
11.2 异常的描述和登记为了更好地管理异常,Windows系统定义了专门的数据结构来描述异常,并定义了一系列代码来标识典型的异常。
在操作系统层次,除了CPU产生的异常,还有通过软件方式模拟出的异常,比如调用RaiseException API而产生的异常和使用编程语言的throw关键字抛出的异常。为了行文方便,我们把前一类称为CPU异常(或硬件异常),把后一类称为软件异常。Windows是使用统一的方式来描述和分发这两类异常的。本节介绍异常的描述方式,11.3节将介绍异常的分发过程。
11.2.1 EXCEPTION_RECORD结构Windows系统使用EXCEPTION_RECORD结构来描述异常,清单11-3给出了这个结构的定义。
清单11-3 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;
其中ExceptionCode为异常代码,是一个32位的整数,其格式是Windows系统的状态代码格式,NtStatus.h中包含了已经定义的所有状态代码,在WinBase.h中可以看到异常代码只是状态代码的别名,例如:
#define EXCEPTION_BREAKPOINT STATUS_BREAKPOINT
#define EXCEPTION_SINGLE_STEP STATUS_SINGLE_STEP
表11-2列出了常见的用于异常代码的状态代码。
字段用来记录异常标志,它的每一位代表一种标志,目前已经定义的标志位如下。
(1)EH_NONCONTINUABLE(1),该异常不可恢复继续执行。
(2)EH_UNWINDING(2),当因为执行栈展开而调用异常处理函数时,会设置此标志。
(3)EH_EXIT_UNWIND(4),也是用于栈展开,较少使用。
(4)EH_STACK_INVALID(8),当检测到栈错误时,设置此标志。
(5)EH_NESTED_CALL(0x10),用于标识内嵌的异常(第24章)。
EH_NONCONTINUABLE位用来表示该异常是否可以恢复继续执行,如果试图恢复运行一个不可继续的异常,便会导致EXCEPTION_NONCONTINUABLE_EXCEPTION异常。
ExceptionRecord指针指向与该异常有关的另一个异常记录,如果没有相关的异常,那么这个指针便为空。
表11-2 用于的异常代码的状态代码(略)
ExceptionAddress字段用来记录异常地址,对于硬件异常,它的值因为异常类型不同而可能是导致异常的那条指令的地址,或者是导致异常指令的下一条指令的地址。例如,非法访问异常(EXCEPTION_ACCESS_VIOLATION)属于错误(Fault)类异常,ExceptionAddress的值是导致异常的那条指令的地址。数据断点触发的调试异常属于陷阱(Trap)类异常,ExceptionAddress的值是导致异常指令的下一条指令的地址。
NumberParameters是附加参数的个数,即ExceptionInformation数组中包含的有效参数个数,该结构最多允许存储15个附加参数。
导致非法访问异常的原因主要来源于CPU的页错误异常#PF(14),但也可能是由于系统检测到的其他违反系统规则的情况。
11.2.2 登记CPU异常对于CPU异常,KiTrapXX例程在完成针对本异常的特别动作后,通常会调用CommonDispatchException函数,并通过寄存器将如下信息传递给这个函数。
(1)将唯一标识该异常的一个异常代码(表11-2)放入EAX寄存器。
(2)将导致异常的指令地址放入EBX寄存器。
(3)将其他信息作为附带参数(最多3个)分别放入EDX(参数1)、ESI(参数2)和EDI(参数3)寄存器,并将参数个数放入ECX寄存器。
CommonDispatchException被调用后,它会在栈中分配一个EXCEPTION_ RECORD结构,并把以上异常信息存储到该结构中。在准备好这个结构后,它会调用内核中的KiDispatchException函数来分发异常。
11.2.3 登记软件异常下面看看软件异常的产生和登记过程。简单来说,软件异常是通过直接或间接调用内核服务NtRaiseException而产生的。
NTSTATUS NtRaiseException (IN PEXCEPTION_RECORD ExceptionRecord,
IN PCONTEXT ContextRecord, IN BOOLEAN FirstChance )
用户模式中的程序可以通过RaiseException ()API来调用这个内核服务。RaiseException API是由KERNEL32.DLL导出的API,供应用程序产生“自定义”的异常,其原型如下。
void RaiseException( DWORD , DWORD ,
DWORD , const DWORD* );
其中是异常代码,可以是表11-2中的代码,也可以是应用程序自己定义的代码。和用来定义异常的常数,相当于EXCEPTION_RECORD结构中的和。事实上,RaiseException的实现也很简单,它只是将参数放入一个 EXCEPTION_RECORD 后便调用NTDLL.DLL中的RtlRaiseException()。RtlRaiseException会将当前的执行上下文(通用寄存器等)放入CONTEXT结构,然后通过NTDLL.DLL中的系统服务调用机制调用内核中的NtRaiseException。
NtRaiseException内部会调用另一个内核函数KiRaiseException。
NTSTATUS KiRaiseException (IN PEXCEPTION_RECORD ExceptionRecord,
IN PCONTEXT ContextRecord, IN PKEXCEPTION_FRAME ExceptionFrame,
IN PKTRAP_FRAME TrapFrame, IN BOOLEAN FirstChance )
ExceptionRecord是指向异常记录的指针,ContextRecord是指向线程上下文(CONTEXT)结构的指针,ExceptionFrame对于x86平台总是为NULL,TrapFrame就是栈帧的基地址,FirstChance表示这是该异常的第一轮(TRUE)还是第二轮(FALSE)处理机会。
内核中的代码可以通过RtlRaiseException(相当于NTDLL.DLL中的版本)来调用NtRaiseException和KiRaiseException。也就是说,不论是从用户模式调用RaiseException API,还是从内核模式调用相应的函数,最后都会转到KiRaiseException。
KiRaiseException内部会通过KeContextToKframes例程把ContextRecord结构中的信息复制到当前线程的内核栈,然后把ExceptionRecord 中的异常代码的最高位清0,以便把软件产生的异常与CPU异常区分开来。接下来KiRaiseException会调用KiDispatchException开始分发该异常。
对于Visual C 程序抛出的异常,比如MFC中从CException派生来的各个异常类对应的异常,throw关键字直接对应的是CxxThrowException函数,CxxThrowException会调用RaiseException,并将ExceptionCode参数固定为0xe06d7363(对应的ASCII码为.msc)。接下来的过程与上面直接调用RaiseException的情况相同。因为C 异常的实现与编译器有关,所以本书只讨论使用Visual C 编译器的情况。
.NET程序抛出的异常(CLR异常)也是通过RaiseException API产生的,其异常代码固定为0xe0434f4d(对应的ASCII码为.COM)。
综上所述,不论是CPU异常还是软件异常,尽管产生的原因不同,但最终都会调用内核中的KiDispatchException来分发异常,也就是说,Windows系统是使用统一的方法来分发CPU异常和软件异常的。
11.3 异常分发过程根据前面两节的介绍,当有异常发生时,CPU会通过IDT找到异常处理函数,即内核中的KiTrapXX系列函数,然后转去执行。但是,KiTrapXX函数通常只是对异常作简单的表征和描述,为了支持调试和软件自己定义的异常处理函数,系统需要将异常分发给调试器或应用程序的处理函数。对于软件异常,Windows系统是以和CPU异常统一的方式来分发和处理的,本节将介绍分发异常的核心函数KiDispatchException和它的工作过程。
11.3.1 KiDispatchException函数Windows内核中的KiDispatchException函数是分发各种Windows异常的枢纽。其函数原型如下。
VOID KiDispatchException ( IN PEXCEPTION_RECORD ExceptionRecord,
IN PKEXCEPTION_FRAME ExceptionFrame, IN PKTRAP_FRAME TrapFrame,
IN KPROCESSOR_MODE PreviousMode, IN BOOLEAN FirstChance )
其中,参数ExceptionRecord指向的是上一节介绍的EXCEPTION_RECORD结构,用来描述要分发的异常。参数ExceptionFrame对于x86系统总是为NULL。参数TrapFrame指向的是 KTRAP_FRAME 结构,用来描述异常发生时的处理器状态,包括各种通用寄存器、调试寄存器、段寄存器等。参数PreviousMode 是一个枚举类型的常量,DDK的头文件中有这个枚举类型的定义。
typedef enum _MODE { KernelMode, UserMode, MaximumMode} MODE;
也就是说,PreviousMode等于0表示前一个模式(通常是触发异常代码的执行模式)是内核模式,1表示用户模式。FirstChance参数表示是否是第一轮分发这个异常。对于一个异常,Windows系统最多分发两轮。
图11-3画出了KiDispatchException分发异常的基本过程(示意图)。
从图11-3中可以看到,KiDispatchException会先调用KeContextFromKframes函数,目的是根据TrapFrame参数指向的KTRAP_FRAME结构产生一个CONTEXT结构,以供向调试器和异常处理器函数报告异常时使用。
接下来,根据前一个模式(异常发生的模式)是内核模式还是用户模式,KiDispatchException会选取左右两个流程之一来分发异常,下面我们分别作进一步说明。
本文截选自《软件调试 第2版 卷2 Windows平台调试 上、下册》
本书是国内当前集中介绍软件调试主题的权威著作。本书第2 卷分为5 篇,共30 章,主要围绕Windows系统展开介绍。第一篇(第1~4 章)介绍Windows 系统简史、进程和线程、架构和系统部件,以及Windows系统的启动过程,既从空间角度讲述Windows 的软件世界,也从时间角度描述Windows 世界的搭建过程。第二篇(第5~8 章)描述特殊的过程调用、垫片、托管世界和Linux 子系统。第三篇(第9~19 章)深入探讨用户态调试模型、用户态调试过程、中断和异常管理、未处理异常和JIT 调试、硬错误和蓝屏、错误报告、日志、事件追踪、WHEA、内核调试引擎和验证机制。第四篇(第20~25 章)从编译和编译期检查、运行时库和运行期检查、栈和函数调用、堆和堆检查、异常处理代码的编译、调试符号等方面概括编译器的调试支持。第五篇(第26~30 章)首先纵览调试器的发展历史、工作模型和经典架构,然后分别讨论集成在Visual Studio 和Visual Studio(VS)Code 中的调试器,最后深度解析WinDBG 调试器的历史、结构和用法。本书理论与实践结合,不仅涵盖了相关的技术背景知识,还深入研讨了大量具有代表性的技术细节,是学习软件调试技术的珍贵资料。本书适合所有从事软件开发工作的读者阅读,特别适合从事软件开发、测试和支持的技术人员阅读。
,