嵌入式开发中的状态机到底怎么写:从思路到代码,零基础讲透
嵌入式开发中的状态机到底怎么写:从思路到代码,零基础讲透
很多嵌入式开发者一看到“状态机”就头大。
不是因为这个东西真的很玄,而是很多人一上来就被一堆代码绕住了:
switch (state) {
case STATE_XXX:
if (...) {
...
state = STATE_YYY;
}
break;
}
看起来像一堆 switch、case、if 拼在一起。
更糟糕的是,有些代码里状态名起得不清楚,事件也没抽象出来,时间判断到处散落,最后状态机变成了“状态迷宫”。
这篇文章我们不讲很抽象的理论,只讲嵌入式里真正能用上的写法:
什么时候该用状态机?
写状态机前应该先想什么?
状态、事件、条件、动作分别是什么?
怎么从一张状态表落到 C 代码?
按键消抖状态机应该怎么写?
LED 模式状态机应该怎么写?
如果你以前面对状态机代码不知道从哪里下手,这篇就从零开始把这条路铺平。
先说结论:状态机解决的是什么问题
状态机解决的不是“语法问题”,而是“流程问题”。
嵌入式程序里经常有这种逻辑:
现在处于 A 阶段。
如果发生了某件事,就进入 B 阶段。
如果过了一段时间,就进入 C 阶段。
如果中途条件不满足,就回到 A 阶段。
比如按键消抖:
平时是松开状态
检测到按下,不要马上认为真的按下
先等 20ms
20ms 后如果仍然按着,才产生按下事件
松开也一样,要确认稳定松开
这就是典型状态机。
再比如 LED 模式:
模式 0:普通闪烁
模式 1:呼吸灯
模式 2:常亮
按 KEY1 切换下一个模式
按 KEY2 改颜色
串口输入 status 打印当前状态
这也是状态机。
一句话:
只要你的程序不是一步做完,而是有阶段、有事件、有跳转,就可以考虑状态机。
状态机的四个核心概念
状态机最基础的东西就 4 个:
| 名称 | 含义 | 嵌入式里的例子 |
|---|---|---|
| 状态 State | 当前处于哪个阶段 | 松开、消抖、按下、等待释放 |
| 事件 Event | 外部发生了什么 | 按下、松开、超时、收到命令 |
| 条件 Guard | 是否允许跳转 | 是否稳定 20ms、是否到达 1 秒 |
| 动作 Action | 跳转时要做什么 | 点灯、打印、产生事件、启动定时 |
![]() |
把它们串起来就是:
在某个状态下,收到某个事件,如果条件满足,就执行动作并切到下一个状态。
你以后看状态机代码,不要先盯着 switch-case。
先问自己四个问题:
现在有哪些状态?
外部会发生哪些事件?
什么条件下允许跳转?
跳转时要做什么动作?
这四个问题答清楚,代码自然就清楚了。
什么时候该用状态机
不是所有代码都要写成状态机。
比如:
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
App_LED_On();
} else {
App_LED_Off();
}
这种“按下亮,松开灭”的简单逻辑,不一定非要上状态机。
但下面这些情况,很适合用状态机。
1. 流程有多个阶段
比如按键:
松开 -> 疑似按下 -> 稳定按下 -> 疑似松开 -> 松开
这已经不只是一个 if 能优雅处理的了。
2. 需要处理时间
比如:
按下超过 20ms 才算有效
按下超过 1000ms 算长按
LED 每 100ms 翻转一次
超时 3 秒没有收到数据就断开连接
只要有“等一段时间再判断”,状态机就很合适。
3. 一个输入在不同状态下含义不同
比如同样是 KEY2:
在普通模式下,KEY2 切换 LED 开关
在呼吸灯模式下,KEY2 切换颜色
在菜单模式下,KEY2 表示确认
这时候如果全靠一堆 if,很快会乱。
4. 需要避免重复触发
比如按键按住不放,如果你直接读电平:
if (key_is_pressed) {
App_LED_Toggle();
}
主循环跑得很快,LED 会疯狂翻转。
状态机可以把“电平”变成“事件”:
按下稳定后,只产生一次 PRESS 事件。
一直按住时,不再重复产生 PRESS。
松开后,下次按下才再次产生事件。
这就是状态机在嵌入式里非常常见的价值。
写状态机前,不要急着写代码
很多状态机写乱,是因为一开始就打开编辑器写 switch。
更稳的方式是先做 5 步。

第 1 步:先写目标现象
不要一上来想代码。
先用自然语言写清楚:
按键按下后,如果稳定超过 20ms,产生 PRESS 事件。
按键松开后,如果稳定超过 20ms,产生 RELEASE 事件。
如果按住超过 1000ms,产生 LONG_PRESS 事件。
这一步很重要。
目标现象写不清楚,代码一定会写乱。
第 2 步:列出状态
按键消抖可以列成:
IDLE 稳定松开
DEBOUNCE_DOWN 疑似按下,等待确认
PRESSED 稳定按下
DEBOUNCE_UP 疑似松开,等待确认
注意状态名要表达“当前处于什么阶段”。
不要起这种名字:
STATE_1
STATE_2
FLAG_A
MODE_B
后面维护的人会很痛苦,你过两天自己也会忘。
第 3 步:列出事件
按键消抖里,外部事件其实很简单:
当前读到按下
当前读到松开
时间超过 20ms
时间超过 1000ms
在代码里,它们可能不是专门的枚举,而是判断条件:
bool down = read_raw(key);
uint32_t now = HAL_GetTick();
第 4 步:写状态跳转表
这是状态机最关键的一步。
比如按键消抖可以写成:
| 当前状态 | 条件 | 动作 | 下一个状态 |
|---|---|---|---|
IDLE |
读到按下 | 记录当前时间 | DEBOUNCE_DOWN |
DEBOUNCE_DOWN |
又读到松开 | 抖动失败 | IDLE |
DEBOUNCE_DOWN |
持续按下超过 20ms | 产生 PRESS 事件 |
PRESSED |
PRESSED |
读到松开 | 记录当前时间 | DEBOUNCE_UP |
PRESSED |
按住超过 1000ms | 产生 LONG_PRESS 事件 |
PRESSED |
DEBOUNCE_UP |
又读到按下 | 抖动失败 | PRESSED |
DEBOUNCE_UP |
持续松开超过 20ms | 产生 RELEASE 事件 |
IDLE |
| 你看,这张表一出来,代码其实已经差不多了。 |
第 5 步:再写 enum + switch
最后才是 C 代码:
switch (state) {
case IDLE:
...
break;
case DEBOUNCE_DOWN:
...
break;
}
状态机不是 switch-case 本身。
switch-case 只是把状态跳转表翻译成 C 代码的一种方式。
例子一:按键消抖状态机
按键消抖是最适合练状态机的例子。
因为它足够简单,但又包含状态、事件、时间、动作。

这一版代码实现 3 个事件:
PRESS 稳定按下
RELEASE 稳定松开
LONG_PRESS 长按
我们把代码拆成两个文件:
Core/Inc/app_key_fsm.h
Core/Src/app_key_fsm.c
app_key_fsm.h
#ifndef APP_KEY_FSM_H
#define APP_KEY_FSM_H
#include "main.h"
#include <stdbool.h>
#include <stdint.h>
typedef enum {
APP_KEY_EVENT_NONE = 0,
APP_KEY_EVENT_PRESS,
APP_KEY_EVENT_RELEASE,
APP_KEY_EVENT_LONG_PRESS
} App_KeyEvent;
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
GPIO_PinState active_level;
uint8_t state;
uint32_t state_tick;
uint32_t long_press_tick;
App_KeyEvent pending_event;
} App_Key;
void App_Key_Init(App_Key *key,
GPIO_TypeDef *port,
uint16_t pin,
GPIO_PinState active_level);
void App_Key_Tick(App_Key *key);
App_KeyEvent App_Key_GetEvent(App_Key *key);
bool App_Key_IsPressed(const App_Key *key);
#endif
先看这个头文件。
它做了两件事:
-
定义事件
App_KeyEvent; -
定义按键对象
App_Key。
为什么要用结构体?
因为一个项目里可能有 KEY1、KEY2、KEY3。状态机逻辑是一样的,但每个按键的数据应该各自保存。
static App_Key s_key1;
static App_Key s_key2;
这样你只写一份状态机逻辑,就能管理多个按键。
app_key_fsm.c
#include "app_key_fsm.h"
#ifndef APP_KEY_DEBOUNCE_MS
#define APP_KEY_DEBOUNCE_MS 20u
#endif
#ifndef APP_KEY_LONG_PRESS_MS
#define APP_KEY_LONG_PRESS_MS 1000u
#endif
typedef enum {
KEY_STATE_IDLE = 0,
KEY_STATE_DEBOUNCE_DOWN,
KEY_STATE_PRESSED,
KEY_STATE_DEBOUNCE_UP
} KeyState;
static bool key_read_raw(const App_Key *key)
{
return HAL_GPIO_ReadPin(key->port, key->pin) == key->active_level;
}
static void key_set_event(App_Key *key, App_KeyEvent event)
{
/*
* 这里只保存一个待处理事件。
* 对新手项目来说足够用。
* 如果后面事件很多,可以升级成事件队列。
*/
if (key->pending_event == APP_KEY_EVENT_NONE) {
key->pending_event = event;
}
}
void App_Key_Init(App_Key *key,
GPIO_TypeDef *port,
uint16_t pin,
GPIO_PinState active_level)
{
key->port = port;
key->pin = pin;
key->active_level = active_level;
key->state = key_read_raw(key) ? KEY_STATE_PRESSED : KEY_STATE_IDLE;
key->state_tick = HAL_GetTick();
key->long_press_tick = HAL_GetTick();
key->pending_event = APP_KEY_EVENT_NONE;
}
void App_Key_Tick(App_Key *key)
{
bool down = key_read_raw(key);
uint32_t now = HAL_GetTick();
switch ((KeyState)key->state) {
case KEY_STATE_IDLE:
if (down) {
key->state = KEY_STATE_DEBOUNCE_DOWN;
key->state_tick = now;
}
break;
case KEY_STATE_DEBOUNCE_DOWN:
if (!down) {
key->state = KEY_STATE_IDLE;
} else if ((uint32_t)(now - key->state_tick) >= APP_KEY_DEBOUNCE_MS) {
key->state = KEY_STATE_PRESSED;
key->long_press_tick = now;
key_set_event(key, APP_KEY_EVENT_PRESS);
}
break;
case KEY_STATE_PRESSED:
if (!down) {
key->state = KEY_STATE_DEBOUNCE_UP;
key->state_tick = now;
} else if ((uint32_t)(now - key->long_press_tick) >= APP_KEY_LONG_PRESS_MS) {
key->long_press_tick = now;
key_set_event(key, APP_KEY_EVENT_LONG_PRESS);
}
break;
case KEY_STATE_DEBOUNCE_UP:
if (down) {
key->state = KEY_STATE_PRESSED;
} else if ((uint32_t)(now - key->state_tick) >= APP_KEY_DEBOUNCE_MS) {
key->state = KEY_STATE_IDLE;
key_set_event(key, APP_KEY_EVENT_RELEASE);
}
break;
default:
key->state = KEY_STATE_IDLE;
key->pending_event = APP_KEY_EVENT_NONE;
break;
}
}
App_KeyEvent App_Key_GetEvent(App_Key *key)
{
App_KeyEvent event = key->pending_event;
key->pending_event = APP_KEY_EVENT_NONE;
return event;
}
bool App_Key_IsPressed(const App_Key *key)
{
return key->state == KEY_STATE_PRESSED ||
key->state == KEY_STATE_DEBOUNCE_UP;
}
这段代码不短,但结构很清楚。
你可以按这个顺序看:
1. enum 定义状态
typedef enum {
KEY_STATE_IDLE = 0,
KEY_STATE_DEBOUNCE_DOWN,
KEY_STATE_PRESSED,
KEY_STATE_DEBOUNCE_UP
} KeyState;
这里列出来的是“按键状态机内部状态”。
它不是给外部用的,所以放在 .c 文件里。
2. struct 保存上下文
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
GPIO_PinState active_level;
uint8_t state;
uint32_t state_tick;
uint32_t long_press_tick;
App_KeyEvent pending_event;
} App_Key;
状态机一定需要记忆。
它要记住:
-
当前状态是什么;
-
什么时候进入这个状态;
-
有没有待处理事件;
-
当前按键接在哪个 GPIO。
这些东西不要到处写全局变量,收进结构体最清楚。

3. Tick() 推动状态机运行
void App_Key_Tick(App_Key *key)
这个函数要周期调用,比如每 5ms 或 10ms 调一次。
它里面做三件事:
-
读当前按键电平;
-
看当前状态;
-
根据条件决定是否跳转。
注意:Tick() 里没有 HAL_Delay()。
状态机不应该卡住主循环。
4. GetEvent() 让主循环取事件
App_KeyEvent App_Key_GetEvent(App_Key *key)
{
App_KeyEvent event = key->pending_event;
key->pending_event = APP_KEY_EVENT_NONE;
return event;
}
这个函数的规则很重要:
事件取走一次,就清空。
否则你按一次按键,主循环后面每一圈都读到同一个事件,LED 会一直翻转。
这也是很多新手按键状态机写错的地方。
main.c 里怎么用这个按键状态机
假设 CubeMX 里已经配置:
KEY1 -> PA0 -> GPIO_Input -> Pull-up
LED -> PB5 -> GPIO_Output
main.h 里有:
#define KEY1_Pin GPIO_PIN_0
#define KEY1_GPIO_Port GPIOA
#define LED_Pin GPIO_PIN_5
#define LED_GPIO_Port GPIOB
在 main.c 里这样用。
Includes 区域
/* USER CODE BEGIN Includes */
#include "app_key_fsm.h"
/* USER CODE END Includes */
PV 区域
/* USER CODE BEGIN PV */
static App_Key s_key1;
static uint32_t s_key_tick = 0;
/* USER CODE END PV */
USER CODE BEGIN 2
/* USER CODE BEGIN 2 */
App_Key_Init(&s_key1, KEY1_GPIO_Port, KEY1_Pin, GPIO_PIN_RESET);
/* USER CODE END 2 */
这里 GPIO_PIN_RESET 的意思是:
按键按下时读到低电平。
如果你的按键是按下读高电平,就改成 GPIO_PIN_SET。
while 循环
/* USER CODE BEGIN 3 */
if (HAL_GetTick() - s_key_tick >= 10) {
s_key_tick = HAL_GetTick();
App_Key_Tick(&s_key1);
App_KeyEvent event = App_Key_GetEvent(&s_key1);
if (event == APP_KEY_EVENT_PRESS) {
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
}
/* USER CODE END 3 */
现在逻辑就很干净:
每 10ms 跑一次按键状态机。
如果产生 PRESS 事件,就翻转 LED。
主循环不关心消抖细节。
消抖细节都在 app_key_fsm.c 里。
这就是封装的价值。
例子二:LED 模式状态机
按键消抖是“底层输入状态机”。
LED 模式是“业务状态机”。
假设我们有两个按键:
KEY1:切换 LED 模式
KEY2:执行当前模式下的动作
LED 有三个模式:
LED_MODE_OFF 关闭
LED_MODE_BLINK 闪烁
LED_MODE_BREATH 呼吸灯
这也可以写成状态机。
先定义状态:
typedef enum {
LED_MODE_OFF = 0,
LED_MODE_BLINK,
LED_MODE_BREATH,
LED_MODE_COUNT
} LedMode;
再定义事件:
typedef enum {
LED_EVENT_NONE = 0,
LED_EVENT_KEY1_PRESS,
LED_EVENT_KEY2_PRESS,
LED_EVENT_TICK_100MS
} LedEvent;
然后写处理函数:
static LedMode s_led_mode = LED_MODE_OFF;
static uint8_t s_led_on = 0;
static void Led_FsmDispatch(LedEvent event)
{
switch (s_led_mode) {
case LED_MODE_OFF:
if (event == LED_EVENT_KEY1_PRESS) {
s_led_mode = LED_MODE_BLINK;
s_led_on = 1;
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
printf("mode -> BLINK\r\n");
}
break;
case LED_MODE_BLINK:
if (event == LED_EVENT_KEY1_PRESS) {
s_led_mode = LED_MODE_BREATH;
printf("mode -> BREATH\r\n");
} else if (event == LED_EVENT_TICK_100MS) {
s_led_on = !s_led_on;
HAL_GPIO_WritePin(LED_GPIO_Port,
LED_Pin,
s_led_on ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
break;
case LED_MODE_BREATH:
if (event == LED_EVENT_KEY1_PRESS) {
s_led_mode = LED_MODE_OFF;
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
printf("mode -> OFF\r\n");
} else if (event == LED_EVENT_KEY2_PRESS) {
printf("BREATH mode action\r\n");
}
break;
default:
s_led_mode = LED_MODE_OFF;
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
break;
}
}
这个例子里,同样的 KEY1 在不同状态下会让模式往下切:
OFF -> BLINK -> BREATH -> OFF
KEY2 在不同状态下也可以有不同意义。
这就是业务状态机的常见写法。
状态机写法的几个工程原则
1. 状态用 enum,不要用魔法数字
不要这样写:
if (state == 2) {
...
}
你过两天就忘了 2 是什么意思。
应该写:
if (state == KEY_STATE_PRESSED) {
...
}
代码是给机器执行的,也是给人看的。
2. 状态名表达“当前阶段”
状态名最好是名词或形容词:
IDLE
PRESSED
WAIT_RESPONSE
SENDING
ERROR
不要把动作当状态:
DO_PRESS
CHECK_BUTTON
SEND_DATA_NOW
动作应该写在状态里,状态表示“现在处于什么阶段”。
3. 事件表示“发生了什么”
事件名适合这样写:
KEY_PRESS
KEY_RELEASE
TIMEOUT
RX_DONE
TX_DONE
CMD_RECEIVED
事件不是状态。
比如 KEY_PRESS 是事件,PRESSED 是状态。
这两个不要混。
4. 状态机里不要长时间阻塞
不要在状态机里写:
HAL_Delay(1000);
也尽量不要写很长时间的阻塞发送。
状态机应该被周期调用,每次快速判断一下,然后返回。
如果要等 1 秒,应该记录时间:
entry_tick = HAL_GetTick();
后面判断:
if (HAL_GetTick() - entry_tick >= 1000) {
...
}
5. 中断里不要跑复杂状态机
中断里适合做:
记录事件
保存数据
置标志
清中断
主循环里再处理状态机。
比如串口接收中断里收到一行命令,可以设置:
cmd_ready = true;
然后主循环里:
if (cmd_ready) {
cmd_ready = false;
Led_FsmDispatch(LED_EVENT_CMD_RECEIVED);
}
这样系统更稳。
6. 每个状态只处理自己关心的事件
不要在每个状态里都处理所有事件。
比如 LED_MODE_OFF 只关心 KEY1_PRESS。
不关心的事件直接忽略。
这就是状态机让代码变清楚的地方:
当前状态决定了哪些事件有效。
状态机和一堆 if else 的区别
很多人会问:
我用 if else 也能写,为什么要用状态机?
小逻辑当然可以用 if else。
但当条件变多之后,一堆 if else 会有几个问题:
-
当前阶段不清楚;
-
某个条件在哪些情况下有效不清楚;
-
新增一个模式时要到处改;
-
出 bug 后很难复现;
-
很容易重复触发。
状态机的好处是:
先看状态,再看事件。
每个状态只处理自己的事。
状态跳转集中在一个地方。
如果你觉得状态机很麻烦,通常不是状态机本身麻烦,而是你还没有先把状态表列出来。
新手写状态机最常见的 8 个坑
1. 状态太少
比如按键只写:
UP
DOWN
然后又想处理消抖、长按、释放,最后代码就会塞满各种标志位。
该拆状态时就拆。
2. 状态太多
另一种极端是:
STATE_KEY_DOWN_WAIT_10MS_MODE_1_LED_ON
状态名变成了“所有条件的组合”。
这时候要想想:
有些东西是不是应该是变量,而不是状态?
比如 LED 颜色可以是变量:
color = RED / GREEN / BLUE
不一定要变成三个状态:
BLINK_RED
BLINK_GREEN
BLINK_BLUE
3. 状态和事件混在一起
PRESSED 是状态。
PRESS 是事件。
差一个字,但含义完全不同。
4. 事件取走后不清空
比如:
App_KeyEvent App_Key_GetEvent(App_Key *key)
{
return key->pending_event;
}
这就错了。
应该取走后清空:
App_KeyEvent App_Key_GetEvent(App_Key *key)
{
App_KeyEvent event = key->pending_event;
key->pending_event = APP_KEY_EVENT_NONE;
return event;
}
否则一个事件会被处理很多次。
5. 到处改状态
状态变量最好只在状态机函数里改。
不要这里改一次:
s_state = A;
那里又改一次:
s_state = B;
后面你会不知道状态到底是谁改的。
6. 状态机里写阻塞延时
状态机里一旦写 HAL_Delay(),它就不再像状态机,而像流程脚本。
裸机工程里更推荐:
记录进入状态的时间
周期调用 Tick
时间到了再跳转
7. 没有默认兜底
状态变量可能因为内存越界、初始化错误、代码 bug 变成非法值。
switch 最后最好有:
default:
state = IDLE;
break;
让系统有机会回到安全状态。
8. 没有调试输出
状态机最适合配合串口打印。
比如每次状态变化时打印:
printf("[FSM] IDLE -> PRESSED\r\n");
调试时非常有用。
但注意:如果状态机在中断里跑,就不要直接 printf。
一个状态机代码模板
以后你写自己的状态机,可以先套这个模板。
typedef enum {
FSM_STATE_IDLE = 0,
FSM_STATE_BUSY,
FSM_STATE_WAIT,
FSM_STATE_ERROR
} FsmState;
typedef enum {
FSM_EVENT_NONE = 0,
FSM_EVENT_START,
FSM_EVENT_DONE,
FSM_EVENT_TIMEOUT,
FSM_EVENT_ERROR
} FsmEvent;
typedef struct {
FsmState state;
uint32_t state_tick;
FsmEvent pending_event;
} Fsm;
static void Fsm_EnterState(Fsm *fsm, FsmState next)
{
fsm->state = next;
fsm->state_tick = HAL_GetTick();
}
void Fsm_Init(Fsm *fsm)
{
fsm->state = FSM_STATE_IDLE;
fsm->state_tick = HAL_GetTick();
fsm->pending_event = FSM_EVENT_NONE;
}
void Fsm_PostEvent(Fsm *fsm, FsmEvent event)
{
if (fsm->pending_event == FSM_EVENT_NONE) {
fsm->pending_event = event;
}
}
void Fsm_Tick(Fsm *fsm)
{
FsmEvent event = fsm->pending_event;
fsm->pending_event = FSM_EVENT_NONE;
switch (fsm->state) {
case FSM_STATE_IDLE:
if (event == FSM_EVENT_START) {
Fsm_EnterState(fsm, FSM_STATE_BUSY);
}
break;
case FSM_STATE_BUSY:
if (event == FSM_EVENT_DONE) {
Fsm_EnterState(fsm, FSM_STATE_IDLE);
} else if (event == FSM_EVENT_ERROR) {
Fsm_EnterState(fsm, FSM_STATE_ERROR);
}
break;
case FSM_STATE_WAIT:
if ((uint32_t)(HAL_GetTick() - fsm->state_tick) >= 1000) {
Fsm_EnterState(fsm, FSM_STATE_IDLE);
}
break;
case FSM_STATE_ERROR:
if (event == FSM_EVENT_START) {
Fsm_EnterState(fsm, FSM_STATE_IDLE);
}
break;
default:
Fsm_EnterState(fsm, FSM_STATE_IDLE);
break;
}
}
这个模板不一定每个项目都照搬,但它体现了几个好习惯:
-
状态用
enum; -
事件用
enum; -
上下文放进
struct; -
统一入口函数
Fsm_Tick(); -
统一状态切换函数
Fsm_EnterState(); -
默认分支回到安全状态。
状态机应该放在哪个文件
简单项目可以先放在 main.c 的 USER CODE BEGIN 4。
但只要逻辑稍微复杂一点,我建议单独拆文件:
Core/Inc/app_key_fsm.h
Core/Src/app_key_fsm.c
Core/Inc/app_led_fsm.h
Core/Src/app_led_fsm.c
Core/Inc/app_motor_fsm.h
Core/Src/app_motor_fsm.c
main.c 只负责调用:
App_Key_Tick(&s_key1);
event = App_Key_GetEvent(&s_key1);
if (event == APP_KEY_EVENT_PRESS) {
App_LedFsm_PostEvent(&s_led_fsm, LED_EVENT_KEY_PRESS);
}
App_LedFsm_Tick(&s_led_fsm);
这样你的工程结构会清楚很多。
状态机的调试方法
状态机调试不要只盯着 LED 看。
最好加串口打印。
1. 打印状态切换
static void Key_EnterState(App_Key *key, KeyState next)
{
printf("[KEY] %d -> %d\r\n", key->state, next);
key->state = next;
key->state_tick = HAL_GetTick();
}
新手阶段可以先打印数字。
后面可以写一个名字转换函数:
static const char *KeyStateName(KeyState state)
{
switch (state) {
case KEY_STATE_IDLE:
return "IDLE";
case KEY_STATE_DEBOUNCE_DOWN:
return "DEBOUNCE_DOWN";
case KEY_STATE_PRESSED:
return "PRESSED";
case KEY_STATE_DEBOUNCE_UP:
return "DEBOUNCE_UP";
default:
return "UNKNOWN";
}
}
然后打印:
printf("[KEY] %s -> %s\r\n",
KeyStateName(old_state),
KeyStateName(next_state));
2. 打印事件
比如:
if (event == APP_KEY_EVENT_PRESS) {
printf("[KEY] PRESS\r\n");
}
这样你能区分:
到底是按键没检测到?
还是检测到了但状态没跳?
还是状态跳了但事件没取出来?
3. 打印时间
状态机里很多 bug 都和时间有关。
可以打印:
printf("[KEY] now=%lu, state_tick=%lu\r\n",
HAL_GetTick(),
key->state_tick);
这样能看到 20ms、1000ms 这些判断是不是符合预期。
本篇小结
状态机不是高深技巧,它是一种把流程写清楚的方法。
你要记住:
状态:现在处于什么阶段
事件:发生了什么事情
条件:是否允许跳转
动作:跳转时做什么
写状态机不要先写代码。
先写:
目标现象
状态列表
事件列表
状态跳转表
最后再用:
enum + struct + switch-case
把它翻译成 C 代码。
对嵌入式开发来说,状态机特别适合:
-
按键消抖;
-
LED 模式切换;
-
串口命令解析;
-
电机启动/运行/停止/故障;
-
通信协议收包;
-
传感器采样流程;
-
Bootloader 升级流程;
-
低功耗唤醒流程。
如果你现在面对状态机代码还是有点发怵,不要急着背模板。
先拿按键消抖练。
按键消抖写明白了,很多嵌入式状态机的基本味道就有了。
下一篇可以继续拆什么
下一篇可以继续沿着工程方法讲:
新建 .c 文件后为什么还要改 Makefile?从 undefined reference 开始讲清楚。
这个问题和状态机一样,也是很多新手“代码明明写了,编译就是找不到”的典型坑。
更多推荐




所有评论(0)