探索 Rust 内联汇编的用法

Beginner

This tutorial is from open-source community. Access the source code

简介

在本实验中,我们将使用 asm! 宏来探索 Rust 中内联汇编的用法。我们将涵盖内联汇编的基本用法、输入和输出、延迟输出操作数、显式寄存器操作数、被破坏的寄存器、符号操作数和 ABI 破坏、寄存器模板修饰符、内存地址操作数、标签以及优化汇编代码的选项。

注意:如果实验未指定文件名,你可以使用任何你想要的文件名。例如,你可以使用 main.rs,并通过 rustc main.rs &&./main 进行编译和运行。

内联汇编

Rust 通过 asm! 宏支持内联汇编。它可用于将手写的汇编代码嵌入到编译器生成的汇编输出中。通常情况下这并非必要,但在需要特定性能或定时要求而无法通过其他方式实现时,或者在访问底层硬件原语(例如内核代码中)时,可能会用到此功能。

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

目前,以下架构支持内联汇编:

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

基本用法

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

## #[cfg(target_arch = "x86_64")] {
use std::arch::asm;

unsafe {
    asm!("nop");
}
## }

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

输入和输出

现在插入一条什么都不做的指令相当无趣。让我们做一些实际对数据进行操作的事情:

## #[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 将其放入任意通用寄存器中。编译器会选择一个合适的寄存器插入到模板中,并在内联汇编执行完毕后从那里读取变量。

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

## #[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 指令:

## #[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 操作数的输入和输出部分指定不同的变量:

## #[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 不能 使用的示例:

## #[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 模式)可能运行良好,但如果你想要优化性能(release 模式或其他优化情况),它可能无法正常工作。

这是因为在优化情况下,编译器可以自由地为输入 bc 分配相同的寄存器,因为它知道它们具有相同的值。然而,它必须为 a 分配一个单独的寄存器,因为它使用的是 inout 而不是 inlateout。如果使用 inlateout,那么 ac 可以分配到同一个寄存器,在这种情况下,第一条指令会覆盖 c 的值并导致汇编代码产生错误的结果。

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

## #[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 等可以通过它们的名称来指定。

## #[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 指令的示例:

## #[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 是保留寄存器,所以汇编需要保留它的值。
    // 所以我们在主汇编前后对其进行压栈和出栈操作。
    // 64 位处理器上的 64 位模式不允许对 32 位寄存器(如 ebx)进行压栈/出栈操作,所以我们必须使用扩展的 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 Manufacturer ID: {}", name);
}

## #[cfg(not(target_arch = "x86_64"))]
## fn main() {}

在上面的示例中,我们使用 cpuid 指令读取 CPU 制造商 ID。这条指令将最大支持的 cpuid 参数写入 eax,并将 CPU 制造商 ID 以 ASCII 字节的形式依次写入 ebxedxecx

即使 eax 从未被读取,我们仍然需要告诉编译器该寄存器已被修改,以便编译器可以保存汇编之前这些寄存器中的任何值。这是通过将其声明为输出,但使用 _ 而不是变量名来完成的,这表示输出值将被丢弃。

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

为了解决这个问题,我们使用 rdi 来存储指向输出数组的指针,通过 push 保存 ebx,在汇编块内将 ebx 中的值读入数组,然后通过 popebx 恢复到原始状态。pushpop 使用寄存器的完整 64 位 rbx 版本,以确保整个寄存器被保存。在 32 位目标上,代码将在 push/pop 中使用 ebx

这也可以与通用寄存器类一起使用,以在汇编代码中获得一个临时寄存器:

## #[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 的所有破坏操作数。

## #[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 等)。

可以通过在模板字符串操作数上使用修饰符来覆盖这个默认设置,就像你在格式字符串中那样:

## #[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)与操作数,而忘记使用模板修饰符,编译器将发出警告并建议使用正确的修饰符。

内存地址操作数

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

## #[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 的代码被放置在同一个代码生成单元中,因此可能会引入任意标签。

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

此外,在 x86 上使用默认的 Intel 语法时,由于 [LLVM 错误],你不应该使用仅由 01 数字组成的标签,例如 011101010,因为它们最终可能会被解释为二进制值。使用 options(att_syntax) 将避免任何歧义,但这会影响整个 asm! 块的语法。(有关 options 的更多信息,请参见下面的 选项。)

## #[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 指令示例为例:

## #[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! 块。

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

总结

恭喜你!你已经完成了内联汇编实验。你可以在 LabEx 中练习更多实验来提升你的技能。