3. 软件架构设计原则:分层架构、模块化、接口隔离与状态机
好,咱们进入正题。这一章我打算聊聊软件架构的四个核心原则。说白了,就是怎么把代码组织得既安全又容易维护。我在医疗设备这行干了十年,见过太多因为架构混乱导致的“血泪史”。嗯,咱们一个一个来看。
3.1 分层架构:HAL / OSAL / APP
分层架构,这名字听起来挺唬人。其实说白了,就是把代码按职责分成几层,各管各的。我个人习惯分成三层:硬件抽象层(HAL)、操作系统抽象层(OSAL)、应用层(APP)。
为什么要分层? 你想想看,如果应用代码直接操作寄存器,那换一个MCU型号,所有代码都得重写。这在医疗设备里是绝对不能接受的。我见过一个项目,因为没分层,换了个Flash型号,结果改了两个月,还出了Bug。
HAL层:负责屏蔽硬件差异。比如读写GPIO、配置定时器、操作ADC。这一层只暴露简单的接口,比如 HAL_GPIO_WritePin()。
OSAL层:负责屏蔽操作系统差异。比如任务创建、信号量、消息队列。如果你的项目用FreeRTOS,那OSAL就是对FreeRTOS API的二次封装。万一以后要切到RT-Thread,只需要改OSAL层,应用层纹丝不动。
APP层:只关心业务逻辑。比如“用户按下启动键后,先检测电极是否连接,再开始输出治疗波形”。这一层不应该出现任何硬件或OS相关的代码。
核心原则: 上层可以调用下层,但下层绝不能调用上层。这叫“单向依赖”。
我在项目中遇到过一个问题:有个同事在HAL层里直接调用了APP层的函数,结果导致循环依赖,编译都过不去。后来我强制要求:所有跨层调用必须通过接口回调或者事件机制。嗯,从那以后清爽多了。
3.2 模块化设计:高内聚、低耦合
模块化,说白了就是把功能拆成一个个独立的“积木”。每个积木只做一件事,并且把这件事做好。
高内聚:一个模块内部的元素应该紧密相关。比如“温度控制模块”,里面应该只包含温度采集、PID计算、加热控制这些函数。别把“按键扫描”也塞进去。
低耦合:模块之间的依赖要尽可能少。比如“温度控制模块”不应该直接知道“显示模块”的内部细节。它只需要通过接口告诉显示模块“当前温度是37.5°C”就行了。
我举个例子。一个理疗仪通常有多个治疗模式:按摩、热敷、电刺激。每个模式可以是一个独立的模块。它们共享底层的硬件驱动,但彼此之间不直接通信。这样,如果我要新增一个“超声波”模式,只需要新建一个模块,不影响其他任何代码。
我的小技巧: 每个模块都用一个独立的 .c 和 .h 文件。头文件里只暴露必要的接口,内部函数全部用 static 修饰。这样别人想乱调用都找不到入口。
3.3 接口隔离原则:小而专的接口
接口隔离原则,是SOLID原则里的“I”。它告诉我们:不要强迫调用者依赖它不需要的方法。
举个例子。假设你有一个“设备管理”接口,里面包含了 Init()、Start()、Stop()、SetPower()、GetTemperature()、Calibrate() 六个方法。如果某个模块只需要 Start() 和 Stop(),那它也被迫依赖了其他四个方法。这不好。
更好的做法是拆成多个小接口:
IControllable:包含Start()、Stop()IPowerManageable:包含SetPower()ITemperatureSensitive:包含GetTemperature()ICalibratable:包含Calibrate()
这样,每个模块只依赖它真正需要的接口。我曾经在一个项目中,因为接口太“胖”,导致一个简单的功能修改,影响了五个模块。后来我花了三天时间重构接口,把一个大接口拆成了六个小接口。嗯,那之后修改起来轻松多了。
注意: 在C语言里,接口通常用结构体+函数指针来实现。不要为了追求“纯面向对象”而过度设计。适可而止。
3.4 状态机设计模式:让逻辑清晰可见
状态机,是嵌入式开发里最常用的设计模式之一。尤其适合理疗仪这种有明确状态转换的设备。
一个理疗仪的状态可能包括:
- 待机:等待用户操作
- 参数设置:用户调整治疗模式、时间、强度
- 治疗中:正在输出治疗波形
- 暂停:治疗过程中用户按了暂停
- 错误:检测到异常(如电极脱落、过热)
每个状态都有明确的进入条件、退出条件、以及在该状态下允许的操作。用状态机来实现,逻辑一目了然,不容易出现“状态混乱”的Bug。
我常用的状态机实现方式有两种:
| 方式 | 优点 | 缺点 |
|---|---|---|
| switch-case | 简单直观,适合状态少于10个 | 状态多了代码会变得很长 |
| 状态表 | 结构清晰,易于扩展 | 需要维护一个表格,稍微复杂一点 |
我个人偏爱状态表。因为它把“当前状态”和“事件”映射到“下一个状态”和“动作”,非常清晰。下面是一个简化的例子:
// 状态表结构
typedef struct {
uint8_t currentState;
uint8_t event;
uint8_t nextState;
void (*action)(void);
} StateTableEntry;
// 状态表
const StateTableEntry stateTable[] = {
{STATE_IDLE, EVENT_START_BUTTON, STATE_PARAM_SET, EnterParamSet},
{STATE_PARAM_SET, EVENT_CONFIRM, STATE_TREATING, StartTreatment},
{STATE_TREATING, EVENT_PAUSE_BUTTON, STATE_PAUSED, PauseTreatment},
{STATE_TREATING, EVENT_ERROR, STATE_ERROR, HandleError},
// ... 更多条目
};
// 状态机执行函数
void RunStateMachine(uint8_t event) {
for (int i = 0; i < sizeof(stateTable)/sizeof(stateTable[0]); i++) {
if (stateTable[i].currentState == currentState && stateTable[i].event == event) {
stateTable[i].action(); // 执行动作
currentState = stateTable[i].nextState; // 切换状态
break;
}
}
}
你看,这样写,每个状态转换都清清楚楚。我曾经在一个理疗仪项目里,用状态表实现了12个状态、20多个事件。调试的时候,只需要打印当前状态和触发的事件,就能快速定位问题。嗯,那感觉真爽。
避坑指南: 状态机里一定要有“默认处理”。如果某个事件在当前状态下没有定义,不能直接忽略,而是要进入“错误状态”或者记录日志。我曾经因为漏了这个,导致设备在异常情况下“卡死”了,用户按任何键都没反应。后来我加了一个 default 分支,专门处理未定义的事件。
3.5 总结一下
这一章咱们聊了四个原则:
- 分层架构:把代码分成HAL、OSAL、APP三层,各司其职。
- 模块化设计:高内聚、低耦合,每个模块只做一件事。
- 接口隔离:小而专的接口,别让调用者依赖它不需要的东西。
- 状态机:用状态表让逻辑清晰可见,避免状态混乱。
这些原则不是理论空谈。我在每个项目里都会严格执行。你想想看,医疗设备关系到患者的生命安全,代码质量就是生命线。架构设计得好,后期维护、升级、认证都会顺利很多。反之,架构混乱的项目,往往会在认证阶段暴露出大量问题,那时候再改,成本就高了。
下一章,咱们聊聊具体怎么实现一个安全可靠的理疗仪控制逻辑。到时候我会结合一个实际案例,把今天讲的这些原则都用上。