3、软件架构设计:日志系统的分层架构
好,咱们接着聊。上一章我们把需求理清楚了,现在该动真格的了——设计软件架构。
说实话,我早年做嵌入式项目时,最怕的就是日志系统。为什么?因为很多人把日志当成「printf 的升级版」,随手往代码里一塞。结果呢?系统跑起来,日志把 Flash 写穿了,或者中断里打日志导致死锁。嗯,这些都是我踩过的坑。
所以这次做智能手表的日志系统,我坚持一个原则:分层。把不同职责拆开,各管各的,出了问题也好排查。
3.1 整体分层思路
我把日志系统拆成四层,你想想看,就像工厂里的流水线:
- 采集层:负责从各个模块「收」日志消息
- 缓冲层:临时存放,防止高频写入把存储搞崩
- 存储层:真正把数据写到 Flash 或 RAM 里
- 输出层:把日志吐给调试串口、蓝牙或者屏幕
每一层只干一件事。接口定义清楚,层与层之间通过函数指针或者回调来通信。这样做的好处是——哪天你想换存储介质,比如从 SPI Flash 换成 SD 卡,你只需要改存储层,其他层动都不用动。
核心原则:上层不关心下层的实现细节,下层不依赖上层的业务逻辑。
3.2 采集层(Log Collector)
采集层是日志系统的「入口」。说白了,就是各个模块调用 log_write() 的地方。
我习惯把采集层设计成纯函数,不涉及任何硬件操作。它只做三件事:
- 接收日志等级(DEBUG、INFO、WARN、ERROR)
- 接收日志标签(比如 "BATTERY"、"BLE"、"UI")
- 接收日志内容(格式化字符串)
代码大概长这样:
/* 采集层接口定义 */
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() |
将格式化后的日志展示给用户 |
说白了,这个架构的核心就是「各司其职」。采集层只管收,缓冲层只管存,存储层只管写,输出层只管展示。每一层都通过接口解耦,你改任何一层,其他层都不受影响。
嗯,我记得有一次项目赶进度,同事直接把日志写入逻辑和业务代码混在一起。结果后来要加蓝牙输出功能,改了一周还没改完。而我这个分层架构,加个蓝牙驱动注册进去,半小时搞定。这就是设计的力量。
下一章,我们聊聊具体的代码实现,把这几层真正跑起来。