A64 指令集基础:从编码到实战
各位好,欢迎来到第二章。这一章我们啃的是 A64 指令集的基础。说实话,指令集这东西,很多做应用开发的同事觉得离自己很远。但做到底层优化、性能调优,你绕不开它。我个人的习惯是,不要求你把每条指令的二进制编码背下来,但核心的编码格式、几类常用指令的套路,必须烂熟于心。
这一章我们聚焦四个核心模块:指令编码格式、数据处理指令(算术、逻辑、移位)、内存访问指令(LDR/STR、LDP/STP)、分支指令(B/BL/RET)。我会结合项目里踩过的坑来讲,希望能帮你少走弯路。
核心观点:理解 A64 指令的固定编码长度(32 位)和规整的字段布局,是后续反汇编分析、性能 profiling 的基石。你不需要手写机器码,但必须能看懂反汇编输出。
2.1 指令编码格式:32 位的艺术
A64 指令集最大的特点就是——所有指令都是 32 位固定长度。这和 x86 那种变长指令完全不同。固定长度意味着什么?意味着取指、解码的硬件逻辑可以做得非常简单、非常快。我在做一款网络处理器的时候,就深刻体会到了这一点:流水线深度固定,分支预测的代价可预测,这对实时系统太重要了。
指令编码主要分为三大类:
- R 型(寄存器型):操作数全部来自寄存器,比如 ADD X0, X1, X2。
- I 型(立即数型):一个操作数是立即数,比如 ADDI X0, X1, #42。
- D 型(内存型):用于加载/存储,比如 LDR X0, [X1, #8]。
你想想看,这 32 位里要塞下 opcode、目标寄存器、源寄存器、立即数、移位量……是不是很挤?没错,所以 ARM64 对立即数的编码做了很多精巧的设计,比如立即数不是直接存 16 位,而是用一种“编码+移位”的方式,能表示的范围更大。这个细节我们后面用到的时候再展开。
我的小技巧:反汇编时,看到一条指令的机器码,可以快速看高 8 位([31:24])判断指令类别。比如 0x8B 开头通常是 ADD 类,0xF8 开头通常是 LDR 类。这个习惯能帮你快速定位代码中的热点指令。
2.2 数据处理指令:算术、逻辑、移位
数据处理指令是 CPU 的“算盘”。A64 在这块做得非常规整:几乎所有算术逻辑指令都支持三种操作数形式——寄存器、立即数、移位后的寄存器。
2.2.1 算术指令
最常用的就是 ADD、SUB、ADDS、SUBS。带 S 后缀的会更新条件标志位(NZCV),不带 S 的不更新。这个区别很重要,我见过不少同事在条件判断前忘了加 S,导致标志位没更新,分支跳错了地方。
// 基本算术
ADD X0, X1, X2 // X0 = X1 + X2
SUB X0, X1, X2 // X0 = X1 - X2
ADDS X0, X1, X2 // X0 = X1 + X2,并更新标志位
SUBS X0, X1, X2 // X0 = X1 - X2,并更新标志位
// 带立即数
ADDI X0, X1, #255 // X0 = X1 + 255
SUBI X0, X1, #16 // X0 = X1 - 16
// 带移位(免费的性能优化)
ADD X0, X1, X2, LSL #2 // X0 = X1 + (X2 << 2)
注意最后一条:ADD X0, X1, X2, LSL #2。这个操作把移位和加法合并成一条指令,不额外消耗时钟周期。我在做矩阵运算时经常用这个技巧,能省下不少指令。
2.2.2 逻辑指令
AND、ORR、EOR、BIC 是四大金刚。BIC(位清除)是个好东西,相当于 AND 加 NOT,一条指令搞定掩码操作。
AND X0, X1, X2 // X0 = X1 & X2
ORR X0, X1, X2 // X0 = X1 | X2
EOR X0, X1, X2 // X0 = X1 ^ X2
BIC X0, X1, X2 // X0 = X1 & ~X2
我曾经踩过的坑:有一次做位图操作,我想清除某几位,用了 AND 加 NOT 两条指令。后来 review 时同事指出,BIC 一条就够了。别小看这一条指令,在循环里跑几千次,差距就出来了。所以,能用 BIC 就别用 AND+NOT。
2.2.3 移位指令
移位操作在 A64 里通常不是独立指令,而是作为其他指令的“修饰符”存在。比如 LSL(逻辑左移)、LSR(逻辑右移)、ASR(算术右移)。但也有一些独立的移位指令,比如 LSLV(可变移位)。
| 指令 | 含义 | 典型用途 |
|---|---|---|
| LSL #n | 逻辑左移 n 位 | 乘以 2^n |
| LSR #n | 逻辑右移 n 位 | 无符号除以 2^n |
| ASR #n | 算术右移 n 位 | 有符号除以 2^n |
| ROR #n | 循环右移 n 位 | 位旋转、加密算法 |
这里有个细节:算术右移(ASR)会保留符号位,逻辑右移(LSR)不会。处理有符号数时一定要用 ASR,否则负数右移会变成正数,结果完全不对。
2.3 内存访问指令:LDR/STR 与 LDP/STP
内存访问是性能瓶颈的重灾区。A64 提供了非常丰富的内存访问指令,但最核心的就是 LDR/STR(单寄存器)和 LDP/STP(双寄存器)。
2.3.1 LDR/STR:单寄存器加载/存储
LDR 和 STR 支持多种寻址模式:基址寻址、前变址、后变址、字偏移等。我个人最常用的是基址加偏移和前变址。
// 基址加偏移(最常用)
LDR X0, [X1, #8] // X0 = *(X1 + 8)
STR X0, [X1, #8] // *(X1 + 8) = X0
// 前变址(先更新基址,再访问)
LDR X0, [X1, #8]! // X1 = X1 + 8; X0 = *X1
STR X0, [X1, #8]! // X1 = X1 + 8; *X1 = X0
// 后变址(先访问,再更新基址)
LDR X0, [X1], #8 // X0 = *X1; X1 = X1 + 8
STR X0, [X1], #8 // *X1 = X0; X1 = X1 + 8
前变址和后变址在栈操作、遍历数组时特别有用。比如函数 prologue 里常用 STP X29, X30, [SP, #-16]!,这就是前变址的典型用法——先减 SP,再存寄存器对。
2.3.2 LDP/STP:双寄存器加载/存储
LDP 和 STP 是 A64 的一大亮点。一条指令可以加载或存储两个 64 位寄存器,而且保证原子性(相对于两个单独的 LDR)。这对栈帧操作、结构体拷贝、寄存器现场保存/恢复来说,简直是神器。
// 保存寄存器对
STP X29, X30, [SP, #-16]! // 先减 SP,再存 X29 和 X30
// 恢复寄存器对
LDP X29, X30, [SP], #16 // 先加载 X29 和 X30,再加 SP
// 结构体拷贝(假设结构体 16 字节对齐)
LDP X0, X1, [X2] // 从 X2 地址加载 16 字节到 X0、X1
STP X0, X1, [X3] // 存储到 X3 地址
性能要点:LDP/STP 在硬件上通常会被实现为双发射,即一个时钟周期内完成两个寄存器的访问。而两个单独的 LDR 可能需要两个周期。所以,能用 LDP/STP 的地方,绝对不要拆成两个 LDR/STR。
我记得有一次优化一个网络包处理函数,里面有个结构体拷贝用了两个 LDR 加两个 STR。改成 LDP/STP 后,那个函数的延迟直接降了 30%。嗯,这就是指令选择带来的质变。
2.4 分支指令:B/BL/RET
分支指令控制程序的执行流。A64 的分支指令设计得非常简洁,主要就三种:无条件分支(B)、带链接的分支(BL)、返回(RET)。
2.4.1 B:无条件分支
B 指令就是直接跳转,不保存返回地址。常用于循环、条件跳转(配合条件标志)。
B label // 直接跳转到 label
B.GT label // 如果大于(GT),则跳转
B.EQ label // 如果等于(EQ),则跳转
条件后缀(GT、EQ、LT 等)依赖于之前算术指令设置的 NZCV 标志位。所以,条件分支前一定要确保标志位是正确的。
2.4.2 BL:带链接的分支
BL 是函数调用的核心指令。它会把下一条指令的地址(返回地址)保存到 X30(LR 寄存器),然后跳转到目标地址。
BL my_function // 调用函数,返回地址存入 X30
这里有个常见的坑:嵌套调用时一定要保存 X30。如果你在函数 A 里调用函数 B,而 A 没有保存 X30,那么 BL B 会覆盖 X30,导致 A 无法正确返回。正确的做法是在 prologue 里用 STP 保存 X29 和 X30。
我曾经踩过的坑:有一次写递归函数,忘了保存 X30,结果递归深度一深,返回地址全乱了。调试了半天才发现是 X30 被覆盖了。从那以后,我写任何函数的第一件事就是检查 prologue 里有没有 STP X29, X30, [SP, #-16]!。
2.4.3 RET:返回
RET 指令从函数返回,它默认跳转到 X30 指向的地址。所以,只要你在调用函数前正确保存了 X30,RET 就能把你带回调用点。
RET // 返回,等价于 BR X30
注意,RET 也可以带一个寄存器参数,比如 RET X5,但绝大多数情况下我们只用默认的 RET(即 RET X30)。
2.5 本章小结
这一章我们覆盖了 A64 指令集的四大基础模块。说实话,这些内容看起来有点枯燥,但它们是后续所有优化的地基。你想想看,如果你连 LDP 和 LDR 的性能差异都不清楚,怎么去做内存访问优化?如果你连 BL 和 B 的区别都搞混,怎么去理解函数调用栈?
我个人建议,学完这一章后,你可以做两件事:
- 打开一个简单的 C 程序,用
objdump -d反汇编,看看编译器生成的指令是不是我们讲的这些。 - 尝试手写一小段汇编,用 LDP/STP 保存和恢复寄存器,用 BL/RET 实现函数调用。