3. 软件架构:状态机设计(空闲、运行、保护、故障)、任务调度与优先级

好,咱们接着聊软件架构。这部分我把它看作是整个按摩仪安全保护的「大脑」。

说白了,你硬件做得再扎实,传感器选得再好,如果软件逻辑一团浆糊,那设备就是个定时炸弹。我个人习惯,在设计这类安全关键系统时,第一步不是写代码,而是画状态机。

3.1 为什么必须用状态机?

你想想看,按摩仪在工作时,会经历各种不同的「阶段」。比如刚开机、正在按摩、用户按了暂停、或者突然检测到异常。

如果不用状态机,代码很容易写成一大坨 if-else 嵌套。我曾经在一个项目里接手过这样的代码,维护起来简直想哭。一个按键按下,要判断十几个条件,稍不留神就漏掉了某个边界情况,导致设备死机。

状态机的好处就是:它把系统的行为「形式化」了。每个时刻,系统只能处于一个确定的状态。事件来了,根据当前状态和事件,决定下一步去哪。逻辑清晰,不容易出幺蛾子。

核心原则: 状态机是嵌入式安全软件的「骨架」。没有骨架,血肉(代码)就是一滩烂泥。

3.2 四个核心状态详解

我们这个按摩仪,我设计了四个核心状态。不多不少,刚好覆盖所有场景。

状态名称 状态标识 核心行为 进入条件
空闲 (IDLE) 0x00 待机、监听按键、低功耗 上电复位、保护解除、故障恢复后
运行 (RUNNING) 0x01 电机驱动、力度调节、定时 空闲状态下按下启动键
保护 (PROTECT) 0x02 立即停止电机、保持传感器监测 检测到过流、过温、堵转等异常
故障 (FAULT) 0x03 完全停机、记录错误码、等待复位 保护动作失败或检测到硬件致命错误

空闲状态: 这是系统的「家」。上电后第一件事就是进空闲。在这个状态里,我只做两件事:扫描按键,以及监测一个非常基础的「心跳」信号。功耗要压到最低,毕竟用户可能一整天都不用。

运行状态: 用户按下启动,就从空闲跳到运行。这里要注意,进入运行的第一件事,不是立刻转电机,而是做一次「安全检查」。比如,再次确认温度传感器读数正常,电流检测回路没问题。我在项目中就遇到过,传感器在空闲时是好的,但一上电瞬间被干扰,导致误判。所以,进入运行前的「自检」必不可少。

保护状态: 这是安全机制的核心。一旦检测到任何异常(比如电流超过1.5A,或者温度超过60度),系统必须无条件从任何状态(包括运行)跳转到保护状态。保护状态下的动作是「硬」的:直接切断电机PWM输出,拉高刹车引脚。同时,蜂鸣器发出急促的报警声。

注意: 保护状态不是故障状态。它意味着系统检测到了「可恢复」的异常。比如用户按得太用力导致堵转,松开后温度降下来,系统可以自动回到空闲。但如果保护状态持续超过5秒,或者连续进入保护状态3次,那就得进故障了。

故障状态: 这是最后的「安全网」。一旦进入故障,系统就锁死了。所有输出全部关闭,只有断电再上电才能恢复。同时,我会在EEPROM里记录一个故障码,方便售后分析。比如0x03代表「过温保护超时」,0x04代表「电流传感器失效」。

3.3 任务调度与优先级设计

好,状态机搭好了,接下来就是怎么跑起来。在嵌入式系统里,我们通常用RTOS(实时操作系统)或者一个简单的前后台系统。对于按摩仪这种成本敏感的设备,我倾向于用前后台系统 + 定时器中断

为什么不用RTOS?说白了,成本。一个RTOS内核要占几KB的RAM和ROM,对于一颗几毛钱的MCU来说,太奢侈了。而且,按摩仪的任务数量很少,前后台完全够用。

我设计的任务调度,核心就是一句话:中断里做紧急的事,主循环里做耗时的事

任务优先级划分

优先级 任务名称 执行位置 最大执行时间
最高 (0) 安全监测任务 定时器中断 (1ms) 0.1ms
高 (1) 状态机调度任务 定时器中断 (5ms) 0.5ms
中 (2) 电机控制任务 主循环 2ms
低 (3) UI显示与按键扫描 主循环 10ms
最低 (4) 通信与日志任务 主循环 50ms

安全监测任务: 这是整个系统里最不能被打断的任务。我用一个1ms的硬件定时器中断来执行它。在这个中断里,我只做三件事:读电流、读温度、读堵转检测引脚。如果发现异常,直接置位一个「保护请求」标志位。注意,不要在中断里调用任何延时函数或者复杂运算。我曾经见过有人在中断里用printf调试,结果系统直接卡死。中断里要快进快出。

状态机调度任务: 5ms执行一次。它读取「保护请求」标志位,以及来自主循环的「按键事件」和「定时事件」,然后根据当前状态,执行状态转移。这个任务也放在中断里,因为状态转移必须及时。比如,从运行到保护的转移,如果延迟了10ms,电机可能已经烧了。

我的经验: 状态机调度任务里,我通常会放一个「看门狗喂狗」操作。如果状态机卡在某个状态超过100ms,看门狗就会复位整个系统。这是最后一道防线。

电机控制任务: 这个放在主循环里。它根据当前状态(是运行还是保护),来计算PWM的占空比。在运行状态下,它读取用户设定的力度值,然后通过PID算法输出PWM。在保护状态下,它直接输出0。

UI显示与按键扫描: 这个任务优先级最低。因为用户按个键,延迟几十毫秒根本感觉不出来。但要注意,按键扫描要做「消抖」处理。我一般用20ms的延时消抖,或者用状态机来做按键检测,防止误触发。

3.4 代码骨架示例

说了这么多,咱们看一段核心代码。这是状态机调度任务的简化版。

// 状态枚举
typedef enum {
    STATE_IDLE = 0,
    STATE_RUNNING,
    STATE_PROTECT,
    STATE_FAULT
} SystemState_t;

// 全局状态变量
SystemState_t currentState = STATE_IDLE;

// 5ms定时器中断服务函数
void TIM5_IRQHandler(void) {
    // 1. 清除中断标志
    // 2. 读取安全监测结果
    uint8_t protectRequest = getProtectRequestFlag();

    // 3. 状态机调度
    switch(currentState) {
        case STATE_IDLE:
            if(key_press_event) {
                // 进入运行前,再做一次安全检查
                if(safety_check_pass()) {
                    currentState = STATE_RUNNING;
                    motor_enable();
                } else {
                    // 安全检查失败,进故障
                    currentState = STATE_FAULT;
                    error_code = 0x10;
                }
            }
            break;

        case STATE_RUNNING:
            if(protectRequest) {
                // 立即进入保护
                currentState = STATE_PROTECT;
                motor_disable();
                buzzer_on();
                protect_timer = 0;
            }
            // 处理定时结束事件
            if(timer_expired) {
                currentState = STATE_IDLE;
                motor_disable();
            }
            break;

        case STATE_PROTECT:
            protect_timer++;
            if(protect_timer > PROTECT_TIMEOUT) {
                // 保护超时,进故障
                currentState = STATE_FAULT;
                error_code = 0x03;
            }
            // 如果异常消失,回到空闲
            if(!protectRequest) {
                currentState = STATE_IDLE;
                buzzer_off();
            }
            break;

        case STATE_FAULT:
            // 故障状态,啥也不干,等看门狗复位
            break;
    }

    // 4. 喂看门狗
    watchdog_feed();
}

你看,整个逻辑非常清晰。每个case里做的事情都很单一。这就是状态机的魅力。

3.5 避坑指南

最后,分享几个我踩过的坑。

  • 状态转移条件要「互斥」:我曾经设计过一个状态机,从运行状态既可以进保护,也可以进空闲。结果有一次两个条件同时满足了,代码执行了第一个case后,状态变了,第二个case就没执行到。后来我改成用if-else if结构,保证每次只执行一个转移。
  • 不要在中断里做浮点运算:MCU的FPU(浮点运算单元)不是标配。如果你在中断里做浮点乘除,轻则增加中断延迟,重则导致堆栈溢出。我一般把所有浮点运算都放在主循环里,中断里只用整数运算。
  • 优先级反转:虽然我们没用RTOS,但在前后台系统里,如果主循环里的一个低优先级任务占用了CPU太久,会导致中断里的高优先级任务无法及时处理。所以,我要求每个主循环任务的执行时间不能超过1ms。如果某个任务确实耗时,就把它拆分成多个小步骤。

嗯,关于软件架构的状态机和任务调度,我就讲这么多。这部分是软件安全的核心骨架,一定要理解透彻。下一章,咱们聊聊具体的硬件驱动和传感器数据处理。