ARM64 指令的真正编码方式
摘要
一篇科普文章,解释 ARM64 (AArch64) 指令如何以 32 位固定长度字编码,破除常见误解,并通过 Apple Silicon 上的 ADD immediate 指令提供动手解码示例。
<p><a href="https://lobste.rs/s/skrixb/how_arm64_instructions_are_really">评论</a></p>
查看缓存全文
缓存时间: 2026/06/25 13:13
# ARM64 指令的真正编码方式
来源:https://medium.com/@tomas.pitner/how-arm64-instructions-are-really-encoded-c7ace7da442d
Tomáš Pitner (https://medium.com/@tomas.pitner?source=post_page---byline--c7ace7da442d---------------------------------------)
## 引言
如果你曾看过 Apple Silicon(M1 及更新芯片)上的 ARM64 汇编,你可能见过这样的指令:
```
add x0, x1, #42
```
大多数程序员就此止步。汇编器将指令转换为机器码,CPU 执行它,一切正常。
*但指令在处理器内部到底是什么样子的?*
与 x86 不同(x86 指令长度在 1 到 15 字节之间变化),**每条 AArch64 指令恰好占用 32 位**。这一看似简单的设计决策使得 ARM64 在你想了解机器码真正面目时出奇地平易近人。
在本文中,我们将选取几条在 Apple Silicon 上使用的真实 ARM64 指令,逐步解码它们,直到编码变得有意义为止。
目标不是记住指令格式。目标是理解每条 ARM64 指令同时是三样东西:
- 程序员可见的汇编语言,
- 处理器执行的机器码,
- 一个结构化的位域集合。
一旦你同时看到这三个视角,指令编码将变得不再神秘。
## 一个常见的误解
常见的困惑来源是:ARM64 处理器执行的是 32 位指令。
这不是与 ARM64 这个名字矛盾了吗?**完全不是。**
术语 *64 位架构* 主要指的是通用寄存器的宽度以及处理器能够高效操作的数值大小。ARM64 提供了 31 个 64 位通用寄存器(`X0` 到 `X30`)并支持 64 位算术运算。
指令大小则是一个完全独立的设计决策。
**因此,ARM64 是一个 64 位架构,但使用固定大小的 32 位指令字。**
(按回车或点击查看完整图像)
(按回车或点击查看完整图像)
## 为什么 ARM64 比 x86 更容易解码
ARM64 在教育中流行的原因之一是其规整的结构。
对于 x86,解码器必须先确定指令的起始和结束位置,然后才能决定指令的含义。
**ARM64 则不同。每条指令恰好占用四个字节。**
现在我们来看一段包含三条指令的*可执行内存*是什么样子:
(按回车或点击查看完整图像)
这意味着指令边界总是预先已知的。硬件和软件反汇编器都能从中受益。从类似 `...0, ...4, ...8` 的圆整地址取指令更快——而且我们只需要在汇编代码中添加 `.p2align 2` 指令,就能轻松地为所有指令实现这一点。C、C++ 或 Swift 等编译器也是如此。
## 示例 1:ADD 立即数
考虑这条指令,它将整数 `42` 与 `x1` 寄存器的内容相加,并将结果放入 `x0`:
```
add x0, x1, #42
```
典型的汇编器可能会将其编码为:
```
9100A820
```
处理器实际看到的只是 32 位指令字:
```
10010001000000001010100000100000
```
这看起来像是随机噪声,但 ARM64 指令是高度结构化的。
对于 **ADD(立即数)** 指令,编码可以简化为:
```
31 22 10 5 0
+----------------------+-----------+-------+------+
| opcode | imm12 | Rn | Rd |
+----------------------+-----------+-------+------+
```
现在我们手动解码它。
*目标寄存器* **Rd** 字段占据最低的五位:
```
100100010000000010101000001 | Rd = 00000 -> X0
```
五位可以表示 32 个值,因此 Rd = X0。接下来的五位包含源寄存器:
```
1001000100000000101010 | Rn = 00001 -> X1
```
十二位的立即数字段包含:
```
1001000100 | imm12 = 000000101010 = 42
```
即十进制 42。
最后,剩下的十个高位标识**指令类型**本身。
```
opcode(ADD)=1001000100 imm12(42)=000000101010 Rn(X1)=00001 Rd(X0)=00000
```
CPU 执行完全相同的解码过程,只不过是在硬件中以极高的速度完成的。反汇编器执行的操作本质上也相同。从一个 32 位值开始:
```
9100A820
```
它提取各个字段:
```
opcode = ADD
Rn = X1
Rd = X0
imm12 = 42
```
并重新构建:
```
add x0, x1, #42
```
一旦你意识到这一点,像 `otool`、`llvm-objdump`、Hopper、IDA 和 Ghidra 这样的工具就不再那么神秘了。它们只是自动重复这个解码过程成千上万次甚至数百万次。
## 为什么寄存器只需要五位
ARM64 编码中反复出现的一个细节是使用 5 位寄存器字段。
原因很简单。ARM64 有 32 个通用寄存器。
```
2^5 = 32
```
因此,五位足以唯一标识任何寄存器。
```
00000 -> X0
00001 -> X1
...
11111 -> X31
```
一旦你认出这个模式,许多指令格式就会突然变得容易理解得多。
## 示例 2:分支指令
控制流指令使用不同的布局。考虑:
```
b somewhere
```
**分支指令** 不存储完整的目标地址。假设当前指令位于:
```
0x100001004 b somewhere // somewhere => 0x100002004
```
目标地址是:
```
0x100002004
```
处理器不需要存储完整的目标地址。它只需要存储*距离*,即**偏移量**。简化的可视化如下:
(按回车或点击查看完整图像)
由于每条 ARM64 指令占用四个字节,因此地址的低两位始终为零,不需要存储在指令中。完整的目标地址计算可以如下:
(按回车或点击查看完整图像)
这使得 ARM64 能够仅用 26 位表示出奇大的分支距离。一个 26 位的有符号偏移量意味着我们可以正向或反向跳转大约 **±128 MiB**。这对于现实世界程序中绝大多数分支来说已经足够。
作为历史对比,这个可到达的范围大致相当于早期运行 MS-DOS 的 IBM PC 著名的 1 MiB 物理地址限制!
## 示例 4:著名的 ADRP
如果你反汇编几乎任何 macOS 可执行文件,迟早会遇到:
```
adrp x0, symbol@PAGE
add x0, x0, symbol@PAGEOFF
```
对于第一次接触 Apple Silicon 的程序员来说,这个序列通常无处不在。
原因是位置无关代码。现代 macOS 可执行文件不能假设固定的虚拟地址,因为操作系统每次运行时可能将它们加载到不同的位置。因此,ARM64 将地址问题分成两部分:一个页对齐的基地址和一个页内的偏移量。
尽管 Apple Silicon 上的 macOS 使用 16 KiB 的虚拟内存页,但 `ADRP` 指令本身操作的是 4 KiB 对齐的地址(由 ARM64 架构定义)。
```
// 当前指令
0x100001004 ADRP x0, symbol@PAGE
0x100001008 ADD x0, x0, symbol@PAGEOFF
0x10000100C LDR x1, [x0] // 将加载 0x1234ABCD
...
0x10000C100 symbol: .long 0x1234ABCD
```
像 `clang` 这样的汇编器会将第一条指令 `ADRP x0, symbol@PAGE` 编译成:
1. 取当前指令的 4 KiB 对齐基地址:`0x100001000`;
2. 取 `symbol` 的 4 KiB 对齐基地址:`0x10000C000`;
3. 计算它们的差值:`0xB` 页(= 11 × 4 KiB);
4. 将 `+11` 编码到 `ADRP` 指令中。
因此,`ADRP` 计算当前指令附近一个 4 KiB 对齐区域的基地址。较低的页内偏移位被有意丢弃。
随后的 `ADD` 指令恢复该页内的偏移量,即 0x0100。
这种技术非常常见,你几乎可以在每个 Apple Silicon 可执行文件中找到类似的指令对。
它们共同重建最终地址,最终 `LDR x1, [x0]` 将加载 `0x1234ABCD`。
猜猜下面的代码会做什么:
```
adrp x0, greeting@PAGE
add x0, x0, greeting@PAGEOFF
bl _puts
```
是的,第一条 `adrp x0, greeting@PAGE` 包含 `greeting` 符号的相对页位置(看起来是在寻址一个字符串)。执行时,它会将编码的 `greeting` 相对页加到当前页上,得到该字符串的绝对地址。
现在,它可以通过 `_puts` 将字符串回显到控制台,而 `_puts` 就是众所周知的 `puts` 标准 C 函数,从这里你的汇编代码中也可以无缝调用。
一旦你认出这个模式,macOS 的反汇编就变得容易得多。
## 查看真实机器码
如果你有一台 Apple Silicon Mac,你可以自己生成机器码。
创建一个小的汇编文件:
```
add x0, x1, #42
ret
```
汇编它:
```
clang -c example.s -o example.o
```
并检查结果:
```
otool -tvV example.o
# 或者
llvm-objdump -d example.o
```
将汇编指令和生成的机器码放在一起看,是培养 ARM64 编码直觉的最快方法之一。
## 隐藏的设计哲学
解码几条指令后,一个模式开始浮现。ARM64 指令编码不是随机的。整个架构中可以看到几个设计目标:
- 固定大小指令
- 简单且可预测的解码
- 大量的寄存器文件
- 高效的分支编码
- 为未来架构扩展留出空间
这些目标解释了指令集中许多重复出现的结构。
ARM64 最令人印象深刻的一点也许是,这些设计目标在架构中始终可见。一旦你理解了几种指令格式,许多其他格式就会开始变得熟悉,因为相同的编码思想被反复重用。
## CPU 看到的是位域,而不是汇编
人类更喜欢符号化的名称,例如:
```
add x0, x1, #42
```
处理器永远不会看到这个文本。相反,它接收到的是:
```
9100A820
```
它展开为:
```
opcode = ADD
Rn = X1
Rd = X0
imm12 = 42
```
并最终成为硬件执行的内部操作。
这个视角是将汇编编程与机器码编程区分开来的关键洞见之一。
汇编语言是面向人类的表示。
处理器最终处理的是字段、掩码、解码器和控制信号。
## 结论
一旦你开始将 ARM64 指令视为位域的集合而不是汇编助记符,许多看似神秘的概念就会变得显而易见。
寄存器占用五位,因为共有 32 个。
分支指令存储相对偏移量,因为每条指令恰好是四个字节长。
像 `CBZ` 这样的指令将多个操作组合成一个编码,以减少代码大小并提高效率。
最重要的是,ARM64 不再显得像魔法。
一旦你理解了指令编码,逆向工程工具也不再显得神奇。
无论你使用 `otool`、`llvm-objdump`、Hopper、IDA 还是 Ghidra,它们都从完全相同的任务开始:将 32 位指令字流解码成人类能理解的东西。
下次你看到像这样的机器字时:
```
9100A820
```
你将知道它不仅仅是十六进制的噪声。它是一个精心结构化的 32 位信息包,告诉处理器该做什么。
## 延伸阅读
本文改编自我即将出版的免费书籍:
***ARM64 Assembly on macOS: A Practical Guide for Apple Silicon***
完整的开放获取版本,包括指令编码、Apple Silicon ABI 约定、Mach-O 内部结构、优化技术、SIMD、安全特性以及实用的 macOS ARM64 编程,已在 Zenodo.org 上以 PDF 和 HTML 格式免费提供:
https://doi.org/10.5281/zenodo.20802832
项目仓库和配套代码:
https://codeberg.org/tpitner/arm-macos
相似文章
编写可移植的ARM64汇编代码
一份关于编写可在Apple Darwin和Linux/BSD系统间移植的ARM64汇编代码的指南,涵盖ABI、符号命名和向量助记符的差异。
Intel 8087浮点芯片的指令解码
对Intel 8087浮点协处理器指令解码的详细逆向工程分析,解释主CPU与协处理器之间的交互、微码ROM的使用以及总线接口单元。
@Underfox3: 本文基于对 Apple 硅芯片的直接测量,呈现了 Apple Neural Engine 的全面指南,…
一份全面的 Apple Neural Engine 逆向工程指南,详细介绍了其在 A11 至 A18 和 M1 至 M5 芯片上的架构、编程接口和性能,基于对私有运行时、编译器和固件的直接测量和静态分析。
Windows堆栈限制检查回顾,后续
Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。
解剖苹果的稀疏映像格式(ASIF)
一篇技术博文解剖了苹果在macOS Tahoe中用于虚拟磁盘的新稀疏映像格式(ASIF),涵盖了从十六进制转储中逆向工程该文件格式的过程。