2、计算机基础回顾:CPU架构(x86/x64/ARM)、寄存器、堆栈与内存布局、程序执行流程
各位同学,咱们今天聊点硬核的。逆向工程说白了,就是跟计算机底层打交道。你如果不懂CPU怎么想、内存怎么摆、堆栈怎么玩,那看反汇编代码就跟看天书一样。我个人习惯,每次带新人,第一件事就是让他们把今天这章内容刻在脑子里。
好,咱们开始。
2.1 CPU架构:x86、x64与ARM
CPU架构,说白了就是CPU的“方言”。不同架构,指令集不同,寄存器名字不同,甚至数据在内存里怎么放都不一样。
2.1.1 x86(IA-32)
这是老大哥了。32位架构,寄存器名字都是e开头,比如eax、ebx、ecx。我在项目中遇到过,很多老旧的工控系统还在跑x86的代码,逆向它们的时候,你看到的就是这些e开头的寄存器。
- 寻址空间:最大4GB(2^32)。
- 指令长度:变长指令,从1字节到15字节不等。
- 特点:CISC(复杂指令集),指令功能强大,但解码复杂。
2.1.2 x64(x86-64 / AMD64)
现在的主流。64位架构,寄存器名字变成了r开头,比如rax、rbx。而且多了8个通用寄存器(r8-r15)。
- 寻址空间:理论上是16EB,但实际物理地址通常支持48位或57位。
- 兼容性:可以运行32位代码(兼容模式)。
- 我建议:现在逆向,90%都是x64的代码。你最好把x64的调用约定(Calling Convention)背熟。
2.1.3 ARM
移动端的霸主。手机、路由器、嵌入式设备,全是它。ARM是RISC(精简指令集),指令定长(通常是4字节)。
- 两种模式:ARM模式(4字节指令)和Thumb模式(2字节指令)。
- 寄存器:r0-r15,其中r13是栈指针(SP),r14是链接寄存器(LR),r15是程序计数器(PC)。
- 避坑指南:我曾经在逆向一个IoT固件时,没注意它用的是Thumb-2混合指令,结果反汇编出来的代码全是乱的。记住,ARM的PC值在指令执行时是当前地址+8(ARM模式)或+4(Thumb模式),这个坑很多人踩过。
2.2 寄存器:CPU的“临时记事本”
寄存器是CPU内部最快的存储单元。你想想看,CPU要算1+1,它得先把1和1放到寄存器里,然后加法器算完,结果再放回寄存器。没有寄存器,CPU就废了。
2.2.1 通用寄存器
这些寄存器干啥都行,但通常有约定俗成的用途。
| x86 | x64 | 常见用途 |
|---|---|---|
| EAX | RAX | 累加器,通常存函数返回值 |
| EBX | RBX | 基址寄存器,常用来存全局变量地址 |
| ECX | RCX | 计数器,循环指令默认用它 |
| EDX | RDX | 数据寄存器,I/O操作或乘法高位 |
| ESI | RSI | 源索引,常用于字符串操作 |
| EDI | RDI | 目标索引,常用于字符串操作 |
| ESP | RSP | 栈指针,指向当前栈顶 |
| EBP | RBP | 基址指针,指向当前栈帧底部 |
push rbp; mov rbp, rsp,这就是典型的“栈帧”建立。逆向时,关注RBP和RSP的变化,就能理清函数的局部变量和参数。
2.2.2 标志寄存器(EFLAGS / RFLAGS)
这个寄存器不存数据,存的是“状态”。比如上一条指令结果是不是0(ZF标志),有没有进位(CF标志),是不是负数(SF标志)。
为什么会这样?因为CPU需要知道计算结果的状态,才能做条件跳转。比如jz(如果为0则跳转),就是检查ZF标志位。
2.2.3 指令指针寄存器(EIP / RIP)
这个寄存器指向CPU当前正在执行的指令地址。你不能直接修改它,但可以通过jmp、call、ret等指令间接改变它。
嗯,这里要注意:在逆向时,如果你看到call指令,它会把下一条指令的地址(返回地址)压入堆栈,然后跳转到目标函数。这是理解程序执行流程的关键。
2.3 堆栈与内存布局
堆栈(Stack)是内存里一块“后进先出”的区域。每个线程都有自己的堆栈。函数调用、局部变量、返回地址,全在堆栈里。
2.3.1 堆栈的工作原理
堆栈从高地址向低地址生长。RSP(栈指针)始终指向栈顶。压栈(push)时,RSP减小;出栈(pop)时,RSP增大。
- 压栈:
push rax→ 先RSP减8,然后把RAX的值写入新RSP指向的位置。 - 出栈:
pop rax→ 先把RSP指向的值读入RAX,然后RSP加8。
2.3.2 内存布局(以x64 Linux为例)
一个进程的内存空间,从低地址到高地址,大致是这样:
- 代码段(.text):存放程序指令,只读。
- 数据段(.data / .bss):存放全局变量和静态变量。
- 堆(Heap):动态分配的内存(malloc/new),向高地址生长。
- 栈(Stack):函数调用和局部变量,向低地址生长。
- 内核空间:用户态程序无法直接访问。
2.4 程序执行流程
程序是怎么跑起来的?说白了,就是CPU不断重复“取指→解码→执行”的循环。
2.4.1 指令周期
- 取指(Fetch):CPU从RIP指向的内存地址读取指令。
- 解码(Decode):指令译码器把机器码翻译成控制信号。
- 执行(Execute):ALU(算术逻辑单元)干活,或者访问内存,或者修改寄存器。
- 回写(Write-back):把结果写回寄存器或内存。
然后RIP自动增加(指向下一条指令),重复以上步骤。
2.4.2 分支与跳转
程序不是一直顺序执行的。遇到if、for、while,CPU就得跳转。
- 无条件跳转:
jmp label→ 直接把RIP改成label的地址。 - 条件跳转:
je label→ 如果ZF标志为1,则跳转;否则继续执行下一条。
cmp eax, 0; je loc_401000,这就是一个典型的if语句。cmp指令会设置标志位,je根据标志位决定是否跳转。我经常用这个来还原高级语言的逻辑。
2.4.3 函数调用
函数调用是逆向的核心。以x64为例,调用约定(Microsoft x64 Calling Convention)是这样的:
- 前四个参数:分别放在RCX、RDX、R8、R9寄存器里。
- 多余参数:从右向左压入堆栈。
- 返回值:放在RAX里。
- 调用者清理堆栈:调用者负责把压栈的参数弹出(或者调整RSP)。
嗯,这里要注意:call指令会先把返回地址(下一条指令的地址)压栈,然后跳转到目标函数。函数执行完,ret指令会把返回地址弹出到RIP,程序就回到调用点继续执行。