状态机这东西,很多嵌入式初学者一听就觉得抽象,总觉得是“学院派”才玩的花活。但实际上,状态机跟单片机开发是天生一对。打个比方:你写一个避障小车,如果全程用 if-else 堆,代码跑到后面自己都懵——到底现在该直行、该转弯、该后退?而状态机就是给程序划分成一组明确的“部门”,每个部门只干自己那摊事,什么时候换部门、换到哪个部门,由事先定好的规则说了算。我自己这几年做 STM32 项目,无论是小车、电源、通信协议解析,还是简单的按键扫描,越到后面越发现:状态机不是一种风格选择,而是嵌入式代码能长期维护的基本功。
这篇文章就以“STM32 小车避障”为例,完整拆一遍状态机的设计与落地过程。从需求分析、状态建模、核心代码,一直讲到调参踩坑,适合刚接触 STM32、或者在裸机程序里被 if-else 折磨过的新手朋友。看完你会明白:状态机不是把简单事情变复杂,而是把“一团乱麻”变成“几条清晰的流水线”。
1. 状态机设计前,先想清楚小车到底要“管”哪几件事
很多教程上来就贴状态转移图、写枚举、画箭头,结果读者看完还是一头雾水:我怎么知道有哪些状态?我怎么知道什么时候该切换?这里有个关键动作被跳过了——从需求反推状态。
1.1 把避障动作拆成一张“行为清单”
避障小车的基本诉求很简单:往前走,遇到障碍物绕开,然后继续走。但“绕开”这个动作拆开来看,涉及到好几个不同的行为阶段:
- 正常情况:保持直行;
- 发现前方有障碍:需要停下来判断;
- 做出决策:向左转、向右转、还是后退再转;
- 绕过障碍后:重新回到直行。
这四类行为,就是四个最基础的状态。我一般习惯先把行为清单写出来,然后再逐一确认。
| 状态名 | 行为描述 | 传感器/输出动作 |
|---|---|---|
| FORWARD | 直行前进 | 左右电机同速正转 |
| TURN_LEFT | 左转避障 | 左电机减速/反转,右电机正转 |
| TURN_RIGHT | 右转避障 | 右电机减速/反转,左电机正转 |
| BACK_OFF | 后退避让 | 左右电机同速反转 |
| STOP | 停止 | 左右电机停转 |
这里我先列了最基本的。实际做的时候你完全可以加状态,比如“沿墙走”、“原地旋转找方向”之类的。但第一次设计时我强烈建议“够用就好”,状态太多,状态转移逻辑会指数级变复杂,反而难调。
1.2 为什么会想到用状态机,而不是继续 if-else
如果你没有状态机概念,避障小车最自然的写法是:
while (1) { distance = get_distance(); if (distance < 30) { turn_left(); } else { forward(); } }这个写法在“正前方有障碍”这个单一场景下是没问题的。但现实情况是:小车在左转避障的过程中,如果左前方还有一个障碍物,你应该怎么办?如果左边和右边都有障碍、被夹住了,又怎么办?一旦出现这类“连续判断”,if-else 就会开始嵌套,嵌套到第三层的时候,你已经很难说清楚程序在某个瞬间究竟处于什么行为阶段。
状态机解决的就是这个问题。它把“当前小车在干什么”和“接下来小车要干什么”彻底分离:
- 状态:只描述“当前行为阶段”,例如正在左转;
- 转移条件:描述“什么事件发生时就换挡”,例如左转持续了 500ms 后认为绕行完成,回到直行。
我在实际项目里最直观的感受是:用 if-else 写避障,调到最后改一个判断条件可能影响三四处逻辑;用状态机写,改一处转移条件就够了。行为之间互相隔离,代码的可读性和可维护性完全不在一个层次。
2. 状态机建模:先画转移表,再想代码怎么写
建模这个步骤很多人会跳过,觉得直接写代码更“效率”。我自己的经验是,跳过的代价通常是在调参阶段花两三倍的时间来改逻辑。先花半小时把状态转移关系理清楚,比什么优化都值。
2.1 理清状态之间的“触发事件”
状态机三要素:状态、事件、动作。动作好理解,就是这个状态里要干什么;事件是“触发状态转移的条件”;状态和状态之间不会无缘无故切换,一定是有某个事件发生了。
避障小车里,事件通常来自两类:一类是传感器事件,比如超声波测到“前方距离 < 阈值”;另一类是时间事件,比如“左转动作已持续 600ms”。我给这个项目定义的事件如下:
EV_FRONT_NEAR:前方障碍物过近(比如 < 30cm);EV_FRONT_CLEAR:前方障碍物已远离(比如 > 40cm);EV_TURN_TIMEOUT:转向持续达到设定时间;EV_BACK_TIMEOUT:后退持续达到设定时间。
有了事件,就可以列出状态转移表。
2.2 状态转移表:让每个状态只关心“我该去哪”
以下是我最常用的初始转移表,直接在纸上画好再誊到电脑里的:
| 当前状态 | 发生事件 | 执行动作 | 下一状态 |
|---|---|---|---|
| FORWARD | EV_FRONT_NEAR | 停止直行 | BACK_OFF |
| BACK_OFF | EV_BACK_TIMEOUT | 停车、选择转向方向 | TURN_LEFT / TURN_RIGHT |
| TURN_LEFT | EV_TURN_TIMEOUT | 恢复直行 | FORWARD |
| TURN_RIGHT | EV_TURN_TIMEOUT | 恢复直行 | FORWARD |
| STOP | EV_FRONT_CLEAR | 恢复直行 | FORWARD |
这里有一个很关键的细节:BACK_OFF 后退之后,默认先偏向一侧转向,而不是继续后退。为什么?因为如果前方障碍一直存在,继续后退可能退到墙边;先转一个角度,大概率能绕开前方障碍。至于优先转左还是转右,取决于两个因素的博弈:一是随机性(避免每次都往同一个方向卡死),二是传感器数据(左边距离大就左转,右边距离大就右转)。这个我在后面“调参避坑”部分细讲。
2.3 状态机编程的三种实现方式,怎么选
建模完成以后,写代码就是水到渠成的事。但状态机的代码实现不止一种,我按项目复杂度排个序给你参考:
| 实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| switch-case 式 | 状态较少(<10),逻辑不复杂 | 直观、易读、好调试 | 状态多了以后 case 分支很长 |
| 函数指针表 | 状态较多,每个状态行为差异大 | 新增状态不用改旧代码,扩展性好 | 间接调用,调试时看调用栈稍微费劲 |
| 事件驱动框架(QM/QP 等) | 复杂业务、层次状态机、可运行多任务 | 专业、可维护性极高 | 学习成本高,小项目杀鸡用牛刀 |
小车避障这种规模,我用 switch-case 就够了。见过很多老工程师写复杂的通信协议解析也用 switch-case 硬扛到上百个状态,真的没必要,那种直接上状态表结构体要好得多。但如果你是第一次接触状态机,我建议老老实实用 switch-case 先把概念走通。
3. STM32 小车避障状态机的具体实现(附可直接移植的代码框架)
接下来进入重头戏——代码。我会先给出状态机的核心框架,然后补充事件检测、电机驱动调用和主循环的写法。这里以 STM32F103 为例,但框架本身与芯片型号无关,换到 STM32F4、G0 系列也完全适用。
3.1 状态与事件的定义
用枚举把状态和事件先定义好。这里有个小习惯:状态枚举和事件枚举分 open,不要混在一起,否则转移表没法写。
/* 状态定义 */ typedef enum { ST_FORWARD = 0, ST_BACK_OFF, ST_TURN_LEFT, ST_TURN_RIGHT, ST_STOP, ST_MAX } car_state_t; /* 事件定义 */ typedef enum { EV_NONE = 0, EV_FRONT_NEAR, EV_FRONT_CLEAR, EV_TURN_TIMEOUT, EV_BACK_TIMEOUT, EV_MAX } car_event_t;3.2 核心状态机框架:switch-case 的经典写法
状态机的核心就是一个函数:传入当前状态和发生的事件,返回新的状态。动作在执行转移时同步触发。
static car_state_t state_machine_run(car_state_t cur_state, car_event_t ev) { car_state_t next_state = cur_state; switch (cur_state) { case ST_FORWARD: if (ev == EV_FRONT_NEAR) { motor_stop(); start_backoff_timer(); next_state = ST_BACK_OFF; } break; case ST_BACK_OFF: if (ev == EV_BACK_TIMEOUT) { motor_stop(); choose_turn_direction(); next_state = ST_TURN_LEFT; /* 具体见 choose_turn_direction */ } break; case ST_TURN_LEFT: if (ev == EV_TURN_TIMEOUT) { motor_stop(); motor_forward(); next_state = ST_FORWARD; } break; case ST_TURN_RIGHT: if (ev == EV_TURN_TIMEOUT) { motor_stop(); motor_forward(); next_state = ST_FORWARD; } break; case ST_STOP: if (ev == EV_FRONT_CLEAR) { motor_forward(); next_state = ST_FORWARD; } break; default: next_state = ST_STOP; break; } return next_state; }这段代码有一个看起来“多此一举”的地方:为什么切换状态前都要motor_stop()?为了状态切换的干净。比如从直行切到后退,如果不先停止,电机可能瞬间反转,电流冲击大、机械冲击也大;先停止再开启反向,是电机控制的常识。状态机的价值也体现在这里——状态切换点就是动作的中断和安全边界,这一下 stop 能避免很多麻烦。
此外,我在状态机的函数里不直接操作电机,而是通过motor_stop()、motor_forward()这类封装函数去操作。目的是把逻辑层和驱动层剥离开,后面改电机驱动接口时不会碰状态机。
3.3 时间事件从哪来:一个 10ms 心跳节拍
状态机里的时间事件,比如“左转持续 600ms”,实际依赖一个稳定的时基。我惯用的做法是用 STM32 的定时器产生一个 10ms 中断,在中断里维护一个“软件计时器”变量。
volatile uint32_t g_tick_10ms = 0; void SysTick_Handler(void) /* 或者 TIM2_IRQHandler */ { g_tick_10ms++; } void delay_10ms(uint32_t ms_times10) { uint32_t start = g_tick_10ms; while ((g_tick_10ms - start) < ms_times10); }这里特别注意:我用的延迟是“读取全局 tick 做差值等待”,不是 interrupt 里塞 while 等待。阻塞式 delay 在状态机里是大忌——delay 期间状态机的“当前状态”没有任何响应能力,传感器来了新事件也处理不了。状态机的核心优势是“快速响应、状态联动”,用阻塞 delay 就等于把这个优势丢掉了。
那我平时怎么用呢?举个例子:进入 TURN_LEFT 状态时,记录turn_start_tick = g_tick_10ms,然后状态机继续每 10ms 被调用一次,每次检查(g_tick_10ms - turn_start_tick) >= TURN_DURATION_MS,达到了就产生EV_TURN_TIMEOUT事件。整个过程中,状态机一直在“活着”,随时能响应其他紧急事件。
3.4 事件采集:传感器数据如何变成状态机的“输入”
有了状态机函数,还需要一个“事件采集”机制,把传感器的物理数据翻译成事件。这块我放在主循环里做:
int main(void) { /* 初始化外设:GPIO、定时器、PWM、超声波 */ system_init(); car_state_t cur_state = ST_FORWARD; motor_forward(); while (1) { car_event_t ev = detect_event(); if (ev != EV_NONE) { cur_state = state_machine_run(cur_state, ev); } delay_10ms_general(1); /* 给个 +- 10ms 周期 */ } } car_event_t detect_event(void) { car_event_t ev = EV_NONE; uint16_t dist = ultrasonic_get_distance_cm(); if (dist < OBSTACLE_NEAR_CM) { ev = EV_FRONT_NEAR; } else if (dist > OBSTACLE_CLEAR_CM) { ev = EV_FRONT_CLEAR; } /* 再处理时间事件 */ if (timeout_check(&g_backoff_timer)) { ev = EV_BACK_TIMEOUT; } if (timeout_check(&g_turn_timer)) { ev = EV_TURN_TIMEOUT; } return ev; }detect_event()这个函数是状态机的“眼睛”。我把它独立出来,原因很简单:后续如果你要加红外传感器、加陀螺仪、加蓝牙遥控,事件检测和状态机本身就可以各改各的,互不干扰。
3.5 转向方向决策:简单策略 + 随机因子
逃避策略很多人会问:小遇到障碍,到底左拐还是右拐?最简单的办法是看左右两侧的传感器数据:哪边空间大往哪边拐。
static void choose_turn_direction(void) { uint16_t left_dist = ultrasonic_get_left_cm(); uint16_t right_dist = ultrasonic_get_right_cm(); if (left_dist > right_dist) { motor_turn_left(); } else if (right_dist > left_dist) { motor_turn_right(); } else { /* 两边一样近,用时间做随机因子,避免死循环 */ static uint8_t flag = 0; if (flag) { motor_turn_left(); } else { motor_turn_right(); } flag = !flag; } }这里加了“时间随机”因子。为什么不直接固定左转?因为如果小车左侧是一堵连续的墙,每次都左转,它就会一直贴墙绕,永远走不出去;利用一个交替标志,至少能给小车更多摆脱局面的机会。对于竞赛用的避障小车,其实很多就是无脑左转也能过关,但是如果目的是产品级或者更复杂的场景,方向决策值得多花点心思。
3.6 完整状态机框架:把代码整合到工程里能跑的最小闭环
把上面的模块拼在一起,我习惯把文件拆为四层:
| 文件 | 职责 |
|---|---|
main.c | 初始化、主循环、事件采集调用 |
state_machine.c/.h | 状态机核心逻辑(switch-case) |
car_act.c/.h | 电机控制动作封装(前进、后退、左右转) |
ultrasonic.c/.h | 传感器驱动(超声波测距、左右探测) |
这样分层有几个很实际的好处。第一,状态机的代码量会非常小,查 bug 时一眼就能扫完。第二,把动作和驱动放在独立文件里,小车底板电机型号换了,只需要改car_act.c,状态机一行不用改。第三,这套结构后面要加蜂鸣器、加指示灯,直接加新模块,不需要改动已有逻辑。
我第一次做这个小车项目的时候,直接把所有代码写在一个main.c里,逻辑乱到后来连自己写的注释都看不懂。后来按这种方式拆完,调试效率明显高了一截。所以如果你现在还在一个文件里堆代码,强烈建议至少把状态机、动作、传感器驱动拆成三个文件。
4. 调参与避坑:做完不是终点,跑得稳才算数
代码写完,把程序烧进 STM32,小车真正跑起来之后,你才会遇到状态机设计时想不到的问题。以下都是我自己在实际调车过程中踩过的坑。
4.1 超声波测距的“非阻塞”陷阱
超声波模块(HC-SR04)的标准读取方式是:发送 10us 高电平触发,然后等待回波,通过回波高电平宽度计算距离。很多新手直接写阻塞式等待回波:
TIM_Cmd(TIM2, ENABLE); while (TIM_GetFlagStatus(...) == RESET); /* 等回波 */这一等,短则几毫秒,长则几十毫秒。问题在于,状态机的主循环被卡住了,避障响应变慢。如果小车速度稍快,等超声波测完距离,车已经撞上障碍了。
我后来改成“非阻塞触发 + 定时器捕获”的方案:定时器输入捕获测量回波脉宽,测距期间主循环继续执行状态机逻辑,测距完成用标志位通知。这样一来,超声波测距完全不阻塞状态机,避障响应速度大幅提升。对初级项目,如果不想用输入捕获那么复杂,也可以用 RTOS 的延时线程或者简单地把测距放在同一个 10ms 节拍里异步处理,但至少你要意识到“阻塞等待”在状态机项目里是隐形杀手。
4.2 转向时间不是随意的,要跟车速和车身结构匹配
转向状态持续多久,直接影响避障效果。如果时间太短,车头还没转够角度就往前走,照样撞到障碍物;时间太长,车可能会过分转向,绕一个大弯。
这里我给一个初始参考值:小车车速在 0.3m/s 左右、车身宽度约 15~20cm 时,左转/右转持续时间一般定在 350~600ms 之间。具体怎么调?我建议写两个参数TURN_LEFT_DURATION_MS和TURN_RIGHT_DURATION_MS,每调一次烧录一次,观察小车行驶轨迹,反复逼近最优值。
注意:左右两个转向时间通常是不一样的。电机性能有差异、重心位置不同,会导致同样时间的左转和右转角度不同。我调整时会先用电池充满状态调好,后面电压下降导致电机转速变慢,转向时间又会有偏差。所以有条件的话可以用编码器闭环控制转向角度,远超时间的精度,初级项目就先用时间控制,接受这个误差也没问题。
4.3 状态机“卡死”的排查思路
小车在实际跑动中偶尔会陷入某个状态出不来,比如一直原地转圈、一直后退。这种问题我总结出一个固定排查套路:
- 加日志输出:每个状态切换时通过串口打印状态编号。比如“当前状态 ST_TURN_LEFT -> 事件 EV_TURN_TIMEOUT -> 下一状态 ST_FORWARD”。这条日志能直接告诉你卡在哪个转移上。
- 检查事件是否产生:如果小车卡在 TURN_LEFT 不退出,多半是
EV_TURN_TIMEOUT事件没有产生。优先检查定时器有没有更新、超时判断的变量有没有被清零。 - 检查死循环等待:如果主循环里任何一个函数存在阻塞等待(比如
while (GPIO_ReadPin(...) == RESET)),状态机可能被卡住,事件检测永远执行不到。这种情况把相关函数改成超时退出——即检测等待超过一定时间就返回默认值。 - 复位后先跑哪个状态:确保上电后默认进入 FORWARD,并且电机不会乱转。很多“卡死”其实是一开始初始化的默认行为就不对。
我在调车时遇到过最典型的场景:小车后退到墙边,超声波测到墙距离一直小于阈值,于是陷入 BACK_OFF -> 超时 -> TURN -> FORWARD -> 又发现前方太近 -> BACK_OFF 的循环。这种属于策略问题而非代码 bug,解决方法是让后退状态改成固定找空档侧转,或者增加一个“连续避障次数达到 N 次”后进入原地旋转直到找到通路的状态。状态机里多加一个状态,问题就迎刃而解。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 小车原地原地转圈不能前进 | 避障策略陷入循环,两侧都有障碍 | 增加原地旋转找方向状态,或用随机方向 |
| 明明前方没有障碍却急停 | 超声波测距读到异常值(如 0 或超级远) | 检查探头接线、供电电压;对测距结果做滤波 |
| 转向后撞到障碍物 | 转向时间太短 | 调大转向持续时间,或建议传感器加装侧面探测 |
| 状态切换日志正常但电机无动作 | 电机控制函数调用被注释/参数异常 | 检查 PWM 占空比输出,测电机驱动输入波形 |
| 上电后小车猛冲 | 初始化后立刻进入 FORWARD,没等传感器就绪 | 增加一个 500ms 的 STOP 初始化状态,再转 FORWARD |
| 电池电压下降后避障变差 | 电机转速下降,转向时长不再匹配 | 加电压补偿,或闭环测速 |
4.5 功能扩展:状态机带来的最大红利
这个小车项目做完以后,同样一套状态机框架,我迅速扩展了好几个功能。加“沿墙走”模式,就是在 FORWARD 状态下再增加一个 “TOO_CLOSE” 事件触发沿墙修正,本来用 if-else 要改二三十行,用状态机只是加了一个状态、加了一个转移事件。加“蓝牙遥控模式”,遥控模式下把“位移指令”抽象成事件,手柄按下产生EV_GO_FORWARD,松开产生EV_STOP,同样是加状态和事件,不动底层驱动。
后来我把避障模块中的状态机框架单独抽出来,套用到串口 AT 指令解析、用在小按键扫描的输入去抖,甚至是上位机对嵌入式设备的一问一答协议。可以说,学一次状态机,能省下未来无数个项目里纠结“怎么组织逻辑”的时间。
5. 给新手的小结:状态机其实是一种“思维习惯”
最后说点我在实际带项目过程中的体会。很多新手觉得状态机难,不是因为代码不会写,而是因为思维还没有“状态化”。如果你发现自己写程序时,经常搞不清楚“程序现在到底在哪一步”,那其实不是代码能力的问题,而是你缺少状态机这一层抽象的思维模型。
我给新人做嵌入式培训时,习惯让他们先做一个非常小的练习:用状态机实现一个长按按键事件识别——短按、长按分别触发不同动作。这个练习里只有 3 个状态(按键释放、按键短按确认、按键长按确认),做完之后绝大多数人一下子就能理解状态机的核心思路。等他们回头再看避障小车,就不再是“背代码”,而是真正在“设计逻辑”了。
写避障小车本身不难,但通过避障小车把状态机的思维模型立起来,收益是长期的。下一次你再面对更复杂的单片机项目,可能第一反应就不再有“又臭又长的主循环”,而是画一张状态转移表,然后逻辑清清楚楚,代码自然顺顺利利。
如果你做小车遇到具体问题,欢迎留言交流,留言带上你的传感器型号和代码截图,交流起来会更高效。