ARM64 指令的真正编码方式

Lobsters Hottest 新闻

摘要

一篇科普文章,解释 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汇编代码

Hacker News Top

一份关于编写可在Apple Darwin和Linux/BSD系统间移植的ARM64汇编代码的指南,涵盖ABI、符号命名和向量助记符的差异。

Intel 8087浮点芯片的指令解码

Ken Shirriff

对Intel 8087浮点协处理器指令解码的详细逆向工程分析,解释主CPU与协处理器之间的交互、微码ROM的使用以及总线接口单元。

Windows堆栈限制检查回顾,后续

The Old New Thing (Raymond Chen)

Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。

解剖苹果的稀疏映像格式(ASIF)

Hacker News Top

一篇技术博文解剖了苹果在macOS Tahoe中用于虚拟磁盘的新稀疏映像格式(ASIF),涵盖了从十六进制转储中逆向工程该文件格式的过程。