内联汇编
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); } }
这将把变量 i
中的输入加 5
,并将结果写入变量 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
,从而导致断言失败。
这是因为在优化的情况下,编译器可以自由地为输入 b
和 c
分配相同的寄存器,因为它知道它们具有相同的值。此外,当使用 inlateout
时,a
和 c
可以分配到同一个寄存器,在这种情况下,第一个 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); } }
如您所见,如果将 a
和 b
分配给同一个寄存器,此汇编片段仍然可以正常工作。
显式寄存器操作数
某些指令要求操作数位于特定的寄存器中。因此,Rust 内联汇编提供了一些更具体的约束说明符。虽然 reg
通常在任何架构上都可用,但显式寄存器高度特定于架构。例如,对于 x86,通用寄存器 eax
、ebx
、ecx
、edx
、ebp
、esi
和 edi
等可以通过其名称寻址。
#![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!( // The x86 mul instruction takes rax as an implicit input and writes // the 128-bit result of the multiplication to 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() { // three entries of four bytes each let mut name_buf = [0_u8; 12]; // String is stored as ascii in ebx, edx, ecx in order // Because ebx is reserved, the asm needs to preserve the value of it. // So we push and pop it around the main asm. // 64 bit mode on 64 bit processors does not allow pushing/popping of // 32 bit registers (like ebx), so we have to use the extended rbx register instead. unsafe { asm!( "push rbx", "cpuid", "mov [rdi], ebx", "mov [rdi + 4], edx", "mov [rdi + 8], ecx", "pop rbx", // We use a pointer to an array for storing the values to simplify // the Rust code at the cost of a couple more asm instructions // This is more explicit with how the asm works however, as opposed // to explicit register outputs such as `out("ecx") val` // The *pointer itself* is only an input even though it's written behind in("rdi") name_buf.as_mut_ptr(), // select cpuid 0, also specify eax as clobbered inout("eax") 0 => _, // cpuid clobbers these registers too out("ecx") _, out("edx") _, ); } let name = core::str::from_utf8(&name_buf).unwrap(); println!("CPU Manufacturer ID: {}", name); } #[cfg(not(target_arch = "x86_64"))] fn main() {}
在上面的示例中,我们使用 cpuid
指令读取 CPU 制造商 ID。此指令将最大支持的 cpuid
参数写入 eax
,并将 CPU 制造商 ID 作为 ASCII 字节,按顺序写入 ebx
、edx
和 ecx
。
即使从未读取 eax
,我们仍然需要告诉编译器该寄存器已被修改,以便编译器可以保存 asm 之前这些寄存器中的任何值。这是通过将其声明为输出但使用 _
而不是变量名来完成的,这表明要丢弃输出值。
此代码还解决了 LLVM 将 ebx
保留为寄存器的限制。这意味着 LLVM 假设它对寄存器具有完全控制权,并且必须在退出 asm 块之前将其恢复到原始状态,因此不能将其用作输入或输出,除非编译器使用它来满足通用寄存器类(例如,in(reg)
)。这使得在使用保留寄存器时 reg
操作数变得危险,因为我们可能会在不知不觉中损坏输入或输出,因为它们共享同一个寄存器。
为了解决这个问题,我们使用 rdi
来存储指向输出数组的指针,通过 push
保存 ebx
,从 asm 块内的 ebx
读取到数组中,然后通过 pop
将 ebx
恢复到其原始状态。push
和 pop
使用该寄存器的完整 64 位 rbx
版本,以确保保存整个寄存器。在 32 位目标上,代码将在 push
/pop
中使用 ebx
。
这也可以与通用寄存器类一起使用,以获取在 asm 代码中使用的临时寄存器
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] { use std::arch::asm; // Multiply x by 6 using shifts and adds 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 {}", // Function pointer to call in(reg) foo, // 1st argument in rdi in("rdi") arg, // Return value in rax out("rax") result, // Mark all registers which are not preserved by the "C" calling // convention as clobbered. 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 寄存器 (ax
、bx
、cx
、dx
),其中前两个字节可以独立寻址。
假设寄存器分配器选择将 x
分配到 ax
寄存器。 h
修饰符将发出该寄存器高字节的名称,而 l
修饰符将发出低字节的名称。因此,汇编代码将被展开为 mov ah, al
,它将值的低字节复制到高字节中。
如果你对操作数使用较小的数据类型(例如 u16
)并且忘记使用模板修饰符,编译器将发出警告并建议使用正确的修饰符。
内存地址操作数
有时,汇编指令需要通过内存地址/内存位置传递操作数。你必须手动使用目标架构指定的内存地址语法。例如,在 x86/x86_64 上使用 Intel 汇编语法,你应该将输入/输出包裹在 []
中,以表明它们是内存操作数。
#![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,你不应该使用完全由 0
和 1
数字组成的标签,例如 0
、11
或 101010
,因为它们最终可能被解释为二进制值。使用 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”(“向后”)或 “f”(“向前”)添加到数字标签中。它将指向此方向上最近由该数字定义的标签。
选项
默认情况下,内联汇编代码块的处理方式与具有自定义调用约定的外部 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!
代码块。
有关可用选项及其效果的完整列表,请参阅参考文档。