3、交互系统架构:嵌入式系统基础、实时操作系统(RTOS)选型、事件驱动架构、状态机设计模式

好,咱们进入第三章。这一章,说白了就是整个除颤仪交互系统的「骨架」和「神经」。你想想看,一个除颤仪,屏幕要亮、按键要响应、高压充放电要精确控制、语音要播报——这么多事情挤在一块儿,怎么让它们不乱套?这就是架构要解决的问题。

我个人习惯,做任何嵌入式交互系统,先搭骨架再填肉。骨架搭歪了,后面代码写得再漂亮也白搭。今天咱们就把这四块内容掰开揉碎了讲清楚。

3.1 嵌入式系统基础:别把MCU当PC用

很多从上层应用转过来的工程师,容易犯一个毛病——把单片机当小电脑用。我见过有人直接在STM32上跑Linux,结果资源不够,卡成PPT。除颤仪这种医疗设备,讲究的是实时、可靠、低功耗,不是功能堆砌。

嵌入式系统的核心就三点:

  • 资源受限:RAM可能就几十KB,Flash几百KB。你写代码得精打细算,别动不动malloc。
  • 实时性要求:除颤仪从检测到心律到放电,时间窗口是以毫秒计的。系统不能因为某个任务卡住就耽误大事。
  • 确定性行为:同样的输入,必须产生同样的输出。不能今天按了键0.1秒响应,明天变成0.5秒。

核心原则:嵌入式开发不是写代码,是管理资源。你管理的不是内存,是时间和确定性。

我在项目中遇到过一件事:有个同事用了一个很酷的第三方库,功能确实强大,但每次初始化要花200ms。这在普通产品上无所谓,但在除颤仪上,200ms意味着患者可能错过一次最佳除颤时机。后来我硬是把那个库换成了自己手写的精简版,初始化时间降到5ms。嗯,有时候「够用」比「强大」更重要。

3.2 实时操作系统(RTOS)选型:别选最火的,选最合适的

除颤仪要不要上RTOS?我的答案是:必须上。裸机跑轮询,你根本没法保证多个任务的实时性。但RTOS选型是个技术活,选错了后面全是坑。

市面上常见的RTOS有FreeRTOS、uC/OS-III、RT-Thread、Zephyr等。我个人的选型标准是:

考量维度 FreeRTOS uC/OS-III RT-Thread Zephyr
资源占用 极小(4KB ROM起) 中等(10KB+) 中等(支持裁剪) 较大(20KB+)
实时性 优秀(抢占式) 优秀(可抢占) 良好 良好
生态与文档 极丰富 成熟但商业 中文友好 Linux风格
医疗认证支持 需自行认证 有安全认证版本 需自行认证 有安全相关文档
我的推荐场景 资源紧张、快速原型 对可靠性要求极高 国内团队、中文生态 需要丰富驱动支持

我的建议:如果你团队经验一般,选FreeRTOS准没错。它简单、稳定、资料多。我做过三个医疗项目,两个用的FreeRTOS,一个用的uC/OS-III。FreeRTOS那个项目开发周期最短,因为遇到问题网上随便一搜就有答案。

选型时还要注意一点:RTOS的调度策略。除颤仪这种系统,我建议用抢占式调度。为什么?因为高压充放电任务优先级必须最高,不能被其他任务打断。我曾经在一个项目里用了时间片轮转,结果充到一半被UI刷新任务抢了CPU,电压没充到位就停了——那真是吓出一身冷汗。

3.3 事件驱动架构:别让CPU空转

嵌入式系统里最忌讳的就是「忙等」。比如你写个while(1)循环,不断检查按键有没有按下——CPU全浪费在轮询上了。事件驱动架构就是来解决这个问题的。

说白了,事件驱动就是:没事别找我,有事我通知你。系统里有一个事件队列,各种事件(按键按下、定时器超时、串口收到数据)都往队列里丢。然后有一个事件分发器,根据事件类型调用对应的处理函数。

我常用的一个简单事件驱动框架长这样:

// 事件类型定义
typedef enum {
    EVT_NONE = 0,
    EVT_KEY_PRESS,      // 按键按下
    EVT_KEY_RELEASE,    // 按键释放
    EVT_TIMER_TICK,     // 定时器滴答
    EVT_ADC_COMPLETE,   // ADC转换完成
    EVT_HV_CHARGE_DONE, // 高压充电完成
    EVT_SHOCK_TRIGGER,  // 放电触发
    EVT_ERROR_OCCUR     // 错误发生
} EventType_t;

// 事件结构体
typedef struct {
    EventType_t type;
    uint32_t param;     // 事件参数,比如按键码
    uint32_t timestamp; // 事件发生时间戳
} Event_t;

// 事件队列(循环队列)
#define EVENT_QUEUE_SIZE 16
static Event_t eventQueue[EVENT_QUEUE_SIZE];
static uint8_t head = 0, tail = 0;

// 投递事件(在中断或任务中调用)
void PostEvent(EventType_t type, uint32_t param) {
    // 关中断保护
    uint32_t saved = DisableIRQ();
    eventQueue[head].type = type;
    eventQueue[head].param = param;
    eventQueue[head].timestamp = GetSysTick();
    head = (head + 1) % EVENT_QUEUE_SIZE;
    RestoreIRQ(saved);
    // 通知事件处理任务
    OS_SignalEvent(EVENT_TASK_ID);
}

// 事件处理任务
void EventTask(void *arg) {
    while(1) {
        OS_WaitEvent(EVENT_TASK_ID);  // 等待事件
        while(head != tail) {
            Event_t evt = eventQueue[tail];
            tail = (tail + 1) % EVENT_QUEUE_SIZE;
            DispatchEvent(&evt);  // 分发处理
        }
    }
}

注意:事件队列一定要用循环队列,并且队列大小要经过计算。我见过有人用链表实现事件队列,结果在中断里malloc——直接导致系统崩溃。中断里绝对不能做动态内存分配!

事件驱动的好处很明显:CPU利用率高、代码解耦、容易扩展。你想想看,如果以后要加一个蓝牙模块,只需要定义新的事件类型,然后在蓝牙中断里投递事件就行,其他代码完全不用改。

3.4 状态机设计模式:把复杂逻辑变成「如果-那么」

除颤仪的交互逻辑有多复杂?我给你列几个状态:待机、自检、心电监测、分析心律、充电、放电、CPR指导、数据回顾……每个状态下的按键响应都不一样。比如在「待机」状态下按「放电」键是无效的,但在「充电完成」状态下按「放电」键就要立刻触发。

这种逻辑用if-else写?写到你怀疑人生。正确的做法是用状态机

我习惯用「有限状态机(FSM)」来处理UI交互。每个状态就是一个节点,每个事件就是一条边。状态机告诉你:当前在哪个状态,来了什么事件,下一步该干什么。

一个典型的状态机实现:

// 状态枚举
typedef enum {
    ST_IDLE = 0,        // 待机
    ST_SELF_TEST,       // 自检
    ST_MONITOR,         // 心电监测
    ST_ANALYZING,       // 分析心律
    ST_CHARGING,        // 充电中
    ST_CHARGED,         // 充电完成
    ST_SHOCKING,        // 放电中
    ST_CPR,             // CPR指导
    ST_ERROR            // 错误状态
} State_t;

// 状态表结构
typedef struct {
    State_t currentState;
    EventType_t event;
    State_t nextState;
    void (*action)(Event_t *evt);  // 状态转移时执行的动作
} StateTransition_t;

// 状态转移表
static const StateTransition_t stateTable[] = {
    // 当前状态       事件              下一状态        动作函数
    {ST_IDLE,         EVT_KEY_PRESS,    ST_SELF_TEST,    ActionStartSelfTest},
    {ST_SELF_TEST,    EVT_ADC_COMPLETE, ST_MONITOR,      ActionEnterMonitor},
    {ST_MONITOR,      EVT_KEY_PRESS,    ST_ANALYZING,    ActionStartAnalysis},
    {ST_ANALYZING,    EVT_TIMER_TICK,   ST_CHARGING,     ActionStartCharge},
    {ST_CHARGING,     EVT_HV_CHARGE_DONE, ST_CHARGED,    ActionChargeComplete},
    {ST_CHARGED,      EVT_SHOCK_TRIGGER,  ST_SHOCKING,   ActionDeliverShock},
    {ST_SHOCKING,     EVT_TIMER_TICK,   ST_CPR,          ActionStartCPR},
    {ST_CPR,          EVT_TIMER_TICK,   ST_MONITOR,      ActionBackToMonitor},
    // ... 其他转移
    {ST_ERROR,        EVT_KEY_PRESS,    ST_IDLE,         ActionResetSystem},
};

// 状态机执行
void RunStateMachine(Event_t *evt) {
    State_t current = g_currentState;
    for(int i = 0; i < sizeof(stateTable)/sizeof(stateTable[0]); i++) {
        if(stateTable[i].currentState == current && stateTable[i].event == evt->type) {
            // 执行动作
            if(stateTable[i].action) {
                stateTable[i].action(evt);
            }
            // 状态转移
            g_currentState = stateTable[i].nextState;
            // 进入新状态时触发进入动作
            OnStateEnter(g_currentState);
            break;
        }
    }
}

关键点:状态转移表一定要完整。每个状态对每个事件都要有定义,哪怕只是「忽略」或者「报错」。我曾经漏掉了一个「充电中状态下收到放电事件」的处理,结果测试时发现按放电键没反应,但系统也没报错——这种「静默失效」在医疗设备上是致命的。

状态机的好处是:逻辑一目了然,测试容易覆盖。你可以对着状态表一条一条测,保证每条转移都正确。我一般会在开发初期就把状态图画出来,贴在工位上。代码写完了,对着图检查一遍,基本不会漏。

3.5 三者如何协同工作?

好,现在咱们把RTOS、事件驱动、状态机串起来。整个架构是这样的:

  1. 硬件中断层:按键、定时器、ADC等外设产生中断,在中断服务程序里投递事件到事件队列。
  2. RTOS任务层:有一个专门的事件处理任务,它从队列里取出事件,调用状态机处理。
  3. 状态机层:根据当前状态和事件,执行动作并转移到新状态。
  4. UI层:状态机执行动作时,会更新UI(比如显示「充电中」、播放语音提示)。

举个例子:用户按下「分析」键。

  • 按键中断 → 投递EVT_KEY_PRESS事件(参数为按键码)
  • 事件处理任务收到事件 → 调用RunStateMachine
  • 状态机当前在ST_MONITOR,收到EVT_KEY_PRESS → 执行ActionStartAnalysis(显示「分析中」,启动心律分析算法)→ 转移到ST_ANALYZING
  • 分析完成后,算法模块投递EVT_TIMER_TICK(或自定义事件)→ 状态机转移到ST_CHARGING

你看,整个过程清晰、可预测、容易调试。我在项目中用这套架构,基本上bug都能在单元测试阶段发现,很少出现集成测试时「按了键没反应」这种玄学问题。

避坑指南:我曾经在一个项目里把事件处理直接放在中断里做,结果中断嵌套导致状态机数据被破坏。记住:中断里只做「投递事件」,不做「处理事件」。处理事件一定要放在任务上下文里,这样才能保证数据一致性。

好了,这一章的内容就这些。总结一下:嵌入式系统是基础,RTOS选型要务实,事件驱动让系统「活」起来,状态机让逻辑「稳」下来。把这四块吃透了,除颤仪的交互系统架构你就拿下了八成。下一章咱们开始真正动手写代码,把今天讲的这些东西落地到实际项目中。