所有人都说汇编是无类型的——所有人都错了 - gingerBill
摘要
本文认为汇编语言实际上是有类型的,并介绍了Odin的内联汇编器作为最佳实现,因为它与语言的类型系统和语义诊断深度集成。
<p><a href="https://lobste.rs/s/wphnca/everyone_says_assembly_is_untyped">评论</a></p>
查看缓存全文
缓存时间: 2026/08/20 18:45
# 人人都说汇编语言无类型——人人都错了
来源:https://www.gingerbill.org/article/2026/08/20/designing-odins-inline-asm/
**核心摘要:**我认为 Odin 的内联汇编器目前是所有语言中最佳的。本文的重点如下。我尚未见过其他汇编器(GCC/Clang/Rust/Go……)能同时具备以下所有特性:
- 内联汇编以`asm`“模板”形式组织,类似过程且可像过程一样调用。
- `asm`模板通过指定破坏区域、固定寄存器、绑定寄存器和临时寄存器的绑定,与代码其余部分集成。
- 汇编语法在不同指令集架构间统一,并与 Odin 语法保持一致。
- 汇编完全经过类型检查,与 Odin 代码其他部分无异。
- 理解汇编语言实际上具有类型系统。
- 通过`core:rexcode`(https://github.com/odin-lang/Odin/tree/master/core/rexcode)编码表实现真正的语义诊断。
- 它在约7天内构建完成。
---
我曾被问及为何 Odin(https://odin-lang.org/)要费心设计自己的自定义内联汇编器(https://odin-lang.org/docs/inline-asm/)。内联汇编难道不是已解决的问题吗?你只需传递一个字符串给汇编器,让它处理其余事务即可。从 GCC 到 Clang 再到 Rust——Rust 的内联汇编(https://doc.rust-lang.org/reference/inline-assembly.html)因宏系统稍微复杂些,但本质并无太大不同——几乎所有语言都是这样做的。这问题已有现成方案,对吗?
这恰恰是我*不*想要的设计,也是大多数语言最终采用的方案。我的初衷始终是创造一个真正*集成*于语言主体而非临时附加的内联汇编器。我真诚地相信,Odin 最终实现的是当前所有语言中最优的内联汇编系统。我并非轻率断言,希望在本文结束时,您至少能理解我为何如此认为。
## 基于字符串的无稽之谈
让我们从我反对的设计说起。以下是使用 x86 AT&T/GAS 语法的 GCC 风格扩展`asm`实现简单“加一”操作的代码:
```c
int dst;
asm ("movl %1, %0\n\t"
"addl $1, %0"
: "=r" (dst) // 输出
: "r" (src) // 输入
: /* 破坏区域 */
);
```
审视此代码并自问:*编译器*(与*汇编器*相对)在此能理解什么?答案是“几乎无法理解”。主体是字符串。`"=r"`和`"r"`是*显式约束字符串*——另一个粘附在真实DSL(https://en.wikipedia.org/wiki/Domain-specific_language)侧边的、弱类型的微小DSL。`%0`和`%1`是你必须手动计数的位置引用。若任何部分出错,编译器不会返回基于你代码类型和语义的错误信息;错误来自汇编器,且出现在更晚阶段,指向的并非你编写的原始文本。
当一项*功能*首先被设计为*逃生舱口*而非*语言组成部分*时,此类情况就会发生。似乎无人曾探究:“如果内联汇编尊重宿主语言的类型系统、调用约定、常量系统及其他特性(如多返回值语义),它应呈现何种形态?”相反,人们只问:“如何以最少的编译器工作量将汇编代码塞进函数?”而字符串成了答案。
这类内联汇编器无视宿主语言的所有特性,只是临时拼凑。我不同;我从头设计了一款新系统。
## 临时附加的简史
字符串并非唯一实现方式,回顾其他语言/编译器的做法颇具价值,因为其中某些方案远优于 GCC/Clang 的实现。遗憾的是,编译器领域此类开发已停滞。
### MSVC
微软的 C 编译器采用了真正不同的方法。MSVC 的`__asm`是*基于语句*的(https://learn.microsoft.com/en-us/cpp/assembler/inline/asm?view=msvc-170),而非基于字符串。你编写真实的指令块,并(关键优势在于)可直接通过名称引用 C 变量和标签,由编译器解析:
```c
int add_one(int x) {
__asm {
mov eax, x // 'x' 是 C 参数,由编译器解析
inc eax
}
// 按照惯例,返回值保留在 eax 中
}
```
无需约束字符串。无需`%0`。无需计数操作数。相比 GCC 的复杂设计,这确实更易读,且长期作为海量 Windows 系统代码的编写方式。但它为何消失了?
首先,它**仅支持 x86**。当微软转向 x64(及后来的 ARM64)时,并未移植此功能。官方指南变为“使用编译器内建函数,或编写独立的`.asm`文件并用 MASM 处理”。x64 编译器的一项明确要求是*不*支持内联汇编器。整个方案因指令集架构边界被废弃,而非跨架构泛化。
其次,即便存在,编译器也并未真正*理解*该代码块。它能解析符号名称,但缺乏显式的破坏区域信息;优化器基本将此区域视为需保守处理的透明屏障。它知道`x`是什么,但未向用户提供任何关于指令*功能*的反馈。
### Turbo Pascal
若追溯更早,你会发现 Turbo Pascal——我对它乃至所有 Pascal 语言都怀有特殊情感。其内联汇编提供*两种*机制,恰好勾勒出完整的设计空间。
第一种机制是`inline`指令,它是“编译器一无所知”理念的最纯粹体现。你将机器码以数字常量序列(即实际操作码字节)提供给它:
```pascal
procedure Cli; inline($FA);
{ $FA = CLI 指令 }
procedure Nops; inline($90/$90);
{ 两个 NOP 字节 }
```
这并非汇编器。这是*你*手动充当汇编器,编译器忠实地将你编写的字节复制到流中。它是终极逃生舱口。Odin 保留了完全相同的能力,即`\#byte`指令,但将其作为*众多指令之一*置于经过检查的模板中,而非作为整个接口。
第二种机制在 Turbo Pascal 6.0(https://www.scribd.com/document/550270834/Tro-Pascal-Version-6-0-Users-Guide-1990)中引入:内置汇编器——`asm ... end`块和`assembler`过程指令。此方案更优,因为它使用真正的助记符,且如同后来的 MSVC,你可以直接命名 Pascal 变量和参数:
```pascal
function AddOne(X: Word): Word; assembler;
asm
mov ax, X { 'X' 是 Pascal 参数 }
inc ax { 结果在 AX 中返回 }
end;
```
这在 1990 年看来极为出色(此时尚在我出生之前),且比当前 C 编译器最终采用的方案更先进。但受限于时代,内置汇编器仅支持至 80286 指令,因此当你需要 386 及其 32 位寄存器时,只能转向外部汇编器。临时附加后,又临时封闭。
MSVC 和 Turbo Pascal 在直觉设计上均优于 GCC,尤其是愚蠢的约束字符串方案。然而两者都在相同节点止步:它们解析标识符,却从未对指令进行建模——未处理操作数类型,仅对立即数范围有限检查,无法控制破坏区域或固定寄存器。
GCC 抛弃了他们的设计,忘记了让汇编语言以其自身方式表达的方面。人们未曾意识到实际上存在一个可为汇编语言泛化的类型系统。这正是 Odin 设计的核心所在,也是本文后续讨论的重点。
## 汇编语言并非无类型
普遍存在一种观点,认为汇编语言是“无类型”的,因此内联汇编本质上是任意操作。这并不正确。突破此观点是设计通用内联汇编器的最重要思想。我曾(https://www.gingerbill.org/article/2021/03/07/untyped-types/)在 Odin 背景下讨论“无类型类型”,但它们实际是(https://wiki.haskell.org/Existential_type)存在类型。传统上,“无类型”实际上意味着一切皆“不透明”且非常脆弱(例如一切皆为 int,且你假设各处皆如此)。汇编语言常被视为此类“无类型”语言的典型例子。
然而,每条指令都有一组有效形式。每种形式规定了每个操作数的*类型*(寄存器、内存、立即数、标签)、每个寄存器的*类别*(通用、向量、掩码)、每个操作数的*宽度*、每个立即数的取值范围,以及指令*破坏*的内容(标志位、内存、特定寄存器)。在 x86 中,`mulps`需要 128 位向量寄存器;`crc32`的某一形式需要 32 位目标寄存器和 8 位内存源;`div`无论如何都会读写`rdx:rax`。
这并非缺乏类型系统:这*本身*就是一个类型系统;一个相当丰富、依赖于指令的类型系统。汇编语言实际上是一种多类型代数,而所有人却假装它是一堆无序的字节。
一旦理解此点,设计问题便不再是“如何将字符串偷运过编译器?”,而是“如何用语言自身的术语表达这种代数?”。事实证明,Odin 已具备大部分现成组件。
## 一种语法,多种指令集架构
首要决策是外围语法。非助记符本身——显然 AMD64 上的`mov`与 arm64 上的`ldr`无关——而是助记符*周围*的一切:如何声明操作数、如何引用寄存器、如何编写内存地址、如何拼写标签等。此处我借鉴了 Plan 9(https://9p.io/sys/doc/asm.html)(及后来的 Go(https://go.dev/doc/asm))的经验:选择*一种*语法并在所有目标间保持一致。Ken Thompson(https://en.wikipedia.org/wiki/Ken_Thompson)的工具链如此实现,Go 继承了这一点,只需学习一次规则确实非常友好。Go 的汇编存在一些问题(及不一致之处),但总体思路卓越。
Odin 本身具有上下文无关文法,因此对于 Odin 的内联汇编,我希望它也具备上下文无关文法,通用形式为:
```
指令 [操作数{, 操作数}]
```
所有地方采用相同文法。指令必须是有效的 Odin 标识符或关键字。显式物理寄存器始终带`%`符号(`%rax`、`%xmm0`、`%al`),避免与你提供的参数名及父作用域中的全局常量冲突。参数和临时名称始终不带符号(因为编译器理解其语义)。内存操作数始终采用 Intel 风格有效地址(`[base + index*scale + disp]`)。标签始终为`.name`。
你只需学习一次此规则,即可适用于我们未来添加的所有指令集架构,尽管底层指令完全不同。它使用与 Odin 相同的标记:数字字面量、注释,甚至分号插入规则。这正是我反复强调的原则:**连贯性优于一致性**。Odin 与*自身*保持连贯,而非与 GAS、NASM 或任何平台的传统汇编器一致。这是 Odin 的内联汇编器,仅此而已。
## Intel 顺序,非 AT&T
上节隐藏的一个决策值得深入探讨,因为它正是争论焦点:主体使用**Intel 操作数顺序**——目标优先,`dst, src`——以及 Intel 风格(但略有差异)的内存寻址,而非 AT&T/GAS 惯例。这是我*偏离* Plan 9 和 Go 之处,即便借鉴了它们的最佳理念。
Plan 9 和 Go 的汇编器按源优先顺序书写操作数,遵循数据流方向(Go 并非完全一致,部分操作数顺序与其他 AT&T 汇编器不一致,很有趣,对吧?/s),因此`MOVQ $0, AX`通过将目标置于*右侧*来清零`AX`。这与 AT&T 顺序相同,与 Intel 相反。我全盘采纳了它们“一种文法适用于所有指令集架构”的哲学,但不想采用它们的操作数顺序。
这看似随意选择,实则不然。首要原因是与 Odin 其他部分保持连贯。`mov dst, src`读作`dst = src`。目标位于左侧,与你编写的其他所有 Odin 语句中的赋值目标位置一致:`x = y`、`x := y`、`name: type = value`(我关于类型转换(https://www.gingerbill.org/article/2026/02/23/designing-odins-casting-syntax/)的论述相同,类型位于左侧,因为声明如此阅读)。AT&T 的`movl %src, %dst`相对于代码库中的所有赋值语句,数据流是反向的。当阅读嵌入在普通 Odin 代码中的模板时,你无需在过程中间翻转箭头指向的心智模型。
第二个原因对*通用*语法尤为重要:目标优先并非 Intel 特例,而是**多数指令集架构的惯例**。ARM 书写`add r0, r1, r2`(目标优先)。RISC-V 书写`add rd, rs1, rs2`(目标优先)。MIPS 文档同样如此。源优先顺序实属狭隘特例。x86/GAS 传统继承自 DEC 和 PDP-11 血统。若你的目标是在所有目标上实现语法统一,应选择多数目标*自身汇编器*已采用的惯例,而非单一工具链历史遗留的特例。颇具讽刺的是,Plan 9 因血统原因选择了狭隘顺序。
AT&T 的其他包袱也因类似原因被抛弃:
### 内存操作数
AT&T 书写`disp(base, index, scale)`——必须记忆的固定位置槽位。Intel 书写`[base + index*scale + disp]`——如实反映地址运算本质。Odin 采用后者,并可无缝扩展至其他目标所需的形式,如 arm64 的`[base + index<>scale]`。
### 操作数大小
AT&T 将宽度嵌入助记符(`movb`、`movw`、`movl`、`movq`)。Odin 无需如此,因为操作数*有类型*,大小来自参数类型;若无寄存器固定,则来自显式`[%rax]:u8`标注(Intel 语法通过`byte`、`word`、`dword`或`qword`前缀标注内存操作数,但 Odin 直接使用 Odin 类型系统)。类型系统已携带 AT&T 分散在众多`mov`不同拼写中的信息。
### 符号
AT&T 无条件地在*每个*寄存器前添加`%`,在*每个*立即数前添加`$`。Odin 的`%`表面相似但作用不同:仅出现于显式*物理*寄存器,且仅用于避免与用户提供的参数和临时名称命名空间冲突。在惯用模板中,你书写裸名(如`foo`、`acc`、`i`),仅在真正需要固定或直接引用寄存器时才使用`%rax`。此符号标记例外情况;并非普遍应用于常见场景的冗余装饰。
将两种风格并列,*可读性*差异显著。首先是 AT&T/GAS 形式:
```assembly
movl %eax, %ebx # ebx = eax(源在左侧)
addl $1, %ebx # ebx += 1
movl 8(%rdi,%rsi,4), %ecx # ecx = *(rdi + rsi*4 + 8)
```
然后是 Odin 中相同三条指令的 Intel 顺序:
```assembly
mov %ebx, %eax // ebx = eax(目标在左侧)
add %ebx, 1 // ebx += 1
mov %ecx, [%rdi + %rsi*4 + 8]
```
请记住,这是 Odin 的*最差*情况,完全使用物理寄存器书写。
相似文章
Odin 的新内联汇编模板
本文详细介绍了 Odin 编程语言的新内联汇编模板,其通用语法灵感来自 Go 的汇编器,并支持 amd64 目标架构。
解析C语言中类型推断声明之险
这篇博文探讨了C23中涉及`auto`作为类型推断说明符或存储类说明符的解析歧义,展示了当`x`是typedef时,GCC和Clang在解析如`auto x = 67;`这样的声明上的分歧,以及属性如何使情况复杂化。
关于WebAssembly作为栈机器的思考
这篇博客文章回应了关于WebAssembly不是纯栈机器的说法,通过讨论其带局部变量的设计并与Forth进行比较,论证它仍然符合栈机器的定义,并且其类似寄存器的局部变量提高了可读性和性能。
SBCL: 终极汇编代码面包板 (2014)
一篇技术博客文章,探讨如何使用SBCL作为汇编代码的面包板,重点介绍基于堆栈的虚拟机技术,如旋转堆栈和高效的原语操作分发,并引用了F18处理器和x87堆栈。
ASM SHADER TOY – 它像Shader Toy,但用汇编语言编程
ASM SHADER TOY 是一款工具,让你使用汇编语言编写着色器,类似于流行的 Shader Toy 平台。