当前位置: 欣欣网 > 码农

探索 Rust 中的内联汇编

2024-07-19码农

内联汇编是 Rust 提供的一种机制,允许开发者在编译器生成的汇编代码中嵌入手工编写的汇编指令。虽然一般情况下不需要使用内联汇编,但在某些情况下,例如需要实现特定的性能要求或特定时序,或者访问底层硬件原语(例如内核代码)时,内联汇编就显得尤为必要。

内联汇编的基本用法

内联汇编通过 asm! 宏实现。所有 asm! 调用必须位于 unsafe 代码块中,因为它们可能会插入任意指令,破坏各种不变式。

以下是一个简单的示例,在汇编代码中插入一条 NOP(无操作)指令:

#![allow(unused)]
fnmain() {
use std::arch::asm;
unsafe {
asm!("nop");
}
}

输入和输出操作

内联汇编不仅可以插入指令,还可以操作数据。以下示例将值 5 写入 u64 类型的变量 x 中:

#![allow(unused)]
fnmain() {
use std::arch::asm;
let x: u64;
unsafe {
asm!("mov {}, 5", out(reg) x);
}
assert_eq!(x, 5);
}

asm! 宏中,第一个参数是一个模板字符串,用于指定指令。模板字符串遵循 Rust 的格式字符串语法。变量的输入和输出通过 in out 指定,并使用 reg 指定变量在通用寄存器中。编译器会选择合适的寄存器插入模板,并在内联汇编执行完成后从寄存器中读取变量。

以下示例使用了输入和输出:

#![allow(unused)]
fnmain() {
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

示例还展示了以下几个方面:

  • asm! 宏可以接受多个模板字符串参数,每个参数都被视为单独的汇编代码行,并在它们之间插入换行符。

  • 可以使用 inout 指定既是输入又是输出的变量,保证输入和输出使用同一个寄存器。

  • 可以使用数字或名称指定参数,方便代码可读性,并允许在不改变参数顺序的情况下重新排列指令。

  • 以下示例使用 inout 操作符对一个变量进行操作:

    #![allow(unused)]
    fnmain() {
    use std::arch::asm;
    letmut x: u64 = 3;
    unsafe {
    asm!("add {0}, 5", inout(reg) x);
    }
    assert_eq!(x, 8);
    }

    延迟输出操作符

    Rust 编译器在分配操作符时非常保守。它假设 out 可以在任何时间写入,因此不能与其他参数共享位置。为了保证最佳性能,尽可能减少寄存器使用至关重要,因为它们不需要在内联汇编块周围保存和重新加载。为了实现这一点,Rust 提供了 lateout 指定符。它可以用于任何仅在所有输入都被使用后才被写入的输出。还存在 inlateout 变体。

    以下示例展示了 inlateout 不能使用的情况:

    #![allow(unused)]
    fnmain() {
    use std::arch::asm;
    letmut 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);
    }

    编译器可以为输入 b c 分配同一个寄存器,因为它知道它们的值相同。但是,它必须为 a 分配一个单独的寄存器,因为它使用的是 inout 而不是 inlateout 。如果使用 inlateout ,则 a c 可以分配到同一个寄存器,在这种情况下,第一个覆盖 c 值的指令会导致汇编代码产生错误的结果。

    以下示例可以 inlateout ,因为输出只在所有输入寄存器都被读取后才被修改:

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

    显式寄存器操作符

    某些指令要求操作数位于特定寄存器中。因此,Rust 内联汇编提供了一些更具体的约束指定符。虽然 reg 通常在任何架构上都可用,但显式寄存器高度依赖于特定架构。例如,对于 x86,通用寄存器 eax ebx ecx edx ebp esi edi 可以通过它们的名称访问。

    #![allow(unused)]
    fnmain() {
    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)]
    fnmain() {
    use std::arch::asm;
    fnmul(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 asu128) << 64) + lo asu128
    }
    }

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

    覆盖寄存器

    在很多情况下,内联汇编会修改不需要作为输出的状态。通常这是因为必须在汇编中使用一个临时寄存器,或者因为指令修改了不需要进一步检查的状态。这种状态通常被称为「覆盖」。需要告诉编译器这一点,因为编译器可能需要在内联汇编块周围保存和恢复这种状态。

    use core::arch::asm;
    fnmain() {
    // 三个四字节的条目
    letmut name_buf = [0_u812];
    // 字符串以 ASCII 形式存储在 ebx、edx、ecx 中
    // 由于 ebx 是保留的,我们获取一个临时寄存器,并将 ebx 中的值移动到该寄存器中。
    // 但是,asm 需要保留该寄存器的值,因此它在主 asm 周围被压入和弹出
    // (在 64 位处理器上的 64 位模式下,32 位处理器将使用 ebx)
    unsafe {
    asm!(
    "push rbx",
    "cpuid",
    "mov [{0}], ebx",
    "mov [{0} + 4], edx",
    "mov [{0} + 8], ecx",
    "pop rbx",
    // 我们使用指向数组的指针来存储值,以简化 Rust 代码,代价是多执行几条 asm 指令
    // 然而,这比显式寄存器输出(如 `out("ecx") val`)更明确地说明了 asm 的工作方式
    // *指针本身* 只是输入,即使它是在后面写入的
    in(reg) 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);
    }

    在示例中,使用 cpuid 指令读取 CPU 制造商 ID。该指令将 eax 写入最大支持的 cpuid 参数,并将 ebx esx ecx 写入 CPU 制造商 ID,以 ASCII 字节形式按顺序存储。

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

    该代码还绕过了 LLVM 对 ebx 的限制,因为它是一个保留寄存器。这意味着 LLVM 假设它对该寄存器拥有完全控制权,并且必须在退出 asm 块之前将其恢复到其原始状态,因此它不能用作输出。为了解决这个问题,我们使用 push 保存寄存器,在 asm 块中从 ebx 读取到一个使用 out(reg) 分配的临时寄存器,然后使用 pop ebx 恢复到其原始状态。 push pop 使用寄存器的完整 64 位 rbx 版本,以确保整个寄存器都被保存。在 32 位目标上,代码将使用 ebx 来代替 push / pop

    这也可以用于通用寄存器类(例如 reg ),以获取用于 asm 代码的临时寄存器:

    #![allow(unused)]
    fnmain() {
    use std::arch::asm;
    // 使用移位和加法将 x 乘以 6
    letmut 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)]
    fnmain() {
    use std::arch::asm;
    extern"C"fnfoo(arg: i32) -> i32 {
    println!("arg = {}", arg);
    arg * 2
    }
    fncall_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)]
    fnmain() {
    use std::arch::asm;
    letmut 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 修饰符将发出该寄存器低字节的寄存器名称。因此,asm 代码将扩展为 mov ah, al ,它将值的低字节复制到高字节。

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

    内存地址操作数

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

    #![allow(unused)]
    fnmain() {
    use std::arch::asm;
    fnload_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! 块的语法。(有关 options 的更多信息,请参阅下面的选项)。

    #![allow(unused)]
    fnmain() {
    use std::arch::asm;
    letmut 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)]
    fnmain() {
    use std::arch::asm;
    letmut 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 代码没有可观察的副作用,并且其输出仅取决于其输入。这允许编译器优化程序减少对内联 asm 的调用次数,甚至完全消除它。

  • nomem 表示 asm 代码不读取或写入内存。默认情况下,编译器将假设内联汇编可以读取或写入它可以访问的任何内存地址(例如,通过作为操作数传递的指针,或全局变量)。

  • nostack 表示 asm 代码不会将任何数据压入堆栈。这允许编译器使用优化,例如 x86-64 上的堆栈红区,以避免堆栈指针调整。

  • 这些选项允许编译器更好地优化使用 asm! 的代码,例如,通过消除输出不需要的纯 asm! 块。

    文章精选

    「Rust