ARM使用加载(Load)/存储(Stroe)指令来读写内存,这意味着你只能使用LDR和STR指令访问内存。在ARM上数据必须从内存中加载到寄存器之后才能进行其他操作,而在x86上大部分指令都可以直接访问内存中的数据。如前所述,在ARM上增加内存里的一个32-bit数据值,需要三个指令(load,increment,store)。为了解释 ARM 上的 Load 和 Store 操作的基本原理,我们从一个基本示例开始,然后再使用三个基本偏移形式,每个偏移形式具有三种不同的寻址模式。为了简单化,每个示例,我们将在同一段汇编代码中使用不同 LDR/STR 偏移形式的。遵循这本段教程的最佳方法是在你的测试环境中用调试器(GDB)运行代码示例。
- 偏移形式:立即数作为偏移量
- 寻址模式:立即寻址
- 寻址模式:前变址寻址
- 寻址模式:后变址寻址
- 偏移形式:寄存器作为偏移量
- 寻址模式:立即寻址
- 寻址模式:前变址寻址
- 寻址模式:后变址寻址
- 偏移形式:缩放寄存器作为偏移量
- 寻址模式:立即寻址
- 寻址模式:前变址寻址
- 寻址模式:后变址寻址
第一个例子:
LDR 将内存中的值加载到寄存器中,STR 将寄存器内的值存储到内存地址。
LDR R2, [R0] @ [R0] - R0中保存的值是源地址。
STR R2, [R1] @ [R1] - R1中保存的值是目标地址。
LDR : 把R0内保存的值作为地址值,将该地址处的值加载到寄存器R2中。
STR : 把R1内保存的值作为地址值,将寄存器R2中的值存储到该地址处。
汇编程序是这样:
.data /*.data段是动态创建的,无法预测 */
var1: .word 3 /* 内存中的变量var1=3*/
var2: .word 4 /* 内存中的变量var2=4*/
.text /* 代码段开始位置 */
.global _start
_start:
ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。
ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。
ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。
str r2, [r1] @ 将R2内的值(0x03)存储到R1中的地址处。
bkpt
adr_var1: .word var1 /* 变量var1的地址位置 */
adr_var2: .word var2 /* 变量var2的地址位置 */
在程序底部有我们的文本池(在代码段用来存储常量、字符串或其他可以引用的位置无关的偏移量),使用adr_var1
和adr_va2
两个标签来存储var1
和var2
的内存地址。第一个LDR
将var1
的地址加载到R0
,然后第二个LDR
将var2
的地址加载到·。之后将R0
中的地址指向的值(0x03
)加载到R2
,最后将R2
中的值(0x03
)存储到R1
中的地址处。
当加载数据到寄存器中时,使用[]
符号意思时:取寄存器中的值作为地址值,然后再从该地址处加载数据到目标寄存器中,如果不加[]
那就是将寄存器中保存的值直接加载到目标寄存器。
同样STR命令中也是一个意思。
这听起来比实际要复杂的多,没关系,下面是一个更直观的演示图:
下面我们看一下调试器中的这段代码:
gef> disassemble _start
Dump of assembler code for function _start:
0x00008074 <+0>: ldr r0, [pc, #12] ; 0x8088 <adr_var1>
0x00008078 <+4>: ldr r1, [pc, #12] ; 0x808c <adr_var2>
0x0000807c <+8>: ldr r2, [r0]
0x00008080 <+12>: str r2, [r1]
0x00008084 <+16>: bx lr
End of assembler dump.
开头的两个LDR
操作中的第二操作数被替换成了[pc, #12
]。这被叫做PC相对寻址。因为我们使用了标签,所以编译器可以计算出文本池中标签的地址相对位置(pc+12
)。您可以使用这种精确的方法自行计算位置,也可以像前面一样使用标签。唯一的区别是,相较于使用标签,你需要计算值在文本池中的确切位置。在这种情况下,它距离有效的PC位置有3个跳转(4+4+4=12)。本章稍后将介绍有关PC相对寻址的介绍。
如果你忘了为什么有效PC指向当前指位置后两个指令,在第二部介绍了[…在执行过程中,在ARM模式下,PC将当前指令的地址加上8(两个ARM指令)作为最终值存储起来,在Thumb模式下,将当前指令加上 4(两个Thumb指令)作为最终值存储起来。而x86中PC始终指向要执行的下一个指令…]
1.偏移模式:立即数作为偏移量
STR Ra, [Rb, imm]
LDR Ra, [Rc, imm]
这里,我们使用立即(整数)作为偏移量。从基寄存器(以下示例中的 R1)中增加或减去此值,在编译时可以用已知的偏移量访问数据。
.data
var1: .word 3
var2: .word 4
.text
.global _start
_start:
ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。
ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。
ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。
str r2, [r1, #2] @ 以R1中的值为基准加上立即数2作为最终地址,将R2中的值(0x03)存储到该地址处,其中R1中的值不会被修改。
str r2, [r1, #4]! @ 前变址寻址:以R1中的值为基准加上立即数4作为最终地址,将R2中的值(0x03)存储到该地址处,其中R1中的值被修改为:R1+4。
ldr r3, [r1], #4 @ 后变址寻址:将R1中的值作为最终地址,获取该地址处的数据加载到R3,其中R1中的值被修改为:R1+4。
bkpt
adr_var1: .word var1
adr_var2: .word var2
假设以上程序文件为ldr.s
,编译并用GDB允许,看看会发生什么。
$ as ldr.s -o ldr.o
$ ld ldr.o -o ldr
$ gdb ldr
GDB
(包含gef
)中,在_start
处设置断点,运行程序。
gef> break _start
gef> run
...
gef> nexti 3 /* 运行后3条指令 */
系统上的寄存器现在填充了以下值(注意,这些地址在你的系统上可能有所不同):
$r0 : 0x00010098 -> 0x00000003
$r1 : 0x0001x009c -> 0x00000004
$r2 : 0x00000003
$r3 : 0x00000000
$r4 : 0x00000000
$r5 : 0x00000000
$r6 : 0x00000000
$r7 : 0x00000000
$r8 : 0x00000000
$r9 : 0x00000000
$r10 : 0x00000000
$r11 : 0x00000000
$r12 : 0x00000000
$sp : 0xbefff7e0 -> 0x00000001
$lr : 0x00000000
$pc : 0x00010080 -> <_start+12> str r2, [r1]
$cpsr : 0x00000010
下一条指令将在偏移地址模式下执行STR
指令。它将把R2
中的值(0x00000003
)存储在:R1
(0x0001x009c
)+偏移(#2
)= 0x1009e
地址处,运行完该条指令后用x/w命令查看0x0001x009c
处的值为0x3
,完全正确。
gef> nexti
gef> x/w 0x1009e
0x1009e <var2+2>: 0x3
再下一条~指令是前变址寻址。可以根据“!
”来识别该模式。唯一区别是,基准寄存器会被更新为最终访问地址。这意味着,我们将R2
(0x3
) 中的值存储到 地址:R1
(0x1009c
)+ 偏移量(#4
) = 0x100A0
,并使用此地址更新 R1
。运行完命令查看0x100A0
地址处的值,然后使用命令info register r1
查看R1
的值。
gef> nexti
gef> x/w 0x100A0
0x100a0: 0x3
gef> info register r1
r1 0x100a0 65696
最后一条LDR
指令是后变址寻址。意思是R1
中的值作为最终访问地址,获取最终访问地址处的值加载到R3
。然后将R1
(0x100A0
)更新为R1(0x100A0)+ 偏移(#4)= 0x100a4
。运行完该命令看看寄存器R1
和R3
的值。
gef> info register r1
r1 0x100a4 65700
gef> info register r3
r3 0x3 3
下图是实际发生的事情:
2.偏移模式:寄存器作为偏移量(寄存器基址变址寻址)
STR Ra, [Rb, Rc]
LDR Ra, [Rb, Rc]
这种偏移是使用寄存器作为偏移量。下面的示例是,代码在运行时计算要访问的数组索引。
.data
var1: .word 3
var2: .word 4
.text
.global _start
_start:
ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。
ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。
ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。
str r2, [r1, r2] @ 以R1中的值为基准地址,R2中的值(0x03)为偏移量,获得最终访问地址,将R2中的值(0x03)存储到该地址处,基准寄存器R1中的值保存不变。
str r2, [r1, r2]! @ 前变址寻址:以R1中的值为基准地址,R2中的值(0x03)为偏移量,获得最终访问地址,将R2中的值(0x03)存储到该地址处,基准寄存器R1中的值更新为R1+R2。
ldr r3, [r1], r2 @ 后变址寻址:以R1中的值为最终访问地址,获取该地址处的数据并加载到R3,基准寄存器R1中的值更新为R1+R2。
bx lr
adr_var1: .word var1
adr_var2: .word var2
当执行第一条STR
指令时,R2
中的值(0x00000003
)被存储到地址:0x0001009c + 0x00000003 = 0x0001009F
。
gef> x/w 0x0001009F
0x1009f <var2+3>: 0x00000003
第二条STR
指令操作是前变址寻址,做了同样的操作,不同的一点是R1
的值会被更新:R1=R1+R2
。
gef> info register r1
r1 0x1009f 65695
最后一条LDR
指令操作是后变址寻址。以R1
中的值为访问地址,获取该地址处的数据并加载到R3
,然后更新R1
的值:R1 = R1 + R2 = 0x1009f + 0x3 = 0x100a2
。
gef> info register r1
r1 0x100a2 65698
gef> info register r3
r3 0x3 3
3.偏移模式:缩放寄存器作为偏移量(寄存器基址变址寻址)
LDR Ra, [Rb, Rc, <shifter>]
STR Ra, [Rb, Rc, <shifter>]
第三中偏移形式是缩放寄存器作为偏移量。这种情况下,Rb是基地址寄存器,Rc
是一个被左移或右移(<shifter>
位移操作)缩放过的立即数(Rc
中保存的值)。意思是桶型位移操作用来缩放偏移量。下面是一个在数组上循环遍历的例子,可以在GDB
中运行看一下:
.data
var1: .word 3
var2: .word 4
.text
.global _start
_start:
ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。
ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。
ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。
str r2, [r1, r2, LSL#2] @ 以R2中的值左移2位(相当于乘以4)为偏移量,加上R1中的基准地址获得最终访问地址,将R2中的值(0x03)存储到该地址,基准寄存器R1中的值不变。
str r2, [r1, r2, LSL#2]! @ 以R2中的值左移2位(相当于乘以4)为偏移量,加上R1中的基准地址获得最终结果地址,将R2中的值(0x03)存储到该地址,基准寄存器R1中的值被修改: R1 = R1 + R2<<2
ldr r3, [r1], r2, LSL#2 @ 以R1中的值为访问地址,加载该地址处的数据到R3,基准寄存器R1中的值被修改: R1 = R1 + R2<<2
bkpt
adr_var1: .word var1
adr_var2: .word var2
下面是程序运行时的样子:
第一条不多赘述,第二条STR
指令操作使用了前变址寻址,也就是:R1
的值0x1009c+R2
中的值左移2
位(0x03<<2=0xc
)= 0x100a8
,并更新R1
的值为0x100a8
:R1 = R1 + 0x03<<2 = 0x100a8 + 0xc = 0x100b4
。
gef> info register r1
r1 0x100a8 65704
最后一条LDR
指令操作使用了后变址寻址。意思是,加载R1
中的值0x100a8
地址处的数据到寄存器R3
,然后将R2
中的值左移两位(0x03<<2=0xc
)得到值0xC
,再加上R1
中的值0x100a8
得到0x100b4
,最后R1
的值更新为0x100a8
:R1 = R1 + 0x03<<2 = 0x100a8 + 0xc = 0x100b4
。
gef> info register r1
r1 0x100b4 65716
总结
记住LDR
和STR
中有三种偏移形式:
- 立即数作为偏移量
-
ldr r3, [r1, #4]
- 寄存器作为偏移量
-
ldr r3, [r1, r2]
- 带有位移操作的寄存器作为偏移量
-
ldr r3, [r1, r2, LSL#2]
如何记住LDR
和STR
这些寻址模式:
如果带有
!
,就是前变址寻址ldr r3, [r1, #4]!
ldr r3, [r1, r2]!
ldr r3, [r1, r2, LSL#2]!
如果基地值寄存器(
R1
)带中括号,就是后变址寻址ldr r3, [r1], #4
ldr r3, [r1], r2
ldr r3, [r1], r2, LSL#2
其他的都是带偏移量的寄存器间接寻址
ldr r3, [r1, #4]
ldr r3, [r1, r2]
ldr r3, [r1, r2, LSL#2]
LDR中的PC相对寻址
LDR
是唯一用来加载数据到寄存器中的指令。语法如下:
.section .text
.global _start
_start:
ldr r0, =jump /* 加载函数标签jump的地址到R0 */
ldr r1, =0x68DB00AD /* 加载值0x68DB00AD到R1 */
jump:
ldr r2, =511 /* 加载值511到R2 */
bkpt
这些指令被称为伪指令,我们可以使用此语法来引用文本池中的数据。在上面的示例中,我们使用这些伪指令引用一个函数的偏移量,在指令中将一个32位常量加载到寄存器中。我需要使用此语法在一个指令中将 32 位常量移动到寄存器中的原因是,ARM 只能一次加载 8 位值。什么?要了解原因,您需要了解 ARM 上如何处理立即数的。
ARM中的立即数
在ARM上加载一个立即数到寄存器中并不像x86上那么简单,ARM对于立即数有很多限制。这些限制是什么以及如何处理它们并不是ARM汇编所关心的,这只是为了有助于你理解,其实有一些技巧可以绕过这些限制(提示:LDR
)。
我们知道ARM指令长度是32位,并且所有指令都是可条件执行指令。其中有16种条件码,就要占用4位(2^4=16),然后还要2位代指目标寄存器,2位代指操作寄存器,1位作为状态标志,加起其他一些操作码占用的位。到这里分配完指令类型,寄存器以及其他位段,最后只剩下12位用来操作立即数,最多只能表示4096个数。
这意味着ARM中MOV
指令只能操作一定范围内的立即数,如果不能直接被调用,就必须被分割成多个部分,用众多小数字拼起来。
还没完,这12位还不全是用来表示一个整数,其中8位用来表示0-255范围的数n
,4位表示旋转循环右移(其实ARM中只有一种位移,就是旋转循环右移,左移也是通过旋转循环右移得到)的次数r
(范围0-30)。所以一个立即数的表示形式是:v = n ror 2*r
。也就是说,只能以偶数进行旋转循环右移,一次移动两位,n组成的有效位图必须能放到一个字节(8位)中。
下面是一些有效和无效的立即数:
Valid values:
#256 // 1 ror 24 --> 256 循环右移12次,每次两位(注意数据是32位长度)。
#384 // 6 ror 26 --> 384 循环右移13次,每次两位。
#484 // 121 ror 30 --> 484
#16384 // 1 ror 18 --> 16384
#2030043136 // 121 ror 8 --> 2030043136
#0x06000000 // 6 ror 8 --> 100663296 (0x06000000 in hex)
Invalid values:
#370 // 185 ror 31 --> 循环右移31位,但超出了(0 – 30)范围,因此不是有效立即数。
#511 // 1 1111 1111 --> 有效位图无法放到一个字节(8位)中。
#0x06010000 // 110 0000 0001.. --> 有效位图无法放到一个字节(8位)中。
译注:1.以上立即数都是32位长度。2.旋转循环右移:每位都向右移动,末位不断放到最前位,类似首尾相连。3.有效位图要能放到一个字节中:例子中*#511*
的二进制为*0000 0000 0000 0000 0000 0001 1111 1111*
,有效位图为*1 1111 1111*
,超过一个字节。*#0x06010000*
的二进制位*0110 0000 0001 0000 0000 0000 0000*
,有效位图*110 0000 0001*
超过一个字节。
其结果是无法一次加载完整的 32 位地址。我们可以通过使用以下两个选项之一来绕过此限制:
用较小的值构造较大的值
不要使用
MOV r0, #511
分成两部分:
MOV r0, #256
和ADD r0, #255
使用加载方式“
ldr r1, =value
”,编译器会很乐意将其转换位MOV
指令,或者是PC
相对寻址来加载。LDR r1, = 511
如果你加载了一个无效的立即数,那么编译器会报错:“Error: invalid constant
”。如果遇到这种问题你应该知道怎么做。
.section .text
.global _start
_start:
mov r0, #511
bkpt
如果尝试编译,编译器会输出类似以下错误:
azeria@labs:~$ as test.s -o test.o
test.s: Assembler messages:
test.s:5: Error: invalid constant (1ff) after fixup
你应该把511
拆成几个小数值,或者用前面介绍的LDR
方式。
.section .text
.global _start
_start:
mov r0, #256 /* 1 ror 24 = 256, so it's valid */
add r0, #255 /* 255 ror 0 = 255, valid. r0 = 256 + 255 = 511 */
ldr r1, =511 /* load 511 from the literal pool using LDR */
bkpt
如果你想判断一个立即数是否是有效的立即数,你可以用我写的python脚本rotator.py :
azeria@labs:~$ python rotator.py
Enter the value you want to check: 511
Sorry, 511 cannot be used as an immediate number and has to be split.
azeria@labs:~$ python rotator.py
Enter the value you want to check: 256
The number 256 can be used as a valid immediate number.
1 ror 24 --> 256