4. 日志系统架构设计

产品级日志系统不是“在代码里加一句 printf”那么简单。它需要在资源受限的 MCU 上同时满足低开销、可追溯、可裁剪、可扩展四个目标。要做到这一点,架构必须先于编码确定:把日志系统拆成若干职责单一的层,明确每层暴露什么接口、由谁调用、在什么上下文执行,再决定它与 RTOS 内核的耦合方式。本节围绕分层架构、模块划分、接口定义、前后台协作和内核集成五个方面展开。

一、核心原理:五层分层架构

推荐采用“记录层—格式化层—缓冲层—传输层—落地层”的五层结构。每一层只依赖下一层的抽象接口,上层不感知底层是串口、RTT 还是文件系统。

  • 记录层(Log Record):面向业务代码,提供 LOG_DEBUG/INFO/WARN/ERROR 等宏,负责编译期等级过滤、模块标签注入、时间戳与任务 ID 采集。
  • 格式化层(Formatter):把结构化记录编码为二进制紧凑帧或文本行,决定是否携带参数、是否压缩。
  • 缓冲层(Buffer):提供环形缓冲或块池,是前后台解耦的关键,决定丢日志策略。
  • 传输层(Transport):把缓冲数据搬到物理通道,如 UART、SEGGER RTT、USB CDC、网络 socket。
  • 落地层(Sink):在主机侧或文件系统中完成持久化、轮转、检索。

分层的价值在于:业务代码只与记录层耦合,更换后端不影响业务;缓冲层把“产生日志”和“输出日志”在时间上解耦,使高优先级任务不必等待慢速串口。

RTOS 日志系统五层架构与前后台协作 前台(任务上下文 / 中断上下文) 记录层 Log Record LOG_INFO(tag, fmt, ...) · 等级过滤 · 时间戳 格式化层 Formatter 二进制帧 / 文本行 · 参数编码 · 模块 ID 缓冲层 Buffer(环形队列 / 块池) 写指针入队 · 满时丢弃/覆盖 · 临界区保护 传输层 Transport UART / RTT / USB CDC / Socket 落地层 Sink(主机 / 文件系统) 持久化 · 轮转 · 检索 · 解析 后台(日志任务 / 空闲钩子 / DMA 完成) 日志消费任务 LogTask 优先级低于业务任务 · 阻塞等待信号量 批量出队 → 调用传输层发送 同步原语 信号量 / 消息队列 / 事件标志组 通知后台“有新日志待发送” RTOS 内核集成点 任务切换钩子 · 空闲钩子 · 中断安全 API tick 时间戳 · 任务名/ID 获取 异常与崩溃快照 HardFault 上下文 · 最后 N 条日志回放 入队 通知

二、模块划分与接口定义

模块划分应遵循“一个模块一个头文件、一个实现文件”的原则,接口尽量窄。下表给出典型模块与关键接口。

模块核心接口执行上下文是否可裁剪
log_corelog_init / log_write / log_set_level任务、中断否
log_formatlog_fmt_encode / log_fmt_decode任务是
log_bufferlog_buf_push / log_buf_pop / log_buf_used任务、中断否
log_transportlog_trx_open / log_trx_send / log_trx_flush任务、DMA 完成是
log_sinklog_sink_write / log_sink_rotate主机或文件系统是
log_hooklog_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)是消费者,负责出队、格式化、发送。两者通过信号量或消息队列同步。

  1. 业务任务调用 LOG_INFO,记录层采集时间戳与任务 ID,格式化层编码为紧凑帧。
  2. 缓冲层执行 push,若缓冲满则按策略丢弃最旧或最新记录,并累加丢包计数。
  3. push 成功后释放信号量,唤醒阻塞中的 LogTask。
  4. LogTask 被调度后批量 pop,调用传输层发送,发送完成再回到阻塞等待。
  5. 若在中断中产生日志,只执行 push 与信号量释放,不等待发送完成。
后台任务优先级应低于所有业务任务,但高于空闲任务。这样既不会抢占业务,又能在系统空闲时及时清空缓冲。若业务负载极高,可设置“缓冲水位阈值”,超过阈值时临时提升 LogTask 优先级。

四、与 RTOS 内核的集成方式

日志系统与内核的集成点主要有四处:

  • 时间戳:通过 xTaskGetTickCount 或内核 tick 钩子获取,保证日志时间与调度时间一致。
  • 任务标识:通过 xTaskGetCurrentTaskHandle 获取任务句柄,映射为任务名或短 ID,便于按任务过滤。
  • 任务切换钩子:在 traceTASK_SWITCHED_IN 中记录切换事件,用于诊断调度抖动与优先级反转。
  • 空闲钩子:在 vApplicationIdleHook 中触发缓冲刷新,利用空闲时间做低优先级发送。

集成时要特别注意:内核钩子运行在调度器上下文中,不能调用可能阻塞的 API。因此钩子内只允许写缓冲或置位事件标志,真正的发送必须交给 LogTask。

五、案例与误区复盘

案例:某工业网关在 1ms 控制周期内每周期打印 3 条 INFO 日志,使用 115200 波特率串口直发。结果控制任务被阻塞,周期抖动超过 200μs。改造方案:引入 4KB 环形缓冲,前台只入队,LogTask 以 500kbps 批量发送,控制任务抖动降至 8μs 以内。

常见误区:把格式化放在中断里执行。snprintf 耗时可达数十微秒,会直接拉高中断延迟。正确做法是中断中只记录原始参数或预编码帧,格式化留给后台任务。

复盘清单:

  • 缓冲是否静态分配,是否存在动态内存申请?
  • 中断路径是否只做入队,是否调用了阻塞 API?
  • 缓冲满时的丢弃策略是否明确,是否有丢包计数?
  • LogTask 优先级是否低于业务任务,是否会被饿死?
  • 时间戳来源是否统一,是否与内核 tick 对齐?
  • 崩溃时最后 N 条日志是否可回放,是否保存在非易失区域?