4. 日志系统架构设计
产品级日志系统不是“在代码里加一句 printf”那么简单。它需要在资源受限的 MCU 上同时满足低开销、可追溯、可裁剪、可扩展四个目标。要做到这一点,架构必须先于编码确定:把日志系统拆成若干职责单一的层,明确每层暴露什么接口、由谁调用、在什么上下文执行,再决定它与 RTOS 内核的耦合方式。本节围绕分层架构、模块划分、接口定义、前后台协作和内核集成五个方面展开。
一、核心原理:五层分层架构
推荐采用“记录层—格式化层—缓冲层—传输层—落地层”的五层结构。每一层只依赖下一层的抽象接口,上层不感知底层是串口、RTT 还是文件系统。
- 记录层(Log Record):面向业务代码,提供 LOG_DEBUG/INFO/WARN/ERROR 等宏,负责编译期等级过滤、模块标签注入、时间戳与任务 ID 采集。
- 格式化层(Formatter):把结构化记录编码为二进制紧凑帧或文本行,决定是否携带参数、是否压缩。
- 缓冲层(Buffer):提供环形缓冲或块池,是前后台解耦的关键,决定丢日志策略。
- 传输层(Transport):把缓冲数据搬到物理通道,如 UART、SEGGER RTT、USB CDC、网络 socket。
- 落地层(Sink):在主机侧或文件系统中完成持久化、轮转、检索。
分层的价值在于:业务代码只与记录层耦合,更换后端不影响业务;缓冲层把“产生日志”和“输出日志”在时间上解耦,使高优先级任务不必等待慢速串口。
二、模块划分与接口定义
模块划分应遵循“一个模块一个头文件、一个实现文件”的原则,接口尽量窄。下表给出典型模块与关键接口。
| 模块 | 核心接口 | 执行上下文 | 是否可裁剪 |
|---|---|---|---|
| log_core | log_init / log_write / log_set_level | 任务、中断 | 否 |
| log_format | log_fmt_encode / log_fmt_decode | 任务 | 是 |
| log_buffer | log_buf_push / log_buf_pop / log_buf_used | 任务、中断 | 否 |
| log_transport | log_trx_open / log_trx_send / log_trx_flush | 任务、DMA 完成 | 是 |
| log_sink | log_sink_write / log_sink_rotate | 主机或文件系统 | 是 |
| log_hook | log_hook_task_switch / log_hook_idle | 内核钩子 | 是 |
接口设计有三个硬性约束:第一,中断安全,log_write 在中断中必须走无阻塞路径,只做入队,不做格式化;第二,零动态内存,缓冲在编译期静态分配,避免堆碎片;第三,可重入,多任务并发写同一缓冲时用临界区或原子操作保护写指针。
/* log_core.h 关键接口 */
int log_init(const log_cfg_t *cfg);
int log_write(uint8_t level, uint16_t tag,
const char *fmt, ...);
void log_set_level(uint8_t level);
int log_flush(void);
/* 中断安全版本:仅入队,不格式化 */
int log_write_isr(uint8_t level, uint16_t tag,
const void *data, uint16_t len);
三、前后台协作机制
前后台协作的本质是生产者—消费者模型。前台(业务任务、中断)是生产者,只负责把日志记录压入环形缓冲;后台(LogTask)是消费者,负责出队、格式化、发送。两者通过信号量或消息队列同步。
- 业务任务调用 LOG_INFO,记录层采集时间戳与任务 ID,格式化层编码为紧凑帧。
- 缓冲层执行 push,若缓冲满则按策略丢弃最旧或最新记录,并累加丢包计数。
- push 成功后释放信号量,唤醒阻塞中的 LogTask。
- LogTask 被调度后批量 pop,调用传输层发送,发送完成再回到阻塞等待。
- 若在中断中产生日志,只执行 push 与信号量释放,不等待发送完成。
四、与 RTOS 内核的集成方式
日志系统与内核的集成点主要有四处:
- 时间戳:通过 xTaskGetTickCount 或内核 tick 钩子获取,保证日志时间与调度时间一致。
- 任务标识:通过 xTaskGetCurrentTaskHandle 获取任务句柄,映射为任务名或短 ID,便于按任务过滤。
- 任务切换钩子:在 traceTASK_SWITCHED_IN 中记录切换事件,用于诊断调度抖动与优先级反转。
- 空闲钩子:在 vApplicationIdleHook 中触发缓冲刷新,利用空闲时间做低优先级发送。
集成时要特别注意:内核钩子运行在调度器上下文中,不能调用可能阻塞的 API。因此钩子内只允许写缓冲或置位事件标志,真正的发送必须交给 LogTask。
五、案例与误区复盘
案例:某工业网关在 1ms 控制周期内每周期打印 3 条 INFO 日志,使用 115200 波特率串口直发。结果控制任务被阻塞,周期抖动超过 200μs。改造方案:引入 4KB 环形缓冲,前台只入队,LogTask 以 500kbps 批量发送,控制任务抖动降至 8μs 以内。
复盘清单:
- 缓冲是否静态分配,是否存在动态内存申请?
- 中断路径是否只做入队,是否调用了阻塞 API?
- 缓冲满时的丢弃策略是否明确,是否有丢包计数?
- LogTask 优先级是否低于业务任务,是否会被饿死?
- 时间戳来源是否统一,是否与内核 tick 对齐?
- 崩溃时最后 N 条日志是否可回放,是否保存在非易失区域?