寄存器级编程:PAC、MMIO 与 volatile 语义

本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 5 篇。上一篇:第一个固件:启动流程、向量表与 GPIO 点灯,下一篇:GPIO 子系统:输入输出模式、上下拉与端口映射陷阱,目录见文末。

寄存器级编程:PAC、MMIO 与 volatile 语义

导读

这一篇是全系列的"底层许可证":搞懂 PAC(外设访问层)、mmio.Mmio、*volatile 指针之后,你将不再被任何 HAL 的能力边界卡住——HAL 没写的外设(CAN、Flash、看门狗、DAC)、HAL 写错的引脚(E/F/G 口),你都能自己写。本篇的实战案例是 PE5 寄存器点灯:恰好绕开第 04 篇末尾提到的上游 bug,也是"为什么要懂寄存器"的最好演示。

三个新名词先对齐:

  • PAC(Peripheral Access Crate,借用 Rust 术语):寄存器定义代码——每个外设一个 extern struct,每个寄存器一个带位域的类型,地址写死。microzig 里由 regz 工具从 embassy 的 stm32-data 数据生成,通过 microzig.chip.peripherals.XXX 访问。
  • MMIO(Memory-Mapped I/O):外设寄存器映射到内存地址空间,读写普通地址就是操作硬件。
  • volatile:告诉编译器"这个访问有副作用,不许合并、删除、重排"。Zig 里 volatile 是指针的属性(*volatile T),不是变量的属性。

为什么需要寄存器层

第 04 篇的 HAL 点灯是"问框架要引脚";寄存器层是"直接跟硅片说话"。需要下潜的三个理由:

理由一:HAL 的能力边界。 F103 的 HAL 模块清单里没有 CAN、Flash、DAC、IWDG、WWDG、低功耗进入函数——这些外设寄存器都在芯片里,microzig 只是没写。第 17、18、19、28 篇的全部内容都是寄存器直写。

理由二:HAL 的 bug。 gpio.Pin.get_port() 的实现(hals/STM32F103/gpio.zig):

pub fn get_port(gpio: Pin) *volatile GPIO {
    const pin: usize = @divFloor(@backingInt(gpio), 16);
    switch (pin) {
        0 => return if (@hasDecl(peripherals, "GPIOA")) peripherals.GPIOA else @panic("Invalid Pin"),
        1 => return if (@hasDecl(peripherals, "GPIOB")) peripherals.GPIOB else @panic("Invalid Pin"),
        2 => return if (@hasDecl(peripherals, "GPIOC")) peripherals.GPIOC else @panic("Invalid Pin"),
        3 => return if (@hasDecl(peripherals, "GPIOD")) peripherals.GPIOD else @panic("Invalid Pin"),
        4 => return if (@hasDecl(peripherals, "GPIOE")) peripherals.GPIOA else @panic("Invalid Pin"),
        5 => return if (@hasDecl(peripherals, "GPIOF")) peripherals.GPIOA else @panic("Invalid Pin"),
        6 => return if (@hasDecl(peripherals, "GPIOG")) peripherals.GPIOA else @panic("Invalid Pin"),
        else => @panic("The STM32 only has ports 0..6 (A..G)"),
    }
}

第 102–104 行:case 4/5/6(E/F/G)的 @hasDecl 检查的是 GPIOE/F/G 是否存在,返回的却全是 GPIOA——典型的复制粘贴漏改。后果:ZET6 的 48 个 E/F/G 引脚用 HAL 操作会静默打到 PA 上。pins.GlobalConfiguration 也救不了(它内部最终走同一个 get_port)。只有寄存器层是安全的。

理由三:理解成本。 volatile 语义、位域与 @bitCast、读改写与原子性——这些概念在 HAL 背后被隐藏,但每一个都会在第 08 篇(中断共享寄存器)、第 22 篇(并发竞态)以更贵的学费再次出现。现在用点灯这个 10 行的例子学会,最便宜。

代码案例:PE5 寄存器点灯全过程

目标:把 PE5 配成推挽输出并拉高(等价于 HAL 的 Pin.from_port(.E, 5).set_output_mode(...) .put(1),但 HAL 做不到)。

const microzig = @import("microzig");

pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{});

comptime {
    _ = microzig.export_startup();
}

pub fn main() void {
    const regs = microzig.chip.peripherals;

    // 第 1 步:开 GPIOE 时钟。
    // GPIO 挂在 APB2 上,RCC.APB2ENR 的 IOPGEN 位(bit6)控制 GPIOE。
    // 位域名字由 regz 生成,这里用 modify 按名字写,避免手算位号出错。
    regs.RCC.APB2ENR.modify(.{ .IOPGEN = 1 });

    // 第 2 步:把 PE5 配成推挽输出 50MHz。
    // GPIO 配置寄存器是 CR[0](CRL,pin0-7) 和 CR[1](CRH,pin8-15),
    // 每个引脚占 4 位:MODE[1:0](低 2 位)+ CNF[1:0](高 2 位)。
    // 推挽输出 50MHz = MODE=0b11, CNF=0b00 -> 4 位编码 0b0011 = 0x3。
    // PE5 在 CRL(低 8 位引脚),偏移 = 5 * 4 = 20 位。
    const gpioe = regs.GPIOE;
    const shift: u5 = 5 * 4;
    const mask: u32 = @as(u32, 0xF) << shift;
    gpioe.CR[0].raw = (gpioe.CR[0].raw & ~mask) | (@as(u32, 0x3) << shift);

    // 第 3 步:BSRR 置位 PE5(原子写,单条 store)。
    gpioe.BSRR.raw = 1 << 5;

    // 停在这里,用万用表/LED 观察 PE5。
    while (true) {
        microzig.cpu.wfi();
    }
}

这段代码刻意全部用 .raw 整数操作而不用位域名字——原因见"逐段解析"最后一节。

逐段解析

PAC 从哪来

microzig.chip.peripherals.GPIOE 的类型是 *volatile types.peripherals.GPIO,值是 @ptrFromInt(0x4001_1800)——GPIOE 的基地址(RM0008 的外设基址表:GPIOA 0x40010800、B 0x40010C00、C 0x40011000、D 0x40011400、E 0x40011800、F 0x40011C00、G 0x40012000)。这份定义由 regz 工具在构建时从 embassy 的 stm32-data JSON 生成(芯片定义里写着 .register_definition = .{ .embassy = .{ .device = "STM32F103ZE" } }),不 vendor 在仓库里。这意味着一个重要的写作纪律:外设实例名(GPIOE)、寄存器成员名(BSRR、CR)以生成数据为准;具体位域字段名不确定时一律用 .raw + 位移。本篇的示例遵守这个纪律。

microzig.chip.peripherals 与 microzig.cpu.peripherals 是两个命名空间:前者是芯片外设(GPIO、RCC、USART),后者是核内外设(scb、nvic、systick、mpu)。写混了报 no field named,第 08 篇操作 NVIC 时用的是 microzig.cpu.peripherals.nvic。

mmio.Mmio 的真实用法

每个有位域的寄存器在生成物里是 mmio.Mmio(packed struct(u32) { ... })。Mmio 是一个 packed struct(Int),唯一公开字段是 raw: Int(整数),方法全是 addr: *volatile Self 的自由函数:

// 读整个寄存器为位域视图
const bits = regs.RCC.APB2ENR.read();     // 返回 packed struct
// 按位域写字段(读-改-写)
regs.RCC.APB2ENR.modify(.{ .IOPGEN = 1 });
// 整数直写
regs.RCC.APB2ENR.raw = 0x1;               // 只开 AFIO
// 整数直读
const v = regs.RCC.APB2ENR.raw;

四条使用规则(每一条都对应真实翻车场景):

  1. raw 是公开字段,regs.CR1.raw |= 1 << 5; 合法且是最短路径——绕开"位域名不确定"的问题。
  2. modify 是读-改-写,非原子。两次 volatile 访问之间可被中断打断;多上下文写同一寄存器要加临界区(第 22 篇)。
  3. modify 会把整个寄存器写回,包括你没列出的字段。对 W1C(写 1 清零)寄存器这是灾难:读到的 1 被原样写回、标志永远清不掉。EXTI 的 PR、定时器的 SR 这类寄存器只用 raw = 直接写。
  4. 只写寄存器(BSRR)不能 modify:读回恒为 0,modify 读改写等于把 0 写回全寄存器。BSRR/BRR 只能 raw =。

*volatile 指针与 @ptrFromInt

生成物用 @ptrFromInt(0x40011800) 从整数造出 *volatile GPIO。Zig 0.17 的相关规则:

  • @ptrFromInt 的结果类型必须已知(const p: *volatile u32 = @ptrFromInt(x); 或由赋值目标/参数类型推导)。
  • *volatile T 的每次读写不可被编译器合并、删除或重排(相对其他 volatile 访问)。这就是 volatile 的全部含义——它不提供原子性(原子要用 @atomicRmw 或中断屏蔽),不建立跨核顺序(M3 单核无此问题),只保证"每次都真的访问内存"。
  • volatile 缺失的后果(真实编译实验):把状态寄存器指针写成普通 *u32,while (sr.* & flag == 0) {} 会被优化成"读一次、死循环"——因为编译器认为没有代码改 sr.*,合并成一次读是合法优化。现象是"轮询永远出不来"。同样,连续两次写同一寄存器会被合并成一次。MMIO 指针必须 *volatile,没有例外。

@bitCast 与 packed struct 描述位域是寄存器层的另一对基石:

const CtrlBits = packed struct(u32) {
    enable: u1,      // bit0
    mode: u2,        // bit2:1
    _rest: u29 = 0,
};

const raw: u32 = 0b101;                       // 假设从寄存器读出
const bits: CtrlBits = @bitCast(raw);         // 整数 -> 位域
// bits.enable == 1, bits.mode == 0b01
const back: u32 = @bitCast(bits);             // 位域 -> 整数

规则:packed struct(uN) 的总位数必须等于 N;第一个字段在最低位;字段顺序错一位寄存器值全错且编译器不报错。0.17 的 @bitCast 是"逻辑位布局"重解释,只接受单参数(结果类型靠推导),位宽不匹配直接编译报错。

PE5 点灯逐步复盘

第 1 步开时钟:F103 复位后所有外设时钟关闭,未开时钟的外设寄存器读恒 0、写无效——这是"代码没错但不工作"的第一大根因。GPIO 全部挂在 APB2 上,APB2ENR 的 IOPGEN 位开 GPIOE。用 modify(.{ .IOPGEN = 1 }) 是安全的(APB2ENR 不是 W1C);用 regs.RCC.APB2ENR.raw |= 1 << 6 也可以——注意这仍是读改写,在中断上下文里操作 RCC 时钟寄存器要小心。

第 2 步配模式:CRL/CRH 每引脚 4 位,MODE[1:0] 在低 2 位、CNF[1:0] 在高 2 位。推挽输出 50 MHz = MODE=11、CNF=00,编码 0b0011。PE5 在 CRL(引脚 0–7),位偏移 20。清旧值再写新值的位操作 (raw & ~mask) | value 是标准手法——不能只写 |=(旧模式位没清掉会残留)。PE5 复位默认值是 0x4(浮空输入),不清掉写 |= 会得到 0x7(MODE=11 但 CNF=11=复用开漏),灯不亮。

第 3 步 BSRR 置位:raw = 1 << 5 单条 store,bit5 置位 PE5;写高 16 位(1 << (16+5))是复位。这也是 put() 的实现原理——现在你能看懂第 04 篇那段 BSRR 代码了。

用 @compileLog 排查生成字段名

写寄存器代码最大的不确定性是"位域字段名到底叫什么"。排查手法:让编译器把类型信息打印出来——

comptime {
    @compileLog(@typeInfo(@TypeOf(microzig.chip.peripherals.GPIOE)).pointer.child);
}

zig build 时编译器会以"错误"形式打印这个类型的完整声明(所有寄存器字段与位域)。同理可以打印中断枚举的所有向量名(第 08 篇会用到):@compileLog(@typeInfo(microzig.cpu.ExternalInterrupt).@"enum".field_names);。@compileLog 是刻意留下的"编译期 println",有它在编译必然失败,用完删掉即可。在确认字段名之前,.raw + 位移 + RM0008 位表是唯一可靠写法——本篇示例正是这么写的。

运行现象

烧录后 PE5 恒为高电平(3.3 V),万用表量 PE5 对 GND 读 3.3 V;接 LED(1 kΩ 串联)则常亮。逻辑分析仪确认无翻转。对照实验很有价值:把同样的操作用 HAL 写一遍——

const p = hal.gpio.Pin.from_port(.E, 5);
p.set_output_mode(.general_purpose_push_pull, .max_50MHz);
p.put(1);

编译通过、烧录成功、PE5 毫无反应——但万用表量 PA5 会发现它被拉高了。这就是上游 bug 的完整现场:get_port() 把端口 4 映射到 GPIOA,模式位与数据位都写去了 PA5。这个对照实验是理解"静默失败"类 bug 的最佳教材:编译器、运行时、甚至逻辑分析仪都不会报错,只有对照原理图才能发现。

常见问题

Q1:寄存器写了"成功"但外设毫无反应。

成因:外设时钟没开。F103 复位后 RCC 的各 ENR 寄存器全 0,未开时钟的外设寄存器读恒 0、写无效——写操作不会报任何错误(总线应答正常)。解决方案:每个新外设动手前先查 RM0008 确认它挂在哪条总线(APB1/APB2/AHB),对应开 RCC 的使能位。第 04 篇的 GPIOB、本篇的 GPIOE、第 09 篇的 USART1+GPIOA+AFIO,模式相同。

Q2:用 modify 清标志位,标志永远清不掉。

成因:该标志寄存器是 W1C(写 1 清零)语义,例如 EXTI 的 PR、定时器的 SR、DMA 的中断标志。modify 先读(挂起标志读出 1)、改你列的字段、然后把整个寄存器写回——别的 1 也被写了回去,效果是"全清"或"清不掉"取决于写入模式,唯独不是你想的"只清我写的位"。解决方案:W1C 寄存器一律 raw = mask(只写要清的位)或 raw = 全 1(全清),不用 modify。判断标准:RM0008 寄存器描述里写 "rc_w1"(read/clear by writing 1)的就是 W1C。

Q3:while ((regs.USART1.SR.raw & (1 << 7)) == 0) {} 死循环。

成因分两种。第一种,volatile 缺失:如果这个轮询读的是普通指针而非 *volatile,编译器合并成一次读,循环条件永远不变。microzig 生成物全是 *volatile,所以如果你手写了 @ptrFromInt 造的指针,检查类型里有没有 volatile。第二种,时钟没开或参数错误导致标志真的不变——用调试器暂停看寄存器原始值,或用 probe-rs read 读地址。解决方案:先确认指针类型带 volatile,再确认外设时钟与配置。

Q4:@bitCast 一个 packed struct 时报 size mismatch 或写出的寄存器值全错。

成因:前者是位宽不等——packed struct(u32) 的字段位数之和不是 32(少写或多写一个 reserved);后者更阴险:位宽对但字段顺序错(第一个字段不在最低位),编译器照单全收。解决方案:位宽问题数一遍位数;顺序问题用"探针"验证——packed struct(u8) { a: u1, b: u1, c: u6 } 里 @bitCast 一个 a=1 的值,结果必是 0x01,不符即顺序反了。写寄存器位域时对着 RM0008 的位表逐位填 _reserved: uN = 0。

Q5:@ptrFromInt 造的指针一访问就 panic(UnalignedPointer)。

成因:*volatile u32 要求 4 字节对齐,手算偏移错了地址(比如把 u16 寄存器按 u32 访问、或基地址加错)。Debug 构建下 Zig 的对齐检查会 panic,Release 下是未定义行为。解决方案:核对外设基址与寄存器偏移(RM0008 表 3 是权威);u16 寄存器用 *volatile u16;确实要对齐不到的地址(如打包数据)用 *align(1) T 显式声明。另一个易错点:@intFromPtr(&regs.X.DR) 取"寄存器地址"喂给 DMA 时,&regs.X.DR 的类型是 *volatile Mmio(...),@intFromPtr 直接可用,不需要再 @ptrCast。

Q6:中断里操作外设寄存器后,主循环的修改丢了。

成因:modify 的读改写序列被中断切开。主循环读了寄存器、改了字段 A,此时中断进来用 modify 改了字段 B 并写回,主循环恢复后把旧值(含旧的 B)写回——字段 B 的修改丢失。解决方案三选一:多上下文共享的寄存器用 raw 一次写完(无读改写窗口);操作前后进临界区(microzig.interrupt.enter_critical_section(),第 22 篇);让中断与主循环操作不同寄存器(比如置位用 BSRR、清零用 BRR,都是无读改写的原子写)。

面试考点

Q1:解释 Zig 中 volatile 的语义:它保证什么、不保证什么?

参考答案:volatile 是指针的属性(*volatile T),保证每次访问真实发生内存读写,且相对其他 volatile 访问不可被合并、删除或重排。不保证:原子性(*volatile u32 的 += 仍是读改写三步)、内存顺序(多核场景需要 @atomicRmw 等显式原子操作)、中断安全(不阻止 ISR 在两次 volatile 访问之间插入)。裸机上的规则:MMIO 指针必须 *volatile(否则轮询被优化成死循环、连续写被合并);需要原子性时用专用寄存器(BSRR)或原子内建或关中断。Zig 0.17 已无 @volatileCast(旧版用于 volatile/非 volatile 指针互转),普通指针与 volatile 指针的转换用 @ptrCast。

Q2:microzig 的寄存器定义(PAC)从哪里来?为什么教程里不确定位域字段名时要用 .raw?

参考答案:regz 工具在构建时从 embassy 的 stm32-data JSON 数据生成(芯片定义写明 .register_definition = .{ .embassy = ... }),生成物不含在 microzig 仓库里,本地没有拉到 stm32-data-generated 依赖就无法逐字核对字段名。因此教程纪律是:mmio.Mmio 的 API(raw/read/write/modify)、*volatile 语义、@ptrFromInt 这些框架层事实可以逐字写;具体外设的寄存器名和位域名标注"以生成数据为准"。实操上用 @compileLog 打印类型拿到真实字段名,或在确认前全部用 .raw + 位移 + RM0008 位表,这是零风险的写法。

Q3:BSRR 为什么能实现原子置位?读改写 ODR 在什么条件下会出错?

参考答案:BSRR(Bit Set/Reset Register)低 16 位每位置 1 使对应引脚置位、高 16 位置 1 使对应引脚复位、写 0 无动作——一次 store 完成动作,且没有读参与,天然原子,多上下文操作不同位互不干扰。ODR 翻转需要读-改-写三步:若中断在读写之间修改了同端口 ODR 的其他位,写回会把中断的修改覆盖掉(丢更新)。触发条件:同一 GPIO 端口被多个执行流(主循环 + ISR,或嵌套 ISR)操作,且至少一个用读改写。规避:全部用 BSRR/BRR(HAL 的 put 就是);必须读改写时进临界区;toggle 只用于确认无共享的引脚。

Q4:W1C(write-1-to-clear)寄存器为什么不能用读改写方式清标志?正确做法是什么?

参考答案:W1C 寄存器的语义是"写 1 的位被清零,写 0 的位不变",读出值反映当前挂起状态。读改写清标志的过程:读到挂起位=1,改写 intending 只清它,写回——写回时挂起位是 1,恰好把它清了,但写回期间新挂起的其他标志位也被写 1 清掉了(因为读出时它们是 1)。更糟的情况:读与写之间新标志挂起,写回时它还没被读到,倒没事;真正的问题是"读到 1 的全部被清"。正确做法:构造一个只含目标位的掩码直接 raw = mask(W1C 写 0 无副作用),或读出后按需清除。EXTI.PR、定时器 SR、DMA 标志寄存器都是这个模式,microzig 的 exti.clear_pending(exti.pending()) 写回读出的位图正是"读出什么清什么"的 W1C 正确用法。

Q5:microzig.chip.peripherals 和 microzig.cpu.peripherals 有什么区别?各举两个成员。

参考答案:前者是芯片外设(由 regz 按芯片数据生成,地址来自芯片存储器映射):GPIOA、RCC、USART1、TIM2 等。后者是 CPU 核内组件(由 cortex_m 模块固定定义,地址来自 ARMv7-M 架构的 SCS 区域 0xE0000000+):scb(System Control Block)、nvic、systick、mpu。区别的根源:芯片外设随型号变化(regz 每芯片生成),核内组件随内核架构变化(microzig 的 cortex_m.zig 手写)。混用报 no field named:在 chip 里找 nvic、在 cpu 里找 GPIOA 都会失败。

Q6:写一个 @compileLog 排查手法,打印 F103 上所有可用的中断向量名。

参考答案:中断向量名在编译期由生成数据动态构造进 microzig.cpu.ExternalInterrupt 枚举(@Enum(...) 运行时构造类型),IDE 补全看不到。打印方法:在任意位置写

comptime {
    @compileLog(@typeInfo(microzig.cpu.ExternalInterrupt).@"enum".field_names);
}

zig build 时编译器把这个数组作为"错误信息"打印出来,包含全部向量名(EXTI0、EXTI9_5、TIM2、USART1、DMA1_Channel1 等)。这是确认向量名拼写的唯一可靠手段——向量名来自 embassy 数据,仓库里只有少数几个(EXTI9_5、TIM2–TIM5、RTC、USB_LP_CAN1_RX0)有直接用例可查。用完记得删掉 @compileLog,它会让编译必然失败。

小结

  • PAC 是 regz 从 embassy stm32-data 生成的寄存器定义,经 microzig.chip.peripherals.XXX(*volatile 指针,地址写死)访问;核内外设在 microzig.cpu.peripherals。
  • mmio.Mmio 的关键 API:.raw 整数直读直写(最可靠)、read() 位域视图、modify() 读改写(非原子、会写回全寄存器,禁用于 W1C 与只写寄存器)。
  • volatile 是指针属性:保证访问真实发生,不保证原子性与顺序;缺 volatile 的轮询会被优化成死循环。
  • packed struct(uN) + @bitCast 描述位域:首字段在最低位、位数必须等于 N、顺序错不报错。
  • PE5 点灯三步:开 IOPGEN 时钟 → CRL 偏移 20 位写 0x3(清旧值)→ BSRR = 1<<5;HAL 同操作会打到 PA5(上游 bug)。
  • 排查字段名用 @compileLog 打印类型;不确定时 .raw + 位移 + RM0008 位表是零风险写法。
  • 下一篇系统讲 GPIO:CRL/CRH 编码表、8 种模式、Speed 反直觉枚举与按键消抖。

系列目录

  1. 系列总览:Zig 裸机开发的定位、生态与平台全景
  2. 开发环境搭建:Zig 0.17、microzig 与 probe-rs
  3. 工程结构剖析:build.zig、MicroBuild 与固件产物
  4. 第一个固件:启动流程、向量表与 GPIO 点灯
  5. 寄存器级编程:PAC、MMIO 与 volatile 语义
  6. GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
  7. 时钟树配置:HSE、PLL 与总线分频
  8. 中断系统:NVIC、优先级分组与向量注册
  9. USART 串口通信:轮询收发与日志重定向
  10. 定时器基础:时基、PWM 与呼吸灯
  11. 输入捕获:频率与占空比测量
  12. 中断驱动串口:环形缓冲区与临界区设计
  13. DMA 传输:从外设搬运到内存
  14. I2C 总线:SSD1306 OLED 显示实战
  15. SPI 总线:W25Q64 Flash 读写与 ID 识别
  16. ADC 采样:单次、扫描与内部温度传感器
  17. 内部 Flash 编程:参数持久化与页擦除
  18. 独立看门狗:复位诊断与可靠性设计
  19. 低功耗模式:Sleep、Stop、Standby 与唤醒
  20. 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
  21. Zig 语言进阶:comptime、错误处理与零成本抽象
  22. 并发模型:中断与主循环的协作与临界区
  23. 协作式调度器:状态机与时间片轮转
  24. 实时性分析:中断延迟、抖动与 WCET
  25. RTOS 内核实现(上):任务控制块与上下文切换
  26. RTOS 内核实现(下):信号量、消息队列与优先级继承
  27. USB 设备开发:CDC 虚拟串口
  28. CAN 总线:bxCAN 过滤器与环回自测
  29. 调试与测试:probe-rs、RTT 与硬件在环
  30. 收官:分层架构、可移植性与 Zig 生态展望
posted @ 2026-10-27 08:52  navysummer_hardware  阅读(12)  评论(0)    收藏  举报