第2章:系统架构设计——分层架构与模块划分
好,咱们接着聊。上一章我们把需求理清楚了,现在该动手画架构图了。
说实话,很多新手工程师一上来就写代码,写到后面发现耦合得跟麻花似的。我早期也犯过这个错,一个药盒项目改了三版,最后不得不重构。从那以后,我养成了一个习惯:先画图,再写代码。
2.1 为什么选分层架构?
药盒这个产品,说白了就是个嵌入式小系统。但它有个特点:硬件可能会变。
你想想看,今天用STM32,明天可能换成GD32。今天用OLED屏,明天可能改成段码LCD。如果代码全揉在一起,换一个硬件就得重写一半代码。
分层架构就是为了解决这个问题。它的核心思想就一句话:上层不关心下层怎么实现,下层不依赖上层的逻辑。
我一般把药盒的软件分成三层:
- 应用层:业务逻辑,比如吃药提醒、用药记录
- 驱动层:直接操作硬件,比如点亮LED、读取按键
- 硬件抽象层(HAL):介于两者之间,屏蔽硬件差异
嗯,这里要注意:HAL层不是必须的。如果你的产品永远不换芯片,那可以省掉。但药盒这种产品,生命周期长,迭代快,我个人建议还是加上。
2.2 模块划分——把大象装进冰箱
分层之后,每一层还要继续拆模块。我习惯按功能来拆,就像整理工具箱一样,螺丝刀放一格里,扳手放另一格。
药盒的模块划分,我一般这样搞:
| 层级 | 模块名称 | 职责说明 |
|---|---|---|
| 应用层 | 提醒管理 | 定时触发吃药提醒,处理用户确认/延迟 |
| 应用层 | 用药记录 | 记录每次吃药时间、剂量,生成日志 |
| 应用层 | 用户交互 | 处理按键输入、屏幕显示、语音播报 |
| 驱动层 | RTC驱动 | 读写实时时钟芯片,获取当前时间 |
| 驱动层 | 按键驱动 | 扫描按键矩阵,去抖,生成事件 |
| 驱动层 | 显示驱动 | 控制OLED/LCD,刷新屏幕内容 |
| 驱动层 | 存储驱动 | 读写EEPROM或Flash,保存配置和日志 |
| HAL层 | 定时器抽象 | 统一接口,屏蔽不同MCU的定时器差异 |
| HAL层 | GPIO抽象 | 统一管脚操作接口,方便移植 |
你看,每个模块的职责都很单一。这就是所谓的高内聚、低耦合。我在项目中遇到过有人把按键扫描和提醒逻辑写在一个函数里,结果改提醒时间的时候,按键莫名其妙不灵了。嗯,这就是耦合的代价。
2.3 数据流图——让数据跑起来
模块分好了,数据怎么在它们之间流动?我习惯画一张数据流图,把关键路径标出来。
药盒的核心数据流是这样的:
- 用户设置提醒:按键输入 → 应用层解析 → 写入存储模块
- 定时触发提醒:RTC驱动 → 提醒管理模块 → 显示/语音驱动
- 用户确认吃药:按键输入 → 应用层记录 → 写入存储模块
我举个例子,定时触发提醒这条路径:
RTC驱动(每秒更新)
→ HAL层定时器回调
→ 提醒管理模块(检查当前时间是否匹配)
→ 显示驱动(弹出提醒界面)
→ 语音驱动(播放提示音)
这里有个坑,我曾经踩过:不要在中断里直接调用驱动层函数。RTC中断里如果直接去刷屏,轻则卡顿,重则死机。正确的做法是:中断里只设置一个标志位,主循环里再去处理。
避坑指南:我曾经在一个项目里,把RTC中断优先级设得特别高,结果按键扫描一直抢不到CPU时间。用户按了键,要等好几秒才有反应。后来我把中断里的操作精简到极致,只做时间更新,其他事情都丢到主循环里做,问题就解决了。
2.4 接口设计——模块之间的契约
模块之间怎么通信?靠接口。我一般把接口定义成头文件里的函数声明,比如:
// 提醒管理模块接口
void Reminder_Init(void);
void Reminder_Check(void);
void Reminder_SetAlarm(uint8_t index, uint8_t hour, uint8_t minute);
void Reminder_CancelAlarm(uint8_t index);
// 显示驱动接口
void Display_Init(void);
void Display_ShowText(uint8_t line, const char* text);
void Display_ShowIcon(uint8_t iconId);
// 存储驱动接口
void Storage_Init(void);
void Storage_Write(uint16_t addr, uint8_t* data, uint16_t len);
void Storage_Read(uint16_t addr, uint8_t* data, uint16_t len);
你看,每个接口都很简洁。参数类型尽量用标准类型,别搞什么自定义结构体传指针,移植的时候会疯掉。我早期就吃过这个亏,换芯片后发现结构体对齐方式不一样,数据全乱了。
个人经验:接口设计有个小技巧——参数尽量用基本类型。比如时间就用uint8_t hour, uint8_t minute,别搞个struct tm传进去。这样不管换什么编译器、什么芯片,接口都不会出问题。
2.5 架构落地的几个要点
光有图还不够,落地的时候有几个细节要注意:
- 文件组织:每个模块一个.c和一个.h文件,放在对应层级的目录下。比如app/reminder.c、drv/display.c、hal/hal_timer.c。
- 命名规范:函数名带模块前缀,比如Reminder_Check()、Display_ShowText()。这样看代码就知道是哪个模块的。
- 依赖方向:应用层只能调用驱动层和HAL层,驱动层不能反过来调用应用层。这是铁律,破了就乱套。
我记得有一次评审代码,发现有人为了省事,在驱动层里直接调用了应用层的提醒函数。我说这不行,他说就这一次。结果后来换屏幕驱动,提醒逻辑也跟着崩了。嗯,架构的规矩,一次都不能破。
好了,这一章我们讲了分层架构、模块划分、数据流图和接口设计。下一章我们开始搭开发环境,把代码写起来。