去年接了一个项目,一个家电控制器,同事离职留下的。代码五千多行,全塞在几个.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的代码,就只有一句话想说------
直接重写,别犹豫。