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三层,各司其职。
  • 模块化设计:高内聚、低耦合,每个模块只做一件事。
  • 接口隔离:小而专的接口,别让调用者依赖它不需要的东西。
  • 状态机:用状态表让逻辑清晰可见,避免状态混乱。

这些原则不是理论空谈。我在每个项目里都会严格执行。你想想看,医疗设备关系到患者的生命安全,代码质量就是生命线。架构设计得好,后期维护、升级、认证都会顺利很多。反之,架构混乱的项目,往往会在认证阶段暴露出大量问题,那时候再改,成本就高了。

下一章,咱们聊聊具体怎么实现一个安全可靠的理疗仪控制逻辑。到时候我会结合一个实际案例,把今天讲的这些原则都用上。