前言
最近迷上了 Crack Me,入门无果。老是看到有大佬发52pojie又有哪个佬把什么黄油给手撕了,心痒痒。干脆也正正经经地去学一下好了。
这当然也算是程序员本职的正经知识(心虚而且超大声)。
常规知识和速记
笔记内容是关于 8086/x86 汇编。
x86体系结构下内存和寄存器都是小端序。小端序指低位在右,高位在左。如0x1
的小端序表示是0000 0001
。
8比特能表示2位16进制数(0xFF
,也就是255
),16比特能表示4位16进制数(0xFFFF
,65535
),32比特能表示8位16进制数(0XFFFFFFFF
,4294967295
)。
数据类型:
助记符 | 描述 |
---|---|
dword | 双字(double word),32比特整型数据。 |
word | 字,16比特整型数据。 |
byte | 字节,8比特整型数据。 |
常用的16进制数记法:
0x2A
,前缀0x
2AH
,后缀H
寄存器
通用寄存器
参考:x86汇编 - 维基百科
64位寄存器 | 32位寄存器 | 16位寄存器 | 8位寄存器 | 用途 |
---|---|---|---|---|
RAX 或R0 | EAX | AX | AL 和AH | Accumlator,累加寄存器,用于算术运算。 |
RBX 或R3 | EBX | BX | BL 和BH | Base,基址寄存器,指向数据块基址(段模式存于段寄存器DS ) |
RCX 或R1 | ECX | CX | CL 和CH | Counter,用于用于移/环指令及循环(没懂)。 |
RDX 或R2 | EDX | DX | DL 和DH | Data,用于数学运算和IO操作。 |
RSI 或R6 | ESI | SI | SIL | Source Index,指向指令流操作中的源。 |
RDI 或R7 | EDI | DI | DIL | Destination Index,指向指令流操作中的目标。 |
RBP 或R5 | EBP | BP | BPL | Stack Base Pointer,指向栈的基地址。 |
RSP 或R4 | ESP | SP | SPL | Stack Pointer,指向栈顶的地址。 |
R8 | R8D | R8W | R8B | 无别名。 |
R9 | R9D | R9W | R9B | 无别名。 |
R10 | R10D | R10W | R10B | 无别名。 |
R11 | R11D | R11W | R11B | 无别名。 |
R12 | R12D | R12W | R12B | 无别名。 |
R13 | R13D | R13W | R13B | 无别名。 |
R14 | R14D | R14W | R14B | 无别名。 |
R15 | R15D | R15W | R15B | 无别名。 |
后续还是用 32 位寄存器的名字称呼这些寄存器。
通用寄存器的用途并不是绝对的,程序可以根据自己的需要将通用寄存器挪作它用。
其中:
16位寄存器本身是32位寄存器的低16位。32位寄存器的高16位没有单独的助记符。
16位寄存器又可以单独分成两个8位寄存器使用。其中如
AH
形式的寄存器表示AX
高位8比特,AL
则表示低位8比特。
EDI 和 ESI
关于EDI
和ESI
这两个寄存器的用途可以参考 stack overflow 的这篇问答。摘一段例子,下面的C代码:
srcp[srcidx++] = argv[j];
可以被编译成下面的汇编语句:
mov edx,[ebp+0c]
mov ecx,[edx+4*ebx]
mov [ebp+4*edi-54],ecx
inc edi
ebp+0c
包含了argv
内容,ebx
就是j
,edi
就是srcidx
。
段寄存器
现代操作系统采用内存分页模式,把所有段寄存器指向同址来禁用内存分段模式。然而FS
和GS
依然用于内存分段模式,用于线程内数据存取。
段寄存器助记符 | 描述 |
---|---|
SS | Stack Segment,栈段 |
CS | Code Segment,代码段 |
DS | Data Segment,数据段 |
ES | Extra Segment,额外数据段 |
FS | 更额外的数据段 |
GS | 更额外的数据段 |
x86一共有6个段寄存器,所有段寄存器都是16比特位宽。
关于段寄存器用途和计算放在主存一节中。
指令指针 EIP
IP 寄存器全称是 Instruction Pointer 寄存器,保存总是保存下一指令的地址。
x86实模式下使用段内存管理,可寻址1MB内存空间,采用基址(段寄存器)左移4位加上偏移量,相当于20比特位宽地址总线。实模式下EIP可以和CS代码段寄存器结合求取下一指令的具体地址。
标志寄存器
助记符 | 描述 |
---|---|
CF | Carry Flag,进位或借位溢出时记为1,否则0 |
PF | Parity Flag,运算结果最低字节有偶数个1位时记为1,否则0 |
AF | Auxiliary Flag,BCD码算术运算中进位或借位溢出,即运算结果第三位发生进借位时记1,否则0 |
ZF | Zero Flag,运算结果为0时记1,否则0 |
SF | Sign Flag,记运算结果最高位(符号位) |
TF | Trap Flag,单步调试记1,否则0 |
IF | Interrupt Enable Flag,是否允许响应中断 |
DF | Direction Flag,串方向标记,指示串指令从高地址向低地址还是低地址向高地址处理。 |
OF | Overflow Flag,指示算术运算是否溢出。 |
IOPL | I/O Privilege Level,I/O特权级,2比特宽,CPL 小于等于IOPL 才允许访问I/O地址空间。 |
NT | Nested Task Flag,当前任务链接上衣任务时置1,否则0。 |
RF | Resume Flag,控制处理器对除障异常的响应。 |
VM | Virtual-8086 Mode Flag,虚拟8086模式标志,置1时进入虚拟8086模式,清0返回保护模式。 |
AC | Alignment Check Flag,该标志以及在CR0寄存器中的AM位置1时将允许内存引用的对齐检查,以上两个标志中至少有一个被清零则禁用对齐检查。 |
VIF | Virtual interrupt flag,该标志是IF标志的虚拟镜像(Virtual image),与VIP标志结合起来使用。使用这个标志以及VIP标志,并设置CR4控制寄存器中的VME标志就可以允许虚拟模式扩展(virtual mode extensions)。 |
VIP | Virtual interrupt pending flag,该位置1以指示一个中断正在被挂起,当没有中断挂起时该位清零。与VIF标志结合使用。 |
ID | Identification flag,程序能够设置或清除这个标志指示了处理器对CPUID指令的支持。 |
主存
运行模式和地址模型
x86 CPU 运行模式主要考虑实模式和保护模式,以及特殊的虚拟8086模式。
实模式有自己的独特地址空间模型,下可视作16位CPU+20比特无保护地址空间,使用段寄存器和通用16位通用寄存器组合寻址,算法base<<4+offset
。最大可寻址1MB。
虚拟8086模式用于在保护模式下运行实模式程序,并不是真正的CPU模式,CPU实际还是运行在保护模式。一些程序利用虚拟8086模式可以实现在保护模式下运行实模式程序,如 dosbox、dosemu 。
保护模式下可以用逻辑地址访问主存,逻辑地址又称远指针(far ptr
),逻辑地址由段选择器加上偏移寻址组成。运行于 IA-32 体系的程序,段选择器最多可以选择 2^14^-1 个段,每个段可以长达 2^32^ 字节。
near/far/huge 指针
near 指针是给定段内用16比特表示的偏移值,最大访问地址空间64KB。
far 指针是32比特表示的偏移值,在16位架构下可以访问段外的内存,32/64位架构下则依然是段内。
huge指针和far指针大小相同,大体目标就是在16位限制下访问更大的地址空间。
平坦内存模型/线性内存模型
平坦内存模型也叫线性内存模型,定义是程序中的内存在同一个连续的地址空间中,不需要通过分段或分页机制间接取址。(从操作系统或硬件角度来说,可能依然有分页或分段,但对用户程序来说无感知)。
Intel 内存模型
下述内存模型是实模式下的,保护模式下更近似于线性模型。
模型 | 数据段指针 | 代码段指针 | 定义 |
---|---|---|---|
Tiny | near | near | CS=DS=SS |
Small | near | near | DS=SS |
Medium | near | far | DS=SS,多个代码段 |
Compact | far | near | 一个代码段,多个数据段 |
Large | far | far | 多个代码段和数据段 |
Huge | huge | far | 多个代码段和数据段,单个数组可能超过64KB |
- 在Tiny模型下,所有段寄存器指针指向相同的段。
- 在所有DS=SS的模型里,数据段指针总是near。
- 栈总是限制在最高64KB。
函数
栈
参考:栈的增长方向? - 知乎
讨论对象是 Windows x86 32位程序。栈从高位向低位增长,需要注意看栈视图的地址是高地址在上还是低地址在上,被调用方的栈帧总是在调用方的增长方向上。
例如下面的汇编指令。
push eax ; 把eax当参数入栈 esp=75f888
push eax ; 把eax当参数入栈 esp=75f884
push eax ; 把eax当参数入栈 esp=75f880
call example.fn ; 调用
add esp,0xc ; 调用方清栈,cdecl调用约定
执行call指令,跳转到被调用函数时,栈上会压入函数的返回地址。
栈指针 frame pointer
栈指针可以通过编译参数 -fomit-frame-pointer
或 /Oy-
来关闭。
在有栈指针(frame pointer)的情况下,每个函数开头会做一个
push ebp
mov ebp,esp
的动作,这个动作做完后,新栈帧的栈底就是 ebp 了,ebp正好指向旧栈帧基地址,在ebp下(和栈增长方向相反)就是函数返回地址和调用方给的实参。
在函数返回前,又会做一个
pop ebp
来完成平栈。
下面就是被调函数执行完函数开头的指令后的栈。
地址(栈向下增长) | 内容含义 |
---|---|
[ebp+0x10] | 第3个参数 |
[ebp+0xc] | 第2个参数 |
[ebp+0x8] | 第1个参数 |
[ebp+0x4] | 函数的返回地址 |
[ebp] | 上一个栈帧基地址,此时esp 和ebp 相同。 |
关闭栈指针的情况下,函数不会在开头保存ebp了,对函数参数的引用也会改为相对esp的偏移。
调用约定
先讨论 cdecl 调用约定,函数调用的一般过程是:
push 0x0 ; 压栈参数 0
push example.50be50 ; 压栈参数 "%d"
call example._printf ; 调用 printf("%d", 0)
add esp,0x8 ; cdecl 约定下,调用者清栈
stdcall
调用约定和cdecl
调用约定的区别在于stdcall
是被调用方清栈:
ret 0x8 ; ret 有一个的可选参数,指示要从栈上弹出多少空间。相当于是先 add esp,0x8 再 ret
cdecl
是大部分编译器包括微软VC++默认的调用约定,stdcall
是所有 Windows API 的调用约定。
name mangling
好像没有广泛使用的译名,可以叫名字修饰、名字重整或改编,意会吧。
对于有使用c/c++编程经验的人可能已经见过很多链接错误:
- undefined reference to ...
- 无法解析的外部符号 ...
如果注意过提示中的符号名应该会发现这些符号名称都不是代码里写的函数名称,而是经过了变形的。
cdecl
调用约定下,name mangling 的规则是在符号前加下划线。比如C库的printf
函数,经过name mangling后是_printf
。
stdcall
调用约定下,name mangling 的规则是在符号前加下划线,符号后加 @参数长度。需要注意的是对于C中的变长参数 variadic function,是不能用 stdcall
调用约定的。
用函数 int fn(int a, int b)
举例,认为 int 是 4 字节长,此时cdecl
下叫_fn
,stdcall
下叫_fn@8
。