ESP32双核下用xTaskCreatePinnedToCore固定任务核后串口打印乱序的定位方法

一、问题现象

在ESP32上使用 xTaskCreatePinnedToCore 把两个任务分别固定到 Core0 和 Core1 后,串口输出经常出现:

  • 一行日志被另一任务的内容截断,如 TaskA: couTaskB: hello
  • 数字或字符串只打印一半
  • 同一任务内部连续 printf 之间插入其他任务输出

单核运行时往往正常,固定双核后概率性出现,这是典型的并发写同一 UART 问题。

二、原理分析

1. 双核并行执行

ESP32 的 Core0 与 Core1 可真正并行。两个任务同时调用 printf 时,底层 fputc/uart_write_bytes 会并发访问同一个 UART 外设与驱动环形缓冲区。

2. printf 非线程安全

newlib 的 printf 内部有 stdio 锁,但 ESP-IDF 默认配置下,若未启用 CONFIG_NEWLIB_STDOUT_LINE_ENDING_CRLF 相关锁或直接使用 uart_write_bytes,多任务并发写会交错。更关键的是:一次 printf 可能被拆成多次底层写,任务切换点恰好落在中间。

3. UART 驱动缓冲

uart_write_bytes 把数据放入 TX ring buffer 后由中断/DMA 发送。多个任务同时写,字节流按写入顺序拼接,但每次写入的边界不保证是完整一行。

4. 任务优先级与时间片

固定核后,若两任务优先级相同且开启时间片,切换更频繁,交错概率上升;高优先级任务抢占低优先级任务正在执行的 printf,也会造成截断。

三、定位步骤

  1. 确认是否并发写:在两个任务打印前后加时间戳(esp_timer_get_time),观察交错点是否落在另一任务执行窗口。
  2. 缩小范围:临时把两个任务都固定到同一核,若乱序消失,基本确认是跨核并发写 UART。
  3. 替换底层接口:把 printf 换成带互斥锁的封装函数,观察是否恢复。
  4. 检查缓冲配置:确认 CONFIG_ESP_CONSOLE_UART_NUM、TX buffer 大小及是否启用 DMA。
  5. 抓取原始字节:用逻辑分析仪或另一路 UART 抓 TX 引脚,区分是“应用层交错”还是“驱动层丢字节”。

四、解决方案与完整代码

核心思路:为串口输出建立全局互斥锁,保证一次日志的原子性;必要时改用行缓冲队列由单一打印任务输出。

#include <stdio.h>
#include <stdarg.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/uart.h"

#define UART_PORT UART_NUM_0
static SemaphoreHandle_t s_log_mutex = NULL;

void log_init(void)
{
    s_log_mutex = xSemaphoreCreateMutex();
    configASSERT(s_log_mutex);
}

/* 线程安全的日志输出:整行原子写入 */
void log_printf(const char *tag, const char *fmt, ...)
{
    char buf[256];
    va_list ap;
    int len;

    va_start(ap, fmt);
    len = vsnprintf(buf, sizeof(buf), fmt, ap);
    va_end(ap);
    if (len < 0) return;
    if (len >= (int)sizeof(buf)) len = sizeof(buf) - 1;

    if (xSemaphoreTake(s_log_mutex, portMAX_DELAY) == pdTRUE) {
        /* 一次写入,避免被任务切换拆开 */
        uart_write_bytes(UART_PORT, tag, strlen(tag));
        uart_write_bytes(UART_PORT, buf, len);
        uart_write_bytes(UART_PORT, "\r\n", 2);
        xSemaphoreGive(s_log_mutex);
    }
}

static void task_a(void *arg)
{
    while (1) {
        log_printf("[A] ", "tick=%lu", (unsigned long)xTaskGetTickCount());
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

static void task_b(void *arg)
{
    while (1) {
        log_printf("[B] ", "value=%d", 42);
        vTaskDelay(pdMS_TO_TICKS(150));
    }
}

void app_main(void)
{
    uart_config_t cfg = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity    = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_DEFAULT,
    };
    uart_driver_install(UART_PORT, 1024, 4096, 0, NULL, 0);
    uart_param_config(UART_PORT, &cfg);
    uart_set_pin(UART_PORT, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE,
                 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);

    log_init();

    xTaskCreatePinnedToCore(task_a, "task_a", 4096, NULL, 5, NULL, 0);
    xTaskCreatePinnedToCore(task_b, "task_b", 4096, NULL, 5, NULL, 1);
}

若日志量极大,建议改为单打印任务 + 队列:各任务把格式化字符串 xQueueSend 到队列,由固定核上的打印任务统一 uart_write_bytes,彻底消除并发写。

五、注意事项

  • 互斥锁只保护“写 UART”这一段,不要在持锁期间做耗时操作或再次打印,避免死锁。
  • printf 与 log_printf 不要混用,否则仍会绕过锁。
  • 中断服务程序中禁止使用带阻塞的互斥锁,ISR 日志应走队列或 ESP_EARLY_LOGI。
  • 确认 uart_write_bytes 的 TX buffer 足够大,避免高波特率下阻塞导致看门狗。
  • 双核共享变量需 volatile 或原子操作,日志锁不能替代数据同步。
  • 调试阶段可临时把两任务绑同核验证,但最终方案应保留锁,不能依赖单核规避。