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,也会造成截断。
三、定位步骤
-
确认是否并发写:在两个任务打印前后加时间戳(
esp_timer_get_time),观察交错点是否落在另一任务执行窗口。 - 缩小范围:临时把两个任务都固定到同一核,若乱序消失,基本确认是跨核并发写 UART。
-
替换底层接口:把
printf换成带互斥锁的封装函数,观察是否恢复。 -
检查缓冲配置:确认
CONFIG_ESP_CONSOLE_UART_NUM、TX buffer 大小及是否启用 DMA。 - 抓取原始字节:用逻辑分析仪或另一路 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或原子操作,日志锁不能替代数据同步。 - 调试阶段可临时把两任务绑同核验证,但最终方案应保留锁,不能依赖单核规避。
暂无评论,来抢沙发~