ESP32双核下xTaskCreatePinnedToCore固定任务核后串口打印乱序的定位与解决

1. 问题现象

在ESP32双核开发中,使用xTaskCreatePinnedToCore将不同任务固定到Core 0和Core 1后,通过printf或ESP_LOG输出调试信息时,经常出现字符串交错、半行覆盖等乱序现象。例如:

TaskA: CouTaskB: Count=1
nt=2

原本独立的打印行被拆散混合,严重影响调试。

2. 根源分析

2.1 多核并发访问UART

ESP32的UART外设是全局资源,两个核心的任务可能同时调用uart_write_bytes,底层驱动未做跨核互斥,导致字节流交错。

2.2 打印函数非原子性

printf内部可能分多次写入(如格式化到缓冲区再输出),若在写入过程中被另一核心的任务抢占,就会插入其他输出。

2.3 任务优先级与调度

固定核后,若两个任务优先级相同且都调用打印,FreeRTOS的时间片轮转或抢占会加剧交错概率。

3. 解决方案

3.1 方案一:互斥锁保护打印

使用FreeRTOS互斥量(Mutex)确保同一时刻只有一个任务能访问UART。

配置步骤:

  • 创建全局互斥量:SemaphoreHandle_t printMutex = xSemaphoreCreateMutex();
  • 封装安全打印函数,在写入前后获取/释放锁。

代码示例:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"

SemaphoreHandle_t printMutex;

void safe_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);
    xSemaphoreTake(printMutex, portMAX_DELAY);
    vprintf(format, args);
    xSemaphoreGive(printMutex);
    va_end(args);
}

// 初始化
void app_main() {
    printMutex = xSemaphoreCreateMutex();
    // 创建任务...
}

注意: 互斥量本身有开销,高频打印可能影响实时性;确保在任务中调用,中断中不可用。

3.2 方案二:专用打印任务+队列

将打印请求发送到队列,由单一任务负责输出,彻底避免并发。

配置步骤:

  • 创建队列:QueueHandle_t printQueue = xQueueCreate(10, sizeof(char*));
  • 创建打印任务(固定到任意核),循环接收队列并输出。
  • 其他任务通过xQueueSend发送格式化后的字符串(需动态分配内存)。

代码示例:

typedef struct {
    char *msg;
} print_msg_t;

QueueHandle_t printQueue;

void print_task(void *pvParameters) {
    print_msg_t msg;
    while (1) {
        if (xQueueReceive(printQueue, &msg, portMAX_DELAY)) {
            printf("%s", msg.msg);
            free(msg.msg);
        }
    }
}

void log_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);
    char *buf = NULL;
    int len = vasprintf(&buf, format, args);
    va_end(args);
    if (len >= 0) {
        print_msg_t msg = { .msg = buf };
        if (xQueueSend(printQueue, &msg, portMAX_DELAY) != pdTRUE) {
            free(buf);
        }
    }
}

// 初始化
void app_main() {
    printQueue = xQueueCreate(10, sizeof(print_msg_t));
    xTaskCreatePinnedToCore(print_task, "print", 4096, NULL, 5, NULL, 0);
    // 其他任务使用log_printf
}

注意: 动态内存分配可能失败,需检查返回值;队列满时根据需求选择阻塞或丢弃。

3.3 方案三:原子化打印函数(临界区)

使用portENTER_CRITICAL/portEXIT_CRITICAL关闭中断和调度,但会影响系统实时性,仅适合低频调试。

代码示例:

void atomic_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);
    portENTER_CRITICAL(&print_spinlock);
    vprintf(format, args);
    portEXIT_CRITICAL(&print_spinlock);
    va_end(args);
}

注意: 临界区应尽量短,避免在内部调用可能阻塞的函数。

4. 完整示例:双核任务打印测试

以下代码演示两个任务固定到不同核心,使用互斥锁保护打印:

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

SemaphoreHandle_t printMutex;

void safe_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);
    xSemaphoreTake(printMutex, portMAX_DELAY);
    vprintf(format, args);
    xSemaphoreGive(printMutex);
    va_end(args);
}

void task_core0(void *pv) {
    int count = 0;
    while (1) {
        safe_printf("Core0 Task: count=%d\n", count++);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void task_core1(void *pv) {
    int count = 0;
    while (1) {
        safe_printf("Core1 Task: count=%d\n", count++);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void app_main() {
    printMutex = xSemaphoreCreateMutex();
    xTaskCreatePinnedToCore(task_core0, "core0", 2048, NULL, 5, NULL, 0);
    xTaskCreatePinnedToCore(task_core1, "core1", 2048, NULL, 5, NULL, 1);
}

运行后输出将严格按行交替,不再乱序。

5. 注意事项

  • 互斥锁不可在中断中使用,中断打印需用xQueueSendFromISR配合专用任务。
  • 队列方案中字符串需动态分配,注意内存泄漏与碎片。
  • 临界区方案会短暂关闭中断,高频使用影响系统响应。
  • 调试阶段可临时提高打印任务优先级,但生产环境应降低以避免阻塞。
  • 使用ESP_LOG时,其内部已有互斥,但跨核仍可能乱序,建议统一封装。

6. 总结

ESP32双核下串口乱序本质是资源竞争。通过互斥锁、专用打印任务或原子操作均可解决,开发者应根据实时性要求和打印频率选择合适方案。推荐使用专用打印任务+队列,兼顾安全性与系统实时性。