奇偶实验验证通过——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;
}
两个必懂的细节:
- 判满用"牺牲一格":
next == rxTail视为满——宁可少用一个格子,也不让"满"和"空"长得一样(都是 head==tail 就分不清了)。这是环形缓冲的经典取舍。 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。然后做这个实验:
- 趁两次打印的间隙,连发一串字符(比如
abcdefgh8 个)——注意 printf 阻塞的 2 秒里你发的字节全被中断收进缓冲区 - 现象 A:下一次打印变成
buf=8——证明"中断在主循环睡觉时也没停止收货" - 现象 B:紧接着 8 个字符一次性连珠炮式回显出来,LED 疯闪 8 下——证明主循环醒来后一口气消费了积压
- 2 秒后
buf回到 0——消费速度跟上来了
这个实验完美演示了"生产者(中断)与消费者(主循环)解耦":收永远不会丢(只要缓冲区够大),处理慢点没关系。这就是为什么所有串口命令系统都用这个架构。
小提示:如果发得太多太快(比如一次粘 200 个字符),超过 128 容量会丢最老的——
rb_put的"满了就丢"策略。想看效果可以试试,丢的是旧数据不是新数据。
验收看到"buf 数字变化 + 连珠炮回显"就回报"继续"——最后一节 2.3 协议帧:给这堆字节加上"语法"(帧头+长度+数据+CRC+帧尾),实现"发 LED_ON 点灯、发 LED_OFF 灭灯"的完整命令系统,那就是模块 2 的验收工程。