内联汇编

10 分钟
本页目录

asm! 把目标架构汇编嵌入 Rust 生成代码,并通过操作数和 options 告诉编译器它的行为。它不是一堵“禁止所有优化”的墙;优化器仍根据你声明的输入、输出和副作用安排周围代码。

使用前先检查标准库或架构 intrinsic 是否已表达所需操作。直接汇编适合确有目标指令或 ABI 要求的边界,普通算术不必为了性能直觉就改写。

输入哪些值在进入时可读输出与破坏哪些寄存器可能被写入内存与标志是否访问内存、改变条件标志控制流与栈是否返回、是否使用栈、调用约定汇编能被组装,不代表这些声明真实;错误约束会在优化后暴露。内联汇编的契约:编译器只知道你声明的部分

从一个边界清楚的指令开始

以下针对 x86-64,采用 asm! 默认的 Intel 语法:

#[cfg(target_arch = "x86_64")]
fn add_wrapping(mut left: u64, right: u64) -> u64 {
    // SAFETY: 只操作声明的寄存器;不访问内存和栈;add 修改标志但不额外声明保留。
    unsafe {
        core::arch::asm!(
            "add {left}, {right}",
            left = inout(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    left
}

fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        assert_eq!(add_wrapping(4, 5), 9);
        assert_eq!(add_wrapping(u64::MAX, 1), 0);
    }
}

函数名明确了模 2^64 回绕语义,不能把它当作开启溢出检查时的普通 Rust + 的完全等价替换。add 会改标志,因此不能附加 preserves_flags。pure 在此表示输出只依赖输入且无其他可观察副作用,允许删除未使用结果等优化。

inout 与 inlateout 差一个关键承诺

操作数承诺
in(reg)初始输入;汇编结束时须满足未列为输出的寄存器约定
out(reg)输出可在输入仍被使用时提前写入,避免与输入不当重叠
inout(reg)同一操作数既输入又输出,较保守地约束分配
lateout(reg)所有输入已读完后才写,可与输入寄存器复用
inlateout(reg)输入输出,并允许满足条件的输入寄存器复用

lateout 允许与输入寄存器复用,前提是写入时机满足约束。多指令模板如果先改了输出,再继续读另一个输入,错误使用 lateout/inlateout 可能在优化器复用寄存器后得到错误结果。Rust 内联汇编

reg 由编译器分配,显式 "eax" 等则指定物理寄存器。显式寄存器操作数不能再用模板占位符引用,而应在模板中直接写寄存器名;子寄存器宽度也要与指令和 Rust 类型对应。

第 1 条:mov output, left写入 output;left 已被读取第 2 条:add output, right仍需要读取原始 rightoutput 不能提前覆盖 right 的寄存器使用 out(reg),保留第二条指令尚未读取的输入多指令模板:输出开始写入时,是否还有输入没读完模板结束时才交回 Rust 输出,与模板内部何时首次写寄存器,是不同概念。

多指令的输出不能无条件改成 lateout

#[cfg(target_arch = "x86_64")]
fn sum(left: u64, right: u64) -> u64 {
    let output: u64;
    // SAFETY: 所有读写寄存器已声明;output 会提前写入,使用 out 避免覆盖未读输入。
    // 只做寄存器计算,不访问内存/栈;add 会修改标志。
    unsafe {
        core::arch::asm!(
            "mov {output}, {left}",
            "add {output}, {right}",
            output = out(reg) output,
            left = in(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    output
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    for left in [0, 1, 7, u64::MAX, 1 << 63] {
        for right in [0, 1, 7, u64::MAX, 1 << 63] {
            assert_eq!(sum(std::hint::black_box(left), std::hint::black_box(right)),
                       left.wrapping_add(right));
        }
    }
}

如果 output 与 right 被分配到同一个寄存器,第一条 mov 就会破坏第二条尚未使用的 right。把输出标为 lateout 会向编译器允许这种复用,不能因为变量名不同或调试构建恰好分配了不同寄存器就认为安全。操作数约束示例

Rust 类型宽度与模板寄存器宽度一起检查

#[cfg(target_arch = "x86_64")]
fn add_u32(mut left: u32, right: u32) -> u32 {
    // SAFETY: e 修饰符选择 32 位子寄存器;只改声明输出和允许改变的标志。
    unsafe {
        core::arch::asm!(
            "add {left:e}, {right:e}",
            left = inout(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    left
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        assert_eq!(add_u32(10, 20), 30);
        assert_eq!(add_u32(u32::MAX, 1), 0);
    }
}

:e 在 x86 寄存器模板中指定 32 位名字。不能假定将 u32 放进 reg 后,模板默认打印的更宽寄存器高位就含有某个可依赖值;应让指令宽度与实际输入一致。跨架构的修饰符和寄存器类别不同,需要按目标文档选择。

第二条指令读到的还是原输入吗?

寄存器约束最难的地方是变量名不同不代表物理位置不同。下图让 output 与 right 故意共用,第一条 mov 的写入就变成第二条 add 的错误输入。看到这个反例后,out 所保留的独立位置就有了具体意义。

正在呈现知识画面
第二条指令读到的还是原输入吗?

过早写 output 会破坏尚未读取的 right;此模板需要 out,不能随意改为 lateout。

RDTSC:低位在 EAX,高位在 EDX

下面仅读取计数值,不是完整的微基准计时边界。运行环境须允许用户态读取 TSC:

#[cfg(target_arch = "x86_64")]
fn read_tsc() -> u64 {
    let low: u32;
    let high: u32;
    // SAFETY: 在允许用户态 RDTSC 的目标环境中,仅破坏声明的 EAX/EDX。
    unsafe {
        core::arch::asm!(
            "rdtsc",
            out("eax") low,
            out("edx") high,
            options(nomem, nostack, preserves_flags),
        );
    }
    ((high as u64) << 32) | low as u64
}

fn main() {
    #[cfg(target_arch = "x86_64")]
    println!("{}", read_tsc());
}

不能给 RAX 同时声明多个冲突输出,也不能只读取 RAX 就声称已经自动拼好了 64 位结果。RDTSC 随外部计数器变化,不能标记 pure。

RDTSC 不提供完整的指令序列化;要衡量前后代码,需要按处理器手册设计排序、计时器读取和编译器可见性,并评估迁核、虚拟化与频率。TSC tick 也不等于当前核心每个实际执行周期。Intel SDM

AArch64 的 CNTVCT_EL0 是虚拟系统计数器,频率与访问权限有自己的约定,不应笼统称为“核心周期计数器”。跨架构移植需要重建测量定义,而不只是替换一条 mrs。Arm 系统计数器

options 不是性能提示,而是可依赖的声明

nomem 表示不读写汇编可见的外部内存等相应保证,允许编译器更积极地处理周围内存操作;它不是“刷新或不刷新 CPU 缓存”。readonly 与 pure 也各有要求,不能把会写缓冲区或访问设备的指令标成纯读或纯计算。

nostack 承诺不使用规定之外的栈空间,包含目标 ABI 的 red zone 规则;preserves_flags 承诺保留相应架构标志。noreturn 声明不会回到后继 Rust 代码。虚假声明可能造成未定义行为,即使调试构建看起来正常。

普通内存读写、volatile、编译器重排约束和 CPU 内存屏障是不同层次;asm! 默认的保守内存影响也不自动让非原子共享访问变成线程安全。

传入地址,不等于声明了内存读取

#[cfg(target_arch = "x86_64")]
fn load(value: &u64) -> u64 {
    let output: u64;
    // SAFETY: value 在调用期间是有效、对齐且无冲突写入的 u64;
    // mov 读取该地址,不写内存/栈,也不修改标志。
    unsafe {
        core::arch::asm!(
            "mov {output}, qword ptr [{address}]",
            address = in(reg) value as *const u64,
            output = lateout(reg) output,
            options(readonly, nostack, preserves_flags),
        );
    }
    output
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        let values = [0u64, 42, u64::MAX];
        for value in &values { assert_eq!(load(value), *value); }
    }
}

address 是寄存器输入,[address] 才执行内存读取,所以不能标记 nomem。单条 mov 在写回输出前读取地址操作数,因此这里的 lateout 可以与地址输入复用;与前面两条指令的模板不同。

readonly 表示不写相关外部内存,但仍会读取。该示例的安全入口接收 &u64,用引用已有的保证约束读取范围和生命周期;若换成任意裸地址,封装就必须增加额外调用前提。MMIO、volatile 和线程同步不能由这段普通读取模板替代。

实际行为声明时应考虑的内容
寄存器纯运算输入、输出、标志;满足条件时可 pure + nomem
通过指针读普通内存不能 nomem,读取前提由接口建立
写缓冲区不能 readonly,需正确独占或同步及有效范围
读取变化的硬件计数器不能仅因不写内存就标 pure
调用外部函数ABI、寄存器破坏、栈、内存和展开约定一起核对

在汇编中调用函数还有完整 ABI 义务

clobber_abi("C") 声明指定 ABI 的调用者易失寄存器集合,但不替你放置参数、维持栈对齐、预留 Windows shadow space 或实现展开信息。与它一起使用输出还有显式寄存器等规则,应按文档逐项检查。

因此一行 call 加一个 clobber_abi 并不是可随处复制的安全调用包装。普通函数调用通常应交给 Rust extern 声明;确需手写入口或完整函数时,再考虑 global_asm!/naked_asm! 及对应目标约束。

验证不能只看“能编译”

在目标平台测试边界输入,对纯运算与 Rust 明确语义的实现比较;至少检查优化构建,因为寄存器复用错误可能只在优化后出现。查看最终反汇编以确认指令、寄存器宽度与周围代码安排。

编译器能发现部分冲突或非法操作数,却通常不能证明模板与 clobber/options 一致。Miri 对内联汇编能力有限,不能用未覆盖的执行结果证明汇编安全。条件编译保护架构语法,CPU 指令扩展的运行条件另行处理,参见 cfg。

如何验证而不把微基准写成指令神话

先将计算语义写成安全 Rust 参照实现,测试零值、边界值、相同输入以及溢出,再比较不同优化等级的结果。相同输入尤其可能影响编译器的寄存器共用选择;测试不应只覆盖几组互不相等的小数。

可以用 rustc --edition=2024 -O --emit=asm example.rs 输出优化后的汇编,检查操作数分配与模板周围代码。查看某个汇编片段只是当前目标、版本和优化配置的证据,不能把寄存器名字固定写进语言契约。

测量性能时另需考虑防止无用计算被删除、测量开销、分支分布、缓存和频率。black_box 能帮助避免部分优化,但既不保证精确计时,也不是安全边界;几次 RDTSC 相减不会自动排除这些变量。若标准 Rust 已生成同样指令,手写 asm 只会增加维护与跨平台成本。