AArch64 汇编学习笔记
- PIE(Position Independent Executable,位置无关的可执行文件)通过随机化可执行文件各个部分在虚拟内存中的地址使得攻击者无法通过预测地址进行恶意行为。
汇编开发工具:
as
:汇编器(Assembler)。常见的汇编器有 GNU as, gas 和 LLVM-asld
:链接器(Linker)。常见的链接器有 GNU ld 和 LLVM-linker 。
# 5-basic.s
.section __TEXT,__text
.globl _main
.p2align 2
_main:
mov w0, #0
ret
注释
在 macOS 的 as
汇编器语法下,如果一行由 #
开头,那么这一行会被认为是注释行。
我们习惯上将注释写在语句的上方(如例程)或后方。在语句后方写注释时,一般采用 ;
作为注释开头的符号,如:
mov w0, #0 ; Mov 0 to register w0
缩进
在最古老的机器上,汇编代码的文本包含四列:标签、助记符、操作数与注释。汇编器通过识别一个文本在哪个列来判断该文本有什么作用。现代的汇编器已经抛弃了这种方法,采用先进的词法分析技术来判断。但是,我们最好仍然按照这种格式来缩进。
也就是说,我们在写一个完整程序的时候,一般会将指令缩进 4 个空格,而如 _main:
之类的标签则不进行缩进。
汇编器指令(Directive)
"Directive"是汇编语言中一个重要的组成部分,然而它的中文译名似乎还不固定,这里暂且叫它汇编器指令。在汇编语言中,以 .
开头的都是汇编器指令,如例程中的 .section
, .globl
等。由汇编器指令开头的语句,一般不会被直接翻译成机器码。汇编器指令并不是告诉汇编器做什么, 而是告诉汇编器如何做。就比如说例程中,mov w0, #0
会被汇编器直接翻译为机器码,最终会由 CPU 直接执行,而 .section __TEXT,__text
, 则不会被翻译成机器码,在最终的可执行文件中也不会找到这句话的踪影。它的作用是告诉汇编器如何汇编。
.section
Mach-O 可执行文件的 Data 部分拥有许多段(Segment), 每个段又有许多节(section)。同一个段的作用往往是类似的,同时在执行的时候一个段会被分配到一个页之中。而 .section
最常用的格式,就是:
.section segname, sectname
其中 segname
是段名,sectname
是节名。我们目前编写的第一个汇编语言程序,只包含纯代码。在 Mach-O 中,纯代码被放在了 __TEXT
段的 __text
节中,因此,我们在文件的第二行写了:
.section __TEXT, __text
代表之后的语句都是 __TEXT
段的 __text
节中。
此外,由于这个节过于常用,因此,汇编器给予了我们一个简单的记号:.text
。我们可以直接用 .text
代替 .section __TEXT, __text
。
除了 __TEXT
段 __text
节后,还有许多段和节。常用的段和节的名称和作用可参见 Assembler Directives。
.globl
在一个程序编译、链接、动态链接的过程中,有一些变量、函数的名字,需要作为字符串存储在二进制程序中,以便将来的某些时候使用。因此,我们可以指定一些标识符的可见性(Visibility)。
对于这个程序而言,我们在学习 C 语言的时候就了解到,main
函数是一个 C 语言程序开始的起点。事实上,链接器需要知道 main
函数这个名字,以便后续与 C 运行时的链接。因此,我们可以用 .globl _main
的方式,让链接器知道我们提供了 main
函数。
_main
macOS 中,C 语言程序执行的起点在汇编层面是 _main
函数。
.p2align
同 .section
和 .globl
一样,这也是一个汇编器指令。这个汇编器指令的作用是指令对齐。
mov
mov
是我们遇到的第一个真正的指令。在汇编语言中,这种能直接翻译成机器码的指令被称作助记符(mnemonic)。在 GNU 语法下,一条指令可以粗略地看作是 助记符+目的+源
,也就是说,它后面紧跟的是目的操作数,然后是源操作数。
首先我们先要理解 mov
。 这是一个在汇编语言中很常见的指令,意思是赋值。mov a b
就是将 b
赋值给 a
。 它可以将立即数赋值给寄存器,可以把寄存器赋值给寄存器。
#0
mov
的源操作数是 #0
。一般来说,在汇编语言中的常数都会在前加 #
符号,让读者看得更清楚。当然,不加这个 #
一样可以正常进行汇编。
此外,我们也可以在前面加 0x
来表示 16 进制数,如
mov w0, #0xFF
ret
这个指令可以类似于 C 语言中的 return
。
总结
因此,根据以上的讨论,我们可以将第一个汇编程序翻译成 C 程序了:
// 5-basic.c
int main() {
return 0;
}
这就是我们第一个汇编程序的作用,也就是将 main
函数返回 0
。
A64 指令集的汇编指令格式一般来说,是:
{opcode {dest{, source1{, source2{, source3}}}}}
其中,opcode
指这条指令的操作码,在汇编语言中常用助记符表示。dest
为目的操作数,source
为源操作数。
寄存器
在 AArch64 架构下,有 31 个通用寄存器。这些通用寄存器可以作为大部分指令的操作数参与运算。
有三套记号用于指代这 31 个通用寄存器:
-
r0
至r30
一般用这套记号来指代这些寄存器本身。这些记号通常用于描述汇编指令行为,不会参与到汇编指令中。
-
x0
至x30
一般用这套记号表示这些寄存器的 64 位部分。例如,x3 表示 r3 寄存器的 64 位部分。由于 AArch64 架构下的通用寄存器都是 64 位的,所以这套记号其实就代表这些寄存器的所有位。
-
w0
至w30
一般用这套记号表示这些寄存器的低 32 位部分。例如,w3
表示 r3
寄存器的低 32 位部分。
寄存器 xzr
和 wzr
被称为零寄存器。所谓零寄存器,就是指读取该寄存器的值,永远为 0;向该寄存器写入数值将无效,也就是说无法向该寄存器写入数值。其中 xzr
为 64 位的零寄存器,wzr 为 32
位的零寄存器。
为什么存在零寄存器:由于精简指令集的原因,部分指令无法直接使用常数作为操作数。但是 0 作为一个特殊的常数经常出现在各种程序逻辑中,那么零寄存器的出现就可以省去将常数 0 存储到寄存器中的步骤。此外,使用零寄存器,也可以简化指令内部的伪指令逻辑。零寄存器并不需要是一个物理意义上的寄存器。
sp
寄存器代表栈顶的内存地址。
pc
寄存器全称为 Program Counter,该寄存器内存储的是即将执行的指令的地址,当 CPU 执行一个指令时,其首先会访问 pc
寄存器,将其存储的值看作下一条指令地址,从内存中获取相应的指令,进一步译码、执行。对于黑客来说,攻击一个程序,往往本质上都是控制程序的 pc
寄存器,使其值由自己控制,从而能够让程序执行攻击者想要执行的指令。
赋值指令
同宽度赋值:
mov x0, x1 ; 源寄存器和目的寄存器的宽度必须相同
扩展赋值:
- 有符号扩展赋值(signed extend):
sxtb
、sxth
、sxtw
- 无符号扩展赋值(unsigned extend):
uxtb
、uxth
、uxtw
(事实上,uxtw
有些特殊,该指令并没有在 ARM 官方文档中记录,汇编器也是将其翻译为ubfx
指令)。
其中,
- 以
b
(byte)结尾的指令:将源寄存器的最低位的一个字节赋值给目的寄存器,并进行相应的扩展。 - 以
h
(halfword)结尾的指令:将源寄存器的最低位的两个字节赋值给目的寄存器,并进行相应的扩展。 - 以
w
(word)结尾的指令:将源寄存器的最低位的四个字节赋值给目的寄存器,并进行相应的扩展。
这类指令的源操作数必须是 32 位寄存器,而目的操作数则可以是 64 位寄存器,也可以是 32 位寄存器(以 w
结尾的指令除外)。
截断赋值:
截断就是指,从大宽度的寄存器向小宽度的寄存器赋值。这一过程比较粗暴,就是直接将相应的部分赋值即可,不考虑任何符号因素。例如,如果想将 x0
的值赋值给 w1
,我们需要做的就是使用 mov w1, w0
,也就是不考虑其高位,也不考虑其符号。
常数赋值:
由于 AArch64 是定长指令集架构,其所有的指令在二进制层面长度都是 32 位。因此无法直接装载太大的立即数。借助 ldr
(load register)伪指令,可以将立即数存储在二进制镜像的数据区,然后产生一个内存读取指令,再通过读取相应内存将立即数载入寄存器。
mov w0, #0 ; 较小的立即数赋值
ldr x1, =0x0123456789abcdef ; 大数必须使用 ldr
数据处理指令
常见的数据处理指令包括加、减、乘、除、求余、与、或、非、异或等。大部分的数据处理指令都是二元运算,即,将两个操作数进行计算,然后赋值给第三个操作数。因此,这些二元运算指令大都有如下的形式:
opcode dest, source1, source2
add dest_reg, src_reg1, src_reg2/imm ; 加
sub dest_reg, src_reg1, src_reg2/imm ; 减
and dest_reg, src_reg1, src_reg2/imm ; 与
orr dest_reg, src_reg1, src_reg2/imm ; 或
mvn dest_reg, src_reg ; 非
eor dest_reg, src_reg1, src_reg2/imm ; 异或
- 乘
mul dest_reg, src_reg1, src_reg2
umull dest_reg, src_reg1, src_reg2
smull dest_reg, src_reg1, src_reg2
其中,mul
指令的三个操作数都是 32 位寄存器,umull
、smull
的源操作数是 32 位寄存器,目的操作数是 64 位寄存器。
umull
代表无符号乘法,smull
代表有符号乘法。
-
除
sdiv dest_reg, src_reg1, src_reg2 udiv dest_reg, src_reg1, src_reg2
其中,
sdiv
代表有符号除法,udiv
代表无符号除法。 -
求余
A64 指令集不提供直接的求余计算。如果我们想求存储有符号整数的寄存器
w1
模w2
的余数,结果存储在w0
中,那么我们可以这么做:sdiv w0, w1, w2 mul w0, w2, w0 sub w0, w1, w0
移位操作
逻辑左移(Logical Shift Left):
lsl w0, w1, #2
操作数的可选移位:将某个寄存器的值乘以 2 的倍数往往是一个常见的中间操作。因此,AArch64 针对这种情况,对部分指令进行了优化。当我们使用部分指令的时候,可以附带一个移位。例如:
add w0, w1, w2, lsl #2 ; 先将 w2 的值乘 4,再加上 w1 的值,赋值给 w0
内存交互指令
基本的内存交互指令是 ldr
和 str
。这两条指令的用法为:
ldr[{sign}]{size} dest_reg, [mem_addr] ; Load Register,将内存数据读取到寄存器中
str{size} dest_reg, [mem_addr] ; Store Register,将寄存器数据存储到内存中
-
{sign}
表示是否有符号扩展。例如,ldrsb
将内存中 1 字节的内容,有符号扩展地存储到寄存器中。ldrb
则是无符号扩展。 -
{size}
是操作长度。b
(byte)表示 1 字节,h
(halfword)表示 2 字节,w
(word)表示 4 字节。当我们想表示的字节与目的操作数的宽度一致时,可以省略。例如,如果想将w0
的全部 4 字节存储到内存中,那么我们既可以写strw w0, [mem_addr]
,也可以写str w0, [mem_addr]
。
例:
strb w0, [mem_addr] ; 将 r0 寄存器最低位的 1 字节的内容,存储到地址为 mem_addr 的内存中
ldrh x1, [mem_addr] ; 将内存 mem_addr 处开始的 2 字节的内容,无符号扩展地存储到 r1 寄存器的低 2 字节位置
ldrsb w2, [mem_addr] ; 将内存 mem_addr 处开始的 1 字节的内容,有符号扩展地存储到 r2 寄存器的最低的 1 字节中
端序:macOS 使用的是小端序,即数值的低位存储到内存的低位。
数据对齐(Alignment)
在绝大多数指令集架构中,都会有数据对齐的要求。意思是说,我们读取/写入内存时,对内存地址本身也是有要求的。一般来说,对齐的字节数与读取/写入的字节数相同。例如,我们使用 ldrw
从内存中读取 4 字节的内容,那么根据要求,我们读取的地址本身,需要是 4 的倍数。
struct AlignedStruct {
short a; // 2 Bytes, pos 0x0
char b; // 1 Byte, pos 0x2
// 1 Byte padding
int c; // 4 Bytes, pos 0x4
}; // pos 0x8
寻址模式
寄存器寻址
将地址存储在寄存器中,访问寄存器指向的内存。
ldr w1, [x0]
基址寻址
基寄存器加偏移
访问结构体的某一字段:
struct Foo {
int a; // pos 0x0
int b; // pos 0x4
};
struct Foo *foo = get_foo_ptr();
// accessing foo->b
// ...
对应的 ASM 代码:
ldr w1, [x0, #4]
基址寄存器加寄存器偏移
char a[64];
for (size_t i = 0; i < 64; i++) {
char b = a[i];
// ...
}
ldr w2, [x0, x1]
对于整型数组:
ldr w2, [x0, x1, lsl #2]
调用 malloc
:
bl _malloc
; Here x0 has heap address
跳转
switch (a) {
case 0: /* do something A */ break;
case 1: /* do something B */ break;
}
// do something C
; Decide whether to do something A, B or C by a's value
zero_case:
; Do something A
b after_switch
one_case:
; Do something B
b after_switch
after_switch:
; Do something C
参考:在 Apple Silicon Mac 上入门汇编语言
参见:ARM Compiler toolchain Assembler Reference | ARM Developer