外观
1.11 状态机设计
这节要搞懂:界面和流程怎么写才不会乱成一团。 需要基础:1.10
一、先看不用状态机会有多惨
做一个闹钟界面,需求很简单:
平时显示时间 → 按 SET 进入设置 → 按 UP 调小时 → 再按 SET 调分钟 → 长按保存退出
用一堆标志位写出来是这样:
c
if (in_setting) {
if (setting_hour) { /* ... */ }
else if (setting_minute) { /* ... */ }
} else if (alarm_ringing && !snoozed) {
/* ... */
}再加两个功能,判断条件就会嵌套五六层。三个月后你自己都看不懂。
二、什么是状态机
状态机(State Machine)是一种程序设计模型。它把系统抽象成有限个状态,系统在收到事件时,按预先定义的规则执行动作并转移到下一个状态。
三个关键词,记住:
| 名词 | 含义 | 例子 |
|---|---|---|
| 状态 State | 系统现在处于什么模式 | 显示时间 / 设置小时 / 闹钟响 |
| 事件 Event | 发生了什么 | 按了 SET / 按了 UP / 超时了 |
| 迁移 Transition | 收到事件后跳到哪 | "显示时间"收到"按 SET" → 跳到"设置小时" |
打个比方:状态机就像自动售货机 —— 待机 → 投币 → 选货 → 出货 → 回到待机。每一步只能干这一步该干的事。
三、先画表,再写代码
| 现在的状态 | 发生了什么 | 做什么 | 跳到 |
|---|---|---|---|
| 显示时间 | 按 SET | 显示设置界面 | 设置小时 |
| 设置小时 | 按 UP | 小时 +1 | 设置小时 |
| 设置小时 | 按 SET | — | 设置分钟 |
| 设置小时 | 10 秒没动 | 放弃修改 | 显示时间 |
| 设置分钟 | 长按 | 保存 | 显示时间 |
| 闹钟响 | 收到"停止"命令 | 停止播放铃声 | 显示时间 |
这张表既是需求文档,也是代码结构。
四、翻译成代码
c
// 1. 列出所有状态
typedef enum {
STATE_SHOW_TIME, // 显示时间
STATE_SET_HOUR, // 设置小时
STATE_SET_MINUTE, // 设置分钟
STATE_ALARM_RING, // 闹钟响铃中
} ui_state_t;
// 2. 列出所有事件
typedef enum {
EVENT_KEY_SET, // 按了 SET
EVENT_KEY_UP, // 按了 UP
EVENT_KEY_LONG, // 长按
EVENT_TIMEOUT, // 超时
} ui_event_t;
// 3. 记住当前状态
static ui_state_t s_state = STATE_SHOW_TIME;
// 4. 照着表写
void ui_handle_event(ui_event_t event)
{
switch (s_state) {
case STATE_SHOW_TIME:
if (event == EVENT_KEY_SET) {
ui_show_setting(); // 显示设置界面
s_state = STATE_SET_HOUR;
}
break;
case STATE_SET_HOUR:
if (event == EVENT_KEY_UP) {
g_alarm_hour = (g_alarm_hour + 1) % 24;
} else if (event == EVENT_KEY_SET) {
s_state = STATE_SET_MINUTE;
} else if (event == EVENT_TIMEOUT) {
s_state = STATE_SHOW_TIME; // 10 秒没动,自动退出
}
break;
case STATE_SET_MINUTE:
if (event == EVENT_KEY_UP) {
g_alarm_minute = (g_alarm_minute + 1) % 60;
} else if (event == EVENT_KEY_LONG) {
alarm_save(); // 保存到 NVS
s_state = STATE_SHOW_TIME;
}
break;
case STATE_ALARM_RING:
alarm_sound_stop(); // 停止播放铃声
s_state = STATE_SHOW_TIME;
break;
}
}加新功能 = 加一个 case 或加一个 if,不用动别的地方。
五、和 FreeRTOS 配起来用 ⭐
这是第三章反复出现的写法,记住这个骨架:
c
QueueHandle_t ui_queue; // 事件队列
void task_ui(void *pvParameters)
{
ui_event_t event;
while (1) {
// 等事件来,最多等 1 秒
if (xQueueReceive(ui_queue, &event, pdMS_TO_TICKS(1000)) == pdTRUE) {
ui_handle_event(event); // 来了事件就处理
} else {
ui_handle_event(EVENT_TIMEOUT); // 1 秒没来,当"超时"处理
}
}
}
// 按键任务只管发事件,不管业务
void key_post_event(ui_event_t event)
{
xQueueSend(ui_queue, &event, 0);
}这样设计的三个好处:
- 所有界面变化都在一个任务里发生 → 不需要加锁(1.7 的问题自动没了)
- 按键任务只负责"报告发生了什么",不掺和业务逻辑
- 加新功能只改状态表
六、几个设计要点
- 状态之间必须互斥:任何时刻只能处于一个状态
- 每个状态都要有出路:别让某个状态进去就出不来
- 动作要短:状态机里别干耗时的活(比如刷全屏),发消息给别的任务做
- 画出来再写:状态超过 4 个,先在纸上画个迁移图
本节要点
- 状态机 = 用"状态 + 事件 + 迁移"三要素描述流程
- 先画表,再照表写
switch-case - ⭐ 任务 + 队列 + 状态机是嵌入式写业务逻辑的标准骨架
- 好处:不用加锁、加功能不动老代码
动手试试
- 在纸上画出上面闹钟的状态迁移图(4 个状态、4 个事件)。
- 把上面的代码敲一遍,用串口输入字母模拟按键(输入
s= 按 SET),打印每次状态变化。 - 加一个新功能:在"显示时间"状态下按 UP 可以切换显示温度,想想要加几行代码。