x86汇编学习笔记

追一下小时候的梦,稍微学习一下逆向工程。

找了一些书籍推荐,最终决定从RE4B(安天翻译的版本)开始。

上册基本是一些C语言基础的汇编实现,暂且忽略掉ARM、Thumb和ARM64平台(篇幅过大),专注看一看x86下的AT&T和Intel语法汇编,开一篇文章记载一下学习汇编指令的过程。

[TOC]

1. 环境搭建

由于书中所用的编译器是MSVC2010/2012和GCC4.x,略显过时,特此自己搭了一套GCC 8.1.0(MinGW)和MSVC2019的环境。

  1. 安装VS2019 社区版,并勾选使用C++的桌面开发
  2. 从SourceForge下载MinGW-w64的离线安装包,需要X86和X64两个版本。
  3. 安装CLion,以利用其对C语言和AT&T语体汇编语言的有限支持。

为免去配置VS的头文件和库文件环境变量的麻烦,可以利用VS自带的环境变量批处理,因此编写如下批处理文件以生成各种架构下不同语体的汇编文件:

1
2
3
4
5
6
7
8
call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsamd64_x86.bat"
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.22.27905\bin\Hostx64\x86\cl.exe" main.c /Famain_msvc_86.asm
call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvars64.bat"
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.22.27905\bin\Hostx64\x64\cl.exe" main.c /Famain_msvc_64.asm
"E:\MinGW\mingw32\bin\gcc.exe" main.c -S -o main_gcc_at_t_86.asm -fno-asynchronous-unwind-tables
"E:\MinGW\mingw\bin\gcc.exe" main.c -S -o main_gcc_at_t_64.asm -fno-asynchronous-unwind-tables
"E:\MinGW\mingw32\bin\gcc.exe" main.c -S -o main_gcc_intel_86.asm -masm=intel -fno-asynchronous-unwind-tables
"E:\MinGW\mingw\bin\gcc.exe" main.c -S -o main_gcc_intel_64.asm -masm=intel -fno-asynchronous-unwind-tables

其中GCC的-fno-asynchronous-unwind-tables参数是为了忽略一些不必要的宏。

最终编译结果有时会与书本上不完全一致,因此本书将作为提纲挈领之用,我将根据书中目录顺序亲手进行汇编,尽力找出不一致的原因,加以拓展学习。

2. 最简函数

C

1
2
3
int f() {
return 123;
}

汇编

GCC AT&T

1
2
3
4
5
6
7
8
	.file	"main.c"
.text
.globl _f
.def _f; .scl 2; .type 32; .endef
_f:
movl $123, %eax
ret
.ident "GCC: (i686-win32-sjlj-rev0, Built by MinGW-W64 project) 8.1.0"

x64的结果几乎一样,除了函数名少了下划线,以及.ident的编译器信息不一样。尽管是x64的汇编,寄存器也只用了EAX。

其中的.file之类的是指示,记录了数据段的开始(.data)、实际程序代码的开始(.text)、字符串常量(书上和某文章说是.string,但实际汇编出来的是.ascii)等。还有一系列.cfi开头的指示,可以记录堆栈信息,在GCC生成汇编指令时可以用-fno-asynchronous-unwind-tables参数忽略。

GCC Intel

Intel语体采用了另一个指令:

1
2
3
f:
mov eax, 123
ret

MSVC x86

MSVC的情况稍复杂一些, 首先它贴心的标记了行号,其次在x86平台生成的汇编语句中出现了函数调用标志,只不过栈顶没有动而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; Listing generated by Microsoft (R) Optimizing Compiler Version 19.22.27905.0 

include listing.inc

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC f
; Function compile flags: /Odtp
_TEXT SEGMENT
_f PROC
; File C:\Users\QHS\CLionProjects\untitled1\2.min_function\main.c
; Line 3
push ebp
mov ebp, esp
; Line 4
mov eax, 123 ; 0000007bH
; Line 5
pop ebp
ret 0
_f ENDP
_TEXT ENDS
END

MSVC x64

和GCC表现完全一致。

1
2
3
4
5
6
7
f	PROC
; File C:\Users\QHS\CLionProjects\untitled1\2.min_function\main.c
; Line 4
mov eax, 123 ; 0000007bH
; Line 5
ret 0
f ENDP

拓展:栈内存

栈内存在内存中自顶向下,添加元素时会使栈底减少。而PUSH就是汇编的入栈指令,其作用是先将栈底减4(32位平台)或8(64位平台),然后将要写入的数写到栈底指向的位置。

例如push ebp等价于:

1
2
sub esp, 4
mov [esp], ebp

POP则是逆向操作,会将内存中的值赋给某寄存器。pop ebp等价于:

1
2
mov ebp, [esp]
add esp, 4

拓展: 函数序言和尾声

函数序言:

将EBP寄存器的值压入栈

将ESP寄存器的值赋值给EBP(将函数开始前的栈底保存到EBP,此时ESP和EBP是该函数的局部变量、参数基准值)。

修改ESP,以给函数局部变量分配空间。

1
2
3
push	ebp
mov ebp, esp
sub esp, X

函数尾声:

做函数序言的逆操作

将EBP的值复制到ESP(将ESP的值恢复到函数开始前)

从栈内存中读取EBP的值。

1
2
mov	esp, ebp
pop 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
2
3
4
int main() {    
printf("Hello, World!\n");
return 0;
}

汇编

GCC x86

AT&T :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
	.file	"main.c"
.text
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "Hello, World!\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
subl $16, %esp
call ___main
movl $LC0, (%esp)
call _puts
movl $0, %eax
leave
ret
.ident "GCC: (i686-win32-sjlj-rev0, Built by MinGW-W64 project) 8.1.0"
.def _puts; .scl 2; .type 32; .endef

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
2
3
4
5
6
7
8
9
10
11
12
main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
call __main
leaq .LC0(%rip), %rcx
call puts
movl $0, %eax
leave
ret
.ident "GCC: (GNU) 8.1.0"
.def puts; .scl 2; .type 32; .endef

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
_DATA	SEGMENT
$SG8163 DB 'Hello, World!', 0aH, 00H
_DATA ENDS
; Function compile flags: /Odtp
_TEXT SEGMENT
_main PROC
; File C:\Users\QHS\CLionProjects\untitled1\3.helloworld\main.c
; Line 3
push ebp
mov ebp, esp
; Line 4
push OFFSET $SG8163
call _printf
add esp, 4
; Line 5
xor eax, eax
; Line 6
pop ebp
ret 0
_main ENDP
_TEXT ENDS

与书中MSVC2010的表现不同,2019用_DATA段取代了CONST段.

与GCC不一样,MSVC使用xor eax eax来置0.

MSVC x64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main	PROC
; File C:\Users\QHS\CLionProjects\untitled1\3.helloworld\main.c
; Line 3
$LN3:
sub rsp, 40 ; 00000028H
; Line 4
lea rcx, OFFSET FLAT:$SG7907
call printf
; Line 5
xor eax, eax
; Line 6
add rsp, 40 ; 00000028H
ret 0
main ENDP

64位MSVC的行为让我有些费解,为什么会将栈顶减小又增加40字节呢。也没有看到将寄存器内容保存到栈里的语句。

其余和书上基本一致,使用rcx寄存器进行传参。

小结

  • 汇编中的括号类似于C中的取地址操作符。
  • 32位平台用栈传参,64位用寄存器传前几个参数。
  • 64位系统中程序结束时EAX为0不代表RAX为0。
  • GCC可能会把printf优化为puts,而MSVC会使用XOR对EAX寄存器置0。