今天开始学学 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
2
3
4
5
6
7
8
#include<stdio.h>
int main(){
int a=1;
for(int i=0;i<15;i++){
a+=i;
}
printf("hello world ARM the value=%d\n",a);
}

编译命令:

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
2
3
4
5
6
7
8
9
10
11
ldr = Load Word
ldrh = Load unsigned Half Word
ldrsh = Load signed Half Word
ldrb = Load unsigned Byte
ldrsb = Load signed Bytes

str = Store Word
strh = Store unsigned Half Word
strsh = Store signed Half Word
strb = Store unsigned Byte
strsb = Store signed Byte

端序

这是第二个要考虑的问题,到底是小端序还是大端序,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 位)。

ARMThumb 状态的区别:

  • 条件执行:所有 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, #123r0 = 123
  • mov r0, r1r0 = r1
  • mov r0, r1, LSL #3r0 = r1 << 3
  • mov r0, r1, LSR #2r0 = r1 >> 2(逻辑右移)
  • mov r0, r1, ASR #4r0 = r1 >>> 4(算术右移)
  • mov r0, r1, ROR #8r0 = ROR(r1, 8)
  • mov r0, r1, RRXr0 = (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
2
3
4
add [rax], 1
sub [rax], 1
xor [rax], 1
...

ARM 的三种寻址模式

  1. 立即数偏移
  2. 寄存器偏移
  3. 缩放寄存器偏移(Scale Register的翻译大概是这样…)

简单的例子

来看最简单的 load 和 store 的例子。

  • LDR R2, [R0]:将 R0 指向的内存加载进 R2 寄存器。
  • STR R2, [R1]:将 R2 的寄存器的值存入 R1 指向的内存

这是最简单的,朴实无华的例子。

立即数偏移

如下所示

1
2
STR    Ra, [Rb, imm]
LDR Ra, [Rc, imm]

[Rb, imm] 实际上访问的内存是 Rb + imm

这种访问模式还有另一种版本:

1
2
STR    Ra, [Rb, imm]!
LDR Ra, [Rc, imm]!

它在访问内存的同时,会将 Rb/Rc 执行更新操作,即等于

1
2
3
4
STR    Ra, [Rb, imm]
add Rb, imm
LDR Ra, [Rc, imm]
add Rc, imm

但是其实根据文档描述,它是先做加法,再存储的,即

1
2
3
4
add    Rb, imm
STR Ra, [Rb]
add Rc, imm
LDR Ra, [Rc]

如图所示:

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
2
3
4
5
6
7
8
9
#include<stdio.h>
int b[50];
int main(){
int a=0;
for(int i=0;i<30;i++){
a+= a<15?a:0;
}
printf("hello world ARM the value=%d\n",a);
}

编译代码如下

其中

1
2
0x0003739c <+40>:    cmp     r0, #30
0x000373a0 <+44>: bge 0x373e8 <main+116>

的含义是,r0 若大于等于(greater or equal)30,则跳转到 0x373e8 地址处,到该地址之后,就认为循环退出了,正常顺序执行 printf 函数。

参考文献