状态机在STM32避障小车中的应用:从状态建模到代码实现
2026/9/17 1:23:53 网站建设 项目流程

状态机这东西,很多嵌入式初学者一听就觉得抽象,总觉得是“学院派”才玩的花活。但实际上,状态机跟单片机开发是天生一对。打个比方:你写一个避障小车,如果全程用 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 状态转移表:让每个状态只关心“我该去哪”

以下是我最常用的初始转移表,直接在纸上画好再誊到电脑里的:

当前状态发生事件执行动作下一状态
FORWARDEV_FRONT_NEAR停止直行BACK_OFF
BACK_OFFEV_BACK_TIMEOUT停车、选择转向方向TURN_LEFT / TURN_RIGHT
TURN_LEFTEV_TURN_TIMEOUT恢复直行FORWARD
TURN_RIGHTEV_TURN_TIMEOUT恢复直行FORWARD
STOPEV_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_MSTURN_RIGHT_DURATION_MS,每调一次烧录一次,观察小车行驶轨迹,反复逼近最优值。

注意:左右两个转向时间通常是不一样的。电机性能有差异、重心位置不同,会导致同样时间的左转和右转角度不同。我调整时会先用电池充满状态调好,后面电压下降导致电机转速变慢,转向时间又会有偏差。所以有条件的话可以用编码器闭环控制转向角度,远超时间的精度,初级项目就先用时间控制,接受这个误差也没问题。

4.3 状态机“卡死”的排查思路

小车在实际跑动中偶尔会陷入某个状态出不来,比如一直原地转圈、一直后退。这种问题我总结出一个固定排查套路:

  1. 加日志输出:每个状态切换时通过串口打印状态编号。比如“当前状态 ST_TURN_LEFT -> 事件 EV_TURN_TIMEOUT -> 下一状态 ST_FORWARD”。这条日志能直接告诉你卡在哪个转移上。
  2. 检查事件是否产生:如果小车卡在 TURN_LEFT 不退出,多半是EV_TURN_TIMEOUT事件没有产生。优先检查定时器有没有更新、超时判断的变量有没有被清零。
  3. 检查死循环等待:如果主循环里任何一个函数存在阻塞等待(比如while (GPIO_ReadPin(...) == RESET)),状态机可能被卡住,事件检测永远执行不到。这种情况把相关函数改成超时退出——即检测等待超过一定时间就返回默认值。
  4. 复位后先跑哪个状态:确保上电后默认进入 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 个状态(按键释放、按键短按确认、按键长按确认),做完之后绝大多数人一下子就能理解状态机的核心思路。等他们回头再看避障小车,就不再是“背代码”,而是真正在“设计逻辑”了。

写避障小车本身不难,但通过避障小车把状态机的思维模型立起来,收益是长期的。下一次你再面对更复杂的单片机项目,可能第一反应就不再有“又臭又长的主循环”,而是画一张状态转移表,然后逻辑清清楚楚,代码自然顺顺利利。

如果你做小车遇到具体问题,欢迎留言交流,留言带上你的传感器型号和代码截图,交流起来会更高效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询