总目录
1. WinDbg概述
2. WinDbg主要功能
3. WinDbg程序调试示例
4. CPU寄存器及指令系统
5. CPU保护模式概述
6. 汇编语言不等于CPU指令
7. 用WinDbg观察托管程序架构
8. Windows PE/COFF文件格式简述
9. 让WinDbg自动打开DotNet Runtime源程序
10. WinDbg综合实战
前言
通过前面三篇文章的学习,同学们应该对使用WinDbg调试C#程序有了基本的了解。
不过,在我的写作过程中,有一种感觉开始变得越来越强烈:如果想说清楚如何使用WinDbg,必须先介绍清楚CPU的基础知识,因为只有了解了CPU,才能懂得寄存器、指令系统和汇编。
因此,我打算从本篇开始,介绍一下Intel CPU基础知识。
CPU知识
Intel公司公布了其CPU系列的标准手册,最有用的就是《Intel® 64 and IA-32 Architectures
Software Developer’s Manual》,这里给的链接是4册合集(以下简称Intel手册),总计有5057页,而且只有英文。
啃完这么大部头手册虽并非不可能,但起码性价比不高。不过呢,这套手册实在写得太好了,其语言简直可以与最优秀的教科书相媲美,用词及语法都相当严谨,凡事都说得由浅入深,有条有理。下载一套手册备用,需要时随手翻翻,要比到网上胡乱找文章看有价值得多。
关键点还在于,对CPU需要了解到什么程度?我的建议是:浅尝辄止!如果非要给一个具体指标,从小白开始,学20小时足够了。不过呢,我还没有找到一套广度和深度都刚刚好的教材或教学视频。一般的汇编语言书籍对CPU的介绍太浅层次了,又对汇编指令介绍得太多了。而一般计算机体系结构方面的书呢又太细节了,初学者根本无法将知识点串起来。索性我就斗胆先把如何学习CPU知识啰嗦一下吧。
CPU寄存器与CPU工作模式
了解CPU的关键在于了解CPU的内部寄存器。
CPU访问内部寄存器的速度要比CPU访问内存条的速度快很多,大致你可以认为CPU读写一次寄存器的时间只有读写一次内存时间的几万分之一。
在Intel手册中,Intel把其各种寄存器的集合称为执行环境(Execution Environment),这种称谓其实是更科学严谨的,因为一条指令的执行通常和不同寄存器的当前状态息息相关,最简单的就是条件转移指令,该指令执行时,可能会发生跳转也可能不会跳转,在高级语言环境,我们是使用if语句,通过if后面的表达式结果到底是true还是false来判断的,而实际在CPU层面,是条件转移指令是否跳转完全是由指令执行时FLAG寄存器的值来决定的,因此Flag寄存器就是条件转移指令的执行环境因素之一。
同一个CPU,其实可以通过程序来修改其寄存器的数据,而有些寄存器又是控制CPU工作状态的。比如当前的CPU已经都是64位硬件结构了,但通过修改其不同控制寄存器的组合,可以使CPU工作于保护模式、IA-32e模式、实地址模式或系统管理模式(SMM)。IA-32e模式又可细分位IA-32兼容模式和64位模式;而不同的模式下,程序可以使用的寄存器也是不同的。举个最简单的例子:通过指令使CPU中CR0控制寄存器的PG位置0,会引发CPU从64位模式切换到IA-32模式(实际切换要比这复杂得多,但我们没必要了解更多细节)。一旦切换成功,以前64位模式下的64位通用寄存器RAX, RBX, RCX,RDX等就都不存在了,取而代之的是32位的EAX,EBX,ECX,EDX寄存器。在64位模式下有16个128位的XMM寄存器和16个256位的YMM寄存器,切换到IA-32模式后就各自只有8个可见了。除此以外,不同模式下指令也不完全相同,每一个更高级别的模式都会引入一些新的扩展指令,而这些新指令在低级模式下是无法执行的。比如在实地址模式下,CS,DS,SS,ES寄存器被称为段寄存器,此模式下的寻址方式是直接使用段寄存器值左移4位再加上指令中的偏移地址的,很多讲汇编语言的书依旧在讲这种早就老掉牙根本没有使用价值的汇编;而同一款CPU,当通过适当修改寄存器值使之进入保护模式后,这四个段寄存器就变成了段选择子,通过段选择子去查找内核态地址空间中的段描述符来寻址,无论是指令格式还是功能都会有巨大不同。
所有以上说的这些,大家其实只要理解到我说的这个程度,也基本可以应对一般程序调试,无需知道更多。原因就在于,实地址模式及传统保护模式都已尽成为了历史,虽然因为历史兼容原因,最新的CPU依旧支持这些模式,但对于应用软件开发的工程师来说,现在编译器和操作系统会自动帮我们将CPU设置成IA-32或IA-32e的flat平面地址模式,地址也已经不是实地址模式下的物理地址,而是虚拟地址,且一般都直接启用了内存分页转储机制,对一般程序员来说,只需将我们在调试器上看到的地址当作真实物理地址即可,一般无需考虑地址转换,甚至分段的保护模式都几乎绝迹了。当然,对于驱动程序开发或者UEFI开发的程序员来说,实地址模式就必须掌握,因为CPU刚上电或RESET时,实际还是运行在实地址模式的。
通常,我们做汇编调试时,只要知道一部分常用寄存器就可以了。以下截图来自于Intel手册第一卷,是64位模式下的寄存器结构。其中我用红色框框起来了部分需要掌握的寄存器,按红线粗细分为三档,线条越粗,用到得越频繁:

通用寄存器
如图所示,最常见的是16个64位的通用寄存器(General purpose regiesters),1个64位的RFLAGs状态寄存器,一个64位的RIP指令指针寄存器,16个128位的XMM寄存器和16个156位的YMM寄存器。
具体介绍这些寄存器之前,我们先简单说一下CPU运行于64位模式和32位模式下寄存器的共性区别。我们以指令指针寄存器为例,在IA-32,即32位CPU运行模式下,该寄存器称为EIP,是32位的。之所以称为EIP是因为它的前身(指传统的8086CPU)指令指针寄存器只有16位,被称为IP,实际是instruction pointer的缩写,该寄存器永远指向下一条要执行的指令的内存首址。后来的CPU扩展成32位时,Intel就将IP寄存器扩展成了EIP,E代表的是extension,也就是扩展的意思。也就是说,以后我们凡是遇到以E开头的寄存器,基本都是32位的寄存器,常见的比如EIP,EAX, EBX, ECX, EDX, ESP, EBP, ESI, EDI。另外,Intel为了让新的CPU能运行以前程序,还保留了原来8086时代的寄存器,比如IP, AX, BX, CX, DX, SP, BP, SI, DI等16位寄存器。再后来,CPU发展到了64位,Intel又重新对寄存器进行了扩展,用字母R替换了原来的E,比如EIP扩展成了RIP, EAX扩展成了RAX等。
调试程序时,如果我们观察寄存器时发现指令指针寄存器是RIP,那么该程序就是运行在64位模式下,如果是EIP,那么该应用程序就是32位的。比如在WinDbg中输入r命令,可以看指令指针寄存器是rip,其数据长度确实是64位(8字节),而且其他寄存器rax, rbx, rcx等都是64位,可以通过这个方法简单判断当前应用程序是否是64位。

如果是32位应用程序,你大概率看到很多以e开头的寄存器,尤其是eip,而且地址也会变成只有4字节(32位),如下图所示:

下面首先介绍一下通用寄存器。鉴于目前32位应用程序比例依旧高于64位应用程序,我们以IA-32模式来介绍。
我们将EAX, EBX, ECX, EDX, ESI, EDI, ESP和EBP合计8个32位寄存器称为通用寄存器。通用寄存器是应用程序中使用最频繁的寄存器,说他们通用,主要是指他们的功能相似,很多时候可以互相代替,比如当ECX中已经保存了有意义的临时数据尚未处理时,就可以改用EAX来存另外一个数据。这些通用寄存器主要用于以下两个目的:
- 算数或逻辑运算的操作数,比如保存加法指令的加数或被加数。举例: Add EAX, 10h
;将EAX的值和10h相加,结果保存到EAX寄存器。 - 地址运算。举例:Mov EAX, [EBP - 28h]
;将EBP寄存器的数据减去28h作为内存地址,取出数据保存到EAX寄存器,此处EBP就用于计算地址。
注意:虽说这些寄存器通用,但有些寄存器在某些指令中还是有着特殊含义。比如:
- 堆栈操作的PUSH和POP指令,总是自动选择使用ESP作为堆栈指针,所以除了管理堆栈以外,我们从来不用ESP做其他用途。
- 字符串操作时,总是使用ECX, ESI和EDI寄存器。
- 栈帧总是用EBP来保存,这本来与CPU无关,但已经成为行业默认约定,所以大家都会遵守。
此外,为了兼容原8086CPU的寄存器,这8个通用寄存器的低16位又可以当作16位通用寄存器使用,使用方法是去掉首字符E,比如BP寄存器对应这EBP寄存器的低16位。而AX, BX, CX, DX四个寄存器又可以再细分为AH, AL等两个8位寄存器,而且这些被拆小的寄存器可以在32位程序模式下直接可用,比如:
Mov Eax, 0105h
Mov BL, AH ;BL = 01
因为这些寄存器经常被用到,故贴图如下:

备注:以上所述IA-32模式和64位模式指的是CPU运行模式,实际上CPU在不同运行模式下还会细分操作数模式,这种细分是通过段描述符来设定的,更详细信息只能通过阅读Intel手册了,不过一般处于64位运行模式的应用程序都是使用64位操作数,而运行于IA-32模式时一般都使用32位操作数模式,所以为了简单化,本文不会再进一步细分,否则一定会导致读者头大。
前面我们给出64位模式下寄存器结构图时,图上的通用寄存器是16个的,在IA-32模式下只有8个,那么另外8个64位通用寄存器是R8 ~ R15,如果使用64位模式下的32位操作数模式,还会将R8 ~ R15的低32位用名字R8D ~ R15D表示。下面是完整总结:

CPU指令系统
CPU指令系统其实非常复杂。首先需要明确的一点,就是CPU并不能运行汇编语言程序。比如上面列出的MOV EAX, 0105H,CPU是无法执行的,CPU真正可以执行的只有二进制编码(由0或1组合而成)。下面举例说明:
下面的截图是Visual Studio调试器截图,这种格式是我们常见的形式,我用红色框选中了两行汇编语言指令举例讲解,而每一行又用竖线分成了3个部分:

先看第一行最右边的部分:mov dword ptr [rbp+7Ch],64h
这一部分就是我们说的汇编语言语句,它的含义是把64h(十进制100)保存到rbp寄存器+7Ch这个地址的内存中去。我们可以在Visual Studio中打开寄存器窗口,查到rbp寄存器的值是RBP = 00000000001CEC30,如下图所示。所以这条语句的执行结果应该是把64h保存到00000000001CEC30 + 7CH = 00000000001CECAC内存单元中去。


为了
验证,我们暂时先不执行这条语句,先查看一下00000000001CECAC地址下存的数据,是00 00 00 00,也就是整数0.
接下来我们点击VS的单步执行运行一下,再看看这个内存的数据:

程序执行后,数据变成了64,和我们的预期完全相符。
接下来我们继续讲这条指令。前面说过,CPU并不懂如何执行mov dword ptr [rbp+7Ch],64h,它懂得的,只有机器码。哪部分是机器码?就是第一行中间部分:C7 45 7C 64 00 00 00。这才是CPU真正执行的指令。至于汇编代码,其实是VS读到了机器码以后,给我们翻译过来的。对于Intel CPU来说,Intel规定了其机器码的格式,而汇编语言有很多公司在开发,不同公司的汇编语言语法并不一样。比如VS使用微软的MASM宏汇编语法,所以把C7 45 7C 64 00 00 00翻译成了mov dword ptr [rbp+7Ch],64h;同样的机器码,如果使用AT&T反汇编工具,其生成的汇编代码就会变成:movq $0x64, (%rbp + 0x7c),不仅数据表达方式不同,甚至连源操作数和目标操作数的顺序都颠倒了。这里没有哪种好哪种不好的问题,而是习惯问题,关键在于汇编和反汇编要使用同样的系统,以达到同样功能的汇编生成同样的机器码。
所以,我想要强调的就是,汇编语句(mov dword ptr [rbp+7Ch],64h)其实仅供参考,最准确的指令含义还要看机器指令C7 45 7C 64 00 00 00。比如同样使用MASM汇编语法,如果上述指令改写成:mov dword ptr [rbp+124], 100,用MASM编译链接器(al.exe)编译,其结果也是完全相同的。
有时候,我们单看汇编语言语句时可能会出现理解歧义,比如可以解释成不同含义,此时我们就需要使用Intel手册,通过机器代码来查该指令的准确含义。我以前确实遇到过这种情况,不过我忘记具体是什么指令了,所以现在无法给出好的例证。
接下来我们分析一下机器码:C7 45 7C 64 00 00 00,显然我们可以将这7个字节分成三部分,C7 45代表的是操作码,该操作码指示CPU将最后四个字节 64 00 00 00保存到以rbp为基准地址,加上第3个字节7C作为偏移的地址中去。因此,该指令包含一个操作码C7 45和两个操作数分别是7C和64 00 00 00。
那么,如果基址寄存器不是rbp,而是rcx,操作码会是什么?如果看Intel手册第2卷,会知道Intel的指令系统设计,其中的ModR/M字节规定了不同的寻址方式编码规则。

而ModR/M字节的细节可以通过Table 2-2查到。

这些说得越来越复杂,但并不要求记住,只要了解了原理就可以了。
继续说第一行代码最左边的部分:00007FF8F0757B85。这块非常简单,就是一个地址,也就是说,在内存地址为00007FF8F0757B85的地方会连续存储如下7个字节:C7 45 7C 64 00 00 00的机器指令码,这个才是指令本质。在vs中显然很容易验证,那就是使用存储器窗口,如下图所示:

接下来我们再分析一下第二行指令:00007FF8F0757BA3 E8 78 91 30 00 call 00007FF8F0A60D20
很显然,00007FF8F0757BA3是本条指令的内存地址,E8 78 91 30 00是真实的机器指令,call 00007FF8F0A60D20 是VS帮我们将机器指令翻译成的汇编代码,而call后面的00007FF8F0A60D20应该就是子程序调用的目标地址。不过,和前一条指令不同,此处的目标地址和机器码并非一一对应。第一条指令中,我们能直接从机器码中找到7C及64 00 00 00,但这条子程序调用指令却找不出对应了。不过,既然VS能翻译出目标地址,我们人类难道还会比VS笨?
怎么做?首先打开Intel手册第二卷目录部分,查到call指令在3-139页。该页的内容如下:

有很多种Call指令,但只有两行以E8开头的指令码和我们的E8 78 91 30 00匹配。所以指令只能是E8 cw或E8 cd。cw的意思是constant word,而cd是指constant dword,也就是cw指后接一个字(两字节),而cd指后接一个双字(四字节)。粗心的朋友一定会认为,vs给我们的指令是E8 78 91 30 00,后接了四个字节,所以自然应该按第二行代码来解析。实际上并没有这么简单,因为机器码是在内存中连续保存的,任何两条机器指令之间并没有分隔符,实际状态就是这样的:

你说e8后面接了两个字节,然后30是下一条指令的操作码亦可,你说E8后面接4个字节,后面的48是下一条指令的开始亦可。但总不能随便解释吧?否则岂不是乱套了?于是我们继续端详上面的两行E8开始的CALL指令,第4列64-bit mode中,第一行写的是N.S.,意思是not supported,也就是不支持,或者说只要是64位模式,那么E8后面就不允许接两字节常数;而下面那条对应的64-bit mode是valid,也就是有效。很显然,我们的程序时运行于64位模式的(还记得R指令显示RIP而不是EIP就代表工作在64位模式吗?)。但CPU不能靠想当然来判断当前是否工作于64位模式,但CPU一定是知道CPU的,通过当前的控制寄存器,CPU当然知道自己工作在64位模式!所以,这个call指令只能对应第二行。接下来我们看最后一列的Descriptio(描述)部分:其意思是说E8后面接32位偏移地址(相对于下一条指令),然后符号扩展成64位作为实际64位目标地址。于是我们的分析就可以开始了:
下一条指令地址是00007FF8F0757BA8,机器码中4字节偏移量对应成十六进制int型是00309178,两者相加,结果恰恰是:00007FF8F0A60D20,Visual Studio计算得一点儿不错!

以上,我们通过两行代码的分析,相信同学们已经基本理解了CPU的指令系统的主要特点:
- 指令由操作码和操作数构成;
- 操作码表达了该操作要做什么,操作数为操作码提供所需数据;
- 任何一条指令都由操作码开始,后接0个或多个操作数;
- 汇编语句和机器指令是一一对应的,但汇编语句只是工具软件对机器指令的解释,真实有效的CPU指令其实只有机器码。
要不要学汇编?
前面说过,汇编语言的书写太多汇编的东西了。
大家不要把汇编语言和CPU指令一一对应起来就完事。其实,汇编语言是一门语言,是要交给汇编程序编译的,所以少不了需要定义语法、词法、语义、标识符等等,很多语句其实并不对应CPU指令,而仅仅是给汇编器的Directive(不知道怎么翻译恰当,反正就是不生成机器代码而仅仅为编译器提供编译信息,就像C#的using System也一样)。所以,如果仅仅是为了调试目的,根本没必要学汇编中的这些directive,因为所有的反汇编中都不会出现他们。
针对调试而言,只要能理解常用的机器指令对应的汇编语句就可以了。这种汇编语句其实很少,基本上CPU指令系统只有数据传送指令,数学运算指令、逻辑运算指令、跳转指令、子程序调用指令等几类,要比高级语言少太多了。所以,我们可以先掌握几种基本指令,比如:
MOV A, B; 将B的值传送给A
ADD A, B;将A+B结果赋值给A
然后,遇到不熟悉的指令怎么办?
以前遇到不熟悉的指令很麻烦,需要查手册。现在有了AI,方便太多了。比如我看到VS中有如下两行指令不太熟悉:
00007FF8F0757BF5 C5 FA 6F 45 38 vmovdqu xmm0,xmmword ptr [rbp+38h]
00007FF8F0757BFA C5 FA 7F 45 60 vmovdqu xmmword ptr [rbp+60h],xmm0
把他们拷贝下来,问一下文心一言,基本都可以得到正确答案,效率高了不是一点半点。

是不是非常快速!而且还回答很周到的,其实这两行指令的目的就是以XMM0(128位寄存器)为中间媒介,将rbp+38h中的16个字节数据转存到rbp+60h地址处,使用XMM0寄存器是因为位数大,一次操作了16个字节,效率高。因此,您是不是也理解了为什么有些书说使用汇编开发的程序速度快?比如我用64位CPU就可以使用最新的AVX指令以提高效率,而如果使用C语言,则C编译器为了确保程序可以在任何CPU上运行,可能就只能生成老CPU具备的指令。所以并非只要用汇编语言开发程序速度就快,而是对CPU充分了解以后用汇编语言开发的程序才能快。

3916

被折叠的 条评论
为什么被折叠?



