环形缓冲区-单片机串口收发的一个好的解决方案

在一个新项目在用到了环形缓冲区的相关内容,特地联合串口相关知识一起梳理一下,也算是做一个记录。

背景

在使用Codex编写一个STM32的FOC工程中时,为了写串口的相关功能,让AI设计了整个工程。细致研究发现,其串口的功能非常完备,可以接受大量消息不阻塞,感觉完全达到了我们购买的产品的串口功能,特此将其中的串口收发相关的内容拿出来研究一下。

整个系统是采用裸机开发的,标准串口功能涵盖了模式切换(FOC力矩 速度 位置三种模式),以及电机启动停止,状态流显示,以及调参的功能。

我认为使用FIFO进行串口收发的操作已经是一个基本操作了,主要的为什么有如下的三点:

串口接收(RX):防数据丢失与解耦

阻塞模式问题:若主程序直接在主循环里轮询等待接收串口数据,主程序将被死锁,无法处理其他任务。

软件 FIFO 作用:每当串口收到一个字节,硬件触发 RX 中断。中断服务程序(ISR)立刻将该字节存入 RX 环形缓冲区(入队)并迅速退出。主程序随后可以在空闲时从缓冲区读取数据(出队)并解析协议包。

结果:极大地缩短了中断响应时间,避免因主程序繁忙导致后续字节覆盖前一字节(Overrun Error)。

串口发送(TX):变“阻塞等待”为“异步发送”

阻塞模式问题:若连续发送 100 字节数据,以 9600 波特率计算,直接发送需要阻塞等待约 104 ms,对实时性系统是致命的。

软件 FIFO 作用:主程序将 100 字节数据一次性写入 TX 环形缓冲区,然后开启串口发送中断(TXE / TC 中断)。之后硬件每发完一个字节,自动触发中断从缓冲区取出下一个字节发送,直到缓冲区为空再关闭中断。

结果:主程序只需耗时几微秒写入 FIFO 即可继续执行,发送过程交由中断后台异步完成。

串口环形缓冲区 FIFO 的特殊优势(无锁特性)

在嵌入式 C 语言开发中,串口环形缓冲区 FIFO 还有一个极其重要的工程特性:SPSC(单生产者单消费者)无锁安全性。

RX 场景:生产者只有 RX 中断(只修改 head 指针),消费者只有 主程序(只修改 tail 指针)。

TX 场景:生产者只有 主程序(只修改 head 指针),消费者只有 TX 中断(只修改 tail 指针)。

由于读写指针各自被唯一的线程/中断控制,只要代码正确使用 volatile 修饰指针,且保证指针更新的原子性,在中断和主程序间传递串口数据时,无需关中断或加互斥锁即可保证线程的安全。

一、硬件配置(main.c)

USART2 配置为 115200 8N1,标准串口参数:

1
2
3
4
huart2.Init.BaudRate = 115200;
huart2.Init.WordLength = UART_WORDLENGTH_8B;
huart2.Init.StopBits = UART_STOPBITS_1;
huart2.Init.Parity = UART_PARITY_NONE;

NVIC 中 USART2 中断优先级设为 3(次低优先级),避免干扰 30 kHz FOC 控制中断:

1
2
HAL_NVIC_SetPriority(USART2_IRQn, 3, 1);
HAL_NVIC_EnableIRQ(USART2_IRQn);

二、中断链路(stm32g4xx_it.c)

接收中断链路:

1
2
3
4
USART2_IRQHandler()           硬件中断入口
  └→ HAL_UART_IRQHandler()    HAL 库统一处理
       └→ HAL_UART_RxCpltCallback()   HAL 回调
            └→ AppSerial_OnRxComplete()   应用层处理

发送完成中断链路:

1
2
3
4
USART2_IRQHandler()
  └→ HAL_UART_IRQHandler()
       └→ HAL_UART_TxCpltCallback()
            └→ AppSerial_OnTxComplete()

三、接收机制详解(app_serial.c)

3.1 初始化时启动单字节中断接收

AppSerial_Init()

1
(void)HAL_UART_Receive_IT(g_uart, (uint8_t *)&g_rx_byte, 1U);

这行代码启动了 单字节中断接收——每次只接收 1 个字节,接收完成后触发中断回调,然后在回调中再次调用自己形成连续接收链。

3.2 接收回调函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void AppSerial_OnRxComplete(UART_HandleTypeDef *uart)
{
    // 1. 检查 UART 句柄
    if ((uart == NULL) || (uart != g_uart)) return;
    
    // 2. 计算环形缓冲区的下一个写入位置
    const uint16_t next = (uint16_t)((g_rx_write + 1U) % APP_RX_RING_SIZE);
    
    // 3. 如果缓冲区没满,把收到的字节存入环形缓冲区
    if (next != g_rx_read) {
        g_rx_ring[g_rx_write] = g_rx_byte;
        g_rx_write = next;
    }
    
    // 4. 重新启动单字节中断接收,等待下一个字节
    (void)HAL_UART_Receive_IT(g_uart, (uint8_t *)&g_rx_byte, 1U);
}

关键设计点:

  • 使用 g_rx_byte(单个 volatile 变量)作为接收缓冲,而不是数组
  • 每次中断只收 1 字节,立即存入环形缓冲区 g_rx_ring[128]
  • 环形缓冲区用 g_rx_write(写指针)和 g_rx_read(读指针)管理
  • 中断中只做最少的操作(存字节、移动指针),不解析命令

3.3 为什么不用 DMA 接收?

这个工程没有使用 DMA 接收串口数据,而是用中断方式逐字节接收。原因:

  1. 串口命令是不定长、以回车换行结尾的文本行,DMA 适合定长接收
  2. 中断方式每字节只做一次内存拷贝(约几十个 CPU 周期),开销极小
  3. 避免了 DMA 的缓冲区管理和超时判断的复杂性

四、发送机制详解

4.1 发送环形缓冲区

1
2
3
4
5
static uint8_t g_tx_ring[APP_TX_RING_SIZE];  // 2048 字节发送环形缓冲区
static volatile uint16_t g_tx_write;           // 写指针
static volatile uint16_t g_tx_read;            // 读指针
static volatile uint16_t g_tx_in_flight;       // 正在 DMA/中断传输中的字节数
static volatile bool g_tx_active;              // 发送是否正在进行

4.2 启动发送(中断方式)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static void AppSerial_StartNextTransmit(void)
{
    // 计算从当前读指针到缓冲区末尾的连续数据长度
    const uint16_t contiguous = (g_tx_write > g_tx_read)
        ? (uint16_t)(g_tx_write - g_tx_read)
        : (uint16_t)(APP_TX_RING_SIZE - g_tx_read);
    
    g_tx_in_flight = contiguous;
    g_tx_active = true;
    
    // 启动 UART 中断发送
    if (HAL_UART_Transmit_IT(g_uart, &g_tx_ring[g_tx_read], contiguous) != HAL_OK) {
        g_tx_active = false;
        g_tx_in_flight = 0U;
    }
}

4.3 发送完成回调

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void AppSerial_OnTxComplete(UART_HandleTypeDef *uart)
{
    // 更新读指针,跳过已发送的数据
    g_tx_read = (uint16_t)((g_tx_read + g_tx_in_flight) % APP_TX_RING_SIZE);
    g_tx_in_flight = 0U;
    g_tx_active = false;
    
    // 如果还有数据待发送,启动下一段
    AppSerial_StartNextTransmit();
}

发送也是中断方式HAL_UART_Transmit_IT),不是阻塞轮询。这样主循环不会被串口发送阻塞。

4.4 格式化输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static void AppSerial_Print(const char *format, ...)
{
    va_list arguments;
    va_start(arguments, format);
    const int length = vsnprintf(g_format_buffer, sizeof(g_format_buffer),
                                 format, arguments);
    va_end(arguments);
    // ... 将格式化后的文本放入发送环形缓冲区
    AppSerial_QueueText(g_format_buffer, count);
}

使用 vsnprintf 支持类似 printf 的格式化输出,但输出到内存缓冲区而不是直接发送。

五、命令解析(主循环中完成)

5.1 主循环调用(app_baremetal.c)

1
2
3
4
5
6
void AppBareMetal_RunOnce(void)
{
    const uint32_t now = HAL_GetTick();
    AppSerial_Process();  // ← 串口数据处理
    // ... 其他任务
}

5.2 行解析过程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void AppSerial_Process(void)
{
    while (g_rx_read != g_rx_write) {  // 环形缓冲区非空
        const char character = (char)g_rx_ring[g_rx_read];
        g_rx_read = (uint16_t)((g_rx_read + 1U) % APP_RX_RING_SIZE);

        if ((character == '\r') || (character == '\n')) {
            // 遇到回车/换行 → 一行结束,解析命令
            if (g_line_length > 0U) {
                g_line[g_line_length] = '\0';
                AppSerial_HandleLine(g_line);  // ← 解析并执行
                g_line_length = 0U;
            }
        } else if ((character == '\b') || (character == 0x7F)) {
            // 退格/删除键处理
            if (g_line_length > 0U) g_line_length--;
        } else if (g_line_length < (APP_LINE_SIZE - 1U)) {
            // 普通字符,追加到行缓冲区
            g_line[g_line_length++] = character;
        } else {
            // 行太长,清空并报错
            g_line_length = 0U;
            AppSerial_Print("ERR line too long\r\n");
        }
    }
}

5.3 命令分发

AppSerial_HandleLine() 使用 strtok 按空格/制表符分割命令和参数,支持的命令包括:

命令 功能 回复示例
help 打印帮助 多行帮助文本
start 启动电机 OK start: encoder check and run requested
stop 停止电机 OK stop
status 打印状态 st=RUN mode=TORQUE fault=0x00000000 ...
mode torque|speed|position 切换控制模式 OK mode TORQUE
torque <A> 设置转矩电流 OK torque 1.0000 A
speed <rpm> 设置转速 OK speed 100.00 rpm
position <deg> 设置位置 OK position 90.000 deg
stream on|off 5 Hz 状态流 OK stream on
telemetry on|off 50 Hz CSV 遥测 OK telemetry on (50 Hz CSV)
tune current <Kp> <Ki> 调参 OK tuning applied to RAM
capture current <A> 电流阶跃捕获 OK 10 kHz current step capture armed

每个命令执行后都会通过 AppSerial_Print() 回复,这就是"上位机发数据再回复"的实现。


六、整体数据流总结

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
上位机发送 "status\r\n"
USART2 RX 硬件接收每个字节
       ↓ (每字节触发一次中断)
USART2_IRQHandler → HAL_UART_IRQHandler → HAL_UART_RxCpltCallback
AppSerial_OnRxComplete()  ← 中断中执行,仅存字节到环形缓冲区
主循环 AppBareMetal_RunOnce() → AppSerial_Process()
逐字节从环形缓冲区取出,拼成一行
       ↓ (遇到 \r 或 \n)
AppSerial_HandleLine("status")
解析命令 → 调用 MotorControl_GetSnapshot() 获取状态
AppSerial_Print("st=RUN mode=TORQUE ...")  ← 格式化回复文本
AppSerial_QueueText() → 放入发送环形缓冲区
AppSerial_StartNextTransmit() → HAL_UART_Transmit_IT()
       ↓ (发送完成触发中断)
HAL_UART_TxCpltCallback → AppSerial_OnTxComplete()
       ↓ (继续发送剩余数据)
上位机收到 "st=RUN mode=TORQUE fault=0x00000000 ...\r\n"

七、设计亮点

  1. 中断与主循环分离:中断只做"存字节"这一个动作,所有解析在主循环完成,不阻塞 30 kHz FOC 控制
  2. 双环形缓冲区:RX 128 字节、TX 2048 字节,生产者-消费者模型,无锁设计(只在 QueueText 中短暂关中断)
  3. 发送不阻塞:使用 HAL_UART_Transmit_IT 中断发送,主循环不会被串口发送阻塞
  4. 行缓冲:支持退格键编辑,遇到回车才解析,符合终端使用习惯
  5. 格式化输出AppSerial_Print 支持 printf 风格格式化,方便输出各种数据类型
  6. 队列满丢弃:发送队列满时直接丢弃新数据,保证编码器 1 kHz 轮询不被阻塞

环形缓冲区解决什么?

1
2
3
写入方向中断)→  [s][t][a][t][u][s][\r][\n][ ][ ][ ]...
                    写指针(g_rx_write)  读指针(g_rx_read)
                     读取方向主循环
  • 生产者(中断):只往 g_rx_write 位置写,写完移动写指针
  • 消费者(主循环):只从 g_rx_read 位置读,读完移动读指针
  • 只要写指针没追上读指针,数据就不会丢
  • 缓冲区满时(写指针追上读指针),丢弃新数据(if (next != g_rx_read) 判断)

核心价值:解耦了"产生数据的速度"和"消费数据的速度"。 中断可以快速把数据存起来,主循环有空了再慢慢处理。即使上位机一次性发 100 字节,只要缓冲区够大(128 字节),一个都不会丢。

为什么是"环形"而不是"线性"?

线性数组用完后要"搬移"数据(把后面的数据移到前面),浪费 CPU。环形缓冲区用取模运算 (index + 1) % SIZE 实现"绕回",写满后自动回到开头,无需搬移,是嵌入式最经典的 FIFO 实现。

发送侧同理

发送也用环形缓冲区(2048 字节),因为主循环可能一次性产生大量回复文本(比如 help 命令输出几十行),而 UART 发送是逐字节进行的。主循环把文本全部塞进发送缓冲区,中断发送完一段再发下一段,主循环不会被串口发送阻塞

Licensed MIT OR GPL3.0 WHATEVERS ON GITHUB_PAGE SHOW YOU