ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

状态机在STM32避障小车中的应用:从状态建模到代码实现

2026/9/17 1:23:57 拓冰建站 浏览量
状态机在STM32避障小车中的应用:从状态建模到代码实现 状态机这东西很多嵌入式初学者一听就觉得抽象总觉得是“学院派”才玩的花活。但实际上状态机跟单片机开发是天生一对。打个比方你写一个避障小车如果全程用 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前方障碍物过近比如 30cmEV_FRONT_CLEAR前方障碍物已远离比如 40cmEV_TURN_TIMEOUT转向持续达到设定时间EV_BACK_TIMEOUT后退持续达到设定时间。有了事件就可以列出状态转移表。2.2 状态转移表让每个状态只关心“我该去哪”以下是我最常用的初始转移表直接在纸上画好再誊到电脑里的当前状态发生事件执行动作下一状态FORWARDEV_FRONT_NEAR停止直行BACK_OFFBACK_OFFEV_BACK_TIMEOUT停车、选择转向方向TURN_LEFT / TURN_RIGHTTURN_LEFTEV_TURN_TIMEOUT恢复直行FORWARDTURN_RIGHTEV_TURN_TIMEOUT恢复直行FORWARDSTOPEV_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-casecar_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 个状态按键释放、按键短按确认、按键长按确认做完之后绝大多数人一下子就能理解状态机的核心思路。等他们回头再看避障小车就不再是“背代码”而是真正在“设计逻辑”了。写避障小车本身不难但通过避障小车把状态机的思维模型立起来收益是长期的。下一次你再面对更复杂的单片机项目可能第一反应就不再有“又臭又长的主循环”而是画一张状态转移表然后逻辑清清楚楚代码自然顺顺利利。如果你做小车遇到具体问题欢迎留言交流留言带上你的传感器型号和代码截图交流起来会更高效。