编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

探索 Rust 中的内联汇编(rust 例子)

wxchong 2024-08-03 03:07:19 开源技术 18 ℃ 0 评论

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

内联汇编的基本用法

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

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

#![allow(unused)]
fn main() {
    use std::arch::asm;

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

输入和输出操作

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

#![allow(unused)]
fn main() {
    use std::arch::asm;

    let x: u64;
    unsafe {
        asm!("mov {}, 5", out(reg) x);
    }
    assert_eq!(x, 5);
}

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

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

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

    let mut x: u64 = 3;
    unsafe {
        asm!("add {0}, 5", inout(reg) x);
    }
    assert_eq!(x, 8);
}

延迟输出操作符

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

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

#![allow(unused)]
fn main() {
    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);
}

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

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

#![allow(unused)]
fn main() {
    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);
}

显式寄存器操作符

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

#![allow(unused)]
fn main() {
    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() {
    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 core::arch::asm;

fn main() {
    // 三个四字节的条目
    let mut name_buf = [0_u8; 12];
    // 字符串以 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 参数,并将 ebxesxecx 写入 CPU 制造商 ID,以 ASCII 字节形式按顺序存储。

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

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

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

#![allow(unused)]
fn main() {
    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() {
    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() {
    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 修饰符将发出该寄存器低字节的寄存器名称。因此,asm 代码将扩展为 mov ah, al,它将值的低字节复制到高字节。

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

内存地址操作数

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

#![allow(unused)]
fn main() {
    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,不应该使用仅由 01 数字组成的标签,例如 011101010,因为它们最终可能会被解释为二进制值。使用 options(att_syntax) 将避免任何歧义,但这会影响_整个_ asm! 块的语法。(有关 options 的更多信息,请参阅下面的选项)。

#![allow(unused)]
fn main() {
    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() {
    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 代码没有可观察的副作用,并且其输出仅取决于其输入。这允许编译器优化程序减少对内联 asm 的调用次数,甚至完全消除它。
  • nomem 表示 asm 代码不读取或写入内存。默认情况下,编译器将假设内联汇编可以读取或写入它可以访问的任何内存地址(例如,通过作为操作数传递的指针,或全局变量)。
  • nostack 表示 asm 代码不会将任何数据压入堆栈。这允许编译器使用优化,例如 x86-64 上的堆栈红区,以避免堆栈指针调整。

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

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表