浮云落笔

浮云落笔

首页
瞬间
反馈
浮云落笔

浮云落笔

首页 瞬间 反馈
  1. 首页
  2. stm32
  3. 环形缓冲区详解

环形缓冲区详解

  • stm32
  • 发布于 2026-09-19
  • 27 次阅读
flor
flor

适用场景:串口/传感器/键盘等"数据碎片化到达"场景的裸机开发 阅读基础:了解 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 + 单一写者——不加锁的秘密

变量

谁写

谁读

为什么安全

rxHead

只有中断写

主循环读

一个写者,永远不打架

rxTail

只有主循环写

中断读(判满时比较)

同上

rxErr

只有中断写

主循环读

同上

两层保护各管一件事:

  • 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 丢旧

缓冲区满了怎么办?两种相反的策略:

丢新(本文主线)

丢旧(进阶变体)

行为

新字节不写入

挤掉最老字节,新字节一定写入

实现代价

零(写方只动 head)

rxTail 变成两边都写 → 读方更新下标必须关中断

适用场景

协议帧(帧断了两种都救不了,正解是容量 ≥ 最大帧长)

命令流/遥控器(连按 50 下,要的是第 50 下的值)

丢旧的实现与代价:

// 进阶变体:丢旧不丢新
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. 错误账本:可观测性

丢包有两种来源,都要记账:

记账位置

丢的原因

性质

RxCpltCallback 里 rxErr++

缓冲区满,装不下

软件丢(自己的策略)

ErrorCallback 里 rxErr++

ORE 溢出 / 帧错误 / 噪声

硬件丢(字节没进 DR 或已损坏)

// 硬件错误回调:溢出等错误发生时 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 字符

醒来 8 字符一坨回显(0.7ms 内到齐)

睡觉时中断照样收货(生产者不停工)

过载

一次粘贴 200 字符

回显前 127 个,err=73

丢新策略 + 牺牲一格(128-1=127)+ 记账可见

LED 奇偶

发 N 字符看 LED

奇数净翻转、偶数不动

回调按字节触发(不是按"消息")

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. 总结

要点

内容

解决的问题

中断世界与主循环世界的节奏差

核心结构

数组 + 两个下标(head 写 / tail 读)+ 取模回绕

满空区分

牺牲一格:(head+1) % SIZE == tail 为满

并发安全

volatile(看得见)+ 单一写者(不打架)= 不用关中断

count

公式推导,不另存(单一事实来源)

丢包

策略明确(丢新/丢旧)+ err 记账(可观测)

消费姿势

批量读 + 排空式

架构位置

中断生产 → 缓冲存储 → 主循环消费

一句话收尾:环形缓冲区是嵌入式世界的万能骨架——中断是快递员,缓冲区是快递柜,主循环是收件人,err 是登记簿。

目录
湘ICP备2025147565号-1
gongan beian 湘公网安备43102602000213号