bsp_uart UART DMA+IDLE 深度解析
1. 概述
GENERIC_DEVICE_CONTROLLER 的 BSP 层提供了一套基于 STM32F103 的 3 端口 UART 驱动框架,核心设计围绕 DMA 循环缓冲区、IDLE 空闲中断、软件尾指针 和 两级 TX 发送队列 展开。三个 USART 端口分工明确:
- USART1 (SCREEN/RS232, 9600 bps) — 人机交互
- USART2 (PRINTER/RS232, 115200 bps) — 日志/打印
- USART3 (PACKER/RS485, 9600 bps) — 工业协议封包
其中仅有 USART3 (PACKER 端口) 启用了 IDLE 空闲中断 用于帧边界检测,其余端口依赖 DMA 半传输/完成中断触发的轮询读取。
2. RX 路径:DMA 循环缓冲区 + 软件尾指针
2.1 数据结构
每个端口维护一个静态分配的 DMA 接收循环缓冲区 (256 字节),配合一个 软件尾指针 (tail) 记录应用层已读取的位置,而 头指针 (head) 通过 DMA 的 CNDTR 寄存器反推得到:
/* bsp_uart_defs.h — 端口上下文结构 */
typedef struct {
USART_TypeDef *Instance;
DMA_Stream_TypeDef *rx_dma_stream;
/* 接收循环缓冲区 */
uint8_t rx_buf[BSP_UART_RX_BUF_SIZE]; /* 256 bytes */
uint16_t rx_tail; /* 软件尾指针 */
/* DMA 传输完成回调 */
void (*rx_activity_cb)(void *ctx);
void *rx_activity_ctx;
/* 发送环形队列 */
uint8_t tx_queue[BSP_UART_TX_QUEUE_SIZE]; /* 256 bytes */
uint16_t tx_q_head;
uint16_t tx_q_tail;
uint8_t tx_dma_seg[BSP_UART_TX_SEG_SIZE]; /* 64 bytes */
volatile uint8_t tx_busy;
SemaphoreHandle_t tx_mutex;
/* FreeRTOS Stream Buffer (仅 PACKER 端口) */
StaticStreamBuffer_t rx_stream_buf_static;
StreamBufferHandle_t rx_stream_buf;
uint8_t rx_stream_buf_storage[BSP_UART_PACKER_STREAM_SIZE]; /* 257 bytes */
/* 错误恢复 */
volatile uint32_t error_flags;
} bsp_uart_ctx_t;
2.2 Head 指针推导
DMA 配置为循环模式 (Circular Mode),从 rx_buf[0] 开始向 rx_buf[255] 写入。DMA 的 CNDTR 寄存器记录 剩余待传输的字节数,因此当前写入位置 (head) 为:
static uint16_t bsp_uart_get_head(bsp_uart_ctx_t *ctx)
{
uint16_t remain = __HAL_DMA_GET_COUNTER(ctx->rx_dma_stream);
uint16_t head = BSP_UART_RX_BUF_SIZE - remain;
if (head >= BSP_UART_RX_BUF_SIZE) {
head = 0;
}
return head;
}
应用层通过 tail 和 head 之间的差值判断可读数据量。由于缓冲区是循环的,当 head 回绕到小于 tail 时,可读数据分为两段:[tail, buf_end] + [0, head)。
3. IDLE 空闲中断 — 帧边界检测
3.1 为什么只用 USART3?
USART1 和 USART2 是面向人/日志的流式数据,没有明确的"帧"概念,应用层按需轮询读取即可。USART3 (PACKER/RS485) 承载工业协议数据,每个报文有明确帧边界,IDLE 中断能够在接收完一帧后立即通知应用层唤醒处理,降低响应延迟。
3.2 IDLE 中断处理
/* stm32f1xx_it.c — USART3 IDLE 中断 */
void USART3_IRQHandler(void)
{
if (__HAL_UART_GET_FLAG(&huart3, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart3);
/* 计算当前帧长度 */
uint16_t head = bsp_uart_get_head(&bsp_uart_ctx[BSP_UART_PORT_PACKER]);
uint16_t avail;
if (head >= bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail) {
avail = head - bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail;
} else {
avail = (BSP_UART_RX_BUF_SIZE - bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail)
+ head;
}
/* 将可用数据推入 Stream Buffer */
if (avail > 0) {
/* 处理回绕情况,分两段拷贝 */
uint16_t first_len = (head > bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail)
? avail
: (BSP_UART_RX_BUF_SIZE - bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail);
xStreamBufferSendFromISR(
bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_stream_buf,
&bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_buf[
bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail],
first_len, NULL);
if (first_len < avail) {
uint16_t second_len = avail - first_len;
xStreamBufferSendFromISR(
bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_stream_buf,
bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_buf,
second_len, NULL);
}
/* 更新 tail */
bsp_uart_ctx[BSP_UART_PORT_PACKER].rx_tail = head;
}
/* 通知任务有数据 */
bsp_uart_notify_rx_activity(BSP_UART_PORT_PACKER);
}
HAL_UART_IRQHandler(&huart3);
}
4. DMA 半传输 / 完成中断
4.1 统一回调
三个端口均使能了 DMA 半传输中断 (HT) 和传输完成中断 (TC)。HAL 库提供的两个回调函数均调用同一个通知函数,确保应用层不会错过任何数据到达事件:
/* 两个回调 → 同一个通知入口 */
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
bsp_uart_notify_rx_activity(bsp_uart_get_port_from_handle(huart));
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
bsp_uart_notify_rx_activity(bsp_uart_get_port_from_handle(huart));
}
bsp_uart_notify_rx_activity() 内部根据端口不同执行不同逻辑:对于 PACKER 端口,IDLE 中断已经处理了数据入队,所以该函数仅用于唤醒等待任务;对于非 PACKER 端口,由于没有 IDLE 中断,该函数是应用层知晓有新数据的唯一信号。
5. 应用层读取策略
5.1 PACKER 端口:Stream Buffer 阻塞读取
USART3 使用 FreeRTOS Stream Buffer (静态分配 257 字节),应用任务可以直接阻塞等待数据:
/* app_packer.c — 阻塞等待完整帧 */
void packer_task(void *param)
{
uint8_t frame_buf[256];
size_t received;
for (;;) {
/* 阻塞等待至少 1 字节,直到超时或完整帧 */
received = xStreamBufferReceive(
bsp_uart_get_rx_stream_buf(BSP_UART_PORT_PACKER),
frame_buf, sizeof(frame_buf),
pdMS_TO_TICKS(100));
if (received > 0) {
process_incoming_frame(frame_buf, received);
}
}
}
IDLE 中断确保每帧数据完整写入 Stream Buffer 后才唤醒任务,避免应用层读到半帧。
5.2 非 PACKER 端口:Polling + vTaskDelay(1)
USART1 和 USART2 使用轮询读取,由 CommTask (周期 ~10ms) 统一调度:
/* app_comm.c — CommTask 轮询读取 */
void comm_task(void *param)
{
uint8_t buf[64];
int16_t len;
for (;;) {
bsp_uart_task_once(); /* 处理 TX 队列 */
/* 轮询非 PACKER 端口 */
for (int port = BSP_UART_PORT_SCREEN;
port <= BSP_UART_PORT_PRINTER; port++) {
do {
len = bsp_uart_receive_poll(port, buf, sizeof(buf));
if (len > 0) {
forward_to_upper_layer(port, buf, len);
}
} while (len > 0);
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
bsp_uart_receive_poll() 内部使用 vTaskDelay(1) 在无数据时让出 CPU,避免忙等。
6. TX 路径:两级发送队列
6.1 总体设计
发送路径分为两层:
- 软件环形队列 (tx_queue, 256 字节) — 应用层写入的原始数据
- 线性 DMA 段缓冲区 (tx_dma_seg, 64 字节) — 从环形队列拷贝到连续内存后启动 DMA 发送
6.2 两阶段发送
/* 阶段一:应用层写入环形队列 */
int bsp_uart_transmit(bsp_uart_port_t port, const uint8_t *data, uint16_t len)
{
bsp_uart_ctx_t *ctx = &bsp_uart_ctx[port];
uint16_t space;
int ret = -1;
taskENTER_CRITICAL();
space = bsp_uart_tx_queue_space(ctx);
if (space >= len) {
/* 整包入队 — 保证要么全进要么不进 */
for (uint16_t i = 0; i < len; i++) {
ctx->tx_queue[ctx->tx_q_head] = data[i];
ctx->tx_q_head = (ctx->tx_q_head + 1) & (BSP_UART_TX_QUEUE_SIZE - 1);
}
ret = 0;
}
taskEXIT_CRITICAL();
return ret;
}
/* 阶段二:bsp_uart_task_once() 启动 DMA 发送 */
void bsp_uart_task_once(void)
{
for (int port = 0; port < BSP_UART_PORT_COUNT; port++) {
bsp_uart_ctx_t *ctx = &bsp_uart_ctx[port];
if (ctx->tx_busy) {
continue; /* DMA 正在发送,跳过 */
}
/* 从环形队列搬运到 DMA 段缓冲区 */
uint16_t q_len = bsp_uart_tx_queue_len(ctx);
if (q_len == 0) {
continue;
}
uint16_t copy_len = (q_len > BSP_UART_TX_SEG_SIZE)
? BSP_UART_TX_SEG_SIZE : q_len;
for (uint16_t i = 0; i < copy_len; i++) {
ctx->tx_dma_seg[i] = ctx->tx_queue[ctx->tx_q_tail];
ctx->tx_q_tail = (ctx->tx_q_tail + 1) & (BSP_UART_TX_QUEUE_SIZE - 1);
}
ctx->tx_busy = 1;
HAL_UART_Transmit_DMA(ctx->huart, ctx->tx_dma_seg, copy_len);
}
}
6.3 TX 完成中断
DMA 传输完成中断中不清除 busy 标志后立即启动下一段——因为中断中调用 HAL_UART_Transmit_DMA 可能造成重入问题。正确的做法是仅设置 tx_busy = 0 并标记"有数据待发",由 bsp_uart_task_once() 在任务上下文中触发下一段发送:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
bsp_uart_port_t port = bsp_uart_get_port_from_handle(huart);
bsp_uart_ctx_t *ctx = &bsp_uart_ctx[port];
ctx->tx_busy = 0; /* DMA 通道释放 */
bsp_uart_set_tx_pending(port); /* 标记有待发数据 */
/* 不在此处调用 bsp_uart_task_once() —
由 CommTask 在 ~10ms 周期中统一处理 */
}
7. 整包入队保证
发送 API 的关键设计约束:要么整包进入环形队列,要么一字节都不进。如果环形队列剩余空间不足,bsp_uart_transmit() 返回负值,调用方需要自行决定重试或丢弃。这避免了半包发送导致协议解析错误。
/* 调用方示例 — 确保整包发送 */
int uart_send_packet(bsp_uart_port_t port,
const uint8_t *pkt, uint16_t pkt_len)
{
int ret;
/* 持有 TX 互斥锁,防止多任务竞争 */
if (xSemaphoreTake(bsp_uart_get_tx_mutex(port),
pdMS_TO_TICKS(100)) != pdTRUE) {
return -1; /* 获取锁超时 */
}
ret = bsp_uart_transmit(port, pkt, pkt_len);
xSemaphoreGive(bsp_uart_get_tx_mutex(port));
return ret;
}
8. TX 互斥锁
每个端口维护一个静态分配的 FreeRTOS 互斥锁 (StaticSemaphore_t),防止多个任务同时写入同一端口的 TX 队列造成数据交错。锁的粒度是"每次 enqueue 操作",而非"整个传输完成"——因为实际的 DMA 发送是异步的。
/* bsp_uart_init.c — 静态创建 TX 互斥锁 */
static StaticSemaphore_t tx_mutex_buf[BSP_UART_PORT_COUNT];
void bsp_uart_init(void)
{
for (int i = 0; i < BSP_UART_PORT_COUNT; i++) {
bsp_uart_ctx[i].tx_mutex =
xSemaphoreCreateMutexStatic(&tx_mutex_buf[i]);
}
}
9. 错误恢复机制
9.1 错误回调
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
bsp_uart_port_t port = bsp_uart_get_port_from_handle(huart);
uint32_t err = huart->ErrorCode;
/* 累积错误标志 */
bsp_uart_ctx[port].error_flags |= err;
/* 通知应用层 */
bsp_uart_notify_error(port);
}
9.2 应用层消费与恢复
/* app_main.c — CommTask 周期性消费错误标志并恢复 */
void app_main_consume_uart_recovery_flags(void)
{
for (int port = 0; port < BSP_UART_PORT_COUNT; port++) {
uint32_t flags = bsp_uart_ctx[port].error_flags;
if (flags) {
bsp_uart_ctx[port].error_flags = 0;
LOG_ERROR("UART port %d error: 0x%08lX, restarting RX", port, flags);
bsp_uart_port_rx_restart(port);
}
}
}
/* 重启 RX:重置 tail、清空缓冲区、重新启动 DMA */
void bsp_uart_port_rx_restart(bsp_uart_port_t port)
{
bsp_uart_ctx_t *ctx = &bsp_uart_ctx[port];
uint32_t primask;
/* 临界区保护 */
__disable_irq();
primask = __get_PRIMASK();
/* 停止 DMA */
HAL_DMA_Abort(ctx->rx_dma_stream);
/* 重置软件状态 */
memset(ctx->rx_buf, 0, BSP_UART_RX_BUF_SIZE);
ctx->rx_tail = 0;
/* 重启 DMA 接收 */
HAL_UART_Receive_DMA(ctx->huart, ctx->rx_buf, BSP_UART_RX_BUF_SIZE);
__set_PRIMASK(primask);
}
10. 调度器未启动时的回退方案
在 FreeRTOS 调度器启动之前 (vTaskStartScheduler() 之前),不能使用任何 FreeRTOS API。为此 BSP 层提供一个阻塞发送模式:
/* bsp_uart.c — 调度器未启动时的回退 */
void bsp_uart_early_putc(bsp_uart_port_t port, uint8_t c)
{
HAL_UART_Transmit(bsp_uart_ctx[port].huart, &c, 1, HAL_MAX_DELAY);
}
void bsp_uart_early_puts(bsp_uart_port_t port, const char *str)
{
HAL_UART_Transmit(bsp_uart_ctx[port].huart,
(uint8_t *)str, strlen(str), HAL_MAX_DELAY);
}
该函数在硬件初始化阶段、调度器启动前使用,用于输出启动日志。调度器启动后,应用层应切换至 bsp_uart_transmit() 非阻塞接口。
11. 静态分配与零 malloc
整个 BSP 层和 APP 层的所有数据结构均为静态分配,无任何动态内存分配。包括:
- 端口上下文 (
bsp_uart_ctx_t) - DMA 接收缓冲区 (256 字节 × 3)
- TX 环形队列 (256 字节 × 3)
- TX DMA 段缓冲区 (64 字节 × 3)
- FreeRTOS Stream Buffer (257 字节,仅 PACKER 端口)
- FreeRTOS 互斥锁静态内存 (
StaticSemaphore_t) - Stream Buffer 静态控制块 (
StaticStreamBuffer_t)
静态分配使得内存使用完全可预测,避免了堆碎片、内存泄漏和 malloc 失败等运行时风险,适用于对可靠性要求严格的嵌入式系统。
12. 临界区策略:PRIMASK vs taskENTER_CRITICAL
12.1 BSP 层:PRIMASK
BSP 层的临界区直接操作 PRIMASK (全局关中断),不依赖 FreeRTOS。这是因为 BSP 层可能在调度器启动前被调用,且需要绝对保证时序敏感的寄存器操作不被任何中断打断:
/* BSP 层 — 使用 PRIMASK 临界区 */
void bsp_uart_port_rx_restart(bsp_uart_port_t port)
{
uint32_t primask = __get_PRIMASK();
__disable_irq();
/* 执行关键操作:停止 DMA、重置缓冲区、重启 DMA */
__set_PRIMASK(primask); /* 恢复原始状态 */
}
12.2 APP 层:taskENTER_CRITICAL
应用层使用 FreeRTOS 的 taskENTER_CRITICAL() / taskEXIT_CRITICAL(),仅禁止当前 CPU 的中断和任务切换,效率更高且不会影响另一个核 (如果有)。在单核 STM32F103 上,两者的实际效果差异主要在于 嵌套调用计数 和 与 FreeRTOS 调度器的交互:
/* APP 层 — 使用 taskENTER_CRITICAL */
void app_layer_operation(void)
{
taskENTER_CRITICAL();
/* 操作应用层共享数据 */
taskEXIT_CRITICAL();
}
| 层级 | 临界区机制 | 影响范围 | 调度器需要? |
|---|---|---|---|
| BSP | __disable_irq() / PRIMASK |
全局关中断 | 否 |
| APP | taskENTER_CRITICAL() |
挂起调度器 + 关中断 | 是 |
13. 总结
GENERIC_DEVICE_CONTROLLER 的 bsp_uart 驱动框架在 STM32F103 上实现了以下关键设计:
- 3 端口差异化设计:PACKER 端口使用 IDLE 中断 + Stream Buffer 实现帧感知接收;非 PACKER 端口使用 DMA HT/TC + 轮询读取,降低中断负载。
- 软件 tail 指针 + DMA CNDTR head:在不停止 DMA 的前提下实现安全的数据消费,内存效率高。
- 两级 TX 发送:环形队列 + 线性 DMA 段,避免 DMA 分散/聚集所需的复杂链表管理。
- 整包入队保证:避免半包被 DMA 发送出去导致协议错误。
- 两阶段发送:中断仅释放 busy 标志,任务上下文启动下一次 DMA,避免重入问题。
- 静态零 malloc:所有资源编译期确定,运行时无堆操作,高可靠性。
- 分层临界区:BSP PRIMASK 隔离硬件操作,APP taskENTER_CRITICAL 轻量同步。
- 错误恢复:中断记录错误标志,任务上下文统一恢复,避免在 ISR 中执行耗时操作。
这套设计在多个工业现场已验证运行,能够稳定处理 9600 bps ~ 115200 bps 的并发收发。下一篇文章将深入分析 USART3 的 RS485 方向控制与自动换向的硬件/软件协同设计。
附录:完整代码清单
以下为 bsp_uart 核心文件的快速索引 (路径基于 GENERIC_DEVICE_CONTROLLER 仓库结构):
bsp/
├── inc/
│ ├── bsp_uart.h — 公共 API 声明
│ └── bsp_uart_defs.h — 端口上下文结构体、宏定义
├── src/
│ ├── bsp_uart.c — 核心实现 (init, transmit, receive_poll, task_once)
│ ├── bsp_uart_dma.c — DMA 配置与 HAL 回调
│ ├── bsp_uart_idle.c — IDLE 中断处理 (仅 USART3)
│ └── bsp_uart_early.c — 调度器未启动时的早期 I/O
app/
├── app_comm.c — CommTask (周期 10ms,轮询非 PACKER 端口)
├── app_packer.c — PackerTask (阻塞读取 Stream Buffer)
└── app_main.c — 错误恢复消费、启动初始化
Comments NOTHING