在嵌入式开发中printf 是最常用的调试工具之一。它使用简单输出直观能帮助开发者快速定位问题。但在基于 FreeRTOS 的多任务系统中很多新手都会遇到这样的问题多个任务同时调用 printf 时会出现字符丢失、乱码、甚至只有一个任务能输出的现象。很多人不知道问题的根本原因只是在网上找了一个 解决方案在 printf 前后加上vTaskSuspendAll()和xTaskResumeAll()。这个方法确实能解决输出错乱的问题但却会引入更严重的系统实时性问题甚至导致死锁。本文将通过一个实战实验复现 printf 线程不安全的现象深入分析其根本原因指出常见错误解法的危害并给出几种工业界常用的正确解决方案。一、实战复现问题1.1 实验环境硬件STM32F407 开发板软件STM32CubeMX 6.10.0Keil MDK 5.38FreeRTOS 10.4.6串口配置USART1115200 波特率8 位数据位1 位停止位无校验1.2 实验代码首先是默认的 printf 重定向代码没有加任何线程保护#include stdio.h #include stm32f4xx_hal.h #include usart.h #ifdef __GNUC__ #define PUTCHAR_PROTOTYPE int _io_putchar(int ch) #else #define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f) #endif PUTCHAR_PROTOTYPE { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }然后创建三个任务每个任务循环打印自己的标识#include FreeRTOS.h #include task.h void task1(void *pvParameters) { while(1) { printf(This is task_1\r\n); // osDelay(1); } } void task2(void *pvParameters) { while(1) { printf(This is task_2\r\n); // osDelay(1); } } void task3(void *pvParameters) { while(1) { printf(This is task_3\r\n); // osDelay(1); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); xTaskCreate(task1, task1, 128, NULL, 1, NULL); xTaskCreate(task2, task2, 128, NULL, 1, NULL); xTaskCreate(task3, task3, 128, NULL, 1, NULL); vTaskStartScheduler(); while(1); }1.3 实验现象现象 1注释掉所有osDelay(1)编译运行串口只输出This is task_3\r\ntask1 和 task2 没有任何输出。现象 2打开所有osDelay(1)编译运行串口能看到三个任务的输出但偶尔会出现字符交错的情况比如This is task_1This is task_2\r\n\r\n。1.4 现象解释FreeRTOS 采用抢占式调度机制相同优先级的任务采用时间片轮转调度。任务创建的顺序是 task1→task2→task3但 FreeRTOS 中最后创建的任务会最先运行。当没有osDelay时task3 最先运行调用 printf 打印字符串。printf 底层调用HAL_UART_Transmit这个函数会先检查串口状态变量gState如果是HAL_UART_STATE_READY就将其设为HAL_UART_STATE_BUSY_TX然后开始发送数据。在数据发送过程中系统产生 Tick 中断触发任务切换。调度器切换到 task2task2 也调用 printf同样调用HAL_UART_Transmit。此时gState还是HAL_UART_STATE_BUSY_TXHAL_UART_Transmit直接返回HAL_BUSY没有发送任何数据。同理task1 运行时也会遇到同样的情况。当任务切换回 task3 时数据已经发送完成gState被设为HAL_UART_STATE_READYtask3 继续打印下一行。如此循环就只有 task3 能输出。当加入osDelay(1)后每个任务打印一次后就阻塞 1ms让出 CPU。此时串口有足够的时间完成数据发送gState会被重置为 READY所以三个任务都能输出。但如果两个任务的 printf 调用刚好在串口发送完成的瞬间发生还是会出现竞态条件导致字符交错。二、根本原因分析printf 在多任务环境下出现问题根本原因有两个HAL 库 UART 驱动的非线程安全实现以及 C 标准库 printf 本身的非线程安全实现。2.1 HAL_UART_Transmit 的非线程安全实现STM32 HAL 库的 UART 驱动使用一个全局变量g_uart_state来标记串口的工作状态。这个变量在HAL_UART_Init中被初始化为HAL_UART_STATE_READY在发送数据前被设为HAL_UART_STATE_BUSY_TX发送完成后在中断或 DMA 回调中被重置为 READY。这个全局变量是竞态条件的根源。当多个任务同时调用HAL_UART_Transmit时可能会出现以下情况任务 A 检查g_uart_state发现是 READY任务切换到任务 B任务 B 也检查g_uart_state发现是 READY任务 B 将g_uart_state设为 BUSY_TX开始发送数据任务切换回任务 A任务 A 不知道g_uart_state已经被修改也将其设为 BUSY_TX开始发送数据两个任务的数据同时发送到串口导致字符交错更糟糕的是HAL_UART_Transmit的阻塞模式就是我们常用的第三个参数为 0xFFFF 的情况是通过轮询标志位实现的。如果在轮询过程中发生任务切换另一个任务也调用HAL_UART_Transmit就会导致数据丢失。2.2 printf 本身的非线程安全实现C 标准库中的 printf 函数默认不是线程安全的。它内部使用了静态缓冲区来存储格式化后的字符串并且维护了一些内部状态变量。当多个任务同时调用 printf 时可能会出现以下情况任务 A 将字符串格式化到内部缓冲区任务切换到任务 B任务 B 也将字符串格式化到同一个内部缓冲区覆盖了任务 A 的数据任务切换回任务 A任务 A 发送缓冲区中的数据结果是任务 B 的字符串这就是为什么即使HAL_UART_Transmit是线程安全的printf 仍然可能出现乱码的原因。三、常见错误解法及其危害很多人在网上找到的 解决方案 是在 printf 前后加上vTaskSuspendAll()和xTaskResumeAll()或者直接修改 fputc 函数在每个字符发送前后挂起和恢复调度器。3.1 错误解法 1在 printf 前后挂起调度器代码如下vTaskSuspendAll(); printf(This is task_1\r\n); xTaskResumeAll();这个方法的原理是挂起所有任务调度确保当前任务独占 CPU直到 printf 完成。这样就不会有其他任务同时调用 printf避免了竞态条件。但这个方法存在严重的问题系统实时性严重下降printf 通过串口发送数据是一个非常耗时的操作。以 115200 波特率为例发送一个字节需要约 8.7us发送一行 16 个字符的字符串需要约 140us。如果发送一个 100 个字符的长字符串需要约 870us。在这段时间内系统不能切换到任何其他任务所有高优先级任务都被阻塞。对于实时性要求高的系统比如电机控制、工业控制这是不可接受的。一个 870us 的延迟可能导致电机失步、传感器数据丢失等严重问题。中断仍然会运行可能导致缓冲区溢出vTaskSuspendAll()只是挂起任务调度器并不会关闭中断。中断服务程序ISR仍然会正常运行。如果 ISR 往队列或消息缓冲区中放入数据但任务不能被调度来处理这些数据就会导致队列溢出数据丢失。容易导致系统死锁如果在vTaskSuspendAll()和xTaskResumeAll()之间的代码出现异常比如发生断言失败、硬件错误导致xTaskResumeAll()没有被调用调度器就会永远挂起系统死锁。嵌套使用会导致问题vTaskSuspendAll()是计数型的调用 n 次vTaskSuspendAll()需要调用 n 次xTaskResumeAll()才能恢复调度器。如果代码中存在嵌套调用而其中某一次xTaskResumeAll()被漏掉就会导致调度器无法恢复。3.2 错误解法 2在 fputc 中挂起调度器还有一些人会修改 fputc 函数在每个字符发送前后挂起和恢复调度器PUTCHAR_PROTOTYPE { vTaskSuspendAll(); HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); xTaskResumeAll(); return ch; }这个方法比第一个方法更糟糕。因为发送一个 100 字符的字符串需要调用 100 次 fputc也就是 100 次挂起和恢复调度器。每次挂起和恢复调度器都有一定的开销这会大大增加系统的负担。而且总的挂起时间和直接包裹整个 printf 是一样的都是 8.7ms。系统在这 8.7ms 内仍然不能处理任何其他任务实时性同样很差。四、正确的解决方案针对 printf 线程不安全的问题工业界有几种成熟的解决方案各有优缺点适用于不同的场景。4.1 方案 1互斥量保护 printf互斥量是解决多线程资源竞争的标准方法。我们可以创建一个互斥量然后封装一个log_printf函数在调用 printf 之前获取互斥量调用完成后释放互斥量。代码实现#include semphr.h SemaphoreHandle_t g_printf_mutex; void log_init(void) { g_printf_mutex xSemaphoreCreateMutex(); } int log_printf(const char *format, ...) { int ret 0; va_list args; if (xSemaphoreTake(g_printf_mutex, portMAX_DELAY) pdTRUE) { va_start(args, format); ret vprintf(format, args); va_end(args); xSemaphoreGive(g_printf_mutex); } return ret; }使用方法c运行void task1(void *pvParameters) { while(1) { log_printf(This is task_1\r\n); osDelay(1); } }优点实现简单代码改动小不影响系统实时性只有当两个任务同时调用log_printf时才会有一个任务阻塞等待互斥量保证 printf 的原子性不会出现字符交错缺点不能在中断服务程序中使用因为互斥量不能在 ISR 中获取调用log_printf的任务会阻塞直到 printf 完成对于长字符串阻塞时间较长适用场景任务数量少printf 调用不频繁不需要在 ISR 中打印日志对实时性要求一般的系统4.2 方案 2日志任务 消息队列这是工业界最常用的方案也是最推荐的方案。我们创建一个专门的日志任务和一个消息队列。所有任务要打印的日志都通过消息队列发送给日志任务由日志任务统一调用 printf 打印。代码实现#include queue.h #define LOG_QUEUE_LENGTH 10 #define LOG_MAX_MESSAGE_LENGTH 64 QueueHandle_t g_log_queue; void log_task(void *pvParameters) { char buffer[LOG_MAX_MESSAGE_LENGTH]; while(1) { if (xQueueReceive(g_log_queue, buffer, portMAX_DELAY) pdTRUE) { printf(%s, buffer); } } } void log_init(void) { g_log_queue xQueueCreate(LOG_QUEUE_LENGTH, LOG_MAX_MESSAGE_LENGTH); xTaskCreate(log_task, log_task, 256, NULL, 2, NULL); } int log_printf(const char *format, ...) { char buffer[LOG_MAX_MESSAGE_LENGTH]; va_list args; int ret; va_start(args, format); ret vsnprintf(buffer, LOG_MAX_MESSAGE_LENGTH, format, args); va_end(args); if (xQueueSend(g_log_queue, buffer, 0) ! pdTRUE) { // 队列满日志丢失 return -1; } return ret; } // 中断中使用的版本 int log_printf_from_isr(const char *format, ...) { char buffer[LOG_MAX_MESSAGE_LENGTH]; va_list args; int ret; BaseType_t xHigherPriorityTaskWoken pdFALSE; va_start(args, format); ret vsnprintf(buffer, LOG_MAX_MESSAGE_LENGTH, format, args); va_end(args); if (xQueueSendFromISR(g_log_queue, buffer, xHigherPriorityTaskWoken) ! pdTRUE) { // 队列满日志丢失 return -1; } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); return ret; }优点完全线程安全支持多个任务同时调用log_printf支持在中断服务程序中使用调用log_printf的任务不会被阻塞因为格式化和发送都在日志任务中进行可以方便地扩展功能比如将日志同时输出到文件、网络等可以实现日志分级、过滤等高级功能缺点需要额外的任务和队列内存日志会有一点延迟取决于日志任务的优先级和队列长度适用场景大多数嵌入式系统需要在 ISR 中打印日志对实时性要求较高的系统4.3 方案 3环形缓冲区 DMA 异步发送如果串口速度很慢或者系统对实时性要求极高即使是日志任务也会被 printf 阻塞可以使用环形缓冲区 DMA 的方式。我们实现一个环形缓冲区log_printf函数将格式化后的字符串写入环形缓冲区然后启动 DMA 发送。DMA 发送完成后触发中断继续发送环形缓冲区中的下一个数据。这种方式下log_printf函数几乎不会阻塞因为它只是将数据写入内存然后启动 DMA不需要等待发送完成。优点完全非阻塞实时性最好即使串口速度很慢也不会影响系统性能缺点实现相对复杂需要占用一个 DMA 通道适用场景对实时性要求极高的系统串口速度较慢printf 阻塞时间较长4.4 方案 4临界区保护仅适用于极短输出如果只是需要打印一两个字符而且对实时性要求极高可以使用临界区保护c运行taskENTER_CRITICAL(); HAL_UART_Transmit(huart1, (uint8_t *)A, 1, 10); taskEXIT_CRITICAL();临界区会关闭所有中断确保当前代码不会被任何中断或任务打断。但临界区的执行时间必须极短不能超过几个微秒。绝对不能在临界区中调用 printf因为 printf 的执行时间很长会导致系统中断响应延迟过大。适用场景极短的调试输出对实时性要求极高的场合五、进阶讨论5.1 为什么会出现只打印一个任务的现象而不是字符交错很多人会疑惑按照多线程竞态条件的理论应该出现字符交错的现象比如This is task_1This is task_2\r\n\r\n但在 STM32 HAL 库中却经常出现只有一个任务能打印的现象。这是因为 HAL 库的 UART 驱动实现方式决定的。HAL_UART_Transmit函数在发现串口忙时会直接返回HAL_BUSY而不是等待串口空闲。所以当一个任务正在发送数据时其他任务的 printf 调用会直接失败没有任何输出。如果 HAL 库的实现是等待串口空闲那么就会出现字符交错的现象。比如一些旧版本的标准外设库或者自己实现的 UART 驱动就可能出现这种情况。5.2 为什么在 fputc 中挂起调度器更糟糕很多人认为在 fputc 中每个字符都挂起调度器比在 printf 前后挂起调度器更好因为每次挂起的时间更短。但实际上这两种方法的总挂起时间是一样的。发送一个 100 字符的字符串在 printf 前后挂起调度器总挂起时间是 8.7ms。在 fputc 中每个字符都挂起调度器总挂起时间也是 8.7ms因为每个字符的发送时间是 87us100 个字符就是 8.7ms。而且在 fputc 中挂起调度器还会增加额外的开销因为每次挂起和恢复调度器都需要保存和恢复上下文这会消耗 CPU 时间。5.3 如何选择合适的解决方案如果是简单的调试任务数量少对实时性要求不高可以使用互斥量方案如果是正式产品推荐使用日志任务 消息队列方案这是最平衡的方案如果对实时性要求极高可以使用环形缓冲区 DMA 方案绝对不要使用vTaskSuspendAll()方案除非是临时调试并且知道其危害六、总结printf 线程不安全是嵌入式多任务开发中非常常见的问题。其根本原因是 HAL 库 UART 驱动的全局状态变量和 C 标准库 printf 的内部静态缓冲区导致的竞态条件。很多新手使用的vTaskSuspendAll()方案虽然能解决输出错乱的问题但会严重降低系统实时性甚至导致死锁是一种错误的解法。工业界常用的正确解决方案有互斥量保护、日志任务 消息队列、环形缓冲区 DMA 等。其中日志任务 消息队列方案实现简单功能强大适用于大多数嵌入式系统是最推荐的方案。在实际开发中我们应该从一开始就设计好日志系统而不是等到出现问题后再用临时的方法修补。一个好的日志系统不仅能解决 printf 线程不安全的问题还能提高系统的可调试性和可维护性。