第28章 ARM指令详解

28.1 立即数标识(#)

在Keil编译器的链接文件里,以及在IDA和objdump程序显示ARM的汇编指令时,立即数(汇编指令中的常量)前面都有“#”标识。然而GCC 4.9的编译文件中没有这种立即数标识。请对照一下 14.1.4节和39.3节中的指令清单。

本章列举了很多例子,它们是由多种编译器生成的汇编代码。有些汇编指令的立即数确实没有井号标识,因为它们是GCC编译出来的文件。

这种问题毕竟谈不上谁比谁更为正宗。我们也建议读者不必深究这种格式上的问题。

28.2 变址寻址

以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语言编译器仍然保持着这种兼容性,只要硬件处理器支持,编译器就可以进行相应的优化编译。

变址寻址的优越性集中体现在数组的操作方面。

28.3 常量赋值

28.3.1 32位ARM

所有的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
28.3.2 ARM64
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

28.4 重定位

我们已经知道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个地方涉及重定位:

最后生成的可执行文件里并没有再出现重定位。因为在编译过程的后续阶段,“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。