嵌入式开发中的状态机到底怎么写:从思路到代码,零基础讲透

很多嵌入式开发者一看到“状态机”就头大。

不是因为这个东西真的很玄,而是很多人一上来就被一堆代码绕住了:

switch (state) {
case STATE_XXX:
    if (...) {
        ...
        state = STATE_YYY;
    }
    break;
}

看起来像一堆 switchcaseif 拼在一起。

更糟糕的是,有些代码里状态名起得不清楚,事件也没抽象出来,时间判断到处散落,最后状态机变成了“状态迷宫”。

这篇文章我们不讲很抽象的理论,只讲嵌入式里真正能用上的写法:

什么时候该用状态机?
写状态机前应该先想什么?
状态、事件、条件、动作分别是什么?
怎么从一张状态表落到 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

先看这个头文件。

它做了两件事:

  1. 定义事件 App_KeyEvent

  2. 定义按键对象 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 调一次。

它里面做三件事:

  1. 读当前按键电平;

  2. 看当前状态;

  3. 根据条件决定是否跳转。

注意: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.cUSER 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 开始讲清楚。

这个问题和状态机一样,也是很多新手“代码明明写了,编译就是找不到”的典型坑。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐