FreeRTOS中的非阻塞串口实现

SingleDog Other Waste

在 FreeRTOS 中,如何实现一个 thread-safe 的 printf?
本文实现了一个线程安全的 printf,同时不用阻塞方式 (临界区, 暂停中断等) 挂起其他任务的架构。
同时,usartTx 使用 dma 方式发送,尽可能地降低阻塞。
usartRx 使用了不定长 dma+Idle 中断的方式。

任务架构

为了不挂起其它任务,最有效的方式当然是

  • 创建一个串口发送任务 usartTxTask
  • 任务使用消息队列接收打印任务;
  • 其它任务需要打印时,向队列中发送数据;
  • usartTxTask 直接调用 DMA 发送队列中的数据。

Printf 实现

FreertosConfig.h 中提供了定义 configPRINTF( x ),自行提供

1
2
void MyPrintFunction(const char *pcFormat, ... );  
#define configPRINTF( X ) MyPrintFunction X

即可使用 configPRINTF( ("Format") ) 打印。
注意由于是宏定义展开,需要使用双括号包裹参数。

MyPrintFunction 的实现依赖可变参数,这里不做展开。
由于 CubeMX 的问题,实现中依赖的 vsnprintf() 似乎是非线程安全的,所以这里添加 mutex 来保证字符串格式化不会出现问题。
详细可见:newlib and FreeRTOS

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
void MyPrintFunction(const char *pcFormat, ...)
{
if (taskTxQueue == NULL || taskTxMutex == NULL) {
return;
}

char tempBuffer[PRINT_BUFFER_SIZE];
va_list args;
int length;
//Keep Thread Safe
if (xSemaphoreTake(taskTxMutex, portMAX_DELAY) == pdTRUE) {
// 格式化字符串
va_start(args, pcFormat);
length = vsnprintf(tempBuffer, PRINT_BUFFER_SIZE, pcFormat, args);
va_end(args);

if (length > 0 && length < PRINT_BUFFER_SIZE) {

xQueueSend(taskTxQueue, tempBuffer, portMAX_DELAY);
}


xSemaphoreGive(taskTxMutex);
}
}

MyPrintFunction 格式化以后,usartTxTask 发送队列中的数据:

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 usartTxTask(void *pvParameters) {
char buffer[PRINT_BUFFER_SIZE];
uint16_t length;

while (1) {
// 从队列接收消息
if (xQueueReceive(taskTxQueue, buffer, portMAX_DELAY) == pdPASS) {
// 获取字符串长度
length = strlen(buffer);

// 使用HAL_UART_Transmit替代DMA版本,确保发送完成
// HAL_UART_Transmit(&hlpuart1, (uint8_t *) buffer, length, HAL_MAX_DELAY);

// 或者如果必须使用DMA,确保等待完成:

HAL_UART_Transmit_DMA(&hlpuart1, (uint8_t *) buffer, length);
// 等待DMA传输完全完成

while (HAL_UART_GetState(&hlpuart1) == HAL_UART_STATE_BUSY_TX ||
HAL_UART_GetState(&hlpuart1) == HAL_UART_STATE_BUSY_TX_RX) {
vTaskDelay(1);
}

}
}
}

注意这里的 while (HAL_UART...) 等待 DMA 发送,由于 Rx 的 DMA 需要配置为 Circular 模式,所以 HAL_UART_GetState(&hlpuart1) 一定会返回 Rx_Busy。初始化时也需要注意使用 HAL_UART_AbortReceive(&hlpuart1); 来避免 HAL_UARTEx_ReceiveToIdle_DMA 返回 HAL_Error

串口配置

本文使用了 HAL_UARTEx_RxEventCallbackIdle 中断,实现了一个双缓冲的不定长串口接收。原始代码来自官方例程 UART_ReceptionToIdle_CircularDMA

  • Idle 中断
    DMA 的 Circular 模式会重复搬运 Rx 寄存器的值到内存中,此时只能通过 Rx 的 Idle 状态来判断当前是否接收完成。Idle 中断触发就在 Rx 为空闲时。
  • HAL_UARTEx_RxEventCallback()
    这似乎是 Idle 中断专用的回调函数,官方文档没有太多介绍,只有这个例程中有一些介绍。
    需要注意的是,HAL_UARTEx_RxEventCallback 的触发时机有三个,需要手动使用 size 来判断当前是什么中断。
    详细在例程的 readme 中有写:
1
2
3
4
5
6
7
8
9
Example : case of a reception of 22 bytes before Idle event occurs, using Circular DMA and a Rx buffer
of size of 20 bytes.
- User calls HAL_UARTEx_ReceiveToIdle_DMA() providing buffer address and buffer size (20)
- HAL_UARTEx_RxEventCallback() will be executed on HT DMA event with Size = 10
Data in user Rx buffer could be retrieved by application from index 0 to 9
- HAL_UARTEx_RxEventCallback() will be executed on TC DMA event with Size = 20
New data in user Rx buffer could be retrieved by application from index 10 to 19
- HAL_UARTEx_RxEventCallback() will be executed after IDLE event occurs with Size = 2
New data in user Rx buffer could be retrieved by application from index 0 to 1

请更新到 HAL 1.6.1. 1.6.0HAL_UART_AbortReceive() 有 Bug,会导致中断回调无法进入。

上板!

Bugs

过程中其实遇到了很多问题:

  • UART_ReceptionToIdle_CircularDMA 失败。
    最后发现是 RxBusy 的问题。

  • HAL_UARTEx_RxEventCallback 不触发。
    打着断点终于发现 HAL_UART_AbortReceive() 会直接更改 huart.ReceptionType 到 normal. HAL 1.6.1 版本修复了这个问题,让 Idle 中断能正常触发。

  • Transmit 函数跑飞到 configASSERT( ( portAIRCR_REG & portPRIORITY_GROUP_MASK ) <= ulMaxPRIGROUPValue );
    按照 prot.c line 766 的指示,调用 NVIC_SetPriorityGrouping( 0 ); 解决。似乎是 RTOS 和外设直接管理的中断有冲突。

  • 改用 G474 的 lpuart1 ,串口不响应。
    参照手册,lpuart1 硬连接到板载 STLINK,此时需要使用 VCOM 模式让 STLINK 模拟串口。也就是,在 CubeMX 中开启 Human Machine Interface 的 VCOM ,然后当作普通串口使用即可。

  • 任务占用的 Heap 异常高。
    这个问题还没有解决,似乎是打印时使用的 vsnprintf() 占用了大量 heap。一个 stack==128 的任务居然占用了 912 字节。

  • Rx 任务 Buffer 在接收慢一次
    似乎是中断中 vTaskNotifyGiveFromISR() 函数执行后中断函数会继续执行,导致 RxTask 中得到的是交换后的旧 Buffer。重写了一下 HAL_UARTEx_RxEventCallback() 的缓冲交换逻辑解决。

验证

串口最后打印

1
2
3
LED2 Walking!
1 Running!
LED2 Walking!

分别来自

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void ledToggle(void* x)//tskIDLE_PRIORITY
{
configPRINTF(("1 Init!\r\n"));
vTaskDelay(pdMS_TO_TICKS(((uint8_t)5)));
while(1)
{
HAL_GPIO_TogglePin(GPIOA,GPIO_PIN_5);
configPRINTF(("1 Running!\r\n"));
vTaskDelay(pdMS_TO_TICKS(((uint8_t)x *500)));
// osDelay(500);//可以混用,真神奇啊
}
}
void ledToggle2(void* x)
{
configPRINTF(("LED2 Rest!\r\n"));
osDelay(5);
while(1)
{
configPRINTF(("LED2 Walking!\r\n"));
vTaskDelay(pdMS_TO_TICKS(((uint8_t)x *500)));
}
}

完整的配置代码会在某个时间之后开源在 singledog957/NUEDC2025
使用了不同的字符串,因为之前 DMA 混合了发送数据,导致 Debug 时的 LED1 InitLED2 Init 都被混合成了 LED2 Init

其它

这是第一次实际上板使用 FreeRTOS,其实一开始还遇到了很多问题,比如 CMSIS FreeRTOS 的封装函数和原生函数的混用、Heap 不够分配失败等等。
在操作系统中,实时性被放大,任务间的同步和异步需要更谨慎地考虑。怎么在有限的资源内发挥出极致的性能,需要的是设计者对全局的把握,也如艺术一般,象征意义远大于实际意义。
再有就是不要对库函数抱有绝对地信任或者畏惧。代码都是人写的,机器永远是对的。

  • Title: FreeRTOS中的非阻塞串口实现
  • Author: SingleDog
  • Created at : 2025-07-09 16:57:00
  • Updated at : 2025-07-09 22:16:55
  • Link: https://www.singledog233.top/RTOS_USART/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
FreeRTOS中的非阻塞串口实现