适用场景:串口/传感器/键盘等"数据碎片化到达"场景的裸机开发 阅读基础:了解 C 语言、中断的基本概念
0. 引子:两个世界的节奏差
串口中断接收有一个根本矛盾:
中断世界:字节什么时候来,由对方(发送方)决定——随时可能来,也可能一秒钟来一百个
主循环世界:什么时候有空处理,由程序逻辑决定——可能正在刷屏、算滤波、睡觉
115200 波特率下每个字节间隔约 87µs,72MHz 的 CPU 在这 87µs 里能执行约 6000 条指令。如果让 CPU 死盯串口(轮询),这 6000 条指令全浪费;如果主循环去干别的,字节来了没人管,下一个字节一到就把上一个挤丢(硬件溢出 ORE)。
环形缓冲区就是两个世界的"时差消化器":中断是快递员(只管把包裹塞进快递柜,几微秒走人),主循环是收件人(有空时一次性取走所有包裹慢慢拆),缓冲区是快递柜,丢包计数器是登记簿
1. 为什么是"环形"
普通数组当缓冲区有两条死路:
写到头就停 → 空间浪费一半
写到头搬回开头 → 每次搬移 O(n),CPU 干无用功
环形的答案:两个下标各自前进、互不等待,数组用完就绕回开头——一条首尾相接的传送带:
┌──▶ [0][1][2][3][4][5][6][7] ──┐
│ ↑tail head↑ │
└────────── 绕回开头 ─────────────┘
rxHead(写下标):数据往哪存,只有生产者(中断)动它rxTail(读下标):数据从哪取,只有消费者(主循环)动它存一个字节 head 前进一步,取一个字节 tail 前进一步,追上对方就停
2. 三件套与三个精妙设计
2.0 数据与下标定义
#define RXBUF_SIZE 128 // 2 的幂:可用 & (RXBUF_SIZE-1) 代替 % 取模(单周期)
uint8_t rxbuf[RXBUF_SIZE]; // 存储区
volatile uint16_t rxHead = 0; // 写下标:只有中断动它
volatile uint16_t rxTail = 0; // 读下标:只有主循环动它
volatile uint32_t rxErr = 0; // 丢包账本:满了没挤进来的字节数
2.1 设计一:牺牲一格——区分"满"和"空"
最坑的问题:head == tail 到底是"空"还是"满"?两种状态长得一模一样!
解法:宁可少用一个格子——留一格空作为"满"的标志:
空:
rxHead == rxTail(两者重合)满:
(rxHead + 1) % RXBUF_SIZE == rxTail(还差一格重合)
128 格的缓冲区有效容量是 127。用一格容量换一个无歧义的状态判断,值。
2.2 设计二:count 用公式算——派生量不另存
// 当前存量字节数
uint16_t rb_count(void)
{
return (rxHead - rxTail + RXBUF_SIZE) % RXBUF_SIZE;
}
两个细节:
为什么要 +RXBUF_SIZE 再取模? 环形回绕后 head 可能数值上小于 tail(比如 head=5、tail=120),直接相减得 -115;加上 128 再取模得 13,才是真实存量。C 语言负数取模还是负数,这一步是环形计算的标配。
为什么不用 count 变量存着、每次加减? 因为 count 会被两边(生产/消费)同时维护——两个写者 = 竞态 = 会漂移的死账本。能从现有状态推导出来的量,永远不要另存一份(单一事实来源原则)。
2.3 设计三:volatile + 单一写者——不加锁的秘密
两层保护各管一件事:
volatile 管"看得见":禁止编译器把读优化成寄存器缓存。没有它,主循环可能永远读不到中断更新后的值——嵌入式三大玄学 bug 之一(症状:加 printf 就好了、开 -O0 就好了)
归属权管"不打架":每个下标只有一个写者,不存在写冲突
这就是这套代码全程不用关中断的原因。
3. 完整架构与代码
3.1 数据流全景
【生产者:中断世界】 【缓冲区】 【消费者:主循环世界】
电脑串口发字节 ┌────────────┐ while(1)
→ RX 引脚 → 移位寄存器 → DR │ rxbuf[128] │ ├─ rb_read(tmp,128) 批量取
→ RXNE=1 → 中断 │ rxHead 写下标│◄─中断写 ├─ 逐字节处理(回显/解析)
→ HAL 读 DR → 回调 │ rxTail 读下标│◄─主循环写 ├─ 报账(buf/err)
├─ 有空位:存 + head++ │ rxErr 总账本 │ └─ HAL_Delay(10)
├─ 满了:rxErr++(丢新) └────────────┘
└─ 挂下一单 Receive_IT
→ 中断退出(全程几微秒)
3.2 中断侧:回调三件事(存、记、挂)
uint8_t rxbyte; // HAL 的落地字节
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
uint16_t next = (rxHead + 1) % RXBUF_SIZE;
if (next != rxTail) { // 没满:写入
rxbuf[rxHead] = rxbyte;
rxHead = next;
} else { // 满了:进不来(丢新策略),记一笔账
rxErr++;
}
HAL_UART_Receive_IT(huart, &rxbyte, 1); // 挂下一单(不断链)
}
}
三个细节:
挂单不能忘:
HAL_UART_Receive_IT是"一单制"——收完这单就结束,回调里不续挂,链路就断(症状:只响应第一个字节,之后任你怎么发都装死)中断快进快出:三件事加起来几微秒,这是中断的铁律
rxErr 记账:丢可以(策略决定),但必须数出来——无声丢数据是最恶劣的 bug
3.3 消费者侧:批量读 + 排空式消费
// 批量读:最多取 n 个字节放进 buf,返回实际取到的个数(0 = 空)
uint16_t rb_read(uint8_t *buf, uint16_t n)
{
uint16_t avail = (rxHead - rxTail + RXBUF_SIZE) % RXBUF_SIZE;
if (n > avail) n = avail; // 要 100 只有 30 就只给 30,不越界
for (uint16_t i = 0; i < n; i++) {
buf[i] = rxbuf[rxTail];
rxTail = (rxTail + 1) % RXBUF_SIZE;
}
return n;
}
// 主循环
uint8_t tmp[RXBUF_SIZE];
while (1)
{
uint16_t n = rb_read(tmp, sizeof(tmp)); // 排空式:有多少取多少
for (uint16_t i = 0; i < n; i++) {
// 逐字节处理(回显 / 喂协议解析器 / 执行命令)
}
HAL_Delay(10); // 轮询周期 = 响应延迟上限
}
为什么必须批量读:如果主循环每次只取 1 个字节就去睡觉,积压 8 个字节要 8 个周期才消费完(睡 2 秒的话要 16 秒)。批量取让"消费速率"和"到达速率"彻底解耦——睡觉时中断照样收货,醒来一口气处理完。
4. 丢包策略:丢新 vs 丢旧
缓冲区满了怎么办?两种相反的策略:
丢旧的实现与代价:
// 进阶变体:丢旧不丢新
void rb_put_old(uint8_t c) // 中断里调用
{
uint16_t next = (rxHead + 1) % RXBUF_SIZE;
if (next == rxTail) { // 满了
rxTail = (rxTail + 1) % RXBUF_SIZE; // 中断里挤掉最老的(中断改 rxTail 不会被主循环插队,安全)
rxErr++;
}
rxbuf[rxHead] = c; // 无论如何都写入
rxHead = next;
}
int rb_get_old(void) // 主循环里调用
{
if (rxTail == rxHead) return -1;
uint8_t c = rxbuf[rxTail];
__disable_irq(); // ★ 丢旧策略的代价:读方更新 rxTail 要关中断
rxTail = (rxTail + 1) % RXBUF_SIZE; // 否则会被中断的"挤掉"动作覆盖掉
__enable_irq();
return c;
}
选型口诀:命令流要最新 → 丢旧;协议帧 → 加大缓冲区,靠 err 发现不够用。
5. 错误账本:可观测性
丢包有两种来源,都要记账:
// 硬件错误回调:溢出等错误发生时 HAL 会终止接收,这里记账 + 重新挂单自愈
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
rxErr++;
HAL_UART_Receive_IT(huart, &rxbyte, 1);
}
}
没有 ErrorCallback 的后果:一次溢出后 HAL 静默终止接收,串口从此"装死",直到重新上电。
一个数字看链路健康度:err=0 一切正常;err 涨了,要么缓冲区不够大,要么中断里干活太慢。
6. 同一个原子操作的三张面孔
"取一个字节"的两行核心(取数 + 下标前移),包装成三种接口:
// 面孔一:取一个,空返回 -1(轮询式消费)
int rb_get(void) { /* 判空 → 取 → tail++ → 返回 */ }
// 面孔二:取一批,返回实际个数(排空式消费)
uint16_t rb_read(uint8_t *buf, uint16_t n) { /* ... */ }
// 面孔三:取一个,空就等(阻塞式,scanf/getchar 的入口)
int __io_getchar(void)
{
while (rxTail == rxHead) { } // 空:原地等(中断会把字节放进来)
uint8_t c = rxbuf[rxTail];
rxTail = (rxTail + 1) % RXBUF_SIZE;
return c;
}
基础原子学扎实,三种包装随手就来。
7. 实验验证(现象即证据)
8. 三个反面案例(读别人代码时的找茬题)
真实项目的环形缓冲区常见三个坑,读代码时值得带着怀疑去找:
① 命名撒谎:函数叫 isEmpty,返回 1 却表示"有数据"——所有调用处都得写 == 0 才敢用。命名的诚实度就是代码的可维护性。
② count 死账本:写方和读方都维护 count 变量(两个写者 = 竞态 = 漂移),结果查询函数干脆不用它、用公式重算。派生量别另存——本文 2.2 节从第一天就做对了。
③ 腾位不用:丢旧实现里满了挤掉一个老字节后直接 return——当前字节也没写进去。持续过载时效果是"丢一个旧、丢一个新、再收一个"约丢一半,新旧交错。读代码时自己推演一遍边界行为,是最好的练习。
9. 扩展视野
2 的幂优化:
% RXBUF_SIZE换成& (RXBUF_SIZE-1)——与指令单周期,除法指令要多周期。取模对任何大小都能用,2 的幂的真正好处是这个位运算技巧多实例化:把下标打包成结构体、缓冲区由 init 传入(数据与控制器分离),一套代码实例化 N 个队列(多串口各收发一个)
发送也进队列:send 只塞 TX 缓冲区,TXE 中断慢慢搬——非阻塞发送
DMA + 环形缓冲:DMA 往缓冲区搬字节,CPU 只管处理——性能天花板
现实世界:操作系统内核、网络协议栈、键盘缓冲、音频缓冲——全是这一套
10. 总结
一句话收尾:环形缓冲区是嵌入式世界的万能骨架——中断是快递员,缓冲区是快递柜,主循环是收件人,err 是登记簿。