背景
在使用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,标准串口参数:
|
|
NVIC 中 USART2 中断优先级设为 3(次低优先级),避免干扰 30 kHz FOC 控制中断:
|
|
二、中断链路(stm32g4xx_it.c)
接收中断链路:
|
|
发送完成中断链路:
|
|
三、接收机制详解(app_serial.c)
3.1 初始化时启动单字节中断接收
在 AppSerial_Init() :
|
|
这行代码启动了 单字节中断接收——每次只接收 1 个字节,接收完成后触发中断回调,然后在回调中再次调用自己形成连续接收链。
3.2 接收回调函数
|
|
关键设计点:
- 使用
g_rx_byte(单个 volatile 变量)作为接收缓冲,而不是数组 - 每次中断只收 1 字节,立即存入环形缓冲区
g_rx_ring[128] - 环形缓冲区用
g_rx_write(写指针)和g_rx_read(读指针)管理 - 中断中只做最少的操作(存字节、移动指针),不解析命令
3.3 为什么不用 DMA 接收?
当然 DMA也可以配合环形缓冲区进行设计的。 这个工程没有使用 DMA 接收串口数据,而是用中断方式逐字节接收。原因:
- 串口命令是不定长、以回车换行结尾的文本行,DMA 适合定长接收
- 中断方式每字节只做一次内存拷贝(约几十个 CPU 周期),开销极小
- 避免了 DMA 的缓冲区管理和超时判断的复杂性
四、发送机制详解
4.1 发送环形缓冲区
|
|
4.2 启动发送(中断方式)
|
|
这段比较巧妙,在此作下笔记:HAL 库的 HAL_UART_Transmit_IT 发送函数,要求传入的内存空间必须是“物理上连续的一块数组”。但是,环形缓冲区在物理上是一个首尾相连的固定数组。当数据存满数组尾部时,新数据会绕回数组的头部。这就导致待发送的数据在内存里可能被“切成了两截”。
这行代码的作用,就是计算出当前这“第一截”物理连续的数据到底有多长。
APP_TX_RING_SIZE:数组的总长度(比如 8,下标是 0 ~ 7),在我的工程中是2048U。
g_tx_write:发送数据的写指针(下一个要写入数据的下标)。
g_tx_read:发送数据的读指针(下一个要发送数据的下标)。
|
|
数据在内存中是一整块连续的,没有跨过数组末尾。
若数据没有“回绕”(g_tx_write > g_tx_read),则待发数据是一整块,直接计算出长度一次性全都发出去。
若数据“回绕”了(g_tx_write <= g_tx_read),写指针已经绕回到了数组开头,待发送的数据被断成了两截:
第一截:从 g_tx_read 开始,一直到数组末尾(APP_TX_RING_SIZE - 1)。
第二截:从数组开头 0 开始,一直到 g_tx_write - 1。
待发送数据跑到了数组前方。因为 HAL_UART_Transmit_IT 只能发连续内存,所以这次只能先把数组末尾的“第一截”发掉。
剩下的“第二截,代码设计非常精妙,它利用了中断接力。第一截长度 contiguous,把长度为第一截长度 contiguous的数据发出去。 硬件串口把这 3 个字节发完后,触发 AppSerial_OnTxComplete 发送完成中断。中断回调函数把读指针推进:g_tx_read(读指针自动回绕到了 0!见下方的发送完成回调函数)。
紧接着回调函数再次调用 StartNextTransmit(): 此时 g_tx_read = 0,g_tx_write(此时无回绕),算出 contiguous ,自动把剩下的第二截顺畅地发送出去!
自动把剩下的第二截 ‘E’,‘F’ 顺畅地发送出去!
用极简的代码,完美解决了环形缓冲区内存不连续的痛点。
4.3 发送完成回调
|
|
发送也是中断方式(HAL_UART_Transmit_IT),不是阻塞轮询。这样主循环不会被串口发送阻塞。
4.4 格式化输出
|
|
使用 vsnprintf 支持类似 printf 的格式化输出,但输出到内存缓冲区而不是直接发送。
发送的时候,是单片机主导的。它可以提前算好 contiguous 长度。他不像接收数据那样,每次接收回调函数中只收1U,而是算出 contiguous 长度,一次性丢给HAL库的HAL_UART_Transmit_IT 发送函数去发送。直接把一整块连续内存丢给硬件,让硬件自己去逐字节发送。
再简单说一下
因为接收数据,CPU必须“被动”的单字节处理,因为不知道外部什么时候会发,也不知道外部一次发送多少字节。
硬件每收到 1 个字节,就会拉响一次“中断警报”(HAL_UART_Receive_IT),CPU 就得立刻放下手头的工作,跑进中断服务函数把这 1 个字节抓进环形缓冲区。
而发送:CPU 可以“主动”批量打包交接,因为数据是单片机自己生成的(比如你要打印一行日志 160 字节)。单片机非常清楚这块数据在内存里的起始地址和字节长度。
只需要调用一次:HAL_UART_Transmit_IT(g_uart, &g_tx_ring[g_tx_read], contiguous);交给了 STM32 的 UART 硬件外设。
ART 硬件控制器接管后,会在后台以固定的波特率(如 115200),自己在硬件电路上一位一位、一个字节一个字节地把这 100 个字节吐给 TX 引脚。
更深层次的
硬件UART传输是不占 CPU 时间的,STM32 内部的 UART(串口)是一个独立于 CPU 核心之外的硬件外设(有自己独立的移位寄存器、数据寄存器和波特率发生器)。
执行到代码AL_UART_Transmit_IT,CPU 做的唯一一件事,就是把内存地址和数据填入 UART 外设的寄存器里,并开启 UART 的发送中断。填完寄存器后(耗时仅几个 CPU 周期),CPU 就可以立刻退出这个函数去做其他任务。期间UART硬件外设会自己一位一位从TX引脚拉高拉低去发送数据。
只有在这一整块数据(contiguous 字节)全部发完的那一刻,UART 硬件才会拉响一次中断(即 AppSerial_OnTxComplete),通知 CPU:“我发完了,你要发下一段吗?”
只要配置得当,串口发送绝不可能卡顿电流环。NVIC(向量中断控制器)中,UART串口发送/接收的中断 被配置为较低优先级,比如Priority 3 或 4。
- 极致追求:如果想连这“1次中断”的开销都省掉?如果在极端的电机控制算法中,你连串口发完触发的那一次中断响应(大约几十个 CPU 周期)都不想给它,可以将发送方式升级为 UART + DMA(直接内存访问):
|
|
DMA(Direct Memory Access)是专门在内存和外设之间搬运数据的“小 CPU”,用它发送时,内存数据直接由 DMA 硬件喂给 UART,整个过程对 CPU 彻底透明。
五、命令解析(主循环中完成)
5.1 主循环调用(app_baremetal.c)
|
|
5.2 行解析过程
|
|
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() 回复,这就是"上位机发数据再回复"的实现。
六、整体数据流总结
|
|
七、设计亮点
- 中断与主循环分离:中断只做"存字节"这一个动作,所有解析在主循环完成,不阻塞 30 kHz FOC 控制
- 双环形缓冲区:RX 128 字节、TX 2048 字节,生产者-消费者模型,无锁设计(只在 QueueText 中短暂关中断)
- 发送不阻塞:使用
HAL_UART_Transmit_IT中断发送,主循环不会被串口发送阻塞 - 行缓冲:支持退格键编辑,遇到回车才解析,符合终端使用习惯
- 格式化输出:
AppSerial_Print支持printf风格格式化,方便输出各种数据类型 - 队列满丢弃:发送队列满时直接丢弃新数据,保证编码器 1 kHz 轮询不被阻塞
环形缓冲区解决什么?
|
|
- 生产者(中断):只往
g_rx_write位置写,写完移动写指针 - 消费者(主循环):只从
g_rx_read位置读,读完移动读指针 - 只要写指针没追上读指针,数据就不会丢
- 缓冲区满时(写指针追上读指针),丢弃新数据(
if (next != g_rx_read)判断)
核心价值:解耦了"产生数据的速度"和"消费数据的速度"。 中断可以快速把数据存起来,主循环有空了再慢慢处理。即使上位机一次性发 100 字节,只要缓冲区够大(128 字节),一个都不会丢。
为什么是"环形"而不是"线性"?
线性数组用完后要"搬移"数据(把后面的数据移到前面),浪费 CPU。环形缓冲区用取模运算 (index + 1) % SIZE 实现"绕回",写满后自动回到开头,无需搬移,是嵌入式最经典的 FIFO 实现。
发送侧同理
发送也用环形缓冲区(2048 字节),因为主循环可能一次性产生大量回复文本(比如 help 命令输出几十行),而 UART 发送是逐字节进行的。主循环把文本全部塞进发送缓冲区,中断发送完一段再发下一段,主循环不会被串口发送阻塞。