3. 硬件抽象层(HAL)与寄存器操作:MMIO 原理与安全封装

嵌入式裸机编程,说白了就是直接跟硬件打交道。而硬件最核心的交互方式,就是读写寄存器。这一章我们聊聊 MMIO(内存映射 I/O)的原理,以及如何在 Rust 里安全、高效地操作它。

3.1 MMIO 原理:为什么寄存器看起来像内存?

大多数嵌入式处理器(比如 ARM Cortex-M、RISC-V)都把外设寄存器映射到一段特定的地址空间。CPU 访问这些地址时,实际上是在跟外设控制器对话——读一个地址可能得到传感器数据,写一个地址可能改变 GPIO 电平。

举个例子,STM32F4 的 USART 数据寄存器地址是 0x4001_1000 + 0x24。你往这个地址写一个字节,串口就会把它发出去。从 CPU 角度看,这跟写 RAM 没区别——都是 load/store 指令。但背后完全是两码事。

关键区别:普通 RAM 访问可以缓存、可以重排、可以合并写操作。但寄存器操作不行——每次读写都必须精确地、按顺序地到达外设。这就是 volatile 存在的理由。

我刚开始做嵌入式时,犯过一个低级错误:用 let val = *ptr 读状态寄存器,结果编译器优化后只读了一次,循环里一直用旧值判断。嗯,从那以后我再也不敢小看 volatile 了。

3.2 使用 core::ptr 进行寄存器读写

Rust 标准库在裸机环境用不了,但 core::ptr 是 core 库的一部分,裸机也能用。最基础的操作就是 read_volatile 和 write_volatile。

use core::ptr;

const USART_DR: *mut u32 = 0x4001_1024 as *mut u32;

fn send_byte(byte: u8) {
    unsafe {
        ptr::write_volatile(USART_DR, byte as u32);
    }
}

fn read_status() -> u32 {
    unsafe {
        ptr::read_volatile(USART_DR)
    }
}

注意这里用了 unsafe——因为裸指针解引用和 MMIO 操作本身都是 unsafe 的。Rust 编译器没法验证你指向的地址是不是合法的外设寄存器。

警告:不要用 *ptr = val 或 let val = *ptr 操作寄存器。普通解引用可能被编译器优化掉,或者产生非预期的访存指令序列。必须用 volatile 操作保证每次读写都真正执行。

3.3 volatile 操作:为什么编译器不能碰你的寄存器?

volatile 告诉编译器:这个地址的内容可能在程序流之外被改变(比如硬件自动更新状态寄存器),或者写这个地址会产生副作用(比如清中断标志位)。

编译器对 volatile 操作的限制:

  • 不消除:即使结果没被使用,volatile 读也不会被删掉
  • 不重排:volatile 操作之间不会交换顺序(但非 volatile 操作可能越过它)
  • 不合并:连续两次 volatile 写同一个地址,两次都会执行

我个人习惯把 volatile 操作封装在 tiny 函数里,而不是到处写 unsafe { ptr::read_volatile(...) }。这样既减少重复,也降低漏掉 volatile 的风险。

小技巧:如果你需要保证 volatile 操作之间的顺序(比如先写控制寄存器,再写数据寄存器),可以考虑插入 compiler_fence 或 atomic::fence。但大多数简单场景下,volatile 的顺序保证就够了。

3.4 构建安全的寄存器封装

裸指针 + volatile 虽然能用,但太原始了。一个像样的 HAL 应该提供类型安全的寄存器访问。我总结了三个层次:

层次 特点 适用场景
裸指针封装 直接暴露 *mut u32,调用者自己保证安全 快速原型、底层 bootloader
结构体封装 用结构体表示寄存器块,方法封装读写逻辑 中等规模驱动、教学示例
类型状态封装 利用 Rust 类型系统在编译期检查寄存器状态 生产级 HAL、安全关键系统

下面重点说说结构体封装——这是最实用、也最常用的方式。

3.4.1 结构体封装示例

/// USART 寄存器块
#[repr(C)]
pub struct UsartRegs {
    dr:  u32,   // 数据寄存器,偏移 0x00
    sr:  u32,   // 状态寄存器,偏移 0x04
    cr1: u32,   // 控制寄存器1,偏移 0x08
    cr2: u32,   // 控制寄存器2,偏移 0x0C
}

impl UsartRegs {
    /// 发送一个字节(轮询方式)
    pub fn send_byte(&self, byte: u8) {
        // 等待发送缓冲区空
        while self.sr_read() & (1 << 7) == 0 {}
        unsafe {
            ptr::write_volatile(&self.dr as *const _ as *mut u32, byte as u32);
        }
    }

    fn sr_read(&self) -> u32 {
        unsafe { ptr::read_volatile(&self.sr as *const u32) }
    }
}

/// 假设 USART1 基地址为 0x4001_1000
const USART1: *const UsartRegs = 0x4001_1000 as *const UsartRegs;

fn main() {
    let uart = unsafe { &*USART1 };
    uart.send_byte(b'H');
}

这里有几个要点:

  • #[repr(C)] 保证结构体字段顺序和内存布局与硬件寄存器排列一致
  • 每个寄存器访问方法内部都用了 volatile 操作
  • 公开方法(如 send_byte)封装了等待逻辑,调用者不需要关心底层细节

核心原则:unsafe 代码要尽量集中、尽量小。把 unsafe 封装在方法内部,对外提供 safe 接口。这样整个项目中 99% 的代码都可以是 safe 的。

3.4.2 位域操作封装

寄存器经常需要操作特定位域。比如控制寄存器的某个位使能中断,另一个位选择时钟分频。我建议用位域结构体 + 方法组合:

pub struct Cr1Fields(u32);

impl Cr1Fields {
    pub fn enable_te(mut self) -> Self {
        self.0 |= 1 << 3;  // TE 位在 bit 3
        self
    }
    pub fn enable_re(mut self) -> Self {
        self.0 |= 1 << 2;  // RE 位在 bit 2
        self
    }
    pub fn build(self) -> u32 {
        self.0
    }
}

impl UsartRegs {
    pub fn modify_cr1(&self, f: impl FnOnce(Cr1Fields) -> Cr1Fields) {
        let old = unsafe { ptr::read_volatile(&self.cr1 as *const u32) };
        let new = f(Cr1Fields(old)).build();
        unsafe { ptr::write_volatile(&self.cr1 as *const _ as *mut u32, new) };
    }
}

// 使用示例
uart.modify_cr1(|f| f.enable_te().enable_re());

这种模式叫「构建器 + 闭包」,在嵌入式 Rust 社区很流行。它有几个好处:

  • 读-改-写操作原子化(在单核裸机环境下,没有中断竞争就是原子的)
  • 位域操作类型安全,不会写错位位置
  • 调用链清晰,可读性强

注意:如果中断可能修改同一个寄存器,读-改-写就不是原子的了。这种情况下需要关中断或使用临界区。我在一个电机驱动项目里就踩过这个坑——中断里改了控制寄存器,主循环的 modify 操作把中断设置覆盖了。

3.5 知识结构图:MMIO 与寄存器封装体系

下面这张图梳理了本章的核心概念和它们之间的关系:

MMIO 与寄存器封装知识体系 MMIO 与寄存器封装知识体系 MMIO 原理 地址映射 外设寄存器 volatile 语义 core::ptr 操作 read_volatile / write_volatile 裸指针封装 结构体封装 类型状态封装 #[repr(C)] 布局控制 位域构建器模式 核心原则:unsafe 集中化,对外提供 safe 接口 从 MMIO 原理出发,经过 volatile 操作,最终构建出类型安全的寄存器封装

3.6 避坑指南:我踩过的几个坑

做嵌入式这么多年,寄存器操作相关的坑我踩过不少。挑几个典型的说说:

  • 忘记 volatile:我曾经写了一个轮询等待标志位的循环,编译器把读操作优化到循环外面,结果程序卡死。从那以后我所有寄存器访问都强制用 volatile。
  • 结构体对齐问题:有一次我定义寄存器结构体时忘了 #[repr(C)],Rust 默认重排了字段顺序,结果写错了寄存器。排查了一整天才发现。
  • 读-改-写被中断打断:在多中断场景下,modify 操作不是原子的。我后来加了临界区保护才解决。
  • 位域操作写错掩码:手动计算位掩码容易出错。我现在都用构建器模式,把掩码计算封装在方法里。

我的建议:如果你刚开始做 Rust 嵌入式,先从结构体封装开始。等你对类型系统更熟悉了,再尝试类型状态封装。不要一上来就追求最 fancy 的方案——实用才是第一位的。