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、事件驱动、状态机串起来。整个架构是这样的:
- 硬件中断层:按键、定时器、ADC等外设产生中断,在中断服务程序里投递事件到事件队列。
- RTOS任务层:有一个专门的事件处理任务,它从队列里取出事件,调用状态机处理。
- 状态机层:根据当前状态和事件,执行动作并转移到新状态。
- UI层:状态机执行动作时,会更新UI(比如显示「充电中」、播放语音提示)。
举个例子:用户按下「分析」键。
- 按键中断 → 投递EVT_KEY_PRESS事件(参数为按键码)
- 事件处理任务收到事件 → 调用RunStateMachine
- 状态机当前在ST_MONITOR,收到EVT_KEY_PRESS → 执行ActionStartAnalysis(显示「分析中」,启动心律分析算法)→ 转移到ST_ANALYZING
- 分析完成后,算法模块投递EVT_TIMER_TICK(或自定义事件)→ 状态机转移到ST_CHARGING
你看,整个过程清晰、可预测、容易调试。我在项目中用这套架构,基本上bug都能在单元测试阶段发现,很少出现集成测试时「按了键没反应」这种玄学问题。
避坑指南:我曾经在一个项目里把事件处理直接放在中断里做,结果中断嵌套导致状态机数据被破坏。记住:中断里只做「投递事件」,不做「处理事件」。处理事件一定要放在任务上下文里,这样才能保证数据一致性。
好了,这一章的内容就这些。总结一下:嵌入式系统是基础,RTOS选型要务实,事件驱动让系统「活」起来,状态机让逻辑「稳」下来。把这四块吃透了,除颤仪的交互系统架构你就拿下了八成。下一章咱们开始真正动手写代码,把今天讲的这些东西落地到实际项目中。