第2章:系统架构设计——分层架构与模块划分

好,咱们接着聊。上一章我们把需求理清楚了,现在该动手画架构图了。

说实话,很多新手工程师一上来就写代码,写到后面发现耦合得跟麻花似的。我早期也犯过这个错,一个药盒项目改了三版,最后不得不重构。从那以后,我养成了一个习惯:先画图,再写代码

2.1 为什么选分层架构?

药盒这个产品,说白了就是个嵌入式小系统。但它有个特点:硬件可能会变

你想想看,今天用STM32,明天可能换成GD32。今天用OLED屏,明天可能改成段码LCD。如果代码全揉在一起,换一个硬件就得重写一半代码。

分层架构就是为了解决这个问题。它的核心思想就一句话:上层不关心下层怎么实现,下层不依赖上层的逻辑

我一般把药盒的软件分成三层:

  • 应用层:业务逻辑,比如吃药提醒、用药记录
  • 驱动层:直接操作硬件,比如点亮LED、读取按键
  • 硬件抽象层(HAL):介于两者之间,屏蔽硬件差异

嗯,这里要注意:HAL层不是必须的。如果你的产品永远不换芯片,那可以省掉。但药盒这种产品,生命周期长,迭代快,我个人建议还是加上。

2.2 模块划分——把大象装进冰箱

分层之后,每一层还要继续拆模块。我习惯按功能来拆,就像整理工具箱一样,螺丝刀放一格里,扳手放另一格。

药盒的模块划分,我一般这样搞:

层级 模块名称 职责说明
应用层 提醒管理 定时触发吃药提醒,处理用户确认/延迟
应用层 用药记录 记录每次吃药时间、剂量,生成日志
应用层 用户交互 处理按键输入、屏幕显示、语音播报
驱动层 RTC驱动 读写实时时钟芯片,获取当前时间
驱动层 按键驱动 扫描按键矩阵,去抖,生成事件
驱动层 显示驱动 控制OLED/LCD,刷新屏幕内容
驱动层 存储驱动 读写EEPROM或Flash,保存配置和日志
HAL层 定时器抽象 统一接口,屏蔽不同MCU的定时器差异
HAL层 GPIO抽象 统一管脚操作接口,方便移植

你看,每个模块的职责都很单一。这就是所谓的高内聚、低耦合。我在项目中遇到过有人把按键扫描和提醒逻辑写在一个函数里,结果改提醒时间的时候,按键莫名其妙不灵了。嗯,这就是耦合的代价。

2.3 数据流图——让数据跑起来

模块分好了,数据怎么在它们之间流动?我习惯画一张数据流图,把关键路径标出来。

药盒的核心数据流是这样的:

  1. 用户设置提醒:按键输入 → 应用层解析 → 写入存储模块
  2. 定时触发提醒:RTC驱动 → 提醒管理模块 → 显示/语音驱动
  3. 用户确认吃药:按键输入 → 应用层记录 → 写入存储模块

我举个例子,定时触发提醒这条路径:

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层,驱动层不能反过来调用应用层。这是铁律,破了就乱套。

我记得有一次评审代码,发现有人为了省事,在驱动层里直接调用了应用层的提醒函数。我说这不行,他说就这一次。结果后来换屏幕驱动,提醒逻辑也跟着崩了。嗯,架构的规矩,一次都不能破

好了,这一章我们讲了分层架构、模块划分、数据流图和接口设计。下一章我们开始搭开发环境,把代码写起来。