中断驱动串口:环形缓冲区与临界区设计
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 12 篇。上一篇:输入捕获:频率与占空比测量,下一篇:DMA 传输:从外设搬运到内存,目录见文末。
中断驱动串口:环形缓冲区与临界区设计
导读
第 09 篇的串口收发全是轮询:read_blocking 死等 RXNE,主循环被"等一个不知道什么时候来的字节"绑架。本篇把接收改成中断驱动——RXNE 一置位就进 ISR 把字节搬进环形缓冲区,主循环想读的时候缓冲区里永远有最新数据。这件事在 STM32CubeHAL 里是三行 HAL_UART_Receive_IT,在 microzig 里却是从零手写:F103 的 UART HAL 没有任何中断 API(没有 enable_rx_interrupt,连中断相关的字段都不存在),我们必须按 RM0008 的位定义直接操作 USART1 寄存器。也因此,这一篇的真正主角不是串口,而是中断与主循环共享数据的全套纪律:单生产者/单消费者环形缓冲为什么可以无锁、溢出策略怎么选、临界区的两种实现(PRIMASK 与 BASEPRI)各付出什么代价。这些纪律从下一篇的 DMA 一直沿用到第 25 篇的 RTOS。
为什么需要中断驱动的串口
轮询接收有三宗罪,我们逐一量化:
- 阻塞。
read_blocking(&buf, null)传null超时是真死等。主循环想"串口有数据就处理、没数据就干别的",轮询写法做不到——除非把timeout设成极小值反复重试,那又是忙等,白烧 CPU。 - 丢字节。USART 硬件只有一级接收缓冲(移位寄存器 + DR)。115200 波特率下一个字节约 87 µs,如果主循环两次检查 RXNE 的间隔超过 87 µs,下一个字节到来时 DR 还没被读走,SR 的 ORE(过载)置位,新字节被丢弃。第 10 篇的
sleep_ms(5)循环里嵌个轮询接收,每睡 5 ms 必丢 50 多个字节。 - 时序耦合。主循环必须"经常回头看",任何一段耗时操作(刷新屏幕、Flash 擦写)都会造成接收空窗。业务逻辑越复杂,串口越不可靠。
中断驱动把责任倒转:硬件收满一个字节 → RXNE 置位 → NVIC 强制 CPU 进 ISR → 字节落进缓冲区。主循环从此只跟缓冲区打交道,随时来随时取,永远不阻塞、不丢字节(只要缓冲区够大、平均消费速率跟得上)。这是所有驱动框架(Linux 的 tty 层、Zephyr 的 UART ISR 驱动)的标准结构。
先声明一个贯穿全篇的边界:microzig 未提供 HAL,本节寄存器直写。依据是附录 B.15 的逐字核查——uart.zig 全文 405 行里没有任何中断 API。寄存器位定义来自 RM0008 §27(USART),regz 生成的字段名在本地不可核对,所以统一用 .raw + 位掩码的方式写,每一位都注明 RM0008 出处。
代码案例:USART1 RX 中断 + 环形缓冲 + 行协议
const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{ .logFn = stm32.uart.log });
comptime {
_ = microzig.export_startup();
}
// USART1 不在 HAL 的 default_interrupts 里,直接注册,无需 overwrite 开关
pub const microzig_options: microzig.Options = .{
.interrupts = .{ .USART1 = .{ .c = usart1_isr } },
};
// ---------- 环形缓冲区:单生产者(ISR)写 head,单消费者(主循环)写 tail ----------
const BUF_LEN = 256; // 必须是 2 的幂,掩码取模零成本
var rx_buf: [BUF_LEN]u8 = undefined;
var head_v: u16 = 0; // ISR 写:下一个写入位置
var tail_v: u16 = 0; // 主循环写:下一个读取位置
// Zig 的 volatile 是指针属性:*u16 可以隐式升级为 *volatile u16(只加不减)
const head: *volatile u16 = &head_v;
const tail: *volatile u16 = &tail_v;
var overflow_count: u32 = 0; // 溢出诊断计数器
fn ring_push(b: u8) void {
const next = (head.* + 1) & (BUF_LEN - 1);
if (next == tail.*) {
overflow_count +%= 1; // 满:丢弃新字节(也可改为覆盖旧数据)
return;
}
rx_buf[head.*] = b;
head.* = next; // head 最后更新:主循环只认这个
}
fn ring_pop() ?u8 {
if (tail.* == head.*) return null; // 空
const b = rx_buf[tail.*];
tail.* = (tail.* + 1) & (BUF_LEN - 1);
return b;
}
// ---------- USART1 寄存器(RM0008 §27) ----------
// USART1 基址 0x40013800:SR=+0x00, DR=+0x04, BRR=+0x08, CR1=+0x0C, CR2=+0x10, CR3=+0x14
// SR : bit5 = RXNE(收到数据), bit3 = ORE(过载)
// CR1: bit13 = UE, bit5 = RXNEIE(RXNE 中断使能), bit3 = TE, bit2 = RE
const usart1 = microzig.chip.peripherals.USART1;
const CR1_RXNEIE: u32 = 1 << 5;
const SR_RXNE: u32 = 1 << 5;
const SR_ORE: u32 = 1 << 3;
fn usart1_isr() callconv(.c) void {
const sr = usart1.SR.raw;
if (sr & SR_ORE != 0) {
// 过载:读 SR(已读)+ 读 DR 即可清 ORE,字节本身已丢,只能计数
_ = usart1.DR.raw;
overflow_count +%= 1;
return;
}
if (sr & SR_RXNE != 0) {
ring_push(@truncate(usart1.DR.raw)); // 读 DR 自动清 RXNE
}
}
// ---------- 主循环:消费 + 行协议 ----------
pub fn main() !void {
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.RTCClockSelection = .LSI,
.flags = .{ .HSEOscillator = true, .RTCUsed_ForRCC = true },
});
time.init_timer(.TIM4);
rcc.enable_clock(.GPIOA);
rcc.enable_clock(.USART1);
const uart = stm32.uart.UART.init(.USART1);
const tx = gpio.Pin.from_port(.A, 9);
const rx = gpio.Pin.from_port(.A, 10);
tx.set_output_mode(.alternate_function_push_pull, .max_50MHz);
rx.set_input_mode(.floating);
try uart.apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) });
stm32.uart.init_logger(&uart);
// HAL 配完 CR1 的 UE/RE/TE 后,补上 RXNEIE(RM0008:bit5)
usart1.CR1.raw |= CR1_RXNEIE;
microzig.interrupt.enable(.USART1);
microzig.interrupt.enable_interrupts();
var line: [64]u8 = undefined;
var line_len: usize = 0;
std.log.info("uart-irq demo ready", .{});
while (true) {
while (ring_pop()) |b| {
if (b == '\n' or b == '\r') {
if (line_len > 0) {
std.log.info("echo: {s} ({d} bytes)", .{ line[0..line_len], line_len });
line_len = 0;
}
} else if (line_len < line.len) {
line[line_len] = b;
line_len += 1;
} else {
line_len = 0; // 超长行丢弃
}
}
if (overflow_count != 0) {
std.log.warn("overflow: {d} bytes lost", .{overflow_count});
overflow_count = 0;
}
// 主循环爱干嘛干嘛,接收永远不会丢
asm volatile ("wfi"); // 没事就睡,等中断
}
}
逐段解析
寄存器直写:RXNEIE 的置位与 RXNE 的清除
打开 RX 中断只差一位:USART1 的 CR1(偏移 0x0C)bit5 是 RXNEIE——"RXNE 中断使能",RM0008 §27.6.4 的位表原文是 RXNE interrupt enable。HAL 的 apply_internal 已经写好了 UE/RE/TE(bit13/2/3),我们在它之后补一行 CR1.raw |= 1 << 5 即可。用 |= 而不是整体赋值,是为了不碰 HAL 刚配好的其他位。
ISR 里读 SR 判断中断源,然后读 DR 清标志——这是 USART 最反直觉的设计:RXNE 不是"写 1 清零"而是"读 DR 后自动清零"。@truncate(usart1.DR.raw) 这一句同时完成两件事:取走字节、清掉 RXNE。如果忘了这层关系、在 ISR 里读 DR 前就 return,RXNE 挂着,NVIC 立刻再次进入 ISR,程序表现为"卡死在串口中断里"。
还有一个容易被漏掉的标志:ORE(bit3,过载)。它在"DR 里的字节还没读走、移位寄存器又收满了一个"时置位,表示硬件层面已经丢了一个字节。ORE 也走 USART1 中断向量(RXNEIE 使能时),所以 ISR 必须处理它,否则过载后 ISR 会无限重入。RM0008 的清除序列是"先读 SR、再读 DR"——代码里读 SR 在前、读 DR 在后,天然满足。诚实地说:ORE 置位意味着字节已经丢了,我们能做的只有记下次数。overflow_count 就是为此存在的——它把"丢没丢数据"从玄学变成可观测的数字,串在串口日志里一翻便知。
regz 生成的寄存器字段名(CR1/SR/DR 这些成员名)依赖 stm32-data-generated,本仓库不含生成物;而 .raw 字段是 mmio.Mmio 的公开字段(core/src/mmio.zig 逐字可查),配合手写位掩码就是完全可控的写法。写这类代码的纪律是:每个掩码常量旁边注释 RM0008 的位定义,后来者不需要猜。
环形缓冲区:为什么单生产者单消费者可以无锁
缓冲区是经典的 SPSC(single-producer single-consumer)队列:head 只有 ISR 写、tail 只有主循环写,两边各写各的索引、各读对方的索引。它的无锁安全性来自三个严格条件:
- 每个索引只有一个写者。
head的写者是 ISR,tail的写者是主循环。两个写者永远不会碰同一个变量,读-改-写冲突不存在。 - 先写数据、后写索引。
ring_push里rx_buf[head] = b在head = next之前——消费者看到head变化时,数据一定已经在那里。顺序颠倒了,消费者就可能读到未写入的槽位。Cortex-M3 是单核顺序执行,普通 store 之间不会乱序,这个先后关系天然成立(有 D-Cache 的多核芯片还要加内存屏障,M3 不用)。 - 索引用 u16 且缓冲区长度是 2 的幂。
(head + 1) & (BUF_LEN - 1)用位与替代取模,且 head/tail 永远只增不缩(溢出回绕由二进制补码的性质天然处理:256 的倍数处回绕后差值计算依然正确)。u16 而不是 usize,让索引更新是单条 store 指令,半更新(torn write)不存在。
volatile 的作用要说准:在 Zig 0.17 里 volatile 是指针属性(*volatile u16),不是变量修饰——var x: volatile u16 这种写法不存在,正确姿势是拿普通变量的地址隐式升级成 volatile 指针(*u16 加 volatile 是安全强转,反过来摘掉才被禁止)。它保证编译器每次访问都真的读写内存,不缓存到寄存器、不删除"看似冗余"的访问。主循环的 ring_pop 里 tail.* == head.* 的判断每次都要看到 ISR 刚更新的 head,没有 volatile 就可能被优化成只读一次。volatile 保证可见性,但不保证原子性——本例不需要原子性,因为条件 1 已经排除了并发写。如果哪天改成多生产者(两个 ISR 都 push),这套结构立刻失效,必须升级为关中断或原子操作(第 22 篇的主题)。
满策略:缓冲满时本例选择"丢弃新字节 + 计数"。另一种是"覆盖旧数据"(新字节顶掉最老的),适合日志流。命令行场景必须选丢弃——丢一半的命令比完整丢一条命令更危险。无论选哪种,overflow_count 都要有:用户报告"串口偶尔没反应"时,先看计数器,立刻能区分"丢在缓冲区"(计数增长)还是"丢在硬件 ORE"(同样计数)还是"压根没丢"(永远是 0)。
临界区:PRIMASK 与 BASEPRI 的两种代价
环形缓冲不需要临界区,但共享数据不止这一种形态——多字节结构、多个变量成组更新、通用队列,都需要"写到一半不许中断插进来"。两种实现:
方式一:PRIMASK 全局关中断(cpsid i/cpsie i)。microzig 已经封装好保存-恢复语义:
const cs = microzig.interrupt.enter_critical_section();
defer cs.leave();
// 临界区:任何中断都不会插入
enter_critical_section 先记下当前全局中断状态再 disable_interrupts(),leave 时按记录恢复——即使这段代码本身运行在已关中断的上下文里,退出时也不会错误地打开中断。代价是一刀切:临界区期间,连优先级最高的电机换相中断都被推迟。临界区长度必须以指令数计,绝不能在里面调 UART 输出或 sleep。
方式二:BASEPRI 分级屏蔽(msr basepri, #N)。只屏蔽优先级数值 ≥ N 的中断(数值越大优先级越低),高优先级中断照常响应:
inline fn setBasepri(n: u8) void {
asm volatile ("msr basepri, %[v]" :: [v] "r" (n) : .{ .memory = true });
}
这是 RTOS 的标准做法:把"允许调用系统服务的中断"压到优先级 5–15,临界区设 BASEPRI = 5 << 4,则只有这些中断被挡住,0–4 级(不碰系统数据的高实时中断)永不延迟。代价是两fold:你得真的做好优先级规划(哪些中断能用缓冲区、哪些绝对不能被挡),而且 F103 的优先级只实现高 4 位——写 basepri = 5 必须左移成 5 << 4 = 80,写 5 等于没设(RM0008 §4.4 的实现位在高 4 位,坑 15 的老朋友)。本篇的缓冲区设计刻意避开了临界区,但如果你的数据结构做不到 SPSC,方式一是默认选项,方式二是优化手段。
顺带一提 microzig.interrupt.enable_interrupts() 与 disable_interrupts() 直接操作 PRIMASK,没有保存-恢复语义——在"已经关中断"的代码里调 enable_interrupts() 会把别人的临界区撬开。永远优先用 enter_critical_section/leave 对。
行协议:主循环的消费姿势
主循环的骨架是三层嵌套:外层 while (true) 永续、中层 while (ring_pop()) |b| 把缓冲区一次清空、内层按 \n 切行。三个细节值得强调:
while (ring_pop()) |b|利用了 Zig 的 optional 解包:ring_pop返回?u8,空时返回null,循环自然终止。消费函数永不阻塞——这和第 09 篇read_blocking的区别是本质性的。- ISR 里绝不打印。
std.log的 logFn 走 UART 轮询发送,一行日志几十到几百微秒,在 ISR 里调用会显著增加中断延迟,甚至和接收中断自己形成活锁。ISR 的职责清单只有三行:读 SR、取字节、进缓冲。 wfi让主循环休眠。缓冲区空了就asm volatile ("wfi")睡下,下一个 RXNE 中断自动唤醒。CPU 静止时的功耗比空转低一个量级,这是第 19 篇低功耗的预演。注意 wfi 之前要把该做的都做完(包括溢出计数打印),否则可能睡过头。
主循环收尾的 overflow_count 打印是诊断出口:把"丢字节"从事后猜测变成运行时指标。量产固件里这个计数器应该接到你的监控/日志里——串口可靠性问题十有八九是它先报的警。
运行现象
烧录后串口先打印 uart-irq demo ready。此后:
- 在串口终端输入
hello回车,回显echo: hello (5 bytes)——逐字节经过 ISR、缓冲区、行解析三道工序再回到你眼前。 - 压测:让 PC 端以 115200 连续倾倒几 KB 文本(
cat big.txt > /dev/ttyUSB0)。回显逐行跟上,overflow: N bytes lost可能出现——如果出现,说明主循环处理单行的速度跟不上持续灌入,属于消费速率问题而非缓冲问题;把行处理换成存队列再处理即可消除。 - 边睡边收:主循环
wfi期间在终端打字,第一笔画下的瞬间就进 ISR——延迟人手感知不到。对比第 09 篇:把read_blocking的timeout改大,打字后要等轮询窗口才能被处理。 - 故意在
ring_push里把overflow策略改成覆盖、再把 BUF_LEN 改成 16 复测压测,能直观看到缓冲区大小与溢出频率的关系:256 字节在 115200 波特率下能容忍约 20 ms 的消费停顿,16 字节只够 1.4 ms。 - 把
CR1.raw |= CR1_RXNEIE注释掉重烧:打字无任何回显——中断没开,字节静静躺在 DR 里没人理。
常见问题
Q1:编译报 no field named 'USART1' in struct ... 或向量名对不上。
向量名由 regz 从 embassy 数据生成,本仓库不含生成物。用附录 C.1 的脚本确认:在任意固件里加 comptime { @compileLog(@typeInfo(microzig.cpu.ExternalInterrupt).@"enum".field_names); },zig build 的报错输出会列出全部合法向量名。F103 上 USART1 的向量名几乎肯定是 .USART1(与 RM0008 的 IRQ37 命名一致),但"几乎肯定"不等于逐字核对过——花 30 秒跑一次脚本,比猜三次编译错误便宜。
Q2:ISR 进得去但永远收不到字节,SR 里 RXNE 也不置位。
按链路逆查:① RXNEIE 没置上——打印或读回 usart1.CR1.raw 确认 bit5 = 1;② NVIC 没使能——microzig.interrupt.enable(.USART1) 与 enable_interrupts() 两句都要有(前者置 ISER,后者 cpsie i);③ 波特率错了导致收到的字节全是帧错误——FRAMING 时 RXNE 照样置位但数据是垃圾,用逻辑分析仪看 RX 线确认信号本身;④ 引脚没配——PA10 必须是浮空输入,配成输出会与外设复用冲突。还有一个隐蔽情况:apply_runtime 失败(返回的 error 被 try 抛出会直接 panic,但若被吞掉,CR1 的 UE 可能没置位)。
Q3:缓冲区没满也丢字节,overflow_count 增长很快。
丢在硬件层而不是缓冲区:SR 的 ORE 在置位。可能性按概率排:① ISR 里有耗时操作(打印、浮点、长循环)导致中断响应变慢;② 有别的同级或更高优先级中断长时间运行,USART1 中断被推迟超过一个字节时间(87 µs @115200);③ 全局中断被某处长时间关闭(某个临界区忘了 leave);④ 波特率虚标——主机和设备的波特率偏差超过 ±2.5% 会稳定出错。定位手段:给 ISR 入口加一个 GPIO 翻转 + 逻辑分析仪,一眼量出 ISR 执行时长与间隔。
Q4:volatile 加在 head/tail 上,为什么 rx_buf 本身不用加?
volatile 的对象是"会被另一个执行流访问的变量"。head/tail 是 ISR 与主循环共享的同步点,双方都要及时看到对方的更新,必须 volatile。而 rx_buf 的每个槽位遵循严格的交接协议:生产者写完槽位后才更新 head,消费者读 head 之后就独占该槽位直到读走并更新 tail——任何时刻一个槽位只有一个执行流在访问,不存在可见性问题。给 rx_buf 加 volatile 也不算错,但会让每次访问都变成显式内存访问,白白放弃优化。理解"哪里需要 volatile"比"全加上保险"更能体现对并发模型的理解。
Q5:临界区里能不能调用 ring_push?为什么?
能,而且是无害的:ISR 已经关着(或即将被屏蔽),ring_push 只写自己负责的 head,没有重入风险。真正要警惕的是反向——在 ISR 里调用"主循环也在用、且没有 SPSC 保护"的函数。比如若把行解析器也放进 ISR,line/line_len 就成了双写者,volatile 救不了。判断标准永远是:这个变量的写者有几个?一个 → 检查交接顺序即可;两个及以上 → 必须临界区或原子操作。第 22 篇会把这套判断系统化。
Q6:这个方案和 DMA 接收(下一篇)怎么选?
中断方案每字节进一次 ISR,115200 下 CPU 负担约 1%(每次 ISR 约 1 µs / 87 µs 间隔),对多数应用足够;优点是逐字节可见、行协议可以内联在 ISR 级别的逻辑里、实现简单。DMA 方案(USART1_RX 映射 DMA1_Channel5)硬件自动搬运,ISR 只在整块完成时进一次,适合高波特率(921600+)或长包;代价是要处理"包什么时候结束"(空闲中断 IDLE 或环形 DMA + 半传输中断),复杂度高一档。经验法则:先中断后 DMA,"profile 显示 ISR 成为瓶颈再升级"。别忘了 microzig 的 DMA 通道还有 100 字节静态缓冲的限制,详见下一篇。
面试考点
Q1:解释单生产者单消费者环形缓冲区的无锁安全性,需要哪三个前提?破坏任何一个会发生什么?
参考答案:① 每个索引只有一个写者(head 归生产者、tail 归消费者)——破坏后索引更新互相覆盖,队列状态损坏;② 先写数据后更新自己的索引(发布顺序)——破坏后消费者可能读到未初始化槽位,M3 单核顺序执行保证了普通 store 不乱序,多核/带写缓冲的平台需内存屏障;③ 索引宽度与原子性匹配(u16/usize 单条 store)——破坏后出现半更新,另一侧读到撕裂值。缓冲区长度取 2 的幂使取模变位与,并让索引自然回绕时差值计算依然成立。volatile 用于保证双方对共享索引的可见性,但不提供原子性——原子性由"单写者"结构保证,这正是无锁设计省掉锁的原因。
Q2:F103 的 USART 接收标志 RXNE 如何清除?ORE 呢?为什么这个设计容易导致 ISR 无限重入?
参考答案:RXNE 由"读 DR"动作自动清除——读 DR 同时取走数据并复位标志。ORE 的清除序列是"先读 SR、再读 DR"。容易重入的原因:这两类标志都是"事件发生了、但软件还没做清除动作"时保持置位,且 ORE/RXNE 共享同一个中断向量。若 ISR 读走数据但没处理 ORE(或因逻辑 bug 既没读 DR 也没清标志),退出 ISR 后 NVIC 看到 pending 依然有效,立即重新进入,主循环被永久饿死。防御写法:ISR 第一行读 SR、对每个使能的中断源分支处理并确保清除路径无遗漏。
Q3:对比 PRIMASK 临界区与 BASEPRI 临界区的机制、适用场景与代价。
参考答案:PRIMASK(cpsid i/cpsie i)屏蔽除 NMI/HardFault 外全部可配置中断,实现简单、语义清晰,适合极短临界区(几条指令);代价是全屏蔽——高优先级中断也被推迟,最长临界区时间直接叠加进所有中断的响应延迟。BASEPRI(msr basepri, N)只屏蔽优先级数值 ≥ N 的中断,高优先级中断不受影响,是 RTOS 保留"绝不延迟的高实时中断"的标准手段;代价是需要优先级规划(哪些中断允许被屏蔽、哪些不允许),且 F103 优先级只实现高 4 位,写 BASEPRI 必须左移 4 位,写错等于没设。两者都必须用"保存旧值再恢复"的语义,避免在已关中断的上下文里错误地打开中断。
Q4:为什么中断服务程序里不应该调用日志输出?如果必须在 ISR 里报告错误,怎么做?
参考答案:日志经 UART 轮询发送,一行几十字节在 115200 下要几毫秒,ISR 执行时间随之暴涨,直接后果是同优先级/低优先级中断被推迟、高频中断场景下主循环饿死;若日志函数内部还有锁或共享缓冲(如带缓冲的 writer),在 ISR 调用还可能死锁或数据错乱。正确做法:ISR 只置标志/写计数器/投递数据,把"报告"延迟到主循环;确需 ISR 内报错(如致命故障),写一个极简路径——把错误码存入专用变量后触发 NVIC_SystemReset 或让看门狗复位,让复位原因机制(第 18 篇)把错误带出来。
Q5:环形缓冲区满了怎么办?比较"丢弃新数据"与"覆盖旧数据"两种策略的适用场景。
参考答案:丢弃新数据(drop-new):保留最早的字节,适合命令/协议流——半截命令比完整丢失更危险,且丢新数据时发送方通常会重试。覆盖旧数据(drop-old):保住最新字节,适合遥测/日志流——旧数据的价值随时间衰减,接收端更关心当下。无论哪种都必须有溢出计数器把丢失变成可观测指标,并在设计阶段计算"最坏消费停顿 × 生产速率 < 缓冲容量"(如 256 字节 @115200 只容忍约 20 ms 停顿),不满足就加缓冲或提高消费速率,而不是指望运行时运气。
Q6:HAL 没有串口中断 API 时,直接操作寄存器的风险控制有哪些手段?
参考答案:① 位定义全部标注出处(RM0008 的节号与位表),掩码定义为命名常量而非散落的魔数;② 只用 .raw + 位运算,不臆造 regz 字段名(本地无法核对的成员名一律不写);③ 用 |=/&= ~ 的读-改-写只在确知无并发冲突的寄存器上使用,W1C 类寄存器直接整体赋值;④ 每一步写操作有对应的验证手段(读回寄存器、逻辑分析仪、串口回环);⑤ 在注释里写明"该写法依赖 microzig 0.17.26 + RM0008,升级时重新核对",把维护成本显式化。寄存器直写不是免责的野路子,而是"边界明确、出处明确、验证明确"的工程实践。
小结
- microzig 的 F103 UART 没有任何中断 API,RX 中断必须寄存器直写:CR1 bit5 = RXNEIE(RM0008 §27),SR bit5 = RXNE、bit3 = ORE,读 DR 清 RXNE。
- USART1 不在 HAL 的
default_interrupts里,.interrupts = .{ .USART1 = ... }直接注册,无需 overwrite 开关。 - SPSC 环形缓冲三前提:单写者索引、先数据后索引、索引单条 store;volatile 保证可见性,结构保证原子性。
- 满策略选丢弃 +
overflow_count计数,把丢字节从玄学变成指标;ORE 是硬件层丢字的标志,清除序列"读 SR 再读 DR"。 - 临界区两式:
enter_critical_section/leave(PRIMASK,保存-恢复)一刀切但简单;BASEPRI 分级屏蔽但要<< 4且需优先级规划。 - ISR 职责三行:读 SR、取字节、进缓冲;打印、浮点、长循环全部留在主循环。
- 主循环
while (ring_pop()) |b|消费 + 行协议解析,没事wfi睡眠。 - 下一篇 DMA:让硬件替 CPU 搬数据,USART TX 不再逐字节占用主循环。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与平台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号