32位 Windows x86 汇编语言学习

作于: 2021 年 9 月 9 日,预计阅读时间 15 分钟

前言

最近迷上了 Crack Me,入门无果。老是看到有大佬发52pojie又有哪个佬把什么黄油给手撕了,心痒痒。干脆也正正经经地去学一下好了。

这当然也算是程序员本职的正经知识(心虚而且超大声)。

常规知识和速记

笔记内容是关于 8086/x86 汇编。

x86体系结构下内存和寄存器都是小端序。小端序指低位在右,高位在左。如0x1的小端序表示是0000 0001

8比特能表示2位16进制数(0xFF,也就是255),16比特能表示4位16进制数(0xFFFF65535),32比特能表示8位16进制数(0XFFFFFFFF4294967295)。

数据类型:

助记符描述
dword双字(double word),32比特整型数据。
word字,16比特整型数据。
byte字节,8比特整型数据。

常用的16进制数记法:

寄存器

通用寄存器

参考:x86汇编 - 维基百科

参考:x64体系结构 - windows hardware

64位寄存器32位寄存器16位寄存器8位寄存器用途
RAXR0EAXAXALAHAccumlator,累加寄存器,用于算术运算。
RBXR3EBXBXBLBHBase,基址寄存器,指向数据块基址(段模式存于段寄存器DS
RCXR1ECXCXCLCHCounter,用于用于移/环指令及循环(没懂)。
RDXR2EDXDXDLDHData,用于数学运算和IO操作。
RSIR6ESISISILSource Index,指向指令流操作中的源。
RDIR7EDIDIDILDestination Index,指向指令流操作中的目标。
RBPR5EBPBPBPLStack Base Pointer,指向栈的基地址。
RSPR4ESPSPSPLStack Pointer,指向栈顶的地址。
R8R8DR8WR8B无别名。
R9R9DR9WR9B无别名。
R10R10DR10WR10B无别名。
R11R11DR11WR11B无别名。
R12R12DR12WR12B无别名。
R13R13DR13WR13B无别名。
R14R14DR14WR14B无别名。
R15R15DR15WR15B无别名。

后续还是用 32 位寄存器的名字称呼这些寄存器。

通用寄存器的用途并不是绝对的,程序可以根据自己的需要将通用寄存器挪作它用。

其中:

EDI 和 ESI

关于EDIESI这两个寄存器的用途可以参考 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就是jedi就是srcidx

段寄存器

现代操作系统采用内存分页模式,把所有段寄存器指向同址来禁用内存分段模式。然而FSGS依然用于内存分段模式,用于线程内数据存取。

段寄存器助记符描述
SSStack Segment,栈段
CSCode Segment,代码段
DSData Segment,数据段
ESExtra Segment,额外数据段
FS更额外的数据段
GS更额外的数据段

x86一共有6个段寄存器,所有段寄存器都是16比特位宽。

关于段寄存器用途和计算放在主存一节中。

指令指针 EIP

IP 寄存器全称是 Instruction Pointer 寄存器,保存总是保存下一指令的地址。

x86实模式下使用段内存管理,可寻址1MB内存空间,采用基址(段寄存器)左移4位加上偏移量,相当于20比特位宽地址总线。实模式下EIP可以和CS代码段寄存器结合求取下一指令的具体地址。

标志寄存器

助记符描述
CFCarry Flag,进位或借位溢出时记为1,否则0
PFParity Flag,运算结果最低字节有偶数个1位时记为1,否则0
AFAuxiliary Flag,BCD码算术运算中进位或借位溢出,即运算结果第三位发生进借位时记1,否则0
ZFZero Flag,运算结果为0时记1,否则0
SFSign Flag,记运算结果最高位(符号位)
TFTrap Flag,单步调试记1,否则0
IFInterrupt Enable Flag,是否允许响应中断
DFDirection Flag,串方向标记,指示串指令从高地址向低地址还是低地址向高地址处理。
OFOverflow Flag,指示算术运算是否溢出。
IOPLI/O Privilege Level,I/O特权级,2比特宽,CPL小于等于IOPL才允许访问I/O地址空间。
NTNested Task Flag,当前任务链接上衣任务时置1,否则0。
RFResume Flag,控制处理器对除障异常的响应。
VMVirtual-8086 Mode Flag,虚拟8086模式标志,置1时进入虚拟8086模式,清0返回保护模式。
ACAlignment Check Flag,该标志以及在CR0寄存器中的AM位置1时将允许内存引用的对齐检查,以上两个标志中至少有一个被清零则禁用对齐检查。
VIFVirtual interrupt flag,该标志是IF标志的虚拟镜像(Virtual image),与VIP标志结合起来使用。使用这个标志以及VIP标志,并设置CR4控制寄存器中的VME标志就可以允许虚拟模式扩展(virtual mode extensions)。
VIPVirtual interrupt pending flag,该位置1以指示一个中断正在被挂起,当没有中断挂起时该位清零。与VIF标志结合使用。
IDIdentification 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位限制下访问更大的地址空间。

平坦内存模型/线性内存模型

参考:flat memory model - wiki

平坦内存模型也叫线性内存模型,定义是程序中的内存在同一个连续的地址空间中,不需要通过分段或分页机制间接取址。(从操作系统或硬件角度来说,可能依然有分页或分段,但对用户程序来说无感知)。

Intel 内存模型

下述内存模型是实模式下的,保护模式下更近似于线性模型。

模型数据段指针代码段指针定义
TinynearnearCS=DS=SS
SmallnearnearDS=SS
MediumnearfarDS=SS,多个代码段
Compactfarnear一个代码段,多个数据段
Largefarfar多个代码段和数据段
Hugehugefar多个代码段和数据段,单个数组可能超过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]上一个栈帧基地址,此时espebp相同。

关闭栈指针的情况下,函数不会在开头保存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++编程经验的人可能已经见过很多链接错误:

如果注意过提示中的符号名应该会发现这些符号名称都不是代码里写的函数名称,而是经过了变形的。

cdecl调用约定下,name mangling 的规则是在符号前加下划线。比如C库的printf函数,经过name mangling后是_printf

stdcall调用约定下,name mangling 的规则是在符号前加下划线,符号后加 @参数长度。需要注意的是对于C中的变长参数 variadic function,是不能用 stdcall 调用约定的。

用函数 int fn(int a, int b) 举例,认为 int 是 4 字节长,此时cdecl下叫_fnstdcall下叫_fn@8

/汇编/ /逆向/