核心数据结构设计:时间戳结构体、日志等级枚举、日志条目结构体、环形缓冲区设计
好,咱们接着往下聊。上一章我们把 Tick 日志系统的整体架构搭起来了,现在该动真格的了——设计核心数据结构。
说白了,数据结构就是系统的骨架。骨架歪了,后面再怎么填肉都别扭。我在好几个项目里都吃过这个亏,一开始图省事随便定义几个结构体,结果后期改得想骂娘。所以这一章,咱们一步到位,把地基打扎实。
时间戳结构体:Tick 系统的灵魂
嵌入式日志系统跟 PC 端最大的区别是什么?
没有绝对时间。或者说,我们更关心的是「相对时间」——系统跑了多少个 Tick,事件发生的先后顺序。所以时间戳结构体,本质上就是一个 Tick 计数器。
typedef struct {
uint32_t tick_high; // 高位,记录溢出次数
uint32_t tick_low; // 低位,当前 Tick 值
} timestamp_t;
你可能会问:一个 uint32_t 不够吗?
嗯,这里要注意。假设你的系统主频是 100MHz,Tick 中断每 1ms 触发一次。一个 uint32_t 能存 2^32 个 Tick,也就是大约 49.7 天。对于很多工业设备来说,这远远不够。我见过一个电力监控项目,设备连续运行了半年多,结果日志时间戳溢出了,排查问题的时候差点把工程师逼疯。
所以我个人习惯用双字结构。tick_low 存当前计数值,tick_high 在溢出中断里加 1。这样组合起来,能覆盖的时间范围是 2^64 个 Tick——按 1ms 算,大约是 5.8 亿年。够用了吧?
日志等级枚举:别什么都往里塞
日志等级这个东西,看着简单,但用不好就是灾难。
typedef enum {
LOG_LEVEL_NONE = 0,
LOG_LEVEL_ERROR = 1,
LOG_LEVEL_WARN = 2,
LOG_LEVEL_INFO = 3,
LOG_LEVEL_DEBUG = 4,
LOG_LEVEL_VERBOSE = 5
} log_level_t;
为什么我列了 6 个等级?
因为在实际项目中,我发现 3 个等级不够用,7 个以上又太啰嗦。6 个刚刚好。ERROR 和 WARN 是必须的,INFO 用于正常流程记录,DEBUG 和 VERBOSE 留给开发阶段。NONE 嘛,用来做过滤——比如正式发布时,直接把等级设成 LOG_LEVEL_WARN,所有 INFO 以下的日志就自动屏蔽了,省空间。
我曾经在一个传感器采集项目里,同事把所有日志都打成了 INFO,结果调试的时候根本分不清哪些是错误、哪些是正常输出。后来我强制要求:ERROR 必须对应「系统无法自动恢复」的故障,WARN 对应「需要关注但不影响运行」的情况。这个约定救了我们好几次。
日志条目结构体:一条日志的完整模样
现在我们把时间戳和等级组合起来,再加上日志内容,就构成了日志条目。
typedef struct {
timestamp_t timestamp; // 时间戳
log_level_t level; // 日志等级
uint16_t module_id; // 模块 ID,用于区分来源
uint16_t line_num; // 代码行号
char message[LOG_MSG_MAX_LEN]; // 日志内容
} log_entry_t;
这里有几个设计细节,我展开说说:
- module_id:用 uint16_t 而不是字符串。为什么?省空间。一个模块 ID 查表就能转成名字,没必要每条日志都存一串字符。我在一个 Bootloader 项目里,用 8 位模块 ID 就覆盖了 30 多个模块,够用。
- line_num:调试时救命的东西。配合 module_id,你能精确定位到是哪一行代码打的日志。别小看这个字段,出问题的时候,它能省掉你一半的排查时间。
- message:固定长度还是可变长度?我个人建议固定长度。虽然浪费一点空间,但内存管理简单得多,不会出现碎片问题。长度选多少?看你的 RAM 预算,我一般用 64 或 128 字节。
你想想看,一条日志 64 字节,100 条也就 6.4KB。对于大部分 MCU 来说,这个开销完全可以接受。
环形缓冲区设计:有限空间里的无限循环
好了,核心来了。日志条目怎么存?用环形缓冲区。
为什么是环形?因为嵌入式系统的 RAM 是有限的。你不能像 PC 那样无限制地追加日志文件。环形缓冲区的思路很简单:固定大小的数组,写满之后从头覆盖。这样永远只占用固定内存,不会溢出。
typedef struct {
log_entry_t *buffer; // 缓冲区指针
uint16_t size; // 缓冲区大小(条目数)
uint16_t head; // 写指针
uint16_t tail; // 读指针
uint16_t count; // 当前有效条目数
} ring_buffer_t;
这个结构体里,head 指向下一个要写入的位置,tail 指向下一个要读取的位置。count 记录当前有多少条有效日志。
操作逻辑其实就三个:
- 写入:把数据写到 head 位置,head 加 1。如果 head 到达末尾,就绕回 0。如果缓冲区满了(count == size),tail 也要跟着移动,丢弃最旧的数据。
- 读取:从 tail 位置读数据,tail 加 1,count 减 1。
- 判空/判满:count == 0 表示空,count == size 表示满。
我曾经在一个电机驱动项目里,因为环形缓冲区的读写指针没处理好,导致日志数据错乱。排查了整整两天,最后发现是 head 和 tail 在边界条件下同时指向了同一个位置,读出来的是半条旧数据半条新数据。从那以后,我每次设计环形缓冲区都会加一个 count 字段做双重校验。
整体结构图
下面这张图,把上面几个结构体的关系串起来了。你可以看到时间戳、日志等级、日志条目是怎么组合成环形缓冲区的。
从这张图可以看得很清楚:log_entry_t 是核心载体,它把时间戳、等级、模块信息、日志内容打包在一起。而 ring_buffer_t 则负责管理这些条目的存储和流转。head 和 tail 像两个指针,在固定大小的数组里循环移动,实现「永远写不爆」的效果。
嗯,数据结构这块就聊到这儿。下一章我们把这些结构体用代码实现出来,看看实际跑起来是什么效果。
公众号:蓝海资料掘金营,微信deep3321