工作中经常会遇到需要使用汇编知识来解决的问题,比如查找崩溃堆栈定位在一些未提供源码的第三方库的崩溃原因等,但是由于汇编不是常用语言,因此很多知识并不会常记于心,常常需要反复查阅搜索才能得到答案,为了节省工作量,这里尝试将搜集到的资料进行整理归纳,方便后续使用。
为了兼容各种机器,这里以Intel Architecture 32-bit(简称IA-32,属于X86体系的32位版本,从最早的80386芯片到后续的Pentium 4使用的都是这种架构)为例,而之后的其他架构都是在IA-32的基础上进行扩展,其基本概念跟具体原理大同小异,暂时先做简单介绍,后续有需要再进行扩充。
IA-64架构是Intel推出的64位处理器架构,基于这种架构的芯片具备64位运算能力,64位寻址空间以及64位数据通路,在数据处理能力,系统稳定性,安全性,可用性等方面都具有突破性的提升。
x86-64架构,有时候会简称为x64,是IA-32架构的扩展版本,在这种架构下,芯片既可以支持原有的32位运算,同时也具备了处理64位运算的能力。
8086 CPU的工作流程见上图,详细的解释可以参考寄存器基础知识四之段寄存器。
1. 数据
计算机运行的程序由数据与指令共同表达,其中数据在汇编上主要可以分成三类:
- 寄存器,指的是数据存放在寄存器中
- 内存引用,指的是存储在内存中的数据,通过地址引用的方式对数据进行读写
- 立即数,指的是参与计算的常量
1.1 寄存器
寄存器是直接内嵌在CPU上的存储组件,其特点是存取速度快,容量小,小到几乎每个寄存器都有自己单独的名字,甚至有多个名字。
IA-32架构总共提供了16个基本的寄存器,这些寄存器按照功能可以分成如下4类:
- 通用寄存器
- 段寄存器
- 状态和控制寄存器
- 指令寄存器
1.1.1 通用寄存器
IA-32的通用寄存器总共为8个,每个都是32bits,他们的名字与对应的含义给出如下:
Name | Meaning | 0~16 bits | 0~8 bits | 8 ~ 16 bits |
---|---|---|---|---|
EAX | 累加和结果寄存器 | AX | AL | AH |
EBX | 数据指针寄存器 | BX | BL | BH |
ECX | 循环计数器 | CX | CL | CH |
EDX | i/o指针 | DX | DL | DH |
ESI | 源地址寄存器 | SI | - | - |
EDI | 目的地址寄存器 | DI | - | - |
ESP | 堆栈指针 | SP | - | - |
EBP | 栈指针寄存器 | BP | - | - |
上面表格中的各个寄存器的含义指的只是一般的用法,并没有限定其使用范围,在有需要的情况下也可以用作他用。
EAX、EBX、ECX和EDX不仅可传送数据、暂存数据保存算数逻辑运算结果,而且也可作为指针寄存器(基址和变址寄存器),存储运算数据在内存中的存放位置。
寄存器AX和AL通常称为累加器(Accumulator),可用于乘、除、输入、输出等操作,使用频率很高。
寄存器BX称为基地址寄存器(Base Register),它可作用存储器指针来使用。
寄存器CX称为计数寄存器(Count Register),在循环和字符串操作时,用于控制循环次数;在进行多位移位操作时时,CL会用于指示移位的位数。
寄存器DX称为数据寄存器(Data Register),在进行乘、除运算时,会被用作默认的操作数参与运算,此外也可用于存放I/O的端口地址。
寄存器ESI、EDI、SI和DI被称为变址寄存器(Index Register),它们主要用于存放存储单元在段内的偏移量(即地址偏移量),可以用于实现多种存储器操作数的寻址方式,需要注意的是,变址寄存器不支持8位细分。另外,作为通用寄存器,这两个寄存器也可用于存储运算的操作数和运算结果。
寄存器EBP、ESP、BP、SP称为指针寄存器(Pointer Register),主要用于存放堆栈中内存单元的偏移量(偏移地址),可用于实现多种存储器操作数的寻址方式,同样不支持8位细分,同样可用于存储运算的操作数和运算结果。这两个寄存器主要用于访问堆栈内的存储单元,并且有如下约束:
- BP为基指针(Base Pointer)寄存器,用它可直接存取堆栈中的数据;
- SP为堆栈指针(Stack Pointer)寄存器,用它只可访问栈顶。
IA-64架构下,通用寄存器从8个扩展到了16个,前面提到的8个32bits寄存器依然存在(不过料想应该被扩展到了64bits),在IA-64架构下被统称为r8d - r15d,之后,在此基础上新增了额外的8个寄存器:
RAX, RBX, CX, RDX, RDI, RSI, RBP, RSP,这批新增寄存器被统称为r8-r15。
1.1.2 段寄存器
在IA-32架构体系中,内存地址在概念上有如下三种类型:
1. 逻辑地址,指的是在机器语言指令中用于表明某个操作数或某条指令的地址。MS- DOS或Windows会把程序分成若干段,每一个逻辑地址都由一个段(segment)和偏移量(offset或displacement)组成。
2. 线性地址,也称虚拟地址virtual address,是一个32位无符号整数,可以用来表示高达4GB的地址,通常用十六进制数字表 示,范围:0x0000 0000到0xffff ffff。
3. 物理地址,用于内存芯片硬件上的内存单元寻址。它们与从微处理器的地址引脚发送到内存总线上的电信号相对应。物理地址由32位或36位无符号整数表示。
所谓的段寄存器指的就是逻辑地址中用于表明每个特殊段的起始地址的寄存器。段寄存器总共有6个,分别为CS, DS, SS, ES, FS, GX,每个段寄存器对应一个段(segment),表达的是某个段在内存中的地址(指针)。
CS,这个寄存器是code segment register的缩写,指的是代码段寄存器,存储的是下一条指令将要执行的指令存储的代码段的基地址,与IP共同作用,指定需要执行的指令的具体地址
DS, ES, FS, GS,这四个寄存器对应的都是数据段寄存器:
- DS: Data Segment Register,数据段寄存器,其值为数据段的段值;
- ES: Extra Segment Register,附加段寄存器,其值为附加数据段的段值;
- FS/GS: 80386起新增的辅助寄存器,其用法与含义与ES完全相同,新增的目的是为了降低ES的负担;
SS,指的是stack segment register,堆栈段寄存器
在IA-64位架构下,段寄存器的数目并无变化,只是使用上会略有不同。
1.1.3 状态和控制寄存器
状态和控制寄存器只有一个,名字为eflags,这个寄存器表示的意义比较复杂,几乎每一个bit都对应一个标记,程序并不直接操控此寄存器,每个标记的赋值是通过运算的结果自动进行修正的。
Bit No. | Name | Meaning |
---|---|---|
0 | CF | 进位标识,算术操作进行了进位和借位,则此位被设置 |
2 | PF | 奇偶标识,结果包含奇数个1,则设置此位 |
4 | AF | 辅助进位标识,结果的第3位像第4位借位,则此位被设置 |
6 | ZF | 零标识,结果为零,此位设置 |
7 | SF | 符号标识,若为负数则设置此位 |
8 | TF | 陷阱标识,设置进程可以被单步调试 |
9 | IF | 中断标识,设置能够响应中断请求 |
10 | DF | 方向标识,用于标示字符处理过程中指针移动方向。 |
11 | OF | 溢出标识,结果像最高位符号位进行借位或者进位,此标志被设置 |
上面列举了其中部分bit的含义,其中8~10位为控制标记,剩下的0,2,4,6,7,11等为状态位,表示的是在某些操作完成后用于表示结果类型的标记。
1.1.3.1 运算结果标志位
1.进位标志CF(Carry Flag)
进位标志CF主要用来反映运算是否产生进位或者借位。如果运算结果的最高位产生了一个进位或者借位,那么其值为1,否则为0。使用该标志位的情况有:多字(字节)数的加减运算,无符号的大小比较运算,移位操作,字(字节)之间移位,专门改变CF值的指令等。
2.奇偶标志位PF(Parity Flag)
奇偶标志PF用于反映运算结果中“1”的个数的奇偶性,如果“1”的个数为偶数,则PF的值为1,否则其值为0。利用PF可以进行奇偶校验检查,或者产生奇偶校验位。在数据传送过程中,为了提供传送的可靠性,如果采用奇偶校验的方法,就可使用该标志。
3.辅助进位标志AF(Auxiliary Carry Flag)
在发生下列情况时,辅助进位标志的值被置为1,否则其值为0:
- 在字操作时,发生低字节向高字节进位或借位时;
- 在字节操作时,发生低4位向高4位进位或借位时;
对以上6个运算结果的标志位,在一般情况编程情况下,标志位CF、ZF、SF、F的使用频率较高,而标志位PF和AF的使用频率较低。
4.零标志(Zero Flag)
零标志ZF用来反映运算结果是否为0.如果运算结果为0,则其值为1,否则其值为0.在判断运算结果是否为0时可以使用此标志位。
5.符号标志SF(Sign Flag)
符号标志SF用来反映运算结果的符号位,它与运算结果的最高位相同。在危机系统中,有符号数采用补码表示法,所以,SF也就反映运算结果的正负号。运算结果为正数时,SF的值为0,否则其值为1。
6.溢出标志OF(Overflow Flag)
溢出标注OF用于反映有符号数加减运算所得结果是否溢出。如果运算结果超过当前运算位数所能表示的范围,则称为溢出,OF的值被置为1,否则,OF的值被置为0。“溢出”和“进位”是两个不同含义的概念。
1.1.3.2 状态控制标志位
状态控制标志位是用来控制CPU操作的,它们要通过专门的指令才能使之发生改变。
1.追踪标志TF(Trap Flag)
当追踪标志TF被置为1时,CPU进入单步执行方式,即每执行一条指令,产生一个单步中断请求,这种方式主要用于程序的调试。指令系统中没有专门的指令来改变标志位TF的值,但程序员可用其它办法来改变其值。
2.中断允许标志IF(Interrupt-enable Flag)
中断允许标志IF是用来决定CPU是否响应CPU外部的可屏蔽中断发出的中断请求。但不管该标志位何值,CPU都必须响应CPU外部的不可屏蔽中断所发出的中断请求,以及CPU内部产生的中断请求。具体规定如下:
- 当IF=1时,CPU可以响应CPU外部的可屏蔽中断发出的中断请求;
- 当IF=0时,CPU不响应CPU外部的可屏蔽中断发出的中断请求。
CPU的指令系统中也有专门的指令来改变标志位IF的值。
3.方向标志DF(Direction Flag)
方向标志DF用来决定在串操作指令执行时有关指针寄存器发生调整的方向。在微机的指令系统汇总,还提供了专门的指令来改变标志位DF的值。
IA-64位架构将控制寄存器扩展到了64位,但是高32bits依然处于空闲状态并未使用,因此表示的含义与用法都与IA-32架构相同。
1.1.4 指令寄存器
指令寄存器依然只有一个,名字为EIP,表示的是当前进程中将要执行的指令所在的位置。因为每个段的最大范围为64k(?),因此EIP的高16位均为0,其低16位名称为IP(Instruction Pointer),存放的是下次将要执行的指令在代码段中的偏移地址,即CS*0x10 + IP共同作用,给出下一条指令的位置。
IA-64架构将此寄存器扩展为RIP 64位寄存器。
1.2 寻址
前面说过,汇编指令中用到的数据有三种类型,不同的类型,其访问的方式(寻址方式)有所不同,这里用一张表给出汇编中常用的几种寻址方式:
这里对上表中一些描述不够详细的点进行一下简单展开:
间接寻址指的是以寄存器中存储的数值作为内存中的地址,从这个地址表达的存储单元中取出数据的寻址方式。
基址+偏移量寻址指的是以给出的立即数作为基址,以寄存器中存储的数值作为偏移量,两者相加作为内存地址的寻址方式。
变址寻址指的是用两个寄存器存储的数值分别作为基址与偏移得到的内存地址的寻址方式,这种寻址方式还有增加立即数作为额外基址的变体以及添加额外参数作为缩放比例的比例变址寻址方式。
语言描述过于抽象,这里给出一个例子:
1.3 数据类型
因为不存在数据结构与类型的说法,因此汇编中的数据类型,指的实际上是立即数的类型,而立即数的类型在IA-32架构下有如下的几种:
2. 指令
在介绍汇编指令之前,先来介绍程序运行的一些基本知识。
一段程序从高级语言编写的角度来看,是由一个个的函数来表达的,在每个函数中又会出现函数间的跳转以及流程控制等逻辑,除此之外,就是线性执行的指令代码。
正因为函数之间的跳转与调用,因此在任意时刻执行的代码从上到下都包含了多层函数调用的层级关系,为了维护这些层级关系,需要一套结构来对函数调用之间的数据进行存储,方便调用前的参数传递与调用完成后的现场恢复等,而实现这个功能的结构我们称之为栈帧结构,如下图所示:
调用时间越早的函数,其对应的帧越接近栈底(即先入栈),对应的地址也就越大(在x86的环境下,栈是朝着低地址的方向伸长的)。每次函数调用的指令call触发的时候,都需要进行现场保护与参数传递,之后就进入被调用函数代码段的执行逻辑,此时指令计数器IP(EIP/RIP)存放的就是被调用函数的起始地址,如下图所示:
介绍完基本的函数堆栈结构之后,我们一起来看下具体的汇编指令,汇编指令从功能上来看,可分成流程控制指令以及顺序执行指令两大类,在这个框架下还可以做进一步的细分控制,具体后面会讲。
2.1 顺序执行指令
2.1.1 数据传送指令
数据传送指令指的是将一个数据从某个位置搬移到另一个位置,其限制条件为,源地址与目标地址不能同时为内存空间,即不支持从内存到内存的数据搬运,想要实现内存到内存的数据搬运,就需要分两次完成。
具有较多的变种,具体如下面几张图所示:
图中给出的指令变种数看起来多,但从功能上来看,可以分成MOVZ与MOVS两种,两者的区别在于高位用符号位扩展(MOVS)还是零扩展(MOVZ)进行扩充:
- 用符号位扩展,目的位置的所有高位用源值的最高位数值进行填充。
- 用零扩展,所有高位都用零填充。
上面给出的传送指令是按照从左到右的方式进行数据传送的,但是在实践中(VS提供的汇编代码)中我们发现,并不是所有的汇编指令都是按照这种顺序的,因此需要根据具体情况进行分析。
此外,有些汇编语言的风格可能跟上面介绍的风格不太一样,不会使用数目众多的变体指令来表达不同类型的操作数,而是会直接将操作数类型限定在参数上:
mov dword ptr [rsp+44h], eax
比如上述指令指的是将eax寄存器中的double world数据赋值给起始地址为段地址为RSP(栈顶),偏移量为44h的堆栈存储单元。
数据传送指令除了MOV之外,还有一个LEA,这个指令与MOV的区别在于:
lea是“load effective address”的缩写,简单的说,lea指令可以用来将一个内存地址直接赋给目的操作数,例如:
lea eax,[ebx+8]就是将ebx+8这个值直接赋给eax,而不是把ebx+8处的内存地址里的数据赋给eax。
mov指令则恰恰相反,例如:mov eax,[ebx+8]则是把内存地址为ebx+8处的数据赋给eax。
作者:匿名用户
链接:https://www.zhihu.com/question/40720890/answer/110774673
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
2.1.2 算数逻辑操作
算数逻辑操作包含基本的加减乘除与与或异或移位等位操作,具体见下图:
2.1.3 push/pop指令
push与pop指令,是用于实现栈操作的两个指令,栈顶地址由SS * 0x10 + SP共同指定。
PUSH指令完成的工作包括:
- 减少 ESP 的值(操作数是 16 位的,则 ESP 减 2,操作数是 32 位的,则 ESP 减 4)
- 将源操作数复制到堆栈
POP指令完成的工作包括:
- 把 ESP 指向的堆栈元素内容复制到一个 16 位或 32 位目的操作数中
- 增加 ESP 的值,如果操作数是 16 位的,ESP 加 2,如果操作数是 32 位的,ESP 加 4
2.2 流程控制指令
常见的流程控制包括条件判断语句,循环语句,跳转语句,函数调用与返回语句。
2.2.1 条件跳转语句
条件跳转流程控制是通过对此前介绍过的状态与控制寄存器各个标记位的判断来实现的,而对这些标记位的设置则涉及到较多的指令。
2.2.1.1 标记位设置指令
CMP指令根据两个操作数之间的差值来对标记位进行设置,其执行的结果不会更改参数计算的寄存器的数据,除此之外,跟SUB指令的结果完全一样。
TEST指令的行为则是除了不会更改到参与计算的寄存器的数据之外,其他行为与AND指令一样。
上图中的条件码指的就是我们前文中说的标记位,这里给出的是除了条件判断跳转指令之外的其他获取标记位结果的方法:将标记位数据赋值给某个寄存器的低8bits。
2.2.1.2 条件判断跳转指令
条件跳转指令具有较多的变体,每个变体分别对应于不同的判断条件或者不同的跳转目标。这里的跳转目标有直接跳转与间接跳转两种,这两种的区别见下图所示:
2.2.2 函数跳转指令
函数跳转指令call,其后可以接地址或者标记作为参数,先来看一个例子:
global main
eax_plus_1s:
add eax, 1
ret
ebx_plus_1s:
add ebx, 1
ret
main:
mov eax, 0
mov ebx, 0
call eax_plus_1s
call eax_plus_1s
call ebx_plus_1s
add eax, ebx
ret
这段代码很好理解,这里就不做解释了。需要注意的是,每次函数调用前,都需要保存现场,简单来说就是需要将函数调用之后的指令存入到前面介绍过的栈帧结构中,栈帧结构的栈顶是通过rsp/esp来存储的,且是朝着地址减小的方向增长的,因此这个过程差不多可以用如下的指令来模拟(实际执行中,并不会出现这两条指令,是硬件自动完成的):
sub esp, 4
mov dword ptr[esp], eip
等到函数返回,执行了ret指令之后,就会从esp中读取之前保存的现场,再执行一次退栈操作。
mov eip,dword ptr[esp]
add esp, 4
3. 案例
3.1 函数调用
VkRenderPass RenderPassHandle;
VERIFYVULKANRESULT_EXPANDED(VulkanRHI::vkCreateRenderPass2KHR(InDevice.GetInstanceHandle(), &CreateInfo, VULKAN_CPU_ALLOCATOR, &RenderPassHandle));
00007FFD697C8113 xor eax,eax
00007FFD697C8115 test rax,rax
00007FFD697C8118 je CreateRenderPass2KHR+848h (07FFD697C8128h)
00007FFD697C811A mov qword ptr [rsp+0A8h],0
00007FFD697C8126 jmp CreateRenderPass2KHR+857h (07FFD697C8137h)
00007FFD697C8128 lea rax,[VulkanRHI::GAllocationCallbacks (07FFD6999CE10h)] //参数1入栈
00007FFD697C812F mov qword ptr [rsp+0A8h],rax //参数1入栈,rsp为栈顶
00007FFD697C8137 mov rax,qword ptr [InDevice]
00007FFD697C813F mov rax,qword ptr [rax+8] //参数2入栈
00007FFD697C8143 mov qword ptr [rsp+118h],rax //参数2入栈,rsp为栈顶
00007FFD697C814B lea r9,[RenderPassHandle] //参数3调用前准备
00007FFD697C8153 mov r8,qword ptr [rsp+0A8h] //参数1调用前准备
00007FFD697C815B lea rdx,[CreateInfo] //参数4调用前准备
00007FFD697C8163 mov rcx,qword ptr [rsp+118h] //参数2调用前准备
00007FFD697C816B call qword ptr [VulkanDynamicAPI::vkCreateRenderPass2KHR (07FFD6999DE08h)] //函数调用
00007FFD697C8171 mov dword ptr [rsp+44h],eax //结果写回栈,rsp为栈顶
00007FFD697C8175 cmp dword ptr [rsp+44h],0 //条件判断,确认是否调用成功
00007FFD697C817A jge CreateRenderPass2KHR+8B9h (07FFD697C8199h) //未成功进入后续错误处理逻辑
00007FFD697C817C mov r9d,689h
00007FFD697C8182 lea r8,[__real@477fff00+2130h (07FFD69937970h)]
00007FFD697C8189 lea rdx,[__real@477fff00+2180h (07FFD699379C0h)]
00007FFD697C8190 mov ecx,dword ptr [rsp+44h]
00007FFD697C8194 call VulkanRHI::VerifyVulkanResult (07FFD698F13B0h)
return RenderPassHandle;
上面给出了一个实际项目中遇到的崩溃堆栈,崩溃出现在call后一条语句上,从上下文分析,应该是将函数调用的结果写回到栈中,不知道为何触发了写权限报错:
Exception thrown at 0x0000000000000000 in UE4Editor.exe: 0xC0000005: Access violation executing location 0x0000000000000000.
经过在汇编中单步执行最终定位到,问题出在函数调用内部,在执行一条跳转语句时,跳转目的地是通过寄存器RAX指定的,而RAX中的地址对应的空间为undefined区域,因此触发异常。
参考
[1]. 汇编笔记:寄存器介绍
[2]. 关于OS系统的x86、x64与IA32、IA64的关系
[3]. 寄存器基础知识四之段寄存器
[4]. 段选择符 段寄存器
[5]. X86_64汇编与IA32比较
[6]. 汇编语言入门七:函数调用(一)
[7]. 汇编语言PUSH和POP指令(压栈和出栈)