x86汇编学习笔记
追一下小时候的梦,稍微学习一下逆向工程。
找了一些书籍推荐,最终决定从RE4B(安天翻译的版本)开始。
上册基本是一些C语言基础的汇编实现,暂且忽略掉ARM、Thumb和ARM64平台(篇幅过大),专注看一看x86下的AT&T和Intel语法汇编,开一篇文章记载一下学习汇编指令的过程。
[TOC]
1. 环境搭建
由于书中所用的编译器是MSVC2010/2012和GCC4.x,略显过时,特此自己搭了一套GCC 8.1.0(MinGW)和MSVC2019的环境。
- 安装VS2019 社区版,并勾选使用C++的桌面开发。
- 从SourceForge下载MinGW-w64的离线安装包,需要X86和X64两个版本。
- 安装CLion,以利用其对C语言和AT&T语体汇编语言的有限支持。
为免去配置VS的头文件和库文件环境变量的麻烦,可以利用VS自带的环境变量批处理,因此编写如下批处理文件以生成各种架构下不同语体的汇编文件:
1 | call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsamd64_x86.bat" |
其中GCC的-fno-asynchronous-unwind-tables参数是为了忽略一些不必要的宏。
最终编译结果有时会与书本上不完全一致,因此本书将作为提纲挈领之用,我将根据书中目录顺序亲手进行汇编,尽力找出不一致的原因,加以拓展学习。
2. 最简函数
C
1 | int f() { |
汇编
GCC AT&T
1 | .file "main.c" |
x64的结果几乎一样,除了函数名少了下划线,以及.ident的编译器信息不一样。尽管是x64的汇编,寄存器也只用了EAX。
其中的.file之类的是指示,记录了数据段的开始(.data)、实际程序代码的开始(.text)、字符串常量(书上和某文章说是.string,但实际汇编出来的是.ascii)等。还有一系列.cfi开头的指示,可以记录堆栈信息,在GCC生成汇编指令时可以用-fno-asynchronous-unwind-tables参数忽略。
GCC Intel
Intel语体采用了另一个指令:
1 | f: |
MSVC x86
MSVC的情况稍复杂一些, 首先它贴心的标记了行号,其次在x86平台生成的汇编语句中出现了函数调用标志,只不过栈顶没有动而已。
1 | ; Listing generated by Microsoft (R) Optimizing Compiler Version 19.22.27905.0 |
MSVC x64
和GCC表现完全一致。
1 | f PROC |
拓展:栈内存
栈内存在内存中自顶向下,添加元素时会使栈底减少。而PUSH就是汇编的入栈指令,其作用是先将栈底减4(32位平台)或8(64位平台),然后将要写入的数写到栈底指向的位置。
例如push ebp等价于:
1 | sub esp, 4 |
POP则是逆向操作,会将内存中的值赋给某寄存器。pop ebp等价于:
1 | mov ebp, [esp] |
拓展: 函数序言和尾声
函数序言:
将EBP寄存器的值压入栈
将ESP寄存器的值赋值给EBP(将函数开始前的栈底保存到EBP,此时ESP和EBP是该函数的局部变量、参数的基准值)。
修改ESP,以给函数局部变量分配空间。
1 | push ebp |
函数尾声:
做函数序言的逆操作
将EBP的值复制到ESP(将ESP的值恢复到函数开始前)
从栈内存中读取EBP的值。
1 | mov esp, ebp |
小结
- AT&T语体中,MOV采取从左向右赋值的语法,而Intel与C类似,从右向左赋值。其他运算表达式也类似,源和目标相反。
- AT&T语体使用圆括号取值,而Intel使用方括号。
- AT&T语体会使用%符号标志寄存器,用$符号标志立即数(暂且理解为常量)。
- AT&T语体会指定操作数据类型,-q是64位(quad),-l是32位long,-w是16位word,-b是8位byte
- 函数调用结束后会把返回值放在EAX寄存器,调用者会从该寄存器取值。
- 某个函数调用开始时,EBP为此时栈顶,ESP随着局部变量声明而变化。函数结束时,将EBP复制到ESP,恢复栈顶为调用开始时的值,再从栈内存中取出原有EBP。
3. Hello World
C
1 | int main() { |
汇编
GCC x86
AT&T :
1 | .file "main.c" |
andl指令将ESP的值指定为16的整数倍,这是一种x86/x64的编译规范。
subl指令将栈顶向下拉了16字节,本来4字节就够了,但是由于被对齐所以分配了16字节。
接下来,LC0是一个全局变量,相当于const char* LC0[] = "Hello, World!"。在此,该变量的指针地址被复制到ESP寄存器,以便printf函数从ESP寄存器中取值。
该函数比最简函数多一个对printf函数的调用,该函数在GCC中被优化为了puts。
最后将0放入EAX中,表示主函数返回值。
Intel语体的MOV指令略有不同:
1 | mov DWORD PTR [esp], OFFSET FLAT:LC0 |
GCC x64
1 | main: |
LC0的代码没有变化,故省略。
可以看出x64跳过了对齐步骤,而数据类型变成64位导致指令最后的l全部变成了q。
r开头的寄存器是e开头寄存器的扩展,以RAX举例:AL占用第0字节,AH占用第一字节,AX占用0和1字节,EAX占用0、1、2、3字节,而RAX占用0-7这8个字节。
传参的方式也发生了变化,不再使用栈内存,而是直接使用寄存器。与书中不一样的是,此处使用了rcx寄存器而不是edi寄存器进行传参,参数值也多了一个(%rip),查阅资料后得知是指令指针寄存器,暂时不明用途。
LEA命令意为Load Effective Address,暂时可以理解为与MOV等价。
而C编译器为了兼容性会返回32位的0,也就意味着程序结束时EAX为0,RAX不一定为0。
MSVC x86
而MSVC产生的汇编很长,似乎将Windows Kits的stdio.h中printf部分也汇编到了同一个文件。
推测可能是某种优化,但是我也没有开启任何优化选项……
摘取main函数的部分如下:
1 | _DATA SEGMENT |
与书中MSVC2010的表现不同,2019用_DATA段取代了CONST段.
与GCC不一样,MSVC使用xor eax eax来置0.
MSVC x64
1 | main PROC |
64位MSVC的行为让我有些费解,为什么会将栈顶减小又增加40字节呢。也没有看到将寄存器内容保存到栈里的语句。
其余和书上基本一致,使用rcx寄存器进行传参。
小结
- 汇编中的括号类似于C中的取地址操作符。
- 32位平台用栈传参,64位用寄存器传前几个参数。
- 64位系统中程序结束时EAX为0不代表RAX为0。
- GCC可能会把printf优化为puts,而MSVC会使用
XOR对EAX寄存器置0。