在Keil编译器的链接文件里,以及在IDA和objdump程序显示ARM的汇编指令时,立即数(汇编指令中的常量)前面都有“#”标识。然而GCC 4.9的编译文件中没有这种立即数标识。请对照一下 14.1.4节和39.3节中的指令清单。
本章列举了很多例子,它们是由多种编译器生成的汇编代码。有些汇编指令的立即数确实没有井号标识,因为它们是GCC编译出来的文件。
这种问题毕竟谈不上谁比谁更为正宗。我们也建议读者不必深究这种格式上的问题。
以64位的寻址指令为例:
ldr x0, [x29,24]
上述指令从X29寄存器中取值,把这个值与24相加,再将算术和对应地址的值复制给X0寄存器。请注意,X29和24都在方括号之中。如果24不在方括号里,指令的涵义就完全不一样了。我们来看:
ldr w4, [x1],28
上述指令在X1寄存器所表示的地址取值,然后进行“指针X1所对应的值+28”的运算。
ARM可以在取值过程中进行立即数的加减运算。它即可以在取值前进行地址运算,还可以在取值后进行数值运算。
x86的汇编指令不支持这种操作。但是包括旧式的PDP-11平台在内,许多其他处理器平台都支持这种运算。PDP-11平台的指令集支持前增量(pre-increment)、前减量(pre-decrement)、后增量(post- increment)、后减量(post-decrement)运算。这大体可以归咎于C语言(基于PDP-11平台开发的)里的*ptr++、*++ptr、*ptr--、*--ptr一类的指令。在C语言中,这些指令确实属于不易掌握的指令。本文将之整理如下。
C术语 |
ARM术语 |
C语句 |
工作原理 |
---|---|---|---|
后增量 |
后变址寻址 |
*ptr++ |
使用*ptr的值后,再进行递增运算 |
后减量 |
后变址寻址 |
*ptr-- |
使用*ptr的值后,再进行递减运算 |
前增量 |
前变址寻址 |
*++ptr |
ptr先递增,后取*ptr的值 |
前减量 |
前变址寻址 |
*--ptr |
ptr先递减,后取*ptr的值 |
前变址寻址的指令带感叹号标识。例如,指令清单3.15的第二条指令“stp x29, x30, [sp,#-16]!”。
Dennis Ritchie(C语言的作者之一)曾经提到过,变址寻址是Ken Thompson(另一位C语言的作者)根据PDP-7平台的特性开发的指令(请参阅[Rit86][Rit93])。现在C语言编译器仍然保持着这种兼容性,只要硬件处理器支持,编译器就可以进行相应的优化编译。
变址寻址的优越性集中体现在数组的操作方面。
所有的Thumb模式的汇编指令都是2字节指令,所有的ARM模式的汇编指令都是4字节指令。ARM模式指令和32位的立即数都占用4字节,又如何进行32位立即数赋值呢?
我们来看:
unsigned int f()
{
return 0x12345678;
};
指令清单28.1 GCC 4.6.3 -O3 ARM mode
f:
ldr r0, .L2
bx lr
.L2:
.word 305419896 ; 0x12345678
可见,0x12345678存储于内存之中,供其他指令调用。但是这种指令增加了CPU访问内存的次数。我们可以改善这一状况。
指令清单28.2 GCC 4.6.3 -O3 -march=armv7-a (ARM mode)
movw r0, #22136 ; 0x5678
movt r0, #4660 ; 0x1234
bx lr
上述指令把一个立即数分为2个部分,并依次存储到同一个寄存器里。它使用MOVW指令先把立即数的低位部分存储到寄存器里,然后再使用MOVT存储这个数的高位部分。
这也就是说,ARM模式的程序需要2个指令才能加载一个32位的立即数。除了0和1之外,程序很少用到常量,所以这种分步赋值的方法不会造成实际问题。有人会问,这种一分为二的做法会不会造成性能的下降呢?虽然肯定比单条指令的效率要低,但是现在的ARM处理器能够检测到这种指令序列并能够对这种指令进行优化。
另外,IDA这样的工具能够识别出这种一分为二指令,并在显示的时候把它们合二为一。
MOV R0, 0x12345678
BX LR
uint64_t f()
{
return 0x12345678ABCDEF01;
};
指令清单28.3 GCC 4.9.1 -O3
mov x0, 61185 ; 0xef01
movk x0, 0xabcd, lsl 16
movk x0, 0x5678, lsl 32
movk x0, 0x1234, lsl 48
ret
其中,MOVK是“MOV Keep”的缩写。它把16位数值存储到寄存器里,而保留寄存器里的其他比特位。实际上这几个MOVK指令先使用LSL指令依次位移了16、32、48位,然后再进行的赋值操作。即,程序通过4条指令把一个64位数值存储到寄存器里。
浮点数
一条指令就可把浮点型数据存储到D-存储器里。我们来看:
double a()
{
return 1.5;
};
指令清单28.4 GCC 4.9.1 -O3 + objdump
0000000000000000 <a>:
0: 1e6f1000 fmov d0, #1.500000000000000000e+000
4: d65f03c0 ret
上面这个单条32位指令如何封装浮点数1.5呢?在ARM64的FMOV指令里,有8个特殊的比特位。这8位空间用于编排浮点型数据。通过VFPExpandImm()函数的算法,编译器把浮点数封装在FMOV指令的8位空间里。这种算法又叫作minifloat[1],实现方法请参见[ARM13a]。我测试了几个浮点数,发现编译器能够通过该函数把30.0和31.0编排在8位指令空间里。但是这8位空间无法封装32.0。根据IEEE 754规范,32.0要占用8个字节的空间。
double a()
{
return 32;
};
上述源程序的汇编指令如下所示。
指令清单28.5 GCC 4.9.1 -O3
a:
ldr d0, .LC0
ret
.LC0:
.word 0
.word 1077936128
我们已经知道ARM64的指令都是4字节(汇编)指令。4个字节的容量有限,无法封装很大的数。尽管如此,程序镜像可能会被操作系统加载内存中的任意地址,这就需要(基址)重定位(relocations/relocs)来进行修正。有关重定位的详细介绍,请参见本书的68.2.6节。
ARM64成对使用ADRP和ADD指令来传递64位指针地址。ADRP指令用来获取标签所在处(本例是main)的4KB分页地址,而ADD指令则负责存储偏移量的其余部分。在Win32下使用GCC(Linaro) 4.9编译了“Hello, word!”程序(即第6章的第一个程序),然后使用objdump工具分析它的目标文件。
指令清单28.6 GCC (Linaro) 4.9 and objdump of object file
...>aarch64-linux-gnu-gcc.exe hw.c –c
...>aarch64-linux-gnu-objdump.exe -d hw.o
...
0000000000000000 <main>:
0: a9bf7bfd stp x29, x30, [sp,#-16]!
4: 910003fd mov x29, sp
8: 90000000 adrp x0, 0 <main>
c: 91000000 add x0, x0, #0x0
10: 94000000 bl 0 <printf>
14: 52800000 mov w0, #0x0 // #0
18: a8c17bfd ldp x29, x30, [sp],#16
1c: d65f03c0 ret
...>aarch64-linux-gnu-objdump.exe -r hw.o
...
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000008 R_AARCH64_ADR_PREL_PG_HI21 .rodata
000000000000000c R_AARCH64_ADD_ABS_LO12_NC .rodata
0000000000000010 R_AARCH64_CALL26 printf
这个目标文件有3个地方涉及重定位:
第一处首先获取页面地址,把地址的低12位舍去,以便在ADRP指令里封装地址的高21位。ADRP获取的偏移量的4KB地址,其最后12位肯定是零,所以可以被忽略;另一方面,ADRP指令也只有21位空间可以封装数据。
其后的ADD指令则用于保存偏移量的低12位。
跳转到printf()函数的BL指令。若把B/BL指令逐位的展开,会看到其中只有26比特可供存储偏移量。实际上ARM模式和ARM64模式的转移指令只可能跳转到4的整数倍的偏移量地址,所以在指令中省略了最后的2位(相当于右移2位)。在执行转移指令时,相当于先对文件中的偏移量左移两位后再进行相应跳转。如此一来,转移指令的偏移量空间是28位,而不是26位;即可跳转至PC±128MB的地址(即偏移量的取值范围)。
最后生成的可执行文件里并没有再出现重定位。因为在编译过程的后续阶段,“Hello!”字符串的相对地址、分页地址,以及puts()函数的相对地址都是可被确定的已知数。链接器linker可依次计算出ADRP、ADD和BL指令所需的实际偏移量。
使用objdump分析最终的可执行文件,可以看到如下所示的代码。
指令清单28.7 objdump of executable file
0000000000400590 <main>:
400590: a9bf7bfd stp x29, x30, [sp,#-16]!
400594: 910003fd mov x29, sp
400598: 90000000 adrp x0, 400000 <_init-0x3b8>
40059c: 91192000 add x0, x0, #0x648
4005a0: 97ffffa0 bl 400420 <puts@plt>
4005a4: 52800000 mov w0, #0x0 // #0
4005a8: a8c17bfd ldp x29, x30, [sp],#16
4005ac: d65f03c0 ret
...
Contents of section .rodata:
400640 01000200 00000000 48656c6c 6f210000 ........Hello!..
BL指令要跳转到的地址可以推算出来。
假如BL那条指令的opcode是0x97ffffa0,即二进制的10010111111111111111111110100000。依据[ARM13a]C5.2.26描述的有关技术规范,opcode的最后26位是imm26。因此imm26的二进制数值为11111111111111111110100000,即imm26 = 0x3FFFFA0。但是imm26的最高数权位即符号位是1,所以它是负数的补码。要把补码转换为原码,则要进行就取非再加一的运算。由此可得原码为负的0x5F+1=0x60,即−0x60。ARM指令里的偏移量是实际偏移量除以4,所以实际偏移量是其4倍,即−0x180。综上,BL将要跳转到的目标地址是0x4005a0−0x180 = 0x400420。请注意:要以BL指令的偏移量为基数进行计算。如果要对PC指针进行计算,那么整个推算过程将完全不同。
如需深入了解AM64的重定位,请参见《ELF for the ARM 64-bit Architecture (AArch64)》(2013)。其官方下载地址是http://infocenter.arm.com/help/topic/com.arm.doc.ihi0056b/ IHI0056B_aaelf64.pdf
。
[1] https://en.wikipedia.org/wiki/Minifloat。