原文: Inline assembly - Rust By Example
Rust 通过 asm!
宏提供对内联汇编的支持。它能用来将手写汇编嵌入到编译器生成的汇编输出中。一般来说不必如此,除非有性能或时序需求无法通过其他方式实现。访问低级硬件原语(例如在内核代码里)也可能需要此功能。
注:示例只给出了 x86/x86-64 汇编,但是其他架构也是支持的
内联汇编目前支持以下架构:
- x86 和 x86-64
- ARM
- AArch64
- RISC-V
- LoongArch
基础用法
我们从理论上最简单的例子开始:
use std::arch::asm;
unsafe {
asm!("nop");
}
这会向编译器生成汇编中插入一个 NOP (无操作) 指令。注意,所有 asm!
调用都需要在以一个 unsafe
块内,毕竟它可能插入任意指令,然后破坏很多不变量。插入指令在 asm!
宏的第一个参数以字符串字面量的形式列出。
输入和输出
不过插入一个什么都不做的指令太无聊了。我们来实际动动数据:
use std::arch::asm;
let x: u64;
unsafe {
asm!("mov {}, 5", out(reg) x);
}
assert_eq!(x, 5);
这会将值 5
写入 u64
变量 x
。可以看到,我们用来指定指令的字面量是一个模板。它约束于 Rust 格式字符串相同的规则;但是插入模板的参数看起来与所熟悉的略有不同。首先,我们需要明确变量是内联汇编的输入还是输出。在此例中,它是一个输出。我们通过写 out
来声明。我们还需要明确汇编需要什么类型的寄存器来放置变量。在此例中,我们通过指定 reg
将其放入任意通用寄存器中。编译器将选择合适的寄存器插入模板,并在内联汇编完成执行后以它为变量。
让我们看看另一个还用到了一个输入的例子:
use std::arch::asm;
let i: u64 = 3;
let o: u64;
unsafe {
asm!(
"mov {0}, {1}",
"add {0}, {number}",
out(reg) o,
in(reg) i,
number = const 5,
);
}
assert_eq!(o, 8);
这会将 5
加到输入变量 i
上,并将结果写入变量 o
。汇编的实现方式是首先将值从 i
复制到输出,然后加上 5
。
该示例说明:
第一,可以看到 asm!
允许多个模板字符串参数,每个都被视为单独的一行汇编代码,就像它们都用换行符连接在一起一样。这使我们可以轻松排版汇编代码。
第二,可以看到输入是通过写入 in
而不是 out
来声明的。
第三,我们的一个操作数有一个我们还没有见过的类型,即 const
。这告诉编译器在汇编模板中直接将此参数扩展为值。这仅适用于常量和字面量。
第四,可以看到我们可以像在任何格式字符串中一样指定参数编号或名称。对于内联汇编模板,这特别有用,因为参数通常不止一次使用。对于更复杂的内联汇编,通常建议使用此功能,因为它可以提高可读性,并允许在不更改参数顺序的情况下重新排序指令。
我们可以进一步改进此例以避免 mov
指令:
use std::arch::asm;
let mut x: u64 = 3;
unsafe {
asm!("add {0}, {number}", inout(reg) x, number = const 5);
}
assert_eq!(x, 8);
可以看到,inout
用于指定既是输入又是输出的参数。这与分别指定输入和输出不同,它保证将二者分配到同一个寄存器。
也可以为 inout 操作数的输入和输出部分指定不同的变量:
use std::arch::asm;
let x: u64 = 3;
let y: u64;
unsafe {
asm!("add {0}, {number}", inout(reg) x => y, number = const 5);
}
assert_eq!(y, 8);
迟输出操作数
Rust 编译器对操作数的分配比较保守。它假设输出可以随时写入,因此不能与任何其他参数共享其位置。但是,为了保证最佳性能,尽可能少地使用寄存器是很重要的,这样就不必在内联汇编块前后保存并重新加载他们。为了实现这一点,Rust 提供了一个 lateout 说明符。这可以用于仅在所有输入都已被使用后写入的任何输出。此说明符还有一个 inlateout 变体。
这是一个在 release
模式或其他优化情况下不能使用 inlateout
的示例:
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
可以分配给同一个寄存器,在这种情况下,第一个加法指令将覆盖变量 c
的初始加载。反观 inout(reg) a
,它能确保为 a
分配单独的寄存器。
但是,以下示例可以使用 inlateout
,因为仅在读取所有输入寄存器后才修改输出:
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
等可以用名字指代。
use std::arch::asm;
let cmd = 0xd1;
unsafe {
asm!("out 0x64, eax", in("eax") cmd);
}
在此示例中,我们调用 out
指令将 cmd
变量的内容输出到端口 0x64
。由于 out
指令仅接受 eax
(及其子寄存器)作为操作数,因此我们必须使用 eax
约束说明符。
注:与其他操作数类型不同,显式寄存器操作数不能在模板字符串中使用:您不能使用
{}
,而应直接写寄存器名称。此外,它们必须出现在操作数列表的末尾,位于所有其他操作数类型之后。
考虑这个用到 x86 mul
指令的例子:
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
。
Clobbered registers
在许多情况下,内联汇编会修改并非输出的状态。通常这是因为我们必须在汇编中使用临时寄存器,或者因为指令修改了我们不需要进一步检查的状态。这种情况通常被称为 “clobbered”。我们需要将此情况告知编译器,因为它可能需要在内联汇编块周围保存和恢复对应状态。
use std::arch::asm;
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);
}
在上面的例子中,我们使用 cpuid
指令来读取 CPU 制造商 ID。此指令将最大支持的 cpuid
参数写入 eax,并将 CPU 制造商 ID 以 ASCII 字节的顺序写入 ebx、edx 和 ecx。
即使从未读取过 eax,我们仍然需要告诉编译器该寄存器已被修改,以便编译器可以保存 asm 之前这些寄存器中的任何值。这是通过将其声明为输出但使用 _
而非变量名来实现的,这表示输出值要被丢弃。
此代码还处理了 ebx
是 LLVM 保留寄存器的限制:LLVM 假定它对寄存器具有完全控制权,必须在退出 asm 块之前将其恢复到其原始状态,因此它不能用作输入或输出,除非编译器使用它来实现通用寄存器类(例如 in(reg))。(译者注:详见 讨论 与 相关 PR)这使得使用保留寄存器时的 reg 操作数变得危险,因为我们可能会在不知不觉中损毁我们的输入或输出,因为它们共享同一个寄存器。
因此,我们使用 rdi
将指针存储到输出数组,通过 push
保存 ebx
,从 asm
块内的 ebx
读入数组,然后通过 pop
将 ebx
恢复到其原始状态。push
和 pop
使用完整的 64 位 rbx 版本的寄存器来确保保存整个寄存器。在 32 位目标上,代码将在 push
/pop
中使用 ebx
。
这也可以与通用寄存器类一起使用,以获取供汇编代码内部使用的临时寄存器:
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 clobbers
默认情况下,asm!
假定任何未指定为输出的寄存器的内容都会被汇编代码保留。asm!
的 clobber_abi
参数告诉编译器根据给定的调用约定 ABI 自动插入必要的 clobber 操作数:任何未完全保留在该 ABI 中的寄存器都将被视为已 clobbered。可以提供多个 clobber_abi
参数,所有指定 ABI 的所有 clobber 都会被插入。
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 等)。
可以通过在模板字符串操作数上使用修饰符来覆盖此默认值,就像使用格式字符串一样:
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),他们的前两个字节可以独立寻址。
假设寄存器分配器已选择在 ax
寄存器中分配 x
。h
修饰符将生成该寄存器高字节的寄存器名称,l
修饰符将生成低字节的寄存器名称。因此,asm 代码将扩展为 mov ah, al
,它将值的低字节复制到高字节中。
如果您使用较小的数据类型 (例如 u16) 和操作数并忘记使用模板修饰符,则编译器将发出警告并建议使用正确的修饰符。
内存地址操作数
有时汇编指令需要通过内存地址/内存位置传递操作数。您必须手动使用目标体系结构指定的内存地址语法。例如,在使用 Intel 汇编语法的 x86/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 可能导致来自其他包的代码被放置在同一个代码生成单元中,因此可以引入任意标签。
因此,您应仅在内联汇编代码中使用 GNU 汇编器数字本地标签。在汇编代码中定义符号可能会因符号定义重复而导致汇编器和/或链接器错误。
此外,在 x86 上使用默认的 Intel 语法时,由于 LLVM bug,不应使用仅由 0
和 1
数字组成的标签,例如 0
、11
或 101010
,因为它们最终可能会被解释为二进制值。使用 options(att_syntax) 将避免任何歧义,但这会影响整个 asm! 块的语法。(有关选项的更多信息,请参阅下面的 选项。)
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
指令为例:
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
表示汇编代码没有可观察到的副作用,并且其输出仅取决于其输入。这允许编译器优化器减少调用内联 asm 的次数,甚至完全消除它。nomem
表示汇编代码不会读取或写入内存。默认情况下,编译器将假定内联汇编可以读取或写入任何可访问的内存地址(例如,通过作为操作数传递的指针或全局指针)。nostack
表示汇编代码不会将任何数据推送到堆栈上。这允许编译器使用优化(例如 x86-64 上的堆栈红区)来避免堆栈指针调整。
这让编译器能够更好地优化使用 asm!
的代码,例如通过消除不需要输出的纯 asm!
块。
请参阅 参考资料 以获取可用选项及其效果的完整列表。