3、汇编语言入门(上):x86汇编基础、通用寄存器、常用指令(mov, add, sub, push, pop, call, ret)
好,咱们今天开始碰汇编。说实话,很多新手一听“汇编”两个字就头大,觉得那是大神才玩的东西。其实真不是。你想想看,我们平时写的C、Python,最终跑到CPU里都是变成一堆0和1。而汇编,就是把这些0和1翻译成了人能勉强看懂的“单词”。说白了,它就是机器码的文本版。
我刚开始学逆向的时候,也对着汇编发怵。后来发现,你不需要背下所有指令,只需要掌握最常用的那二三十个,就能看懂大部分代码了。今天这一节,咱们就把最核心的x86汇编基础、寄存器,以及几条出现频率最高的指令给啃下来。
3.1 x86汇编是什么?
x86汇编,指的是Intel和AMD的32位/64位CPU使用的汇编语言。我们常说的“32位程序”就是跑在x86指令集上。它的特点是:指令长度不固定,功能强大,但历史包袱也重。
我个人习惯把汇编看成“CPU的遥控器”。你按下一个键(指令),CPU就执行一个动作。比如你按“mov”,它就把数据从一个地方搬到另一个地方。就这么简单。
核心概念:汇编语言 = 助记符 + 操作数。助记符就是指令的名字(如mov),操作数就是指令要操作的对象(如寄存器、内存地址、立即数)。
3.2 通用寄存器:CPU的临时工作台
寄存器是CPU内部的小块存储,速度极快,但数量有限。你可以把它想象成你桌面上的几个便签纸——随手拿来记东西,但写满了就得擦掉重来。
在32位x86架构下,有8个通用寄存器。它们的名字和用途我列在下面:
| 寄存器 | 32位名 | 16位名 | 8位名(高/低) | 常见用途 |
|---|---|---|---|---|
| 累加器 | EAX | AX | AH / AL | 算术运算、函数返回值 |
| 基址寄存器 | EBX | BX | BH / BL | 常作为内存基址指针 |
| 计数寄存器 | ECX | CX | CH / CL | 循环计数器、移位次数 |
| 数据寄存器 | EDX | DX | DH / DL | I/O操作、乘除法辅助 |
| 源变址 | ESI | SI | - | 字符串操作源指针 |
| 目的变址 | EDI | DI | - | 字符串操作目的指针 |
| 栈基址 | EBP | BP | - | 函数栈帧基址 |
| 栈指针 | ESP | SP | - | 指向栈顶,不要乱改 |
小技巧:你在逆向分析时,看到EAX通常就是函数的返回值。如果函数返回一个整数,十有八九在EAX里。我曾经调试一个崩溃的驱动,就是靠检查EAX的值定位到错误码的。
嗯,这里要注意:ESP这个寄存器很特殊。它指向当前栈的顶部。你如果随便改ESP,程序立马崩溃给你看。我刚开始学的时候手痒,写了个mov esp, 0,结果整个调试器都卡死了……别学我。
3.3 常用指令:mov, add, sub
这三条指令是汇编里的“三原色”。几乎所有程序都离不开它们。
3.3.1 mov —— 数据搬运工
mov 就是把数据从一个地方复制到另一个地方。注意是“复制”,不是“移动”。源操作数不变。
mov eax, 1234h ; 把立即数0x1234存入EAX
mov ebx, eax ; 把EAX的值复制到EBX
mov ecx, [eax] ; 把EAX指向的内存地址中的值读入ECX
格式:mov 目标操作数, 源操作数。目标不能是立即数,源和目标不能同时是内存地址(x86不支持内存到内存的直接mov)。
避坑指南:我曾经在分析一个恶意软件时,看到mov [eax], eax这种写法。它把EAX的值写入EAX指向的内存地址。如果你没搞清楚哪个是地址、哪个是值,很容易看晕。记住:带方括号的表示“内存地址里的内容”。
3.3.2 add / sub —— 加减法
这两个就是算术加减。没什么玄乎的。
add eax, 10 ; EAX = EAX + 10
sub ebx, ecx ; EBX = EBX - ECX
add [esp], 4 ; 栈顶的值加4
它们会影响标志寄存器(EFLAGS)里的ZF(零标志)、SF(符号标志)、CF(进位标志)等。比如sub eax, eax,结果为零,ZF会被置1。这个在条件跳转里非常关键。
3.4 栈操作:push 和 pop
栈是一种后进先出的数据结构。在x86里,栈从高地址向低地址生长。也就是说,你每push一次,ESP就减小4(32位下),然后把数据放进去。
push eax ; ESP = ESP - 4, 然后把EAX的值写入[ESP]
pop ebx ; 把[ESP]的值读入EBX, 然后ESP = ESP + 4
为什么需要栈?因为寄存器不够用啊。你想想看,一个函数里可能要调用子函数,子函数也会用寄存器。如果不把原来的值保存起来,回来就丢了。所以函数开头通常会把要用到的寄存器push进去,返回前再pop出来。
实战经验:我在逆向一个加壳程序时,发现它大量使用push/pop来混淆代码。比如push eax; pop eax,看似什么都没做,其实是为了消耗时间或者干扰静态分析。遇到这种“花指令”,直接忽略就好。
3.5 函数调用:call 和 ret
这两个指令是程序流程控制的核心。没有它们,代码就只能顺序执行,没法实现函数调用。
3.5.1 call —— 调用子程序
call 做了两件事:
- 把下一条指令的地址(返回地址)压入栈。
- 跳转到目标地址执行。
call 0x401000 ; 调用地址0x401000处的函数
call eax ; 调用EAX中保存的地址
3.5.2 ret —— 返回
ret 从栈顶弹出返回地址,然后跳转过去。它和call正好配对。
ret ; 从栈顶弹出地址,跳转
ret 8 ; 弹出地址后,ESP再加8(用于清理参数)
你看,一个函数调用的生命周期就是:call压入返回地址 -> 执行子函数 -> ret弹出地址返回。就这么简单。
个人习惯:我在分析一个函数时,会先看它的开头有没有push ebp; mov ebp, esp。如果有,这就是一个标准的函数帧。然后看结尾的pop ebp; ret。中间的就是函数体。这个模式在逆向里太常见了,闭着眼睛都能认出来。
3.6 知识体系总览
下面这张图把本章的核心知识点串在了一起。你可以把它当成一张地图,随时回来对照。
这张图里,寄存器是基础,数据指令负责运算,栈指令管理临时数据,控制流指令决定程序走向。四者配合,就能构成完整的程序逻辑。
3.7 小结
今天咱们把x86汇编最核心的几条指令过了一遍。你不需要一次全记住,但一定要理解它们背后的逻辑:mov是搬数据,add/sub是算数,push/pop是管理栈,call/ret是函数调用。这些指令在逆向分析里出现的频率极高,几乎每段代码里都有它们的身影。
我建议你找个调试器(比如x64dbg或OllyDbg),随便打开一个简单的exe,单步执行几行,亲眼看看寄存器怎么变、栈怎么动。看十遍不如动手一遍。嗯,下一节我们会继续深入,看看更多实用的指令和寻址方式。