3、ARM汇编实战:编写第一个ARM汇编程序、使用GCC交叉编译、反汇编工具(objdump, readelf)、分析简单C程序的汇编输出。
好,咱们直接动手。前面两章讲了理论,这一章咱们来点实在的。我个人习惯是,学汇编一定要先跑通一个最小的例子,哪怕它只是把寄存器里的值加一。你想想看,如果连个能跑的程序都写不出来,后面那些高级分析全是空中楼阁。
3.1 第一个ARM汇编程序:点亮一盏“灯”
这里说的“灯”,不是真的LED,而是让程序在某个寄存器里写个值,然后我们能通过调试器看到它。说白了,就是验证我们的工具链和环境是通的。
先看代码。我写了一个最简单的ARM汇编,功能是把立即数 0x1234 加载到寄存器 r0 里,然后返回。
@ first.S - 第一个ARM汇编程序
.section .text
.global _start
_start:
mov r0, #0x1234 @ 将立即数 0x1234 加载到 r0
mov r7, #1 @ Linux syscall number for exit
swi 0 @ 触发软中断,退出程序
嗯,这里要注意。这个程序是给Linux用户态用的,不是裸机程序。为什么?因为我用了 swi 0 这个系统调用。在ARM Linux里,r7 存放系统调用号,1 就是 exit。这样程序跑完能正常退出,不会崩溃。
3.2 使用GCC交叉编译
写好了源码,下一步就是编译。我们用的是交叉编译器,因为目标平台是ARM,而你的电脑很可能是x86。我个人习惯用 arm-linux-gnueabihf-gcc 这个工具链。
编译命令很简单:
arm-linux-gnueabihf-gcc -o first first.S -nostdlib -static
解释一下参数:
-nostdlib:不链接标准C库。因为我们没调用任何C函数,链接了反而会报错。-static:静态链接。这样生成的可执行文件不依赖动态库,方便我们后续分析。
编译完成后,用 file 命令看一眼:
$ file first
first: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, not stripped
看到 ARM 和 32-bit 了没?说明编译成功了。如果这里显示的是 x86-64,那说明你忘了用交叉编译器,或者环境变量没配好。我曾经在这上面栽过跟头,折腾了半小时才发现是路径问题。
3.3 反汇编工具:objdump 和 readelf
有了可执行文件,我们就可以用反汇编工具来“解剖”它了。这两个工具是逆向工程师的左膀右臂。
3.3.1 objdump:看汇编代码
objdump 最常用的功能就是把二进制文件里的机器码反汇编成汇编指令。命令如下:
arm-linux-gnueabihf-objdump -d first
输出结果大概长这样:
first: file format elf32-littlearm
Disassembly of section .text:
00008054 <_start>:
8054: e3a00123 mov r0, #0x1234
8058: e3a07001 mov r7, #1
805c: ef000000 svc 0
看到左边那列 e3a00123 了吗?那就是机器码。右边是反汇编出来的指令。你会发现,我们写的 swi 0 被反汇编成了 svc 0。其实是一回事,svc 是 Supervisor Call 的缩写,是 swi 的新名字。嗯,这里要注意,不同版本的GCC可能输出不同的助记符,但意思一样。
3.3.2 readelf:看文件结构
readelf 用来查看ELF文件的头部信息、段表、符号表等。对于逆向分析来说,段表信息特别重要。
arm-linux-gnueabihf-readelf -S first
输出节选:
There are 6 section headers, starting at offset 0x20f8:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00008054 000054 000010 00 AX 0 0 4
[ 2] .ARM.attributes ARM_ATTRIBUTES 00000000 000064 00002d 00 0 0 1
...
注意看 .text 段:它的地址是 0x8054,大小是 0x10(16字节)。正好对应我们那三条指令(每条4字节)。
为什么地址不是从0开始?因为Linux可执行文件默认有一个加载地址偏移。这个偏移量由链接脚本决定。你想想看,如果地址从0开始,那和空指针冲突了,操作系统可不允许。
3.4 分析简单C程序的汇编输出
光看汇编程序还不够,我们得看看C语言编译出来是什么样。这样才能把高级语言和底层指令对应起来。
写一个简单的C程序:
// simple.c
int add(int a, int b) {
return a + b;
}
int main() {
int x = 3;
int y = 5;
int z = add(x, y);
return z;
}
用GCC编译并反汇编:
arm-linux-gnueabihf-gcc -O0 -o simple simple.c
arm-linux-gnueabihf-objdump -d simple
我们重点关注 add 函数的反汇编:
000083f4 <add>:
83f4: e52db004 push {fp} ; 保存帧指针
83f8: e28db000 add fp, sp, #0 ; 设置帧指针
83fc: e24dd00c sub sp, sp, #12 ; 分配栈空间
8400: e50b0008 str r0, [fp, #-8] ; 存储第一个参数
8404: e50b100c str r1, [fp, #-12]; 存储第二个参数
8408: e51b2008 ldr r2, [fp, #-8] ; 加载第一个参数
840c: e51b300c ldr r3, [fp, #-12]; 加载第二个参数
8410: e0823003 add r3, r2, r3 ; 执行加法
8414: e1a00003 mov r0, r3 ; 返回值放入 r0
8418: e24bd000 sub sp, fp, #0 ; 恢复栈指针
841c: e49db004 pop {fp} ; 恢复帧指针
8420: e12fff1e bx lr ; 返回
你看,即使是最简单的加法,在 -O0(不优化)的情况下,编译器也生成了大量指令。它把参数从寄存器 r0、r1 存到栈上,然后又读回来,再计算。这明显是多余的。为什么?因为 -O0 是为了方便调试,所有变量都放在内存里,这样调试器可以随时修改它们的值。
如果我们用 -O2 优化一下:
arm-linux-gnueabihf-gcc -O2 -o simple_opt simple.c
arm-linux-gnueabihf-objdump -d simple_opt
再看 add 函数:
000083f4 <add>:
83f4: e0800001 add r0, r0, r1 ; 直接相加
83f8: e12fff1e bx lr ; 返回
只有两条指令!这才是我们期望的ARM汇编。优化后的代码直接把参数从 r0 和 r1 里取出来相加,结果放回 r0,然后返回。干净利落。
-O0 编译的代码。因为嵌入式设备对代码大小和执行效率要求极高,厂商通常会用 -Os(优化大小)或 -O2。所以,如果你在反汇编中看到大量冗余的压栈、弹栈操作,那多半是调试版固件,或者你分析的是未优化的代码。
3.5 本章知识体系图
下面这张SVG图,把本章的核心逻辑串起来了。从源码到可执行文件,再到反汇编分析,每一步都有对应的工具和输出。
这张图把整个流程串起来了。你从左边或中间开始都行,最终都会汇聚到ELF文件,然后通过不同的工具去分析它。我个人习惯是先跑 file 确认架构,再用 readelf 看段表,最后用 objdump 看具体指令。这个顺序能帮你快速定位问题。
好了,这一章的内容就到这里。你动手试一下,把代码敲进去,跑一遍流程。遇到问题很正常,多看看错误信息,多查查手册。嗯,就这样。