正式开始之前, 我们先来了解一下什么是 ARM, 以及对应的一些概念.
Wikipedia 上是这么介绍 ARM 的:
ARM (stylised in lowercase as arm, formerly an acronym for Advanced RISC Machines and originally Acorn RISC Machine) is a family of reduced instruction set computer (RISC) instruction set architectures for computer processors, configured for various environments.
ARM 是 高级-RISC(精简指令集)-机器 的缩写, 是精简指令集架构的家族. 同时 Arm Ltd. 也是开发和设计、授权这项技术的公司名称.
目前用的比较多的架构是 ARMv7 和 ARMv8, 这两个名字各自都是一个系列.
在 ARMv7 以及之前都是最多支持 32 位架构(更早还有 16 位, 甚至更低), 那么 32 位架构对应的 ISA 也就是指令集称为 A32. 32 位下指令的地址空间最大只有 4GB, 苹果系列的代表是 iPhone 4 使用的 A4 芯片, 以及 iPhone 4s 使用的 A5 芯片.
2011 年面世的 ARMv8-A 架构增加了对 64 位地址空间的支持, 对应的 ISA 称为 A64. 这里用的词是“增加”, 也就意味着在支持 32 位的基础上增加了对 64 位的支持. 所以也可以看出来所谓的 32/64 位指的就是可寻址的最大地址空间. 苹果系列从 iPhone 5s 开始的 A7 芯片一直到 A15, 以及 Apple M1 系列开始都是基于 ARMv8.x-A 规范的.
那我们见到的 AArch64 是什么呢? 其实它和 AArch32 被称为 “执行状态” (execution state), 那么我们可以说 ARMv8-A 同时支持 AArch32 和 AArch64 两种状态, 在 AArch64 状态下, 运行的是 A64 指令集.
这里要注意 ARMv7/ARMv8-A、AArch32/AArch64 以及 A32/A64 在概念上的的区别, 但很多时候, 描述的范围都挺笼统的, 有些也是可以互相指代的, 大家知道就好.
上面说到指令集, 指令集是做什么用的呢? 我们为什么要了解这些?
指令集本质上定义了 CPU 提供的“接口”, 软件通过这些“接口”调用 CPU 硬件的能力来实现编程. 编译器在这里起到很关键的角色, 它把上层代码根据对应的架构, 编译为由该架构支持的指令集对应的二进制代码, 最终运行在 CPU 上.
对 C 系语言来说, 我们说的跨平台, 其实就是通过同一份源码在编译时, 根据不同 target 架构指令集, 生成不同的二进制文件来实现的.
对我们来说熟悉 ARM 汇编指令, 我们就能知道我们平常写的代码背后的本质, 以及背后的原理, 从而写出更高效, 更可靠的代码. 主要是编译器内部对 C/C++ 概念的实现原理.
这个系列也是本着这个初衷展开, 适合对 AArch64 不熟, 或者熟悉 x86/64 的汇编, 想了解 AArch64 的同学. 而且对 C/C++ 语法或者特性背后实现感兴趣的同学.
我其实也是最近才开始捡起来, 之前学习的 x86 汇编早就还给老师了. 相当于一边学习一边总结吧. 好处是我大概知道刚开始可能会遇到哪些问题, 在此基础上, 尽可能的减少阅读门槛, 这不是一个手册, 而是一个循序渐进, 目的性很强的一个系列.
因为目前 Apple M1 芯片就是基于 ARMv8.x-A 的, 我们为了方便试验, 接下来都选择使用基于 ARMv8-A A64 指令集来做解释.
ARM 使用的是精简指令集(RISC, Reduced Instruction Set Computer), 相对的就是x86/64 的复杂指令集(CISC, Complex Instruction Set Computer).
ADD W0, W1, W2
FADD S0, S1, S2
. FADD V0.2D, V1.2D, V2.2D
FP Frame Pointer
, 方法调用的时候, 指向栈基址, 用于方法调用后恢复栈.LR Link Register
, 也可以通过 LR
来使用. 在方法调用前, 保存返回地址.ADR Xd, .
, 点表示当前行, ADR 取地址, 相当于取当前行的地址, 也就相当于 PC 寄存器的值(图 1)
(图 2)
指令的构成通常是这样的:
Operation Destination, Op1[, Op2 ..]
不一定所有的制定规则都是这样的, 为了减少理解的成本, 我们先介绍几个简单却又必须的指令, 其他的指令会在后面用到时再做介绍.
// X1 存储了一个地址, 把 X1 寄存器里的地址对应的值, load 到 X0 寄存器中. 相当于 X0 = *X1
ldr X0, [X1]
// X0 = X0 + 1
ADD X0, X0, #1
// 再把 X0 寄存器的值, 保存到 X1 地址对应的内存中, 相当于 *X1 = X0
str X0, [X1]
// 访问内存可以加一个 offset, 相当于把 X0 保存到 新地址 = (地址 X1 + 4) 对应的内存中. lrd 也同理.
str X0, [X1, #4]
// ldp(load pair registers) 和 ldr 类似, 一次 load 两个
ldp X0, X1, [sp, #num]
// 同理, stp(store pair registers) 保存两个 register 到内存
stp X0, X1, [sp #num]
// 用 mov 移动一个寄存器或者立即数到目的寄存器中
mov X0, X1
mov X0, #0x01
通过 label 在 code segment 里定义 local data:
msg: ascii "Hello" // 定义字符串
number: word 0x12345678 // 定义一个 4 字节的数据. byte, word(4bytes), quad(8bytes)
// ADR 取地址符, 把 Hello 字符串的地址放入 X1 寄存器:
adr X1, msg
// 算数运算, 加减乘除: add, sub, mul, sdiv/udiv (signed/unsigned div):
add x0, x1, x2
// 逻辑运算, lsl/lsr logical shift left/right.
lsl X0, #16 // 把 X0 左移 16 bits
lsr X0, #16
// 控制流, 通过 b 指令跳转
// 直接跳转到 .LBB0_6
b .LBB0_6
// less or equal
b.le .LBB0_2
// greater or equal
b.ge .LBB0_4
// not equal
b.ne .LBB0_4
//TODO(xueshi)
熟悉程序加载到内存之后的布局, 对编写/阅读汇编代码至关重要, 这里我们熟悉一下经典的内存布局, 主要目的是方面理解后面的汇编代码. 这里不展开西说, 更详细的大家可以自行查询资料.
下面讨论的地址都是虚拟地址, 虚拟地址最终会被操作系统映射到真实的物理地址中. 所以我们也可以知道在 32 bit 指令集下, 虽然寻址空间最大 4GB, 因为用了虚拟内存, 实际上每个执行的进程都有 4GB 的寻址空间(一般是 1G 内核空间, 3G 用户空间), 并不是共享的.
当一个可执行程序被 load 到一个进程空间之后, 内存布局如下. 按段(Segment)来划分的, 逐个来介绍.
|--------------|
| Kernal Space |
|--------------| 高地址
| | 栈地址 从高到低 向⬇增长
| Stack |
| |
|--------------|
| |
| 待分配内存 |
| |
|--------------|
| | 堆地址 从低到高 向⬆增长
| Heap |
| |
|--------------|
| Data Segment |
|--------------|
| Code Segment |
|--------------| 低地址
栈操作是看懂汇编代码必备的, 因为每个函数几乎都要开辟自己的一片栈空间, 我们也称为 stack frame, 也就是我们常见到的 “栈帧”, 随着函数调用创建, 函数结束调用释放销毁.
Stack frame 主要有两个基础用途, 一个是存储临时变量, 再者是函数调用和传参. 后者会在后面的文章的讲述, 这里我们主要看一下在没有函数调用的情况下栈空间的使用.
随便实现一个 test
函数, 在 main 函数里调用它:
long test() {
long x = 5;
long y = 3;
long z = 4;
return x + y;
}
int main() {
test();
return 0;
}
如图3, 在 GodBolt 里使用 armv8-a clang 11.0.1
编译器 生成汇编代码(这里省略 main 函数):
(图3)
test(): // @test()
// 栈空间是从高地址往低地址分配空间的, 我们看到有 x y z 三个本地临时变量
// 共 3*long = 24bytes, 也就是需要 24 字节的栈空间
// 但是 arm64 有个约定, 分配栈空间的大小须为 16 字节的倍数, 所以这里需申请 32bytes
// sp = stack pointer, 指向栈顶(也是栈空间里可用的最低地址)
// 我们看到这里直接 通过 sp=sp-32 来开辟了 32 字节的空间
// 而且 32 是立即数, 也就是编译器在编译期就已经确定了的.
sub sp, sp, #32 // =32
// 申请之后可用的栈空间是这样的, sp 指向了栈顶:
// | sp + 24| 8 bytes
// | sp + 16| 8 bytes
// | sp + 8 | 8 bytes
// | sp | 8 bytes
// 对应 x=5, 不能直接把 5 放到内存, 需要寄存器中转一下, 先把 5 放入 x8 寄存器
mov x8, #5 // 立即数以#开头, 这里把5放到x8寄存器中
// sp 既然是指针, 也就是地址, 所以支持
// 1. 地址支持加减运算, 2: 存取(store/load) 数据都需要使用 [] 来找到地址所对应的值
// 然后接上面, 把 x8 也就是 5, 放入了 sp + 24 对应的地址里
str x8, [sp, #24]
mov x8, #3 // 同上, 操作y
str x8, [sp, #16]
mov x8, #4 // 同上, 操作z
str x8, [sp, #8]
操作完之后, 栈空间是这样的:
// | sp + 24| 就是 x, 值为 5
// | sp + 16| 就是 y, 值为 3
// | sp + 8 | 就是 z, 值为 4
// | sp | 未使用
// 可见这里入栈顺序和临时变量定义的顺序是一致的
// 操作 x + y
ldr x8, [sp, #24] //把 x 读取到x8
ldr x9, [sp, #16] //把 y 读取到x9
// 现在 x0 = x8+x9, 保存着相加的结果值 8
add x0, x8, x9
// 释放分配的栈空间, 其实就是把 sp + 32, 相当于 sp 指针向上移动了 32 个字节
// 那我们知道栈空间分配的方向是从高地址到低地址, 释放就是相反的方向也容易理解了.
add sp, sp, #32 // =32
// 默认返回 x0, 后文会介绍
ret
main: // @main
...省略
我们总结一下, 其实也很简单, 记住下面几个就够了:
sub sp, #size
, 就是减小 sp 地址的方式分配栈内存, 分配 size 字节.
ps: AArch64 要求每次分配的栈空间 size 必须是 16 bytes 的倍数add sp, #size
, 就是增加 sp 地址的方式释放栈内存, 释放的和开始分配的要一致str x寄存器, [sp, #offset]
的方式 保存 数据到 栈空间ldr x寄存器, [sp, #offset]
的方式 加载栈空间 数据到 寄存器