嵌入式串口日志系统设计:从printf到平台化日志框架
2026/9/16 23:11:22 网站建设 项目流程

不少从裸机开发起步的朋友,都有过这种体验:程序功能已经写完,跑起来却像“黑盒”,只能在关键时刻靠 GPIO 翻转或者串口printf打印几个变量;等日志量一多,输出又乱成一团,时间点对不上,模块之间互相干扰,遇到 bug 只能靠“人肉插桩”。这篇是《嵌入式架构实践:EPLAT+AI》系列的第 1-2 篇,目标是把“串口日志”从随手可用的printf,升级成一套适合嵌入到平台层、可复用、可过滤、可扩展的日志系统,并说明它在 EPLAT 这样一个偏平台化、事件驱动的嵌入式架构里应当承担什么角色。

如果你是刚开始搭自己的项目框架,或者正在思考“我的代码里 printf 是不是一定要保留”,这篇文章会比较适合你。文章会从概念说起,逐步拆解日志模块的头文件设计、核心实现、串口适配方法、运行验证、常见坑点以及工程化建议,代码以 C 语言为主,不绑定具体芯片型号,但会给出 STM32 HAL / ESP-IDF 这类常见环境的对接思路。

1. 背景与核心概念

1.1 串口日志为什么值得做成“系统”

很多工程师在项目早期会认为:串口日志就是printf,再加一个串口调试助手,看到输出就可以了。这样做在原型验证阶段确实没有问题,等代码量增长到几万行、模块数超过十个以后,整套打印代码就会暴露出一系列问题:

  • 没有统一等级。调试信息和错误信息混在一起,打印开关一开就刷屏,一关就什么也看不见。
  • 没有模块标签。看到一行输出,不知道来自传感器驱动、通信协议、还是任务调度模块。
  • 缺少时间信息。日志可以告诉你“出错”,但没法告诉你“距离系统启动过了多少毫秒出错”。
  • 与业务代码耦合太深。驱动里直接printf,以后想改输出到 RTT、网络日志或片内 Flash,必须逐个文件修改。

把串口日志做成“系统”,本质上是解决两个问题:一是让日志输出行为统一可控;二是让日志数据本身具备可分析性。日志不只是给人看的提示文字,在系统开发、联调、异常分析中,它更像是一份埋点数据。只有通过统一框架采集,这些数据才可能被后续的远程日志、自动化用例诊断,甚至 AI 辅助分析调用。

1.2 EPLAT 架构中的日志定位

EPLAT 可以理解为一层嵌入式平台抽象。它把硬件驱动、系统服务、业务模块分层管理,业务代码尽量不直接操作寄存器或者 SDK 接口。日志系统在 EPLAT 中的位置,不是某个业务模块,而是公共设施(Infrastructure)的一部分。

也就是说,日志模块处于应用层与硬件驱动层之间:

应用业务层(业务状态机、算法、协议) ↓ 统一接口 EPLAT 平台服务层(日志、任务调度、消息队列、时间管理、故障记录) ↓ 平台适配层 MCU 驱动层(UART、SPI、Flash、RTC、定时器)

日志模块对外只暴露几个通用接口,比如初始化、设置级别、输出一条日志;底层是串口、DMA、RTT 还是 Flash,通过回调函数适配。上层模块不用关心日志具体走什么通道。这也是 EPLAT 强调“平台化”的价值:接口归接口,实现归实现,替换底层不影响业务调用。

1.3 从“超级大循环”到事件驱动,日志的位置跟着变了

早期嵌入式软件常用“超级大循环”,程序在一个while(1)里不断轮询各个标志位。这种结构下,串口日志往往可以直接阻塞发送,因为整个系统都在等这一条日志写完,实时性要求不高。

随着系统复杂度上升,越来越多的架构开始走向事件驱动、状态机加消息队列。程序不再是一个“大循环”集中处理业务,而是由定时器、中断、事件队列把 CPU 时间切分给不同任务。这个时候,日志系统如果还在某个上下文里长时间阻塞发送,就可能影响任务切换,甚至导致看门狗超时。更高层的问题是:事件驱动系统对“时序”更敏感,如果日志里没有时间戳,排查竞态、超时、中断丢失类问题会非常困难。

所以,串口日志虽小,但它是否具备“平台化、时间化、非阻塞化”的设计,往往能反映整个嵌入式架构的成熟度。

2. 环境准备与版本说明

2.1 示例环境与芯片选择

本文中的代码是软件分层示范,不依赖特定 MCU,因此只需要一个可编译运行 C 代码的嵌入式工程。为了讲解不至于太抽象,下面给出一套参考环境,读者可以根据自己的开发板替换。

项目参考值说明
目标芯片STM32F103C8T6 / ESP32-C3仅作示例,任意 MCU 均可
开发方式裸机或简单 RTOS本文示例以裸机为主,RTOS 只要加锁策略做微调即可
编译环境Keil MDK / arm-none-eabi-gcc / ESP-IDF具体版本按官方工具链安装
UART 外设USART1 / UART0波特率建议 115200 或 921600
串口工具SecureCRT / CuteCom / 任意串口助手用于查看日志输出

版本差异提醒:不同芯片厂商的 HAL 库、SDK 版本差异较大,直接复制 STM32 的 UART 发送代码到 NXP、GD32、ESP32 上不一定能编译通过。所以建议先不要纠结某个具体版本,重点理解“日志模块通过函数指针注册输出通道”的思想,再把底层发送函数替换成自己工程里的串口驱动。

2.2 最小工程目录规划

日志系统虽小,也建议按模块划分文件,不要全部塞进main.c。下面是一份适合平台化扩展的目录结构:

project/ ├── app/ │ └── main.c ├── modules/ │ ├── sensor/ │ │ └── sensor_drv.c │ └── protocol/ │ └── protocol.c ├── platform/ │ ├── hal/ │ │ ├── hal_uart.h │ │ └── hal_uart_stm32.c │ └── eplat/ │ ├── log/ │ │ ├── log.h │ │ └── log.c │ ├── osal/ │ └── common/ ├── Makefile 或 *.uvprojx └── README.md

其中platform/hal放板级驱动,platform/eplat/log放可复用的平台日志组件。后续如果要增加 RTC 时间戳、Flash 日志、远程日志,也都可以围绕log目录扩展,而不需要改动业务层代码。

3. 串口日志系统的核心设计

3.1 日志不只是 printf:等级、标签与时间戳

首先要建立一个认知:printf是文本输出函数,日志系统是基于格式输出的“服务”。一条完整的日志应至少包含三个信息:

  1. 等级。建议最少包含 DEBUG、INFO、WARN、ERROR 四级,方便在开发阶段过滤不需要的信息。
  2. 模块标签(TAG)。例如SENSORMODBUSBATTERY,帮助识别日志来源。
  3. 时间戳。至少是系统启动后的 tick 计数,最好是结合 RTC 的日期时间,便于判断异常发生的时序。

例如下面这行日志,就是一个结构清晰的结构化文本:

[t=00001234][INFO][SENSOR] temperature=26.5, humidity=41.2
  • t=00001234表示系统启动后 12.34 秒:
  • INFO是日志等级;
  • SENSOR是模块标签;
  • temperature=26.5, humidity=41.2是业务内容。

相比裸printf,这种格式让每条日志都有了上下文。日志文件即使断点查看,也能快速判断当时系统运行到什么阶段。

3.2 输出接口抽象:不要让日志模块绑死串口

日志组件最容易犯的错误,是内部直接调用某个厂家的 UART 发送库。例如在log.c里写HAL_UART_Transmit(...),短时间内能用,但之后想切到 Segger RTT 调试、TCP 远程日志、或者是“串口同时被 AT 指令占用”时,就非常难受。

更好的做法是注册回调:

typedef void (*log_output_fn)(const char *data, uint16_t len);

日志模块自身只负责拼接日志文本,然后把整条数据交给回调函数。如果回调最终指向串口,日志就输出到串口;如果回调指向 Flash 写入函数,日志就变成离线记录。这种方式也便于单元测试:在 PC 上跑一个测试用例时,可以注册一个回调把日志输出到文件。

3.3 时间戳从哪里来

MCU 上获取可供打印的时间信息一般有三种做法:

  1. 软件 tick:利用 SysTick 或通用定时器产生 1ms 中断,维护一个volatile uint32_t tick。日志里打印相对启动时间,适合分析启动过程、任务周期和超时问题。
  2. RTC 日历时间:使用芯片内部 RTC 或外部 RTC 芯片,获取年-月-日 时:分:秒,适合带电池供电、需要云端追溯日志的设备。
  3. tick + RTC 组合:日志头打印 RTC 时间,消息尾部或单独字段打印毫秒 tick,能同时满足日期追踪和毫秒级时序分析。

不同产品对时间精度的要求不一样,日志模块应先定义好“时间提供函数”,例如Log_GetTick(),而不是在业务代码里到处读SysTick->VAL。这样后续升级 RTC 场景时,只需修改模块内部实现。

3.4 阻塞输出与异步输出

裸机项目里使用最多的日志输出是:

HAL_UART_Transmit(&huart1, buffer, len, timeout);

这种“阻塞发送”实现简单,时序可控,适合日志量不大、波特率较高、调用位置不在关键中断上下文中的场景。

但阻塞发送有两个明显问题:

  • 如果日志量很大,大量时间会耗在等待 UART 发送上。
  • 如果在中断服务函数里调用阻塞发送,且 UART 硬件 FIFO 或 DMA 配置不合适,可能导致中断处理时间过长,影响实时响应。

因此,正式项目中更常见的设计是“日志写入环形缓冲区,由后台任务或串口中断异步发送”。这里不要求第一版就做成异步,但模块边界应该给异步预留位置。例如log.c可以先做“同步发送版本”,后续把log_output_fn的实现替换成“写入 ringbuffer 并触发 DMA”即可。

4. 实战:搭建一个可扩展的串口日志模块

下面我们一起来实现一个最小可用的 EPLAT 日志模块。文件拆成log.hlog.c,通过log_output_fn注册输出通道,不直接依赖任何具体芯片。

4.1 日志模块接口设计

先定义log.h。这个头文件是给所有业务模块包含的,因此要尽量简洁。

/* 文件路径:platform/eplat/log/log.h */ #ifndef EP_LOG_H #define EP_LOG_H #include <stdint.h> #include <stddef.h> #ifdef __cplusplus extern "C" { #endif typedef enum { LOG_LEVEL_DEBUG = 0, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR, LOG_LEVEL_NONE } log_level_t; /* 日志输出回调:把整条格式化好的日志文本交给底层通道 */ typedef void (*log_output_fn)(const char *data, uint16_t len); /* 初始化日志模块:注册输出回调,设置默认级别 */ void Log_Init(log_output_fn output, log_level_t initLevel); /* 运行时调整日志级别 */ void Log_SetLevel(log_level_t level); /* 打印一条日志,方法和 printf 类似 */ void Log_Out(log_level_t level, const char *tag, const char *fmt, ...); /* 简化的宏,方便不同模块调用 */ #define LOG_DEBUG(tag, ...) Log_Out(LOG_LEVEL_DEBUG, tag, __VA_ARGS__) #define LOG_INFO(tag, ...) Log_Out(LOG_LEVEL_INFO, tag, __VA_ARGS__) #define LOG_WARN(tag, ...) Log_Out(LOG_LEVEL_WARN, tag, __VA_ARGS__) #define LOG_ERROR(tag, ...) Log_Out(LOG_LEVEL_ERROR, tag, __VA_ARGS__) #ifdef __cplusplus } #endif #endif /* EP_LOG_H */

这个接口设计的核心点在于:业务模块只看到LOG_INFOLOG_ERROR这样的宏,不会被平台细节污染。以后即使在日志库内部增加“导出到网络”的能力,上层代码也不需要改。

4.2 日志模块核心实现

log.c负责格式化、拼接等级与标签、调用输出回调。这里仍以“同步输出”为主,时间戳使用一个简单 tick 计数,定时器中断里调用Log_TickInc()即可更新。

/* 文件路径:platform/eplat/log/log.c */ #include "log.h" #include <stdarg.h> #include <stdio.h> #include <string.h> /* 单条日志缓冲,按项目需求调整;如果打印内容较长可适当加大 */ #define LOG_BUFFER_SIZE 256 /* 简单 tick 计数:假设在 1ms 定时器中断中调用 Log_TickInc() */ static volatile uint32_t s_tickCnt = 0; /* 当前全局日志级别 */ static log_level_t s_logLevel = LOG_LEVEL_INFO; /* 输出回调 */ static log_output_fn s_logOutput = NULL; void Log_TickInc(void) { s_tickCnt++; } uint32_t Log_GetTick(void) { return s_tickCnt; } void Log_Init(log_output_fn output, log_level_t initLevel) { s_logOutput = output; s_logLevel = initLevel; } void Log_SetLevel(log_level_t level) { s_logLevel = level; } static const char *levelToString(log_level_t level) { switch (level) { case LOG_LEVEL_DEBUG: return "DEBUG"; case LOG_LEVEL_INFO: return "INFO"; case LOG_LEVEL_WARN: return "WARN"; case LOG_LEVEL_ERROR: return "ERROR"; default: return "????"; } } void Log_Out(log_level_t level, const char *tag, const char *fmt, ...) { if (level < s_logLevel) { return; } if (s_logOutput == NULL) { /* 如果尚未注册输出通道,丢弃日志,避免崩溃 */ return; } char line[LOG_BUFFER_SIZE]; int len = 0; /* 先拼时间戳、等级、模块标签 */ len = snprintf(line, sizeof(line), "[t=%08lu][%s][%s] ", (unsigned long)Log_GetTick(), levelToString(level), (tag != NULL) ? tag : "-"); if (len < 0) { return; } if (len >= (int)sizeof(line)) { len = (int)sizeof(line) - 1; } /* 再拼业务正文 */ va_list args; va_start(args, fmt); int bodyLen = vsnprintf(line + len, sizeof(line) - len, fmt, args); va_end(args); if (bodyLen < 0) { return; } if (len + bodyLen >= (int)sizeof(line) - 1) { len = (int)sizeof(line) - 1; } else { len += bodyLen; } /* 统一追加换行 */ line[len++] = '\n'; /* 交给注册的输出回调 */ s_logOutput(line, (uint16_t)len); }

几个实现细节要注意。

  • s_tickCntvolatile修饰,因为它在定时器中断里被修改,主循环里被读取。
  • 日志缓冲大小要合理。如果单个日志超过LOG_BUFFER_SIZEvsnprintf会自动截断,不会导致内存越界。
  • 如果s_logOutput为空,直接返回而不是调用空指针,避免初始化顺序不正确时崩溃。
  • 在中断上下文调用Log_Out时,由于这个实现内部用了vsnprintf,比较耗时。如果中断频率很高,建议只在中断里记录 ERROR 级日志,或者走异步环形缓冲。

4.3 注册串口输出通道

为了让日志最终从串口打印出来,需要把日志模块的“输出回调”和板级 UART 驱动连接起来。这里以 STM32 HAL 为例给出参考代码。如果是其他平台,请替换为实际 UART 发送函数。

/* 文件路径:platform/hal/hal_uart.h */ #ifndef HAL_UART_H #define HAL_UART_H #include <stdint.h> void HAL_UART_Init(uint32_t baud); void HAL_UART_Send(const char *data, uint16_t len); #endif
/* 文件路径:platform/hal/hal_uart_stm32.c */ #include "hal_uart.h" /* 假设工程已经通过 CubeMX 生成并初始化了 huart1 */ extern UART_HandleTypeDef huart1; void HAL_UART_Init(uint32_t baud) { /* 如果 CubeMX 生成代码已经完成初始化,可留空或仅做调试串口使能 */ (void)baud; } void HAL_UART_Send(const char *data, uint16_t len) { /* 阻塞发送,timeout 可以按波特率调整 */ HAL_UART_Transmit(&huart1, (uint8_t *)data, len, 1000); }

这段代码的核心是串口输出回调只读数据,不做拼装,不关心业务封装。后续如果改用 DMA 发送,只需替换这个函数的内部实现。

4.4 在应用层调用

main.c中完成初始化与宏调用,效果如下。

/* 文件路径:app/main.c */ #include "log.h" #include "platform/hal/hal_uart.h" /* 适配函数:日志模块通过这个回调把文本交给板级串口发送 */ static void logSendToUart(const char *data, uint16_t len) { HAL_UART_Send(data, len); } int main(void) { /* 板级初始化部分省略,例如 SystemClock_Config()、MX_GPIO_Init() 等 */ HAL_UART_Init(115200); /* 注册日志输出通道,并设置默认级别为 DEBUG */ Log_Init(logSendToUart, LOG_LEVEL_DEBUG); /* 业务代码中直接使用宏打日志 */ LOG_INFO("MAIN", "system start"); LOG_WARN("SENSOR", "i2c timeout, retry=%d", 3); LOG_ERROR("APP", "fatal error code=0x%02X", 0xA5); while (1) { /* 正常业务循环 */ } } /* 在定时器中断中维护 tick */ void SysTick_Handler(void) { Log_TickInc(); }

需要注意的是,SysTick_Handler的具体名称取决于芯片启动文件和中断向量表。STM32 中通常叫SysTick_Handler,ESP-IDF 中则可能叫vApplicationTickHook或在裸机定时器中断里处理。只要保证每隔 1ms 调用一次Log_TickInc()即可。

4.5 预期输出与验证方法

如果一切正常,串口终端上应看到类似下面三行日志:

[t=00000000][INFO][MAIN] system start [t=00000001][WARN][SENSOR] i2c timeout, retry=3 [t=00000002][ERROR][APP] fatal error code=0xA5

在初始化的第一个 tick 之前,时间可能仍是 0,这不影响使用。当 tick 计数开始递增后,每条日志都会带上距离启动的毫秒数。

验证时建议按以下步骤走:

  1. 先用一个只含LOG_INFO的测试函数确认串口能收到内容。
  2. 再调用Log_SetLevel(LOG_LEVEL_WARN),观察 DEBUG、INFO 日志是否被过滤。
  3. 最后故意制造一个错误状态,确认 ERROR 日志能正常输出。
  4. 如果出现乱码,优先检查波特率、时钟频率和串口工具编码。

5. 常见问题与排查思路

串口日志系统在落地中经常遇到的问题,并不一定是“代码跑不通”,更多是表现诡异。下面列几个高频问题。

问题现象常见原因解决思路
串口完全无输出没有注册输出回调,或 UART 初始化失败确认调用了Log_Init(logSendToUart, LOG_LEVEL_DEBUG)
日志输出乱码波特率不一致,或系统时钟配置不对核对串口工具波特率与HAL_UART_Init参数
只输出一部分日志LOG_BUFFER_SIZE太小,长文本被截断调大缓冲区,或拆分成多条日志打印
调用日志后系统变卡阻塞发送占用较长 CPU 时间改为 DMA 异步发送,或降低日志频率
中断里调用打印后死机中断里做阻塞等待,低优先级任务无法调度中断里只保留 ERROR 或使用异步队列
时间戳永远是 0没有在定时器中断中调用Log_TickInc()将 tick 维护函数放入 1ms 定时器回调或 SysTick
浮点数打印异常C 库未开启浮点格式化支持编译器选项中启用浮点支持,或用整数定点传输

此外,有一个比较隐蔽的问题:在多线程或 RTOS 环境下,两个任务同时调用Log_Out(),可能在拼接line的过程中产生相互覆盖问题。上述裸机示例没有加锁。RTOS 项目中,建议在Log_Out()内部加互斥锁,或在任务创建前确认只有一个日志输出任务真正调用底层发送回调。

6. 最佳实践与工程建议

6.1 把日志当作数据来设计

很多嵌入式日志只适合人眼阅读,但到了批量化问题复盘时,效率很低。比较好的做法是让日志文本尽量保持“机器可解析”的结构,例如使用key=value或 JSON 风格:

[t=00001234][ERROR][BATTERY] voltage=3100, current=1200, state=2

这种格式稳定后,既可以直接人工看,也可以被脚本或 AI 工具解析。EPLAT+AI 的后续目标是:把日志数据交给分析模型,做异常分类、故障预测或自动定位。而要训练模型或写分析规则,日志字段格式稳定是第一前提。如果每条日志都只是自由文本“something wrong...”,后续分析成本会很高。

6.2 日志级别的编译期裁剪

运行期级别可以帮助调试,但会把所有Log_Out的字符串都编译进固件,占用较多 Flash。对资源紧张的 MCU,建议同时支持编译期裁剪。例如在log.h中增加条件编译:

#define LOG_ENABLE_DEBUG 1 #if LOG_ENABLE_DEBUG #define LOG_DEBUG(tag, ...) Log_Out(LOG_LEVEL_DEBUG, tag, __VA_ARGS__) #else #define LOG_DEBUG(tag, ...) ((void)0) #endif

这样量产固件可以关闭 DEBUG 级日志,减少代码体积;在开发版中打开,方便联调。需要注意,使用((void)0)而不是完全删除,可以避免调用处出现未使用参数警告。

6.3 日志输出与实时性解耦

符合工程化预期的发送链路,是业务代码把日志写入一个快速缓冲区,后续由更“空闲”的上下文真正发送。例如定时器驱动 DMA、串口空闲中断或专用日志任务。即使第一版没有做异步,也建议在接口层保留替换空间。

如果产品对启动速度很敏感,不要在主循环早期频繁打印 DEBUG 日志;更多时候,日志系统初始化慢 1ms,业务不会受影响,但日志缓冲区溢出或者阻塞等待 UART,往往会造成难以排查的启动时序问题。

6.4 注意安全与数据脱敏

日志系统也可能成为数据泄露的入口。如果产品需要打印调试信息,尤其是网络配置、设备密钥、用户账号等敏感字段,必须在打印前做脱敏处理。比如只显示后几位;不要完整输出固件版本秘钥。这不仅是信息安全要求,也是嵌入式产品发布后的基本底线。

6.5 从串口日志走向可观测性

串口日志只是起点。当系统进一步复杂时,可以考虑几种延伸方向:

  • 系统异常复位时,把最后一段日志保存到 Flash,下次启动时输出,帮助分析死机原因。
  • 用 CRC 或序号包裹日志帧,便于上位机检测丢行。
  • 把日志通过低功耗蓝牙、Wi-Fi 或 4G 网关上送到服务器,实现远程诊断。
  • 结合 AI 侧工具做日志异常聚类,前提是日志字段规范、级别统一。

这些演进都要求当前日志模块提供稳定接口和数据格式,这也是为什么我们在第 1-2 节先把基础系统打好。

7. 总结:先让日志可控,再谈 AI 诊断

串口日志系统看似简单,但真正影响项目长期可维护性的,不是某一条printf写得对不对,而是是否用统一的框架把输出、过滤、时间、通道管理起来。EPLAT 中的日志模块,第一步先解决“有日志、可分级、可区分模块”的问题;进一步再解决“日志去哪里、何时输出、如何异步”的问题;最后才有可能把日志变成 AI 工程实践的可靠训练语料或诊断数据。

代码不需要一次写得多复杂。建议你先把自己的项目里的主要模块切换到这个统一日志接口,体会一下多模块调试时筛选级别的便利;之后再逐步增加 RTC 时间戳、环形缓冲区、DMA 发送和异常记录功能。真正跑起来以后,你会发现,之前很多靠“疯狂加打印”才能定位的问题,现在只要看时间轴就能找到线索了。如果这篇文章对你有帮助,可以收藏备用;下一篇我会继续更新 EPLAT 系列中的事件驱动与任务调度设计,欢迎保持关注。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询