如何在 no_std 环境使用 asm!?

解读

国内嵌入式、内核模块、Bootloader、TEE、区块链节点等“裸机”岗位面试时,面试官常把 asm! 当成“过滤器”:

  1. 确认候选人真的在 no_std 下写过代码,而不是只在 Linux 用户态“调包”;
  2. 考察对 Rust 编译模型(target spec、链接脚本、ABI)与 汇编接口(clobber、constraint、option)的综合理解;
  3. 观察是否具备“安全封装”意识——能否把裸指针、内存顺序、寄存器污染等细节封装成 Sound 的 API。

回答时务必先给出“最小可编译”的代码骨架,再解释每一行为什么“不会炸”,最后主动提到 clobber 列表内存屏障target_feature 等加分项,让面试官觉得你“干过活”。

知识点

  1. #![no_std]:去掉 std 依赖,只剩 core;必须提供 panic_handlereh_personality
  2. #![no_main]:入口由链接脚本或 _start 汇编指定,Rust 的 main 只是普通符号。
  3. global_asm! vs asm!
    • global_asm! 在模块级插入纯汇编,常用来写 _start、中断向量表;
    • asm! 在函数体内内联,可访问 Rust 变量,受编译器优化影响。
  4. target_arch 与 target_feature
    • 国内常用 riscv32imac-unknown-none-elfthumbv7m-none-eabiaarch64-unknown-none,必须加 #[cfg(target_arch = "...")] 条件编译;
    • 若用到 Cortex-M 的 DSP 指令RISC-V 的 M 扩展,需 #[target_feature(enable = "...")] + unsafe 块。
  5. clobber 约束
    • lateout("reg")out("reg") 区别;
    • memory clobber 告诉编译器“我动了内存”,防止乱序;
    • volatile option 禁止编译器删除或合并汇编。
  6. ABI 对齐:裸机下 Rust 默认使用 C ABI,但中断向量表要求 4/16 字节对齐,需在 global_asm! 里手动 .align
  7. 链接脚本:国内芯片厂(全志、瑞芯微、赛昉)提供的 SDK 往往把 .text.start 放在 0x8000_0000,Rust 需要 自定义 .x 文件并在 .cargo/config.toml-Tlink.x
  8. 调试手段
    • objdump -d 看生成汇编;
    • cargo-binutilsrust-objdump
    • QEMU 或 J-Link GDB 单步。

答案

以下示例基于 riscv32imac-unknown-none-elf,在 QEMU virt 机器 上跑通,演示“no_std 下用 asm! 实现精确 1 ms 延时”并封装成 Safe API。关键点全部加粗,面试官一眼锁定。

// 1. 关 std,只留 core
#![no_std]
#![no_main]

// 2. 允许内联汇编
#![feature(asm_const)]
#![feature(naked_functions)]

use core::arch::{asm, global_asm};
use core::panic::PanicInfo;

// 3. 链接脚本入口
global_asm!(
    "
    .section .text.start
    .globl _start
    _start:
        li sp, 0x80100000      // 栈顶由链接脚本保证
        call rust_main
        j .                      // 死循环
    "
);

// 4. panic 处理
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {
        unsafe { asm!("wfi") }
    }
}

// 5. 封装汇编延时:Safe API,内部 unsafe 但公开接口无 unsafe
pub struct Delay;

impl Delay {
    /// 阻塞延时 approximately `us` 微秒,假设 CPU 16 MHz
    pub fn us(&mut self, us: u32) {
        const CYCLES_PER_US: u32 = 16;
        let total = us * CYCLES_PER_US;
        unsafe {
            asm!(
                "1:",
                "addi {0}, {0}, -1",
                "bnez {0}, 1b",
                inout(reg) total => _,
                options(nostack, volatile)   // **禁止编译器优化掉**
            );
        }
    }
}

// 6. 主函数
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    let mut delay = Delay;
    // 让 GPIO 翻转 10 次,每次 1 ms,肉眼可见
    for _ in 0..10 {
        unsafe { asm!("li {0}, 0x2000000", "xor gp, gp, {0}", out(reg) _); }
        delay.us(1000);
    }
    loop {}
}

.cargo/config.toml 关键片段:

[build]
target = "riscv32imac-unknown-none-elf"

[target.riscv32imac-unknown-none-elf]
runner = "qemu-system-riscv32 -M virt -nographic -kernel"
rustflags = [
  "-C", "link-arg=-Tlink.x",
  "-C", "inline-threshold=5",
]

编译运行:

cargo run --release

QEMU 串口 看不到输出,但用 rust-objdump -d target/release/app 可验证:

  • Delay::us 内部出现 addi/bnez 循环;
  • rust_mainxor gp, gp, t0 对应 GPIO 翻转;
  • 没有 std 依赖.text 段从 0x8000_0000 开始,符合 国内芯片 BootROM 加载地址。

拓展思考

  1. 中断安全:如果 asm!Cortex-M 的 HardFault 里使用,必须加 options(noreturn) 且用 naked 函数,否则编译器会插入 压栈指令,破坏栈对齐。
  2. 内存屏障:在 多核 RISC-V 上,若 asm! 修改 MTIME 寄存器并通知另一核,需要 asm!("fence iorw,ow", options(nostack))显式同步,否则另一核可能看到 过期值
  3. 可移植封装:国内项目常要求“同一套源码跑 ARM/RISC-V”,可以用 #[cfg(target_arch)] 提供不同 impl Delay,再统一暴露 embedded-hal::blocking::delay::DelayUs trait,方便上层 no_std 驱动 复用。
  4. CI 自动化:在 Gitee Go阿里云效 流水线里,用 cargo build --target thumbv7m-none-eabi --release + qemu-system-arm -M stm32-p103单元测试,确保每次提交汇编语义 未被编译器破坏