汇编指令与寄存器

在接下来的几篇文章中,我们将介绍AArch64架构下的具体的汇编语言的写法。目前,我们的所有修改都是基于之前最基本的程序:

# 5-basic.s
    .section    __TEXT,__text
    .globl  _main
    .p2align    2
_main:
    mov    w0, #0
    ret

第一个汇编程序一章中我们提到,将其编译完成并运行之后,可以通过

echo $?

来获得程序的返回值,也就是存储在w0里的值。这一技巧将在这几章中反复使用。

汇编指令

A64指令集的汇编指令格式一般来说,是

{opcode {dest{, source1{, source2{, source3}}}}}

的形式。

其中,opcode指这条指令的操作码,在汇编语言中常用助记符表示。dest为目的操作数,source为源操作数。

以我们上一章用到的mov指令为例:

mov    w0, #0

这条指令中:

  • mov为助记符,表示这条指令是一条move指令
  • w0#0为这条指令的操作数。由于在A64指令集中目的操作数在源操作数之前,因此w0为目的操作数,#0为源操作数
  • 这条指令可以理解为,将源操作数#0 move到目的操作数w0之中

A64指令集的汇编指令是RISC架构的指令集。RISC架构指令集的最主要的特点就是其指令种类少,且指令都是定长的(32位)。这一特点带来的一个显著结果就是,大量我们在汇编层面看到的指令实际上都是某些指令的别名(alias)。也就是说,某些指令语句的机器码是相同的。这样CPU只需要实现一些更通用的指令逻辑,而将特殊的指令逻辑的翻译工作交给汇编器来执行。

例如,在codes/7-alias-instructions.s文件中,包含两条汇编语句:

neg    w0, w1
sub    w0, wzr, w1

第一条语句neg w0, w1的意思是将w1寄存器的值看作有符号整数,取其相反数赋值给w0寄存器;第二条指令sub w0, wzr, w1的意思是用0减去w1的值赋值给w0寄存器。显而易见,这两条汇编指令是等价的。而在AArch64指令集下,后者正是前者的别名。也就是说,汇编器总是会将neg w0, w1翻译为sub w0, wzr, w1指令。而同时,为了方便开发者阅读反汇编代码,标准要求sub w0, wzr, w1总应该反汇编为neg w0, w1

我们对编译、链接后的程序7-alias-instructions使用otool -tvV 7-alias-instructions进行反汇编,结果中有一段:

_main:
0000000100003fac	neg	w0, w1
0000000100003fb0	neg	w0, w1

由此可见,汇编器确实会将别名的指令翻译为同一个指令。

寄存器

寄存器是直接参与运算的部件。本小节将介绍AArch64架构下主要用到的部分寄存器。

通用寄存器

在AArch64架构下,有31个通用寄存器。这些通用寄存器可以作为大部分指令的操作数参与运算。

有三套记号用于指代这31个通用寄存器:

  • r0r30

    一般用这套记号来指代这些寄存器本身。这些记号通常用于描述汇编指令行为,不会参与到汇编指令中。

  • x0x30

    一般用这套记号表示这些寄存器的64位部分。例如,x3表示r3寄存器的64位部分。由于AArch64架构下的通用寄存器都是64位的,所以这套记号其实就代表这些寄存器的所有位。

  • w0w30

    一般用这套记号表示这些寄存器的低32位部分。例如,w3表示r3寄存器的低32位部分。

例如

ldr    x3, =0x0123456789abcdef

这条汇编指令将0x0123456789abcdef这个64位数存储到了x3中,也就是说r3寄存器现在的值就是0x0123456789abcdef。但是,如果我们直接访问w3,可以发现w3寄存器中存储的是0x89abcdef

在官方指南中的这张图可以直观地展示这三套记号的关系:

Registers

零寄存器

寄存器xzrwzr被称为零寄存器。所谓零寄存器,就是指读取该寄存器的值,永远为0;向该寄存器写入数值将无效,也就是说无法向该寄存器写入数值。其中xzr为64位的零寄存器,wzr为32位的零寄存器。

也就是说,下面这种写法

mov    w0, wzr

mov    w0, #0

的效果应当是相同的。我们可以编译并运行codes/7-zero-register.s文件,利用echo $?查看结果。

那么我们为什么需要这种零寄存器呢?直接用常数0不就好了?事实上,以ARM、RISC-V、MIPS为代表的一众RISC指令集中,都会有零寄存器的存在。关于其存在的意义,可以参考Stackoverflow的问答Why MIPS uses R0 as ”zero“以及知乎提问RISC-V RV32I中零寄存器有什么用?。总结而言,由于精简指令集的原因,部分指令无法直接使用常数作为操作数。但是0作为一个特殊的常数经常出现在各种程序逻辑中,那么零寄存器的出现就可以省去将常数0存储到寄存器中的步骤。此外,使用零寄存器,也可以简化指令内部的伪指令逻辑。

同时,在官方指南中提到:

In instruction encodings, the value 0b11111 (31) is used to indicate the ZR (zero register). This indicates that the argument takes the value zero, but does not indicate that the ZR is implemented as a physical register.

意思是说,零寄存器并不需要是一个物理意义上的寄存器,只需要在指令内部逻辑中加一些额外的检查即可。

所以可以看出,零寄存器的作用大、实现简单,因此AArch64中才会使用零寄存器。

其他寄存器

其他常用的寄存器有sp寄存器与pc寄存器。

sp寄存器代表栈顶的内存地址。关于栈、内存交互,在后面的文章中会具体提到。

pc寄存器全称为Program Counter,熟悉计算机组成原理的开发者一定了解,pc寄存器在指令执行时起了至关重要的作用。该寄存器内存储的是即将执行的指令的地址,当CPU执行一个指令时,其首先会访问pc寄存器,将其存储的值看作下一条指令地址,从内存中获取相应的指令,进一步译码、执行。对于黑客来说,攻击一个程序,往往本质上都是控制程序的pc寄存器,使其值由自己控制,从而能够让程序执行攻击者想要执行的指令。