背景
在使用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 接收串口数据,而是用中断方式逐字节接收。原因:
- 串口命令是不定长、以回车换行结尾的文本行,DMA 适合定长接收
- 中断方式每字节只做一次内存拷贝(约几十个 CPU 周期),开销极小
- 避免了 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 的格式化输出,但输出到内存缓冲区而不是直接发送。
五、命令解析(主循环中完成)
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"
|
七、设计亮点
- 中断与主循环分离:中断只做"存字节"这一个动作,所有解析在主循环完成,不阻塞 30 kHz FOC 控制
- 双环形缓冲区:RX 128 字节、TX 2048 字节,生产者-消费者模型,无锁设计(只在 QueueText 中短暂关中断)
- 发送不阻塞:使用
HAL_UART_Transmit_IT 中断发送,主循环不会被串口发送阻塞
- 行缓冲:支持退格键编辑,遇到回车才解析,符合终端使用习惯
- 格式化输出:
AppSerial_Print 支持 printf 风格格式化,方便输出各种数据类型
- 队列满丢弃:发送队列满时直接丢弃新数据,保证编码器 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 发送是逐字节进行的。主循环把文本全部塞进发送缓冲区,中断发送完一段再发下一段,主循环不会被串口发送阻塞。