Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

内联汇编

Rust 通过 asm! 宏提供了内联汇编支持。它可以用于在编译器生成的汇编输出中嵌入手写的汇编代码。通常这不是必需的,但在无法通过其他方式实现所需性能或时序要求时可能会用到。访问底层硬件原语(例如在内核代码中)也可能需要这个功能。

注意:这里的示例使用 x86/x86-64 汇编,但也支持其他架构。

目前支持内联汇编的架构包括:

  • x86 和 x86-64
  • ARM
  • AArch64
  • RISC-V

基本用法

让我们从最简单的例子开始:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; unsafe { asm!("nop"); } } }

这将在编译器生成的汇编代码中插入一条 NOP(无操作)指令。请注意,所有 asm! 调用都必须放在 unsafe 块内,因为它们可能插入任意指令并破坏各种不变量。要插入的指令以字符串字面量的形式列在 asm! 宏的第一个参数中。

输入和输出

插入一个什么都不做的指令相当无聊。让我们来做些实际操作数据的事情:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let x: u64; unsafe { asm!("mov {}, 5", out(reg) x); } assert_eq!(x, 5); } }

这将把值 5 写入 u64 类型的变量 x。你可以看到,我们用来指定指令的字符串字面量实际上是一个模板字符串。它遵循与 Rust 格式化字符串相同的规则。然而,插入到模板中的参数看起来可能与你熟悉的有些不同。首先,我们需要指定变量是内联汇编的输入还是输出。在这个例子中,它是一个输出。我们通过写 out 来声明这一点。我们还需要指定汇编期望变量在什么类型的寄存器中。这里我们通过指定 reg 将其放在任意通用寄存器中。编译器将选择一个合适的寄存器插入到模板中,并在内联汇编执行完成后从该寄存器读取变量的值。

让我们再看一个使用输入的例子:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let i: u64 = 3; let o: u64; unsafe { asm!( "mov {0}, {1}", "add {0}, 5", out(reg) o, in(reg) i, ); } assert_eq!(o, 8); } }

这段代码会将 5 加到变量 i 的值上,然后将结果写入变量 o。具体的汇编实现是先将 i 的值复制到输出寄存器,然后再加上 5

这个例子展示了几个要点:

asm! 宏支持多个模板字符串参数,每个参数都被视为独立的汇编代码行,就像它们之间用换行符连接一样。这使得格式化汇编代码变得简单。

其次,我们可以看到输入参数使用 in 声明,而不是 out

第三,我们可以像在任何格式字符串中一样指定参数编号或名称。这在内联汇编模板中特别有用,因为参数通常会被多次使用。对于更复杂的内联汇编,建议使用这种方式,因为它提高了可读性,并且允许在不改变参数顺序的情况下重新排列指令。

我们可以进一步优化上面的例子,避免使用 mov 指令:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut x: u64 = 3; unsafe { asm!("add {0}, 5", inout(reg) x); } assert_eq!(x, 8); } }

我们可以看到 inout 用于指定既作为输入又作为输出的参数。这与分别指定输入和输出不同,它保证将两者分配到同一个寄存器。

也可以为 inout 操作数的输入和输出部分指定不同的变量:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let x: u64 = 3; let y: u64; unsafe { asm!("add {0}, 5", inout(reg) x => y); } assert_eq!(y, 8); } }

延迟输出操作数

Rust 编译器在分配操作数时采取保守策略。它假设 out 可以在任何时候被写入,因此不能与其他参数共享位置。然而,为了保证最佳性能,使用尽可能少的寄存器很重要,这样就不必在内联汇编块前后保存和重新加载寄存器。为此,Rust 提供了 lateout 说明符。这可以用于任何在所有输入被消耗后才写入的输出。此外还有一个 inlateout 变体。

以下是一个在 release 模式或其他优化情况下 不能 使用 inlateout 的例子:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut a: u64 = 4; let b: u64 = 4; let c: u64 = 4; unsafe { asm!( "add {0}, {1}", "add {0}, {2}", inout(reg) a, in(reg) b, in(reg) c, ); } assert_eq!(a, 12); } }

在未优化的情况下(如 Debug 模式),将上述例子中的 inout(reg) a 替换为 inlateout(reg) a 仍能得到预期结果。但在 release 模式或其他优化情况下,使用 inlateout(reg) a 可能导致最终值 a = 16,使断言失败。

这是因为在优化情况下,编译器可以为输入 bc 分配相同的寄存器,因为它知道它们具有相同的值。此外,当使用 inlateout 时,ac 可能被分配到同一个寄存器,这种情况下,第一条 add 指令会覆盖从变量 c 初始加载的值。相比之下,使用 inout(reg) a 可以确保为 a 分配一个单独的寄存器。

然而,以下示例可以使用 inlateout,因为输出仅在读取所有输入寄存器后才被修改:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut a: u64 = 4; let b: u64 = 4; unsafe { asm!("add {0}, {1}", inlateout(reg) a, in(reg) b); } assert_eq!(a, 8); } }

如你所见,即使 ab 被分配到同一个寄存器,这段汇编代码片段仍能正确运行。

显式寄存器操作数

某些指令要求操作数必须位于特定寄存器中。因此,Rust 内联汇编提供了一些更具体的约束说明符。虽然 reg 通常适用于任何架构,但显式寄存器高度依赖于特定架构。例如,对于 x86 架构,通用寄存器如 eaxebxecxedxebpesiedi 等可以直接通过名称进行寻址。

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let cmd = 0xd1; unsafe { asm!("out 0x64, eax", in("eax") cmd); } } }

在这个例子中,我们调用 out 指令将 cmd 变量的内容输出到端口 0x64。由于 out 指令只接受 eax(及其子寄存器)作为操作数,我们必须使用 eax 约束说明符。

注意:与其他操作数类型不同,显式寄存器操作数不能在模板字符串中使用。你不能使用 {},而应直接写入寄存器名称。此外,它们必须出现在操作数列表的末尾,位于所有其他操作数类型之后。

考虑以下使用 x86 mul 指令的例子:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; fn mul(a: u64, b: u64) -> u128 { let lo: u64; let hi: u64; unsafe { asm!( // x86 的 mul 指令将 rax 作为隐式输入, // 并将乘法的 128 位结果写入 rax:rdx。 "mul {}", in(reg) a, inlateout("rax") b => lo, lateout("rdx") hi ); } ((hi as u128) << 64) + lo as u128 } } }

这里使用 mul 指令将两个 64 位输入相乘,得到一个 128 位的结果。唯一的显式操作数是一个寄存器,我们用变量 a 填充它。第二个操作数是隐式的,必须是 rax 寄存器,我们用变量 b 填充它。结果的低 64 位存储在 rax 中,用于填充变量 lo。高 64 位存储在 rdx 中,用于填充变量 hi

被破坏的寄存器

在许多情况下,内联汇编会修改不需要作为输出的状态。这通常是因为我们必须在汇编中使用临时寄存器,或者因为指令修改了我们不需要进一步检查的状态。这种状态通常被称为"被破坏"。我们需要告知编译器这一点,因为它可能需要在内联汇编块前后保存和恢复这种状态。

use std::arch::asm; #[cfg(target_arch = "x86_64")] fn main() { // 三个条目,每个四字节 let mut name_buf = [0_u8; 12]; // 字符串按顺序以 ASCII 格式存储在 ebx、edx、ecx 中 // 由于 ebx 是保留寄存器,汇编需要保留其值 // 因此我们在主要汇编代码前后执行 push 和 pop 操作 // 64 位处理器的 64 位模式不允许对 32 位寄存器(如 ebx)进行 push/pop 操作 // 所以我们必须使用扩展的 rbx 寄存器 unsafe { asm!( "push rbx", "cpuid", "mov [rdi], ebx", "mov [rdi + 4], edx", "mov [rdi + 8], ecx", "pop rbx", // 我们使用指向数组的指针来存储值,以简化 Rust 代码 // 虽然这会增加几条汇编指令,但更清晰地展示了汇编的工作方式 // 相比于使用显式寄存器输出(如 `out("ecx") val`) // *指针本身*只是一个输入,尽管它在背后被写入 in("rdi") name_buf.as_mut_ptr(), // 选择 cpuid 0,同时指定 eax 为被修改寄存器 inout("eax") 0 => _, // cpuid 也会修改这些寄存器 out("ecx") _, out("edx") _, ); } let name = core::str::from_utf8(&name_buf).unwrap(); println!("CPU 制造商 ID:{}", name); } #[cfg(not(target_arch = "x86_64"))] fn main() {}

在上面的示例中,我们使用 cpuid 指令读取 CPU 制造商 ID。该指令将最大支持的 cpuid 参数写入 eax,并按顺序将 CPU 制造商 ID 的 ASCII 字节写入 ebxedxecx

尽管 eax 从未被读取,我们仍需要告知编译器该寄存器已被修改,这样编译器就可以保存汇编前这些寄存器中的任何值。我们通过将其声明为输出来实现这一点,但使用 _ 而非变量名,表示输出值将被丢弃。

这段代码还解决了 LLVM 将 ebx 视为保留寄存器的限制。这意味着 LLVM 假定它对该寄存器拥有完全控制权,并且必须在退出汇编块之前将其恢复到原始状态。因此,ebx 不能用作输入或输出,除非编译器将其用于满足通用寄存器类(如 in(reg))。这使得在使用保留寄存器时,reg 操作数变得危险,因为我们可能会在不知情的情况下破坏输入或输出,原因是它们共享同一个寄存器。

为了解决这个问题,我们采用以下策略:使用 rdi 存储输出数组的指针;通过 push 保存 ebx;在汇编块内从 ebx 读取数据到数组中;然后通过 popebx 恢复到原始状态。pushpop 操作使用完整的 64 位 rbx 寄存器版本,以确保整个寄存器被保存。在 32 位目标上,代码会在 push/pop 操作中使用 ebx

这种技术还可以与通用寄存器类一起使用,以获得一个临时寄存器在汇编代码内使用:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; // 使用移位和加法将 x 乘以 6 let mut x: u64 = 4; unsafe { asm!( "mov {tmp}, {x}", "shl {tmp}, 1", "shl {x}, 2", "add {x}, {tmp}", x = inout(reg) x, tmp = out(reg) _, ); } assert_eq!(x, 4 * 6); } }

符号操作数和 ABI 破坏

默认情况下,asm! 假定汇编代码会保留所有未指定为输出的寄存器的内容。asm!clobber_abi 参数告诉编译器根据给定的调用约定 ABI 自动插入必要的破坏操作数:任何在该 ABI 中未完全保留的寄存器都将被视为被破坏。可以提供多个 clobber_abi 参数,所有指定 ABI 的破坏都将被插入。

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; extern "C" fn foo(arg: i32) -> i32 { println!("arg = {}", arg); arg * 2 } fn call_foo(arg: i32) -> i32 { unsafe { let result; asm!( "call {}", // 要调用的函数指针 in(reg) foo, // 第一个参数在 rdi 中 in("rdi") arg, // 返回值在 rax 中 out("rax") result, // 将所有不被 "C" 调用约定保留的寄存器 // 标记为被破坏 clobber_abi("C"), ); result } } } }

寄存器模板修饰符

在某些情况下,需要对寄存器名称插入模板字符串时的格式进行精细控制。当一个架构的汇编语言对同一个寄存器有多个名称时,这种控制尤为必要。每个名称通常代表寄存器的一个子集"视图"(例如,64 位寄存器的低 32 位)。

默认情况下,编译器总是会选择引用完整寄存器大小的名称(例如,在 x86-64 上是 rax,在 x86 上是 eax 等)。

可以通过在模板字符串操作数上使用修饰符来覆盖这个默认设置,类似于格式字符串的用法:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut x: u16 = 0xab; unsafe { asm!("mov {0:h}, {0:l}", inout(reg_abcd) x); } assert_eq!(x, 0xabab); } }

在这个例子中,我们使用 reg_abcd 寄存器类来限制寄存器分配器只使用 4 个传统的 x86 寄存器(axbxcxdx)。这些寄存器的前两个字节可以独立寻址。

假设寄存器分配器选择将 x 分配到 ax 寄存器。h 修饰符将生成该寄存器高字节的名称,而 l 修饰符将生成低字节的名称。因此,汇编代码将被展开为 mov ah, al,这条指令将值的低字节复制到高字节。

如果你对操作数使用较小的数据类型(例如 u16)并忘记使用模板修饰符,编译器将发出警告并建议使用正确的修饰符。

内存地址操作数

有时汇编指令需要通过内存地址或内存位置传递操作数。你必须手动使用目标架构指定的内存地址语法。例如,在使用 Intel 汇编语法的 x86/x86_64 架构上,你应该用 [] 包裹输入/输出,以表明它们是内存操作数:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; fn load_fpu_control_word(control: u16) { unsafe { asm!("fldcw [{}]", in(reg) &control, options(nostack)); } } } }

标签

重复使用命名标签(无论是局部的还是其他类型的)可能导致汇编器或链接器错误,或引起其他异常行为。命名标签的重用可能以多种方式发生,包括:

  • 显式重用:在一个 asm! 块中多次使用同一标签,或在多个块之间重复使用。
  • 通过内联隐式重用:编译器可能会创建 asm! 块的多个副本,例如当包含该块的函数在多处被内联时。
  • 通过 LTO 隐式重用:链接时优化(LTO)可能导致其他 crate 的代码被放置在同一代码生成单元中,从而可能引入任意标签。

因此,你应该只在内联汇编代码中使用 GNU 汇编器的数字局部标签。在汇编代码中定义符号可能会由于重复的符号定义而导致汇编器和/或链接器错误。

此外,在 x86 架构上使用默认的 Intel 语法时,由于一个 LLVM 的 bug,你不应使用仅由 01 组成的标签,如 011101010,因为它们可能被误解为二进制值。使用 options(att_syntax) 可以避免这种歧义,但这会影响_整个_ asm! 块的语法。(关于 options 的更多信息,请参见下文的选项。)

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut a = 0; unsafe { asm!( "mov {0}, 10", "2:", "sub {0}, 1", "cmp {0}, 3", "jle 2f", "jmp 2b", "2:", "add {0}, 2", out(reg) a ); } assert_eq!(a, 5); } }

这段代码会将 {0} 寄存器的值从 10 递减到 3,然后加 2 并将结果存储在 a 中。

这个例子展示了几个要点:

  • 首先,同一个数字可以在同一个内联块中多次用作标签。
  • 其次,当数字标签被用作引用(例如作为指令操作数)时,应在数字标签后添加后缀 "b"("backward",向后)或 "f"("forward",向前)。这样它将引用该方向上由这个数字定义的最近的标签。

选项

默认情况下,内联汇编块的处理方式与具有自定义调用约定的外部 FFI 函数调用相同:它可能读写内存,产生可观察的副作用等。然而,在许多情况下,我们希望向编译器提供更多关于汇编代码实际行为的信息,以便编译器能够进行更好的优化。

让我们回顾一下之前 add 指令的例子:

#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; let mut a: u64 = 4; let b: u64 = 4; unsafe { asm!( "add {0}, {1}", inlateout(reg) a, in(reg) b, options(pure, nomem, nostack), ); } assert_eq!(a, 8); } }

可以将选项作为可选的最后一个参数传递给 asm! 宏。在这个例子中,我们指定了三个选项:

  • pure:表示汇编代码没有可观察的副作用,其输出仅依赖于输入。这使得编译器优化器能够减少内联汇编的调用次数,甚至完全消除它。
  • nomem:表示汇编代码不读取或写入内存。默认情况下,编译器会假设内联汇编可以读写任何它可访问的内存地址(例如通过作为操作数传递的指针或全局变量)。
  • nostack:表示汇编代码不会向栈中压入任何数据。这允许编译器使用诸如 x86-64 上的栈红区等优化技术,以避免栈指针调整。

这些选项使编译器能够更好地优化使用 asm! 的代码,例如消除那些输出未被使用的纯 asm! 块。

有关可用选项的完整列表及其效果,请参阅参考文档