AArch64 指令集架构
本文介绍AArch64的指令集架构。
A64 指令集架构学习笔记
1. 简单顺序执行
Arm 架构采用简单顺序执行(SSE)模型来描述指令。这意味着处理器会按照指令在内存中出现的顺序,一次读取、解码并执行一条指令。
顺序阶段(In-Order)
在这个阶段,指令严格按照在程序/内存中出现的顺序依次流过:
- Fetch(取指): 从内存或一级指令缓存(L1 I-Cache)中抓取指令。
- Decode, Rename, Dispatch(译码、重命名、分发):
- Decode:解析指令含义。
- Rename:通过“寄存器重命名”解除伪数据依赖(RAW / WAW 冲突),防止不同指令抢占同一个通用寄存器。
- Dispatch:将指令分发到对应的发射队列中。
- Issue(发射): 当指令所需的输入数据就绪时,将其发送到具体的执行单元。
乱序阶段(Out-of-Order)
指令被发射后,不再需要等待前面的慢指令,只要资源和数据就绪就可以并行或乱序执行。图中展示了不同的专用硬件执行单元:
- Branch: 处理分支预测与跳转指令。
- Integer 0 / Integer 1: 处理单周期的基础整数算术/逻辑运算(如加减法、位操作)。
- Integer Multi-Cycle: 处理耗时较长的多周期整数运算(如乘法、除法)。
- FP/ASIMD 0 / FP/ASIMD 1: 处理浮点数运算(Floating-Point)以及 SIMD 向量运算(NEON/SVE 指令)。
- Load / Store: 分别负责从内存加载数据到寄存器,以及将数据写入内存。
举例:
假设有以下4条按顺序排列的指令:
1 | 1. LDR R0, [R1] ; 从内存读取数据到 R0(从内存找数据非常慢,需要等待) |
实际执行的结果可能是:
1 | 时间轴 --------------------------------------------------------> |
最终结果:
- 实际执行完成的顺序可能是:
第 3 条->第 4 条->第 1 条->第 2 条 - 对外的结果: CPU 会在最后重排,确保让程序“感觉”所有指令依然是按
1 -> 2 -> 3 -> 4顺序生效的。
2. AArch64 的寄存器
2.1 通用寄存器
通用寄存器(General-Purpose Registers)
- 数量与位数: 提供 31 个通用寄存器,每个均可作为 64 位使用(命名为
X0~X30)。 - 访问方式(X 与 W):
- 同一个寄存器既可以用作 64 位的 **
X**,也可以访问其低 32 位的 **W**(如W0是X0的低 32 位)。 - 寄存器的名称决定了计算的位宽:
ADD W0, W1, W2执行 32 位加法;ADD X0, X1, X2执行 64 位加法。 - 高位清零机制: 当向
W寄存器写入数据时,对应的 64 位寄存器的高 32 位会被自动清零。
- 同一个寄存器既可以用作 64 位的 **
浮点与向量寄存器(FP & Vector Registers)
- 数量与位数: 拥有独立的一组 32 个 128 位寄存器。
- 标量访问模式: 可通过不同视图读取部分位宽,名称决定计算精度:
B(8-bit) /H(16-bit)S(32-bit 单精度,如FADD S0, S1, S2)D(64-bit 双精度,如FADD D0, D1, D2)Q(128-bit)
- 向量访问模式(V 寄存器/SIMD):
- 当以
V的形式调用时,128 位寄存器被视为包含多个独立数据的向量。 FADD V0.2D, V1.2D, V2.2D:将寄存器切分为 2 个 64 位元素,并行进行向量浮点加法。ADD V0.2D, V1.2D, V2.2D:结构相同,但进行的是向量整数加法。
- 当以
2.2 其他寄存器
零寄存器(XZR / WZR)
在指令执行中,我们经常需要清零变量或丢弃没用的计算结果。
XZR(64位)/WZR(32位): 读它永远返回纯粹的0,写它会被系统直接忽略。- 优势: 靠硬件特性省去了专门的清零指令,代码更高效。
堆栈指针(SP)
SP 在 A64 中被收回了通用寄存器的身份,主要用来做内存加载/存储(Load/Store)的基地址。
- 多栈机制: Armv8-A 硬件为每个异常级别(Exception Level)都配了独立的
SP。 - 安全隔离: 指令用到
SP时,硬件会自动切到当前运行级别的栈指针,从底层防止权限越界。
链接寄存器(X30 / LR)
负责程序跳转时的返回地址管理:
- 普通函数返回: 由
X30(即LR) 负责保存返回地址。 - 系统异常返回: 硬件会改用独立的
ELR_ELx寄存器,避免异常处理打乱正常的函数调用链。
程序计数器(PC)
老 Arm32 或 x86 允许直接用算术指令改写 PC,但 A64 彻底把 PC 封锁了。
- 如何读取: 需要使用相对寻址指令间接获取当前地址(比如
ADR Xd, .,其中.表示当前位置)。 - 设计目的: 剥离通用属性后,大大简化了现代 CPU 的乱序流水线与分支预测逻辑。
2.3 系统寄存器
Arm64 里的系统寄存器是用来管底层硬件配置的(比如控制 MMU 内存管理、异常处理等)。它和普通寄存器最大的不同是不能直接进行加减计算或做 LDR/STR 内存读写。
想要改动系统寄存器,必须走“中转”流程:
MRS Xd, <system_reg>:把系统寄存器的值读到通用寄存器Xd中。MSR <system_reg>, Xn:把通用寄存器Xn修改后的值写回系统寄存器。
另外,系统寄存器名称结尾的 _ELx(如 SCTLR_EL1)标明了访问它所需的最低权限级别。如果你在用户态(EL0)尝试读取内核态(EL1)的寄存器,硬件会直接报异常打断程序。
3. 数据处理
3.1 算术与逻辑运算
基础指令结构
标准的算术(如 ADD)与逻辑(如 AND)指令通常包含 4 部分:
- 操作码(Operation): 决定具体干啥。如果在末尾加
S(如ADDS),代表运算后会更新 CPU 的标志位(标志位用于后面的条件判断)。 - 目标寄存器(Destination): 存放结果。如果是 32 位的
W寄存器,写入后对应X寄存器的高 32 位会自动清零。 - 操作数 1(Operand 1): 必须是寄存器。
- 操作数 2(Operand 2): 可以是寄存器或常数(立即数)。如果是寄存器,还能附带可选的移位操作;如果是常数,因为要直接编进指令里,所以取值范围有限。
特殊情况:单操作数指令
大多数指令需要两个输入,但有些只需要一个输入操作数(寄存器或常数):
MOV(赋值): 把常数或另一个寄存器的值传给目标寄存器(如MOV X0, #1)。MVN(按位取反): 把输入值按位取反后传给目标寄存器(如MVN W0, W1,相当于 $W0 = ~ W1$)。
3.2 浮点数
语法结构一致: 浮点指令的格式与前面的整数指令非常相似,只不过操作的是专门的浮点寄存器。
标志性前缀 F: 浮点指令的操作码全部以字母 F 开头(如 FADD 加法、FSUB 减法、FDIV 除法)。
寄存器决定精度: 使用什么前缀的寄存器,就代表什么精度的运算:
H(16位): 半精度,例如FDIV H0, H1, H2S(32位): 单精度,例如FADD S0, S1, S2D(64位): 双精度,例如FSUB D0, D1, D2
8位和16位浮点数支持
核心差异就在于指数位(Exponent,用 E 表示)和尾数/小数位(Mantissa,用 M 表示)的分配不同。
16 位浮点:FP16 与 BF16
- FP16:从 Armv8.2-A 开始引入,主要用于兼顾精度和精简内存。如果芯片实现了 SVE/SVE2,或者升级到了 Armv9-A,FP16 就变成了强制支持的标准。
- BF16 (BFloat16):专为神经网络/AI 训练设计。它的特点是指数位范围与 FP32 完全一致,只是截短了尾数精度,因此和 FP32 之间的相互转换极其简单高效。
8 位浮点:FP8 (OCP8 标准)
- 由 Arm、Intel 和 Nvidia 联合制定,从 Armv9.2-A 开始支持。
- 包含 E5M2(侧重动态范围)和 E4M3(侧重数值精度)两种模式,可通过
FPMR寄存器灵活配置,方便不同输入的计算。
更小数据类型的转换 (2/4/6 位)
- Armv9.6-A / 9.7-A 引入了查找表指令(LUT),专门用来高效地把 2 位、4 位或 6 位这类超小数据格式快速转换为标准格式进行处理。
浮点数是可选的吗?
在 Armv8-A 架构中,浮点运算支持是强制要求的(Mandatory),尤其是当运行 Linux 这类功能丰富的操作系统时,硬件层面必须具备浮点支持。
只有在极少数运行完全自研的专有软件栈时,理论上才允许省略浮点单元。不过像 GCC、Arm Compiler 6 等主流编译器工具链,默认都会假设 CPU 拥有浮点功能并据此生成代码。
3.3 位操作
位域插入(BFI): 将源寄存器中指定长度的一段二进制位,精准“塞入”到目标寄存器的指定位置。利用 BFI 结合零寄存器 WZR,还能快速清空(置 0)某一段特定的二进制位。
无符号位域提取(UBFX): 从源寄存器的某位置抠出一段指定长度的二进制位,右对齐放到目标寄存器(低位),高位自动补 0。
字节/位顺序反转(如 REV16、RBIT): 专门用来倒序排列字节或二进制位,在处理大小端(Endianness)数据格式转换时非常实用。
3.4 扩展和饱和
在第一条指令中SXTB,B表示字节。它取字节的最低字节W0,并将其符号扩展至 32 位。
UXTH是半字()的无符号扩展H。它取 W1 的低 16 位,并将其扩展为 32 位。
前两个示例的目标地址是W寄存器,这意味着扩展位为 32 位。第三个示例的目标地址是X寄存器,这意味着符号位扩展位为 64 位。
Arm64 如何处理 8 位/16 位小整数计算
由于硬件没有专门的 8/16 位通用寄存器,处理 uint16_t 或 int16_t 计算时,CPU 必须先将数据扩展到 32 位,再配合带扩展后缀的计算指令将结果饱和截断回 16 位范围。这就导致 16 位运算相比原生的 32 位运算会多消耗一条扩展指令。
例 1:原生的 32 位加法(add32)
当 C 语言处理 32 位数据 uint32_t a + b 时,硬件可以直接映射到 W 寄存器完成,只需一条指令:
1 | add32: |
例 2:16 位无符号加法(uadd16)
处理 uint16_t a + b 时,为了清空高位杂音,CPU 先用 AND 把 W1 截取为 16 位,再在 ADD 中加上 , UXTH 后缀,边加边对另一个参数做 16 位无符号扩展和截断:
1 | uadd16: |
例 3:16 位有符号加法(sadd16)
处理 int16_t a + b 时,先用 SXTH 指令保留 W1 的符号位扩展成 32 位,再用带 , SXTH 后缀的加法完成计算并做饱和处理(结果限制在 16 位有符号范围内,防止越界错乱):
1 | sadd16: |
3.5 格式转换
位模式直接复制:FMOV
机制:仅仅将通用寄存器里的二进制位(0 和 1)原封不动地搬运到浮点寄存器中,不改变数值的底层解释方式。
例子:
如果通用寄存器
X0中保存整数2(对应十六进制0x0000_0000_0000_0002):1
FMOV D0, X0
执行后,
D0里的二进制位依然是0x0000_0000_0000_0002。但如果把它当作双精度浮点数解读,其数值会变成极小的极非规范数 9.88131 * 10^-324(并不是浮点数 2.0)。
数值等价转换:SCVTF / UCVTF
机制:将整数转换为数值上最接近的浮点数表示(有符号/无符号数值转换)。
例子:
同样是把
X0(整数2)转存到浮点寄存器:1
SCVTF D1, X0 ; S = Signed, CVT = Convert, F = Float
执行后,
D1的实际十六进制编码会被重构为0x4000_0000_0000_0002,解读为浮点数正是 **$2.0$**。
浮点转整数:FCVTxx
机制:将浮点数转换为最接近的整数格式。指令后缀
xx用来控制不同的舍入模式(Rounding Mode)(例如向零舍入、向最近值舍入等)。例子:
1
FCVTNS X0, D0 ; 将 D0 中的浮点数按“四舍五入(Round to Nearest)”转换为有符号整数
3.6 向量和矩阵数据
A64 架构也支持矢量数据处理。可用的两种矢量处理方式是:
- 高级 SIMD,也称为 NEON。
- 可扩展矢量扩展(SVE 和 SVE2)。SVE 在 Armv8-A 中引入,并针对高性能计算 (HPC) 工作负载进行了优化。Armv9-A 引入了 SVE2,它扩展了基础 SVE,以支持更多应用场景。
高级 SIMD(NEON)
NEON 是 Arm 的传统单指令多数据(SIMD)扩展技术,支持 128 位向量操作,广泛应用于多媒体编解码和常规图形信号处理。
可扩展向量扩展(SVE & SVE2)
- SVE(Armv8-A): 突破了固定向量长度的限制,支持硬件自适应的可变长度向量计算,主要面向高性能计算(HPC)领域。
- SVE2(Armv9-A): 在 SVE 的基础上进行了大幅扩充,将向量计算能力推广到更广泛的场景,如 GEMV(矩阵向量乘法)、非线性求解器、稀疏矩阵计算及特征提取等。
可扩展矩阵扩展(SME & SME2)
- SME(Armv9-A): 基于 SVE2 专门为矩阵运算设计的可选扩展。它引入了矩阵图块存储(Matrix Tile Storage)和外积(Outer Product)指令,大大提升了矩阵乘法等密集运算的效率。
- SME2: 进一步强化了矩阵计算能力,新增多向量并行指令、多向量谓词、范围预取以及 2b/4b 权重压缩技术,专门用于极大加速 AI 模型推理与深度学习工作负载。
💡 总结: 从 NEON 的固定长度向量,到 SVE 的可扩展向量,再到 SME 的专用矩阵加速,Arm64 逐步构建起了从常规多媒体处理到现代 AI/ML 运算的全方位并行计算生态。
4. Loads and stores
4.1 数据size
标准尺寸传输(32 位 / 64 位)
通过寄存器名称直接指定读写 32 位还是 64 位:
- 32 位加载:
LDR W0, [<address>](将 32 位数据读取到W0) - 64 位加载:
LDR X0, [<address>](将 64 位数据读取到X0)
子寄存器尺寸传输(低于 32 位的数据)
当需要写低于完整寄存器宽度的数据(如单字节 8 位、半字 16 位)到内存时,使用带后缀的 STR 指令,只截取寄存器低位的数据写入:
- 写入 1 字节(8 位):
STRB W0, [<address>](仅写入W0的最低字节 B) - 写入半字(16 位):
STRH W0, [<address>](仅写入W0的低 16 位 H) - 写入单字(32 位):
STRW X0, [<address>](仅写入X0的低 32 位 W)
4.2 零扩展和符号扩展
默认零扩展
1 | LDRB W4, [<addr>] ; 将 1 字节数据读取到 W4 的低 8 位,W4 的高 24 位以及 X4 的高 32 位全部清零(Zero-extend) |
显示控制:符号扩展
如果在加载指令中加上 S 后缀(如 LDRSB、LDRSH、LDRSW),硬件会提取数据的符号位(最高位),并将其一路填充覆盖至整个目标寄存器的最高位,用来保持有符号数的正负值不变。
1 | LDRSB W0, [<addr>] ; 将 1 字节有符号数读入,并用其符号位填充扩展至整个 W0(32 位) |
4.3 寻址
基址寄存器寻址(Base Register)
- 语法:
[X1] - 机制:直接使用寄存器中保存的完整虚拟地址。
- 示例:
LDR W0, [X1]-> 从X1指向的地址读取数据到W0。
偏移寻址(Offset Addressing)
- 语法:
[X1, #12] - 机制:在基址上加上一个固定的常数(或另一个寄存器的值)作为偏移量,用于访问结构体成员或数组元素。基址寄存器
X1的值保持不变。 - 示例:
LDR W0, [X1, #12]-> 从X1 + 12的地址读取数据。
预索引寻址(Pre-index Addressing)
- 语法:
[X1, #12]!(注意末尾有感叹号!) - 机制:先计算新地址
X1 + 12并访问内存,**访问完成后自动将X1更新为新地址X1 + 12**。 - 示例:
LDR W0, [X1, #12]!-> 先读X1 + 12,然后X1 = X1 + 12。
后索引寻址(Post-index Addressing)
- 语法:
[X1], #12(偏移量放在方括号外面) - 机制:先使用
X1当前的旧地址访问内存,访问完成后再将X1更新为X1 + 12。常用于出栈操作或循环遍历数组。 - 示例:
LDR W0, [X1], #12-> 先读X1,然后X1 = X1 + 12。
4.4 加载对和存储对
LDP(Load Pair)与 STP(Store Pair)是Arm64 中用于成对传输数据指令。它们可以在一条指令内同时完成两个寄存器的数据加载或存储,是函数调用、栈操作(Push/Pop)以及内存拷贝等场景下的性能利器。
指令基本语法与语义
LDP 和 STP 按从左到右的操作数顺序,依次与连续的内存地址进行交互:
- **
LDP W3, W7, [X0]**:从X0处读取 32 位存入W3,紧接着从X0 + 4处读取 32 位存入W7。 - **
STP D0, D1, [X4]**:把双精度浮点寄存器D0写入X4,并将D1写入X4 + 8。
这两条指令配合预/后索引(Pre/Post-index),能够非常高效地实现压栈和出栈:
1 | STP X0, X1, [SP, #-16]! ; 压栈:SP 先减 16,再将 X0, X1 写入栈顶 |
(注:在 AArch64 中,栈指针 SP 必须保持 128 位对齐)
内存对齐、原子性与访问顺序
硬件在处理成对读写时,实际上会拆分为两次独立的内存访问:
- 对齐要求:按“单次访问的大小”而非“总传输大小”对齐。例如
LDP W3, W7, [X0]传输总计 8 字节,但只需要 4 字节(32 位)对齐即可。 - 原子性:只要目标地址已对齐,每次单独的访问都是单拷贝原子的(Single-copy Atomic);若硬件支持
FEAT_LSE2扩展,特定场景下整个成对操作可实现完全的单拷贝原子性。 - 访问顺序:架构不保证两个寄存器写入/读取内存的先后顺序。如果业务场景对内存访问的顺序有严格要求,则不建议使用成对指令。
严禁在 MMIO 中使用 LDP/STP
在编写底层驱动或访问硬件外设(MMIO 寄存器)时,编译器生成或手动编写 LDP/STP 往往会引发隐蔽的 Bug:
- 顺序不可控:对外设寄存器的读写顺序通常十分敏感,而 Pair 指令的内部访问顺序是未定义的。
- 访问宽度不匹配:很多硬件外设(如 GICv3 中断控制器)仅支持严格的 32 位对齐访问,用
LDP读取两个连续外设寄存器可能会被拆分或合并成硬件不支持的传输形式。 - 故障定位困难:如果
LDP/STP在访问 MMIO 时触发 Exception 异常,系统提供的 Syndrome 信息可能无法可靠地重建到底是“前半部分”还是“后半部分”指令引发的故障。
4.5 使用浮点寄存器
在现代高性能计算与系统开发中,浮点寄存器(Floating-point/SIMD Registers)不仅能用来算浮点数,更是批量搬运数据的利器。
使用浮点寄存器进行内存读写
与通用寄存器(X/W)类似,Arm64 允许直接使用 LDR、STR 以及 LDP 指令配合浮点寄存器(如 D 寄存器为 64 位,Q 寄存器为 128 位)来搬运内存数据:
1 | LDR D1, [X0] ; 从 [X0] 加载 64 位数据到 D1 寄存器 |
使用浮点寄存器寻址的限制规则
在使用浮点寄存器进行读写时,需要注意以下三点硬件特性的约束:
- 尺寸由寄存器类型硬性决定:数据的传输宽度完全取决于指令中指定的寄存器前缀(如
S代表 32 位,D代表 64 位,Q代表 128 位),无法通过指令后缀额外调整。 - 不支持符号扩展:无法像通用寄存器那样使用
LDRSB或LDRSH自动进行符号填充扩展。 - 寻址基址仍须是通用寄存器:计算内存地址的基址寄存器必须依然是
X寄存器,不能使用浮点寄存器来保存内存地址。
为什么纯整数代码里也有浮点读写?
在阅读底层标准库(如 memcpy()、memset() 或 strcpy())的汇编代码时,经常会看到 Q 寄存器的读写身影。
这并不是因为代码在处理浮点计算,而是因为浮点寄存器拥有 128 位的超大容量(相比通用寄存器的 64 位直接翻倍)。借助 LDP Q0, Q1, [...] 这类成对加载指令,单条指令就能单次吞吐 256 位数据。这大幅减少了循环迭代次数与指令开销,能够极大提升纯数据搬运与内存拷贝时的硬件吞吐效率。
4.6 特殊指令
Load-Acquire and Store-Release
Load-Acquire (LDAR) 和 Store-Release (STLR) 是 Arm64 架构中用于处理多线程并发与内存顺序的核心指令。它们相当于带有“隐式内存屏障(Memory Barrier)”的读写操作。
在 Arm 这种弱内存模型(Weak Memory Model)架构中,CPU 为了优化性能可能会乱序执行指令。这两个指令就像单向的“内存屏障关卡”,强制规范了多核/多线程之间的内存可见性顺序:
Load-Acquire(获取:LDAR)
- 方向:从内存读取数据(Load)。
- 作用:单向防止指令“向上”重排序。
- 规则:保证在
LDAR之后的所有内存读写指令,绝对不能被 CPU 提前重排序到LDAR之前执行。只有当LDAR成功从内存拿到数据后,后面的读写指令才能开始。
Store-Release(释放:STLR)
- 方向:向内存写入数据(Store)。
- 作用:单向防止指令“向下”重排序。
- 规则:保证在
STLR之前的所有内存读写指令,必须全部执行完毕并对其他 CPU 核可见后,STLR自身的写入操作才能生效。
把这两个指令想象成一个临界区(加锁/解锁)的过程:
- 进入临界区(获取锁):使用
LDAR读锁状态。它确保你拿锁之后才能去修改临界区里的数据(防止数据读取跑到了加锁之前)。 - 退出临界区(释放锁):使用
STLR写锁状态。它确保你在临界区里对数据的修改全部完成后,才正式把锁释放出去(防止释放锁的操作跑到了修改数据之前)。
64 字节原子加载和存储
在企业级系统中,为了快速向硬件加速器的队列中添加任务,需要实现极低延迟的硬件通信机制。
针对这种场景,Armv8.7-A 与 Armv9.2-A 架构引入了专门的 64 字节原子加载与存储指令:
- 原子加载:
LD64B指令,用于原子读取 64 字节数据。 - 原子存储:
ST64Bx系列指令,用于将 64 字节数据原子压入硬件队列,并在需要时返回入队(Enqueue)结果状态。
这张架构图核心展示了 Arm CPU 如何通过一条指令完成向加速器(Accelerator)的任务推送与结果确认:
- 下发任务(Enqueue):CPU 使用
ST64BV...指令,将 64 字节 的任务数据通过系统总线(Interconnect)和 PCIe 直接原子写入加速器的硬件队列(Work Item Queue)。 - 确认结果(Check Success):写入完成后,硬件会立即将“是否成功入队”的状态反向返回给 CPU。
以前需要多次读写与确认的操作,现在用一条
ST64BV指令就能实现“推任务入队 + 拿成功状态”的低延迟硬件级交互。
A64 内存操作优化指令(memcpy / memset)
memcpy() 和 memset() 系列函数在各类计算负载中应用极其广泛,其执行效率对系统性能至关重要。
为了在不同微架构的处理器上均能提供标准且高效的实现,A64 指令集专门引入了 CPYx 和 SETx 系列指令,用于在硬件层面直接优化内存拷贝与内存填充操作。
5 程序流程
改变指令执行顺序的一种方法是使用分支指令。分支指令可以改变程序流程,常用于循环、决策和函数调用。
5.1 循环和决策
无条件分支指令 (Unconditional Branch Instructions)
A64 指令集中的无条件分支指令主要分为 直接分支 和 间接分支 两类:
B <label>(Branch)- 机制:直接、相对于程序计数器(PC-relative)的跳转。
- 特点:跳转的目标偏移量(Offset)直接编码在指令内部。
- 范围限制:由于指令编码长度有限,跳转范围限制在 ±128MB 以内。
BR <Xn>(Branch with Register)- 机制:间接、绝对地址(Absolute)跳转。
- 特点:跳转的目标地址存放在 64 位通用寄存器
Xn中,突破了指令内编码偏移量的范围限制。
条件分支指令
为你整理了当前页面(Conditional branch instructions)的知识点。这一节的核心是 条件分支指令(Conditional Branch Instructions),可以直接作为你博客中该模块的笔记小节:
条件分支指令 (Conditional Branch Instructions)
条件分支用于根据特定条件改变程序的执行流程。与无条件分支不同,条件分支指令的跳转范围通常较小,因为指令编码中需要占用一部分比特位来存储条件码。
1. 核心指令类型
B.<cond> <label>(基于条件码跳转)- 机制:通过检查
PSTATE中的 ALU 标志位(通常由上条指令如CMP生成)来判断是否满足<cond>。 - 跳转范围:±1MB(由于条件码占用了部分编码位,范围小于无条件分支
B的 ±128MB)。
- 机制:通过检查
CBZ/CBNZ(基于零值比较跳转)- 机制:直接测试寄存器的值,无需预先生成 ALU 标志位。
CBZ <Xn>, <label>:若寄存器Xn == 0,则跳转。CBNZ <Xn>, <label>:若寄存器Xn != 0,则跳转。
- 机制:直接测试寄存器的值,无需预先生成 ALU 标志位。
TBZ/TBNZ(基于特定位测试跳转)- 机制:测试寄存器中指定的单比特(bit),无需检查整个寄存器的值。
TBZ <Xn>, #<imm>, <label>:若Xn的第#imm位为0,则跳转。TBNZ <Xn>, #<imm>, <label>:若Xn的第#imm位为1,则跳转。
- 机制:测试寄存器中指定的单比特(bit),无需检查整个寄存器的值。
2. C 语言控制流映射示例
if条件判断映射1
if (a == 5) b = 5;
汇编实现:
1
2
3
4CMP W0, #5 ; 比较 a (W0) 是否等于 5
B.NE skip ; 不等于则跳转跳过赋值
MOV W8, #5 ; b = 5
skip:while循环映射1
2
3
4while (a != 0) {
b = b + c;
a = a - 1;
}汇编实现:
1
2
3
4
5
6loop:
CBZ W8, skip ; 若 a (W8) == 0 则跳出循环
ADD W9, W9, W10 ; b = b + c
SUB W8, W8, #1 ; a = a - 1
B loop ; 跳回循环开头
skip:
5.2 条件码的生成与标志位
在 A64 架构中,条件分支指令(如 B.EQ)依赖于 PSTATE 寄存器中的 ALU 标志位 来判断是否跳转。
标志位更新控制:S 后缀
与许多传统架构自动更新标志位不同,A64 允许软件精确控制是否更新标志位:
- 无
S后缀(如ADD X0, X1, X2):仅执行计算并写入目标寄存器,不更新 ALU 标志位。 - 带
S后缀(如ADDS/SUBS):执行计算的同时,更新 ALU 标志位。
逻辑隔离:这种设计允许在产生标志位的指令(如
SUBS)和使用该标志位的条件跳转指令(如B.EQ)之间插入其他普通计算指令(如AND),且不会干扰跳转条件的判定。
四大 ALU 标志位 (ALU Flags)
标志位反映了最近一次带 S 指令的运算结果状态:
- **
N(Negative)**:结果为负数。 - **
Z(Zero)**:结果为 0(例如:结果为 0 时Z = 1,否则Z = 0)。 - **
C(Carry)**:无符号运算产生进位。 - **
V(Overflow)**:有符号运算产生溢出。
条件码映射 (Condition Codes)
条件码是用于检查标志位的组合逻辑,通常成对出现:
- **
EQ(Equal)**:检查Z == 1(运算结果为 0,即两数相等)。 - **
NE(Not Equal)**:检查Z == 0(运算结果不为 0,即两数不等)。
纯测试/比较指令(指令别名)
除了普通的算术指令加上 S 之外,A64 还提供了专用于测试条件但不保存运算结果的指令。它们本质上是将目标寄存器设为零寄存器 (XZR/WZR) 的别名指令:
- **
CMP X0, X7**:比较两数。- 等价于:
SUBS XZR, X0, X7(计算 $X0 - X7$,丢弃结果,仅更新标志位)。
- 等价于:
- **
TST W5, #1**:测试位状态。- 等价于:
ANDS WZR, W5, #1(执行按位与,丢弃结果,仅根据测试位更新标志位)。
- 等价于:
5.3 条件选择指令 (Conditional Select Instructions)
在 A64 指令集中,条件选择指令用于替代简单的分支指令(B.cond),通过无分支的算术逻辑运算来实现 if-else 的选择逻辑。
(1) 基础指令:CSEL
语法:
CSEL Xd, Xn, Xm, cond逻辑:
if cond is true then Xd = Xn else Xd = Xm$$
示例:
CMP W1, #0+CSEL W5, W6, W7, EQ相当于 C 语言中的:
W5 = (W1 == 0) ? W6 : W7;
(2) 组合复合指令与别名 (Variants & Aliases)
A64 允许将条件选择与其他操作(如自增、取反)合并为一条指令:
- **
CSINC(Conditional Select Increment)**:- 语法:
CSINC Xd, Xn, Xm, cond - 逻辑:若
cond为真则 $Xd = Xn$,否则 $Xd = Xm + 1$。
- 语法:
- **
CINC(Conditional Increment)**:- 机制:
CSINC的别名指令,用于条件自增。 - 逻辑区别(注意与
CSINC反转):CSINC X0, X0, X0, cond:若条件为假,X0才自增。CINC X0, X0, cond:若条件为真,X0才自增。
- 机制:
(3) 代码实现对比 (Branching vs Conditional Select)
编译器在遇到简单的 if-else 逻辑时,通常会自动优化为条件选择:
| 逻辑需求 (C) | 传统分支实现 (Branching) | 条件选择实现 (Conditional Select) |
|---|---|---|
if (a != 0) b++; |
CMP W0, #0 B.EQ skip ADD W1, W1, #1 skip: |
CMP W0, #0 CINC W1, W1, NE |
if (a == 0) y++; else y--; |
CMP W0, #0 B.NE else ADD W1, W1, #1 B end else: SUB W1, W1, #1 end: |
CMP W0, #0 SUB W2, W1, #1 CSINC W1, W2, W1, NE |
6 函数调用
6.1 函数调用与返回
调用:
BL/BLR- 执行跳转前,CPU 会自动将返回地址写入链接寄存器 **
LR(X30)**。
返回:
RET- 间接跳转回
LR保存的地址。
为何不用
BR LR替代RET?- 逻辑功能完全等效,但
RET带有“函数返回”的硬件语义,能配合 CPU 的分支预测器(Branch Predictor)预加载指令,避免流水线停顿(Pipeline Flush)。
- 执行跳转前,CPU 会自动将返回地址写入链接寄存器 **
6.2 程序调用标准
为了让不同开发者编写的代码或编译器生成的代码能够顺畅交互,Arm 架构定义了一套通用寄存器的使用与保护规则,即 **PCS (Procedure Call Standard)**。
(1) 参数传递与返回值规范
- 通用参数传递:前 8 个参数按顺序使用寄存器
X0-X7(如果是 32 位类型则使用对应W0-W7)传递。超出 8 个的额外参数通过栈(Stack)传递。 - 返回值:函数返回值同样通过
X0(或W0)返回。 - 特殊语言映射:在 C++ 中,
X0默认用于隐式传递指向当前对象的this指针。
(2) 寄存器分类与保存规则
PCS 将寄存器明确分为两大类,区分在函数调用时由谁负责保存与恢复:
- 易损寄存器 / 调用者保存寄存器 (Caller-saved / Corruptible)
- 范围:
X0-X15(其中X0-X7兼做参数/返回值寄存器)。 - 规则:被调用函数(Callee)可以直接覆盖使用这些寄存器,无需事先保存或事后恢复。如果调用者(Caller)后续还需要使用这些值,必须在调用前自行存栈。
- 范围:
- 被调用者保存寄存器 (Callee-saved / Non-corruptible)
- 范围:
X19-X28。 - 规则:被调用函数(Callee)若想使用这些寄存器,必须在入口处将其压栈保存,并在函数返回前从栈中恢复,保证调用者感知不到它们的变化。
- 范围:
| X0-X7 | X8-X15 | X16-X23 | X24-X30 |
|---|---|---|---|
Parameter and Result Registers (X0-X7) |
XR (X8) |
IP0 (X16) |
Callee-saved Registers (X24-X28) |
| - | Corruptible Registers (X9-X15) |
IP1 (X17) |
FP (X29) |
| - | - | PR (X18) |
LR (X30) |
| - | - | Callee-saved Registers (X19-X23) |
- |
(3) 具备特殊用途的寄存器
- XR (X8) — 间接结果指针寄存器 (Indirect Result Register)
- 当函数返回大型数据结构(如
struct)时,调用者会预先分配内存空间,并将该内存的起始地址通过X8传递给子程序。
- 当函数返回大型数据结构(如
- IP0 / IP1 (X16 / X17) — 过程内调用临时寄存器
- 专供链接器(Linker)插入代码片段(Veneer)使用。例如当
B或BL指令的跳转范围超过限制时,链接器会借助IP0/IP1自动生成长跳转代码片段。 - 平时写代码一般不用这两个寄存器,主要就是给 veneer 代码用
- 专供链接器(Linker)插入代码片段(Veneer)使用。例如当
- **LR (X30)**:链接寄存器(Link Register),保存函数返回地址。
- **FP (X29)**:帧指针(Frame Pointer)。
当程序崩溃需要看“调用栈(Backtrace)”时,CPU 就是顺着 FP 像顺藤摸瓜一样,一层层找回上一级函数是谁调用的。
FP 永远指向当前函数栈帧(Stack Frame)的底部/基准点(高地址)。
1 | func: |
1 | 高地址 (High Address) |
(4) 其他状态与浮点寄存器规范
- ALU 标志位:
PSTATE中的 ALU 标志位(N, Z, C, V)无需在函数调用前后保留,子程序可以随意改变它们。 - **浮点/向量寄存器 (VFP/SIMD)**:
D0-D7:用于传递浮点参数与返回值(易损寄存器)。D8-D15:被调用者保存寄存器(Callee-saved)。D16-D31:易损寄存器。
7 系统调用
系统调用是低特权级软件(如用户态应用)向高特权级实体(如操作系统、Hypervisor)请求服务或功能的受控机制。在 A64 架构中,系统调用通过触发异常(Exception)来实现跨特权级跳转。
核心系统调用指令
- **
SVC(Supervisor Call)**:- 目标层级:EL1 (OS Kernel)。
- 场景:普通应用程序(EL0)向操作系统内核请求服务(如打开文件、分配内存)。
- **
HVC(Hypervisor Call)**:- 目标层级:EL2 (Hypervisor)。
- 场景:操作系统(EL1)向虚拟化管理层请求服务。(EL0 无法直接调用)
- **
SMC(Secure Monitor Call)**:- 目标层级:EL3 (Firmware / Secure Monitor)。
- 场景:OS 或 Hypervisor 向安全固件请求服务。(EL0 无法直接调用)
特权级不降低原则 (No Privilege Loss)
- 基本规则:异常调用绝不允许导致 CPU 降低特权级别。
- 边界行为:如果从高于或等于目标异常级别的状态下执行系统调用指令(例如在 EL2 执行
SVC),异常会在当前 Exception Level 触发并处理(即在 EL2 处理该异常),而不会降级到 EL1。