内联汇编

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 中。

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

  • 首先,同一个数字可以在同一个内联块中多次用作标签。
  • Second, that when a numeric label is used as a reference (as an instruction operand, for example), the suffixes “b” (“backward”) or ”f” (“forward”) should be added to the numeric label. It will then refer to the nearest label defined by this number in this direction.

选项

默认情况下,内联汇编块的处理方式与具有自定义调用约定的外部 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! 块。

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