ARM汇编基础学习(1)
今天开始学学 ARM 指令集,也为之后安卓的学习铺铺路。
ARM 是一种 RISC(精简指令集计算)处理器,因此拥有简化指令集(100 条指令或更少)和比 CISC(复杂指令集) 更多的通用寄存器。与英特尔不同,ARM 使用仅在寄存器上操作的指令,并使用 Load/Store 内存模型进行内存访问,这意味着只有 Load/Store 指令可以访问内存,其余的运算操作必须在寄存器中完成。
环境准备
手上没有合适的 ARM 设备,只能用交叉编译器 + qemu 模拟,我认为这对于想学习 ARM 的同学来说也是成本最低的方案了。
- 编译器:
LLVM 18.0.0 - 运行环境:
qemu-arm-static
编译器自己 git 拉源码构建,构建命令:
1 | cmake -G "Visual Studio 17 2022" -DLLVM_ENABLE_PROJECTS="clang;lld" -DLLVM_TARGETS_TO_BUILD="X86;ARM;AArch64;Mips" -DLLVM_ENABLE_PLUGINS=On -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_FLAGS_DEBUG="/Ox" -DCMAKE_CXX_FLAGS_DEBUG="/Ox" -DLLVM_ENABLE_RTTI=ON -DLLVM_OBFUSCATION_LINK_INTO_TOOLS=OFF -DCMAKE_INSTALL_PREFIX="../Windows" ../ |
自己构建 LLVM 比较吃经济和配置,构建确保留出 512G 的固态空间和超过 24G 的内存,如果不行可以直接用官方 release 的 clang arm 版。
编译环境
直接下载 arm 的 gcc 环境,解压,目录可以跟我不一致,也不一定要按照我摆放的位置。如果使用 apt 安装 arm 套件,去 /usr/arm-linux-gnueabi 和 /usr/lib/gcc-cross/arm-linux-gnueabi 中复制出来一下文件,然后用 clang 编译,缺啥用 L 和 B 参数指定路径就行。
测试代码
1 |
|
编译命令:
1 | clang -g 1.c -o main -fuse-ld=lld -target arm-linux-gnueabihf --sysroot=D:\Linux\ARM32\tool -L"D:\Linux\ARM32\tool\lib" -L"D:\Linux\ARM32\tool\gcc\13" -B"D:\Linux\ARM32\tool\gcc\13" --static |
真不建议用动态链接,巨多坑,想单纯学 ARM 还是静态链接吧。
运行环境
模拟运行:
环境 over,开始正式学习。
静态分析结果
可以发现明显的特点就是所有指令都是等长的(4字节)。
ARM指令集学习
数据类型
和 x86 类似,分字节、字、双字、四字、8 字等等,但是和 x86 又有点不一样。
- 字节:1byte
- 半字:2bytes
- 字:4bytes
如图所示
ARM 的操作指令都有尾缀指示操作数的大小,例如最基础的 ldr 和 str 指令
1 | ldr = Load Word |
端序
这是第二个要考虑的问题,到底是小端序还是大端序,x86 指令几乎都是小端序。而 ARM 在三版之前是小端序,在这之后是双端模式,由CPU的某个控制寄存器决定是小端还是大端模式。
ARM寄存器
寄存器的数量取决于 ARM 版本。根据 ARM 参考手册,除了 ARMv6-M 和 ARMv7-M 架构的处理器外,其他 ARM 架构有 30 个通用 32 位寄存器。前 16 个寄存器可在用户模式访问,额外的寄存器可在特权软件执行模式下访问(ARMv6-M 和 ARMv7-M 除外)。R0-15。这 16 个寄存器可分为两组:通用寄存器和特殊用途寄存器。
下面做个详细介绍:
- R0-R12:在常见操作期间可用于存储临时值、指针(内存位置)等。例如,R0 在算术操作期间可以作为累加器使用,或用于存储先前调用函数的结果。R7 在与系统调用(syscall)工作时很有用,因为它存储系统调用号,而 R11 帮助我们跟踪栈边界,充当帧指针。此外,ARM 的函数调用约定规定,函数的前四个参数存储在 r0-r3 寄存器中。
- R13: SP(栈指针),即对应了 x86 的 esp。
- R14: LR(链接寄存器)。当进行函数调用时,链接寄存器会更新为指向函数调用起始处的下一条指令的内存地址。这样做允许程序在“子”函数执行完毕后返回到调用它的“父”函数。
- R15: PC(程序计数器)。程序计数器会自动递增所执行指令的大小。这个大小在 ARM 状态下始终为 4 字节,在 THUMB 模式下为 2 字节。当执行分支指令时,PC 会持有目标地址。在执行过程中,PC 在 ARM 状态下存储当前指令地址加 8(两条 ARM 指令),在 Thumb(v1)状态下存储当前指令地址加 4(两条 Thumb 指令)。这与 x86 不同,在 x86 中 PC 始终指向下一个将要执行的指令。
ARM的两种模式
ARM 处理器可以在两种主要状态下运行,即 ARM 状态和 Thumb 状态。这两种状态之间的主要区别在于指令集,其中 ARM 状态下的指令始终为 32 位,而 Thumb 状态下的指令为 16 位(但也可以是 32 位)。
ARM 和 Thumb 状态的区别:
- 条件执行:所有 ARM 状态下的指令都支持条件执行。一些 ARM 处理器版本可以通过使用 IT 指令在 Thumb 模式下实现条件执行。条件执行能够提高代码密度,因为它减少了需要执行的指令数量,并减少了昂贵的分支指令数量。
- 32 位
Thumb指令有一个.w后缀。 - 如果当前程序状态寄存器中的 T 位被设置,处理器就处于 Thumb 模式。
ARM指令简介
基本的 ARM 指令如下格式所示:
MNEMONIC{S}{condition} {Rd}, Operand1, Operand2
- MNEMONIC:指令助记符
- S:操作数大小(可选)
- condition:指令执行的条件(可选)
- Rd:存储操作数结果
- Operand1:第一个操作数
- Operand2:第二个操作数
ARM 指令的第二个操作数既可以是立即数,也可以是寄存器本身,或附带各种移位与旋转操作形成的“寄存器经桶形移位器处理后的值”,例如:
- #123:一个经过 ARM 特殊编码限制的立即数。
- Rx:直接使用寄存器的原始值(如 R0,R1……)。
- Rx, LSL n:寄存器的值左移 n 位,右侧补 0。
- Rx, LSR n:寄存器的值逻辑右移 n 位,高位补 0。
- Rx, ASR n:寄存器的值算术右移 n 位,高位补符号位。
- Rx, ROR n:寄存器的值循环右移 n 位,移出的低位从高位重新进入。
- Rx, RRX:寄存器的值右移 1 位,最低位进入进位标志,最高位由进位标志补入。
举几个例子:
mov r0, #123→r0 = 123mov r0, r1→r0 = r1mov r0, r1, LSL #3→r0 = r1 << 3mov r0, r1, LSR #2→r0 = r1 >> 2(逻辑右移)mov r0, r1, ASR #4→r0 = r1 >>> 4(算术右移)mov r0, r1, ROR #8→r0 = ROR(r1, 8)mov r0, r1, RRX→r0 = (C << 31) | (r1 >> 1)
前面讲到,ARM 指令可以附加条件。
movle r0, r1:当上一条指令的操作结果满足了小于等于的条件,则执行r0 = r1。
下面总结一下 ARM 的常见运算指令
| 指令 | 描述 |
|---|---|
| MOV | 移动数据 |
| MVN | 移动和取反 |
| ADD | 加法 |
| SUB | 减法 |
| MUL | 乘法 |
| LSL | 逻辑左移 |
| LSR | 逻辑右移 |
| ASR | 算术右移 |
| ROR | 右旋转 |
| CMP | 比较 |
| AND | 按位与 |
| ORR | 按位或 |
| EOR | 按位异或 |
| LDR | 加载 |
| STR | 存储 |
| LDM | 加载多个 |
| STM | 存储多个 |
| PUSH | 压栈 |
| POP | 出栈 |
| B | 分支 |
| BL | 带链接分支 |
| BX | 分支和交换 |
| BLX | 带链接的分支和交换 |
| SWI/SVC | System Call 系统调用 |
Load/Store内存模型
开头说过,ARM 只能使用 Load/Store 内存模型去访问内存。在 x86 中,不仅仅 mov 指令可以访问内存,一切运算都有可以访问内存指令模式,例如:
1 | add [rax], 1 |
ARM 的三种寻址模式
- 立即数偏移
- 寄存器偏移
- 缩放寄存器偏移(Scale Register的翻译大概是这样…)
简单的例子
来看最简单的 load 和 store 的例子。
LDR R2, [R0]:将R0指向的内存加载进R2寄存器。STR R2, [R1]:将R2的寄存器的值存入R1指向的内存
这是最简单的,朴实无华的例子。
立即数偏移
如下所示
1 | STR Ra, [Rb, imm] |
[Rb, imm] 实际上访问的内存是 Rb + imm。
这种访问模式还有另一种版本:
1 | STR Ra, [Rb, imm]! |
它在访问内存的同时,会将 Rb/Rc 执行更新操作,即等于
1 | STR Ra, [Rb, imm] |
但是其实根据文档描述,它是先做加法,再存储的,即
1 | add Rb, imm |
如图所示:
pwndbg 指示了该条指令预期修改的内存地址,正如介绍的那样。
寄存器偏移
其实就是把上述例子中的立即数改成了寄存器。。
条件执行与分支
如下表所示
| Condition Code | Meaning (for cmp or subs) | Status of Flags |
|---|---|---|
| EQ | Equal | Z==1 |
| NE | Not Equal | Z==0 |
| GT | Signed Greater Than | (Z==0) && (N==V) |
| LT | Signed Less Than | N!=V |
| GE | Signed Greater Than or Equal | N==V |
| LE | Signed Less Than or Equal | (Z==1) || (N!=V) |
| CS or HS | Unsigned Higher or Same (or Carry Set) | C==1 |
| CC or LO | Unsigned Lower (or Carry Clear) | C==0 |
| MI | Negative (or Minus) | N==1 |
| PL | Positive (or Plus) | N==0 |
| AL | Always executed | – |
| NV | Never executed | – |
| VS | Signed Overflow | V==1 |
| VC | No signed Overflow | V==0 |
| HI | Unsigned Higher | (C==1) && (Z==0) |
| LS | Unsigned Lower or same | ` (C==0) |
以下列程序编译为例:
1 |
|
编译代码如下
其中
1 | 0x0003739c <+40>: cmp r0, #30 |
的含义是,r0 若大于等于(greater or equal)30,则跳转到 0x373e8 地址处,到该地址之后,就认为循环退出了,正常顺序执行 printf 函数。