我用状态机模式重构了一个烂摊子

去年接了一个项目,一个家电控制器,同事离职留下的。代码五千多行,全塞在几个.c文件里,main函数里if else叠了十几层。最离谱的是------一个按键要管三个功能:短按、长按、双击。领导说"改改就能用"。

我看了三天,决定重写。

第一次意识到"设计模式"不是花架子

说出来不怕你笑话,我之前觉得嵌入式谈设计模式就是扯淡。MCU就那点资源,一个状态机结构体把RAM扒几层皮,还不如全局变量来得直接。这想法一直持续到我debug那个双击按键的代码。

老代码大概是这样的:

复制代码
static uint8_t key_state = 0;
static uint32_t press_time = 0;

void key_scan(void) {
    if(HAL_GPIO_ReadPin(KEY_GPIO, KEY_PIN) == GPIO_PIN_RESET) {
        if(key_state == 0) {
            key_state = 1;
            press_time = HAL_GetTick();
        } else if(key_state == 1 && (HAL_GetTick() - press_time) > 3000) {
            key_state = 2;
            // 长按处理
        }
    } else {
        if(key_state == 1 && (HAL_GetTick() - press_time) < 200) {
            // 双击还是单击?根本判断不了
            key_state = 3;
        }
        // 更多状态混合...
    }
}

双击和单击判断永远是靠一个全局定时器死等,按下抬起之间加延时,延时期间其它按键全堵死。一个月里被测试小姐姐提了七个bug,全是按键响应异常。

状态机真香

后来我用一个正经的状态机模式重写了按键模块。别被"状态机"三个字吓到,其实就是一张表:

复制代码
typedef struct {
    uint8_t current_state;
    uint8_t event;
    uint8_t next_state;
    void (*action)(void);
} StateTransition;

StateTransition key_fsm[] = {
    {STATE_IDLE,      EVT_KEY_DOWN,  STATE_PRESS,    on_key_press},
    {STATE_PRESS,     EVT_KEY_UP,    STATE_RELEASE,  on_key_release},
    {STATE_RELEASE,   EVT_KEY_DOWN,  STATE_DOUBLE,   on_double_click},
    {STATE_PRESS,     EVT_TIMEOUT,   STATE_LONG,     on_long_press},
    {STATE_DOUBLE,    EVT_KEY_UP,    STATE_IDLE,     on_double_done},
    {STATE_LONG,      EVT_KEY_UP,    STATE_IDLE,     on_long_done},
};

加一个简单的调度函数,每次扫描按键就去查表。没有if嵌套,没有全局定时器死等。按键延时用系统tick做超时事件,不阻塞其他任务。整个模块从五百行缩到八十行。

说实话,这玩意儿坑就坑在------很多人把状态机想复杂了。什么层次状态机、嵌套状态机、Harel状态图...入门根本不需要那些。一张二维表,一个dispatch函数,能干翻90%的按键/菜单/通信协议场景。

后来我发现更大的坑是"任意修改全局变量"

老代码里到处都是这种操作:

复制代码
extern uint8_t system_mode;
extern uint16_t adc_value;
extern uint32_t fault_flag;

// 在定时器中断里
void TIM_IRQHandler(void) {
    if(fault_flag & 0x01) {
        system_mode = 3;
        adc_value = 0;
    }
    // 200行后又在另一个地方改了system_mode...
}

你永远不知道一个全局变量在哪个中断里被改了。调个bug要在十几个文件里搜变量名。后来我引入了命令模式------所有模块之间的通信都通过一个消息队列,各模块只暴露接口,不暴露内部状态。

复制代码
typedef struct {
    uint16_t cmd_id;
    uint32_t param;
    void (*callback)(void);
} Command;

void post_command(Command *cmd);       // 发送命令
uint8_t poll_command(Command *out);    // 轮询取命令

每个模块内部怎么实现、用什么变量,外面根本不用管。调试的时候在post_command里加一行打印,所有模块交互一目了然,比示波器还好使。

还有个观察者模式,特适合传感器采集

之前读多个传感器的做法是:main loop里轮询一遍,谁先读到数据谁先处理。后来发现某个传感器初始化慢的时候,后面全卡住。

改用观察者模式:每个传感器是一个subject,采集到数据就notify出去。UI模块、日志模块、报警模块各自注册为observer,收到通知各自处理。

复制代码
// 注册观察者
observer_register(SENSOR_TEMP, ui_update_temp_display);
observer_register(SENSOR_TEMP, log_record_temperature);
observer_register(SENSOR_TEMP, alarm_check_overtemp);

// 传感器采集完成时
void temp_sensor_on_data_ready(int16_t value) {
    observer_notify(SENSOR_TEMP, &value);
}

采集线程不用管谁在等数据,展示模块也不用管数据怎么来的。解耦之后,加一个新模块只需要注册一个回调,零侵入。

别为了用模式而用模式

有次跟一个朋友聊,他说他写了一个抽象工厂模式来管理led灯。我说你几个灯?他说两个。...两个灯你搞什么抽象工厂,一个数组就解决了。

在嵌入式里用设计模式,目标永远是可维护性和可读性,不是为了写进简历里好看。状态机、命令、观察者这三板斧,够解决80%的代码混乱问题。剩下的?剩下的等你真遇到再说,到时候你自然知道该用什么。

反正从那之后,我再看到main函数里if套if套if的代码,就只有一句话想说------

直接重写,别犹豫。