浮云落笔

浮云落笔

首页
瞬间
反馈
浮云落笔

浮云落笔

首页 瞬间 反馈
  1. 首页
  2. stm32
  3. 2.2 后半:环形缓冲区——中断与主循环的"交接制度"

2.2 后半:环形缓冲区——中断与主循环的"交接制度"

  • stm32
  • 发布于 2026-09-15
  • 20 次阅读
flor
flor

奇偶实验验证通过——2.2 前半毕业。开讲后半段:环形缓冲区 + getchar。

一、先看要解决什么问题

现在的架构:回调里直接干活(回显+翻 LED+挂单)。上一节说了,回调里每字节最多有 87µs 预算,活重了就溢出丢数据。

新架构把"收"和"处理"拆开:

中断(回调):只做一件事——把字节塞进缓冲区,几微秒,立刻撤退
                     ↓ 缓冲区(快递柜)
主循环:有空时从缓冲区取字节 → 回显/解析/执行命令(想干多慢干多慢)

为什么必须是"环形"? 主循环取走前面的字节时,中断还在往后面塞——两个下标各自前进、互不等待,数组用完就绕回开头,像一条首尾相接的传送带。普通数组要么越界、要么频繁搬移,环形是唯一优雅解。

二、环形缓冲区的三件套(背下来,所有嵌入式通用)

#define RXBUF_SIZE 128                    // 2 的幂,方便取模

uint8_t  rxbuf[RXBUF_SIZE];              // 存储区
volatile uint16_t rxHead = 0;            // 写下标(中断用)
volatile uint16_t rxTail = 0;            // 读下标(主循环用)

三个操作,每个都只有两三行:

// 中断里调用:塞一个字节(满了就丢,保新弃旧)
void rb_put(uint8_t c)
{
    uint16_t next = (rxHead + 1) % RXBUF_SIZE;
    if (next != rxTail) {                // 没满
        rxbuf[rxHead] = c;
        rxHead = next;
    }
}

// 主循环里调用:取一个字节(空返回 -1)
int rb_get(void)
{
    if (rxTail == rxHead) return -1;     // 空
    uint8_t c = rxbuf[rxTail];
    rxTail = (rxTail + 1) % RXBUF_SIZE;
    return c;
}

// 主循环里调用:查存量
uint16_t rb_count(void)
{
    return (rxHead - rxTail + RXBUF_SIZE) % RXBUF_SIZE;
}

两个必懂的细节:

  1. 判满用"牺牲一格":next == rxTail 视为满——宁可少用一个格子,也不让"满"和"空"长得一样(都是 head==tail 就分不清了)。这是环形缓冲的经典取舍。
  2. volatile 不能省:rxHead 在中断里改、主循环里读;rxTail 反之。没有 volatile,编译器可能把"读 rxHead"优化成读寄存器缓存,主循环永远看不到新数据——这是嵌入式三大玄学 bug 之一(症状:加 printf 就好了、开 -O0 就好了)。

三、改造代码(在现有 usart_led 上改,三步)

第 1 步 · main.c 的 USER CODE BEGIN PV 区,替换原来的 rxbyte:

/* USER CODE BEGIN PV */
#define RXBUF_SIZE 128
uint8_t  rxbuf[RXBUF_SIZE];
volatile uint16_t rxHead = 0;
volatile uint16_t rxTail = 0;
/* USER CODE END PV */

第 2 步 · 回调瘦身(USER CODE 4 区)——中断里只剩"存+挂单":

/* USER CODE BEGIN 4 */
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;
        }
        HAL_UART_Receive_IT(&huart1, &rxbyte, 1);   // 挂下一单
    }
}
/* USER CODE END 4 */

(rxbyte 那行全局变量保留,它是 HAL 落地字节的地方。)

第 3 步 · 主循环改成"消费者"(WHILE 区):

/* USER CODE BEGIN WHILE */
while (1)
{
    int c = rb_get();
    if (c >= 0) {
        uint8_t ch = (uint8_t)c;
        HAL_UART_Transmit(&huart1, &ch, 1, 100);    // 回显(搬到主循环了)
        HAL_GPIO_TogglePin(led1_GPIO_Port, led1_Pin);
    }
    printf("buf=%u\r\n", rb_count());               // 每 2 秒报一次存量
    HAL_Delay(2000);
/* USER CODE END WHILE */

/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */

等等——rb_get/rb_count 还没定义。把三件套函数放进 USER CODE BEGIN 0 区(和 __io_putchar 作伴):

// 环形缓冲区:主循环取字节,空返回 -1
int rb_get(void)
{
    if (rxTail == rxHead) return -1;
    uint8_t c = rxbuf[rxTail];
    rxTail = (rxTail + 1) % RXBUF_SIZE;
    return c;
}

// 环形缓冲区:当前存量字节数
uint16_t rb_count(void)
{
    return (rxHead - rxTail + RXBUF_SIZE) % RXBUF_SIZE;
}

(rb_put 不单独定义了,逻辑直接写在回调里——少一层函数调用,中断路径更短。)

四、验收(这次的现象很有戏剧性)

编译烧录后,串口助手每 2 秒会蹦一行 buf=0。然后做这个实验:

  1. 趁两次打印的间隙,连发一串字符(比如 abcdefgh 8 个)——注意 printf 阻塞的 2 秒里你发的字节全被中断收进缓冲区
  2. 现象 A:下一次打印变成 buf=8——证明"中断在主循环睡觉时也没停止收货"
  3. 现象 B:紧接着 8 个字符一次性连珠炮式回显出来,LED 疯闪 8 下——证明主循环醒来后一口气消费了积压
  4. 2 秒后 buf 回到 0——消费速度跟上来了

这个实验完美演示了"生产者(中断)与消费者(主循环)解耦":收永远不会丢(只要缓冲区够大),处理慢点没关系。这就是为什么所有串口命令系统都用这个架构。

小提示:如果发得太多太快(比如一次粘 200 个字符),超过 128 容量会丢最老的——rb_put 的"满了就丢"策略。想看效果可以试试,丢的是旧数据不是新数据。

验收看到"buf 数字变化 + 连珠炮回显"就回报"继续"——最后一节 2.3 协议帧:给这堆字节加上"语法"(帧头+长度+数据+CRC+帧尾),实现"发 LED_ON 点灯、发 LED_OFF 灭灯"的完整命令系统,那就是模块 2 的验收工程。

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