3、软件架构设计:日志系统的分层架构

好,咱们接着聊。上一章我们把需求理清楚了,现在该动真格的了——设计软件架构。

说实话,我早年做嵌入式项目时,最怕的就是日志系统。为什么?因为很多人把日志当成「printf 的升级版」,随手往代码里一塞。结果呢?系统跑起来,日志把 Flash 写穿了,或者中断里打日志导致死锁。嗯,这些都是我踩过的坑。

所以这次做智能手表的日志系统,我坚持一个原则:分层。把不同职责拆开,各管各的,出了问题也好排查。

3.1 整体分层思路

我把日志系统拆成四层,你想想看,就像工厂里的流水线:

  • 采集层:负责从各个模块「收」日志消息
  • 缓冲层:临时存放,防止高频写入把存储搞崩
  • 存储层:真正把数据写到 Flash 或 RAM 里
  • 输出层:把日志吐给调试串口、蓝牙或者屏幕

每一层只干一件事。接口定义清楚,层与层之间通过函数指针或者回调来通信。这样做的好处是——哪天你想换存储介质,比如从 SPI Flash 换成 SD 卡,你只需要改存储层,其他层动都不用动。

核心原则:上层不关心下层的实现细节,下层不依赖上层的业务逻辑。

3.2 采集层(Log Collector)

采集层是日志系统的「入口」。说白了,就是各个模块调用 log_write() 的地方。

我习惯把采集层设计成纯函数,不涉及任何硬件操作。它只做三件事:

  1. 接收日志等级(DEBUG、INFO、WARN、ERROR)
  2. 接收日志标签(比如 "BATTERY"、"BLE"、"UI")
  3. 接收日志内容(格式化字符串)

代码大概长这样:

/* 采集层接口定义 */
typedef enum {
    LOG_LEVEL_DEBUG = 0,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR,
    LOG_LEVEL_FATAL
} log_level_t;

typedef struct {
    log_level_t level;
    const char *tag;
    const char *msg;
    uint32_t timestamp;
} log_record_t;

/* 采集层对外接口 */
int log_collect(log_level_t level, const char *tag, const char *fmt, ...);

这里有个细节:timestamp 从哪里来?我建议在采集层就打好时间戳,而不是等到存储层再打。因为存储层可能有延迟,时间就不准了。我在项目中遇到过,因为时间戳打晚了,排查问题时发现日志顺序都是乱的……

小技巧:采集层里可以加一个「等级过滤」功能。比如当前系统只允许 INFO 及以上等级的日志通过,DEBUG 的直接丢弃。这样能大幅减少后续层的压力。

3.3 缓冲层(Log Buffer)

缓冲层是容易被忽视的一层。很多人直接把采集到的日志往 Flash 里写,结果呢?高频写入把 Flash 磨坏了。

智能手表这种场景,传感器数据上报很频繁,一秒可能几十条日志。如果每条都直接写 Flash,寿命扛不住。所以缓冲层的作用就是——攒一批,再写一次

我常用的方案是「环形缓冲区」:

/* 缓冲层数据结构 */
#define LOG_BUFFER_SIZE 256

typedef struct {
    log_record_t records[LOG_BUFFER_SIZE];
    uint32_t head;
    uint32_t tail;
    uint32_t count;
} log_buffer_t;

/* 缓冲层接口 */
int log_buffer_push(log_record_t *record);
int log_buffer_pop(log_record_t *record);
int log_buffer_is_full(void);
int log_buffer_is_empty(void);

缓冲层的工作流程是这样的:

  • 采集层把日志丢进缓冲区
  • 缓冲区满了,或者定时器触发,就把一批数据交给存储层
  • 如果缓冲区溢出,丢弃最旧的日志(或者丢弃当前日志,看你的策略)

嗯,这里要注意:中断里不要操作缓冲层。我早期犯过这个错,中断里 push 日志,结果和主循环的 pop 冲突了,数据全乱了。后来加了互斥锁才解决。

避坑指南:我曾经在缓冲区设计上吃过亏——缓冲区太小,导致高频日志频繁溢出;缓冲区太大,又浪费 RAM。建议根据实际业务估算:比如每秒最多 50 条日志,每条 64 字节,缓冲 2 秒的数据,那就是 50 * 64 * 2 = 6.4KB。智能手表 RAM 有限,这个量级还算合理。

3.4 存储层(Log Storage)

存储层是真正落盘的地方。智能手表常用的存储介质有两种:

存储介质 优点 缺点 适用场景
内部 Flash 速度快、无需额外芯片 容量小、擦写次数有限 少量关键日志
外部 SPI Flash 容量大、成本低 速度慢、需要驱动 大量日志存储
RAM 缓存 速度极快 掉电丢失 临时调试

我个人的建议是:分层设计,存储介质可配置。比如调试阶段用 RAM,发布后用 SPI Flash。接口定义如下:

/* 存储层接口 */
typedef struct {
    int (*init)(void);
    int (*write)(uint8_t *data, uint32_t len);
    int (*read)(uint8_t *buf, uint32_t addr, uint32_t len);
    int (*erase)(uint32_t addr, uint32_t len);
} log_storage_driver_t;

/* 注册存储驱动 */
int log_storage_register(log_storage_driver_t *driver);

你看,这样设计后,存储层只关心「怎么读写」,不关心「写的是什么」。采集层和缓冲层完全不用改。

关键点:Flash 写入前必须先擦除。而且擦除是按扇区来的,不是按字节。所以存储层里要维护一个「写入指针」,记录当前写到哪了。我习惯用「日志头 + 日志体」的格式,日志头里记录时间戳和长度,方便读取时解析。

3.5 输出层(Log Output)

输出层是面向用户的。它把存储层里的日志,以人类可读的形式展示出来。

智能手表上,输出方式有这么几种:

  • 串口输出:调试时最常用,通过 USB 转串口线连到 PC
  • 蓝牙输出:通过 BLE 把日志传到手机 App
  • 屏幕显示:在手表屏幕上滚动显示最近几条日志
  • 文件导出:把日志打包成 CSV 或 JSON,通过 USB 导出

输出层的接口也很简单:

/* 输出层接口 */
typedef struct {
    int (*output)(const char *formatted_log);
    int (*flush)(void);
} log_output_driver_t;

/* 注册输出驱动 */
int log_output_register(log_output_driver_t *driver);

输出层可以同时注册多个驱动。比如调试时同时输出到串口和蓝牙,发布时只输出到蓝牙。这个策略由上层决定,输出层只管「把格式化好的字符串发出去」。

经验之谈:输出层里最好加一个「格式化器」。比如把二进制的时间戳转成 "2025-01-15 14:30:22" 这种人类可读的格式。我见过有人直接输出原始时间戳,排查问题时还得拿计算器算……太痛苦了。

3.6 模块间接口定义总结

最后,我把四层之间的接口整理成一张表,方便你对照实现:

接口方向 接口名称 说明
采集层 → 缓冲层 log_buffer_push() 将日志记录压入环形缓冲区
缓冲层 → 存储层 log_storage_write() 将一批日志写入存储介质
存储层 → 输出层 log_storage_read() 从存储介质读取日志数据
输出层 → 用户 log_output_output() 将格式化后的日志展示给用户

说白了,这个架构的核心就是「各司其职」。采集层只管收,缓冲层只管存,存储层只管写,输出层只管展示。每一层都通过接口解耦,你改任何一层,其他层都不受影响。

嗯,我记得有一次项目赶进度,同事直接把日志写入逻辑和业务代码混在一起。结果后来要加蓝牙输出功能,改了一周还没改完。而我这个分层架构,加个蓝牙驱动注册进去,半小时搞定。这就是设计的力量。

下一章,我们聊聊具体的代码实现,把这几层真正跑起来。