如何读写 x86_64 寄存器?

解读

在国内 Rust 后端/系统级岗位面试中,这道题常被用来快速区分“只会写业务代码”与“真正写过底层或做过 FFI/内核开发”的候选人。面试官并不期待你背出 16 个通用寄存器名字,而是想确认:

  1. 你知道 Rust 对“寄存器”这一硬件资源的访问必须落到不安全代码
  2. 你能给出可编译、可运行、符合调用约定的最小片段;
  3. 你意识到特权级限制——用户态只能读写允许被访问的寄存器(如 RFLAGS 只能改 TF/DF 等特定位,CR3 根本动不了);
  4. 你清楚volatile 语义:编译器绝不能把寄存器读写优化掉,否则时序或状态机就崩了。

一句话:把“寄存器”当成“随时会变的全局状态”,用 asm! 做 volatile 访问,同时尊重 ABI 与特权级

知识点

  • asm!(stable since 1.59)与 global_asm! 的区别与使用场景
  • in/out/inlateout/inout 操作数约束:如何告诉编译器“这条指令会改写寄存器”
  • volatilenomem/preserves_flags 等选项:防止 LLVM 把指令重排或删除
  • x86_64 调用约定(System V AMD64):RAX、RCX、RDX 为 caller-saved;RBX、RBP、R12–R15 为 callee-saved
  • 特权级与权限:用户态通过 RDMSR/WRMSR 指令访问 MSR 会触发 #GP(0),需内核配合或 /dev/cpu/*/msr 驱动
  • 内存屏障:带 lock 前缀的指令(如 lock; addl $0, (%rsp))可兼作编译器屏障
  • Rust 内联汇编占位符{0}, {1} 对应操作数列表顺序,寄存器名直接写 "rax" 即可绑定到该寄存器

答案

下面给出用户态可运行的完整示例,演示读 CR0→改位→写回(仅示范流程,真正改 CR0 需要 ring0;这里用 CR0 只读模拟 演示语法)。重点在于模板可复制到任何真实寄存器

#![feature(asm_const)] // 1.79+ 可省,stable 1.59+ 用 asm! 即可

#[repr(transparent)]
struct Cr0(u64);

impl Cr0 {
    /// 读取 CR0
    #[inline(always)]
    pub fn read() -> Self {
        let val: u64;
        unsafe {
            asm!(
                "mov {}, cr0",
                out(reg) val,
                options(nostack, preserves_flags, readonly)
            );
        }
        Cr0(val)
    }

    /// 写入 CR0(仅 ring0 可用;用户态会 #GP)
    #[inline(always)]
    pub unsafe fn write(self) {
        unsafe {
            asm!(
                "mov cr0, {}",
                in(reg) self.0,
                options(nostack, preserves_flags)
            );
        }
    }

    /// 开关写保护位(WP,bit 16)
    pub fn toggle_wp(&mut self) {
        self.0 ^= 1 << 16;
    }
}

/// 读取 RFLAGS 并返回 TF 位
pub fn read_rflags_tf() -> bool {
    let flags: u64;
    unsafe {
        asm!(
            "pushfq",
            "pop {}",
            out(reg) flags,
            options(nostack, preserves_flags)
        );
    }
    (flags >> 8) & 1 != 0
}

/// 设置 TF 位(仅影响本线程,调试场景常用)
pub unsafe fn set_tf(enable: bool) {
    let old: u64;
    unsafe {
        asm!(
            "pushfq",
            "pop {}",
            out(reg) old,
            options(nostack, preserves_flags)
        );
        let new = if enable { old | (1 << 8) } else { old & !(1 << 8) };
        asm!(
            "push {}",
            "popfq",
            in(reg) new,
            options(nostack)
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cr0_syntax_demo() {
        let c = Cr0::read();
        println!("CR0 = 0x{:x}", c.0);
        // 真正产品代码里,这里应通过内核模块做 CR0 修改
    }

    #[test]
    fn rflags_demo() {
        let tf = read_rflags_tf();
        println!("TF = {}", tf);
    }
}

要点回顾

  1. asm! 块里用 out(reg) / in(reg) 告诉编译器数据方向;
  2. options(nostack, preserves_flags) 避免编译器插入栈操作或假设标志位不变;
  3. 用户态不要尝试写 CR3/CR0/CR4,会立即段错误;若产品需要,走内核驱动/dev/cpu/*/msr 接口;
  4. MSR的访问可用 rdmsr/wrmsr 指令,但需 ring0msr 内核模块授权;Rust 侧写法完全一致,仅指令换成 "rdmsr" / "wrmsr",并把 ECX 置为 MSR 编号。

拓展思考

  1. 跨平台封装:在 aarch64 上寄存器读写用 mrs/msr 指令,Rust 侧可用 cfg(target_arch = "x86_64") 做条件编译,提供统一 Registers::read_system_reg(id: u32) -> u64 接口,让同一套代码在服务器、嵌入式、Mac M 系列芯片上都能跑
  2. 零开销抽象:用 const 泛型把 MSR 编号做成编译期常量,例如 Msr<0xC0000080>编译器直接把常量塞进 ECX,无运行时开销
  3. 与 async/await 结合:在用户态驱动 virtio 设备时,往往需要轮询 MMIO 寄存器等待 QUEUE_READY 位。可以把寄存器读封装成 poll_fn,配合 waker无锁异步通知既保留 Rust 内存安全,又拿到 C 级别的寄存器时序控制
  4. 安全边界:把全部 asm! 封装在最小 unsafe 模块里,对外只暴露 safe API,并在文档里用 # Safety 写明“调用者必须确保 CPU 特权级正确”。让团队里的业务开发同事无需写 unsafe 也能调寄存器,这是 Rust 在内核模块、云原生 hypervisor 项目里落地时的最佳实践

掌握以上思路,你在面试中就能从语法、ABI、特权级到生态落地层层展开,直接对标国内大厂做 Rust 系统层岗位的技术深度要求