做嵌入式项目这么多年,我越来越觉得“状态机”这东西是被严重低估的。就拿最常见的stm32小车避障来说,新手拿到板子第一反应往往是“让小车跑起来”,然后写一个巨大的while循环,把传感器、电机、延时全揉在一起,结果一遇到复杂路况就乱套。后来我转用状态机思路,把所有运行逻辑拆成一个个互斥状态,用事件驱动转移,stm32小车避障这种多分支任务一下子就清晰了。这篇文章我就拿一个实际调过的避障小车项目来拆:状态机到底怎么设计、代码怎么写、避障策略怎么融合进去,以及调试中踩过的那些坑。无论你是刚点亮LED的入门选手,还是想给毕业设计加分的在校生,只要手里有一块stm32开发板和一台能跑的小车底盘,这套思路都能直接套用。
1. 状态机是什么,做小车避障为什么绕不开它
1.1 用红绿灯理解状态机
很多人一听“状态机”就觉得是理论课的东西,其实你天天都在用。红绿灯就是一个最典型的状态机:红灯亮、绿灯亮、黄灯亮,这三个状态互斥,车辆只能根据当前灯色做对应动作。灯从红变绿不是“我随便变的”,而是等了一个固定时间,这个时间就是触发事件;变灯的同时还伴有动作——比如从红灯状态退出时,可能会启动倒计时显示器。再比如自动售货机、洗衣机程序,本质都是状态机。
放到stm32开发里也一样。小车避障这个任务,表面上是“会走”“会躲”,实际拆开看就是几个固定模式:往前直走、停下来看看、往左转、往右转、倒车、停车。这些模式任意时刻只能有一个在运行,这就是状态。切换到哪个模式取决于传感器发现了什么,这就是事件。把这种逻辑用代码规范地表达出来,就叫状态机。
1.2 为什么嵌入式里特别吃这一套
我做过的不少stm32项目,早期都栽在“逻辑复杂度”上。单纯点个灯、读个温湿度计,顺序执行完全没问题。但一旦涉及避障小车这种需要同时响应多个输入的任务,顺序执行的短板就出来了:超声波测距要等回波,电机调速要持续输出PWM,转向舵机要留足转动时间,OLED显示还要刷新。如果不用状态机,最容易写成“测距-延时-判断-转向-延时”这种阻塞式结构,小车跑起来一顿一顿的,遇到突发障碍根本反应不过来。
状态机之所以适合,是因为它天然是事件驱动的。主循环只需要不断收集事件,然后查表跳到对应状态执行动作,整个过程不阻塞、可预测、好调试。任何一个时刻,程序在哪个状态、因为什么事件跳过来、接下来要做什么,都是确定的。这一点对后期排查问题太重要了,我调试过那么多带pwm、定时器、串口的中小型stm32项目,凡是逻辑复杂的,最后都会回归到状态机这种写法上。
2. 避障小车的状态划分与转移关系设计
2.1 先从需求倒推:小车避障到底要“会”什么
设计状态机之前,我习惯先把需求一张张列出来,不是马上写代码。以我这台小车为例,硬件是stm32f103c8t6最小系统板,配一个HC-SR04超声波传感器、一个双路电机驱动、两个直流减速电机,外加一个舵机云台用来转动超声波探头方向。需求其实就三条:没有障碍就直走;发现障碍就停下判断;根据左右两侧的空间选择转向避让,然后继续直走。
这里要提醒一下,需求列得越细,状态划分越简单。很多人在这一步偷懒,直接写代码,后面状态数会失控。我把这个项目的需求细化成了几条可验证的行为描述,比如“前方距离小于20厘米时必须停车”“转向过程中要持续检测新障碍”“如果左右都堵死就后退并掉头”。这些描述直接决定了后面要定义哪几个状态。
2.2 状态定义与转移条件
基于上面的需求,我把小车运行划分为六个核心状态:
| 状态名 | 含义 | 退出条件 |
|---|---|---|
| S_INIT | 系统初始化,等待启动 | 按下启动键或上电自检完成 |
| S_FORWARD | 正常直行 | 前方测距值低于安全阈值 |
| S_CHECK | 停车检测左右距离 | 左右探测完成,产生转向结论 |
| S_TURN_LEFT | 左转避让 | 转向角度或时间达标 |
| S_TURN_RIGHT | 右转避让 | 转向角度或时间达标 |
| S_BACK | 倒车脱困 | 倒车时间达标,重新进入S_CHECK |
这里面的关键设计是S_CHECK。很多新手会把“检测左边”“检测右边”拆成两个独立状态,我不建议这么干,因为拆开以后状态数量翻倍,转移图变得很乱。更好的做法是让舵机在S_CHECK状态下依次测量左侧和右侧,然后根据结果决定跳转到左转还是右转。“检测左边”和“检测右边”只是S_CHECK内部的一个测量步骤,不上升到状态级别。
转移条件一定要写得明确。我这个项目里,S_FORWARD到S_CHECK的转移条件是distance < 20,单位厘米;S_TURN_LEFT到S_FORWARD的条件是累计转向时间 > 600ms,同时要满足前方距离 > 25。如果只按时间转移,转完以后正前方可能还有障碍,小车就会撞上去,所以我在转向状态里会持续用舵机回正探头测前方,两个条件同时满足才切走。
2.3 转移表怎么画,怎么检查完整性
状态转移表是状态机设计阶段最重要的产物。我通常用一张二维表来表达:行是当前状态,列是可能发生的事件,交叉格填“下一个状态+要执行的动作”。这样做的好处是能一眼看出有没有遗漏的转移。比如S_TURN_LEFT状态下如果突然检测到正前方距离小于15厘米,怎么办?很多设计里这个格子是空的,程序跑起来就会卡住。我在设计阶段就把这种极端情况补上:转向中遇到更近的障碍,立即切到S_BACK倒车。
补全转移表之后,一定要检查两个完整性:每个状态至少有一个出口;每个可能事件在每个当前状态下都有明确处理,哪怕处理是“保持当前状态不变”。我见过太多项目在代码里写着写着,某个状态遇上某个事件没处理,然后就出现“小车不动了”的假象,其实就是状态机进入了非法分支。这个项目里我最后把转移表打印出来贴在调试台旁边,后面又基于它写了个状态日志,排查效率比纯靠脑子记高太多了。
3. 基于STM32的状态机代码实现
3.1 数据结构与状态表定义
环境搭建这里我不展开说太多,简单提一句:用keil5建stm32标准库工程,记得先装好对应型号的芯片包,否则编译会报找不到器件。我用的还是标准库,虽然HAL库现在很流行,但标准库在逻辑简单的项目里写起来更直白,适合学习状态机。
代码实现第一步是定义状态枚举和事件枚举。事件不只是“前方有障碍”这种传感器事件,还包括定时器事件,比如“转向超时”“测量完成”。把所有事件都枚举出来,后面写调度器就清爽。
typedef enum { S_INIT = 0, S_FORWARD, S_CHECK, S_TURN_LEFT, S_TURN_RIGHT, S_BACK, S_STATE_COUNT } State_t; typedef enum { EV_START = 0, EV_NO_OBSTACLE, EV_OBSTACLE_NEAR, EV_CHECK_DONE, EV_TURN_TIMEOUT, EV_OBSTACLE_CLOSER, EV_BACK_DONE, EV_STOP, EV_COUNT } Event_t;接下来是状态转移表。我推荐用结构体数组,每个元素保存“当前状态+事件”对应的下一状态和要执行的动作函数指针。这种方式比满屏switch-case更容易维护,后期加一个状态只需要加一行表项,不用去翻散落在各处的case。
typedef void (*ActionFn)(void); typedef struct { State_t cur_state; Event_t event; State_t next_state; ActionFn action; } TransItem_t; const TransItem_t trans_table[] = { { S_INIT, EV_START, S_FORWARD, act_motor_start }, { S_FORWARD, EV_OBSTACLE_NEAR, S_CHECK, act_stop_and_scan }, { S_CHECK, EV_CHECK_DONE, S_TURN_LEFT, act_turn_left }, { S_CHECK, EV_CHECK_DONE, S_TURN_RIGHT, act_turn_right }, { S_TURN_LEFT, EV_TURN_TIMEOUT, S_FORWARD, act_motor_start }, { S_TURN_RIGHT, EV_TURN_TIMEOUT, S_FORWARD, act_motor_start }, { S_BACK, EV_BACK_DONE, S_CHECK, act_stop_and_scan }, // ... 实际项目里大概二十多行 };这里有个细节值得说:S_CHECK状态下同一个事件EV_CHECK_DONE会因为左右距离比较结果不同跳到不同状态,所以表里写了两行,但真正的判断逻辑放在动作函数act_stop_and_scan里,它把转向结论写到一个全局变量turn_direction,然后转移查表的时候再根据这个变量匹配行。这个“表驱动+条件变量”组合,比在事件采集处到处塞if else要规整得多。
3.2 事件采集与主循环调度
状态机调度器我通常放在主循环里,配合一个10毫秒的定时器节拍。定时器每10毫秒置一个标志位,主循环检测到标志位后收集事件、查表执行。注意整个循环里绝对不能用阻塞式延时,否则状态机响应实时性全废了。
int main(void) { SystemInit(); // 初始化GPIO、定时器、串口、PWM等 board_init(); current_state = S_INIT; while (1) { if (timer_10ms_flag) { timer_10ms_flag = 0; Event_t ev = collect_event(); handle_event(ev); } } } Event_t collect_event(void) { uint16_t dist_cm = ultrasonic_get_distance(); if (dist_cm < 20 && dist_cm > 0) return EV_OBSTACLE_NEAR; if (turning_timeout) return EV_TURN_TIMEOUT; // 其他传感器事件... return EV_NO_OBSTACLE; } void handle_event(Event_t ev) { for (int i = 0; i < ARRAY_SIZE(trans_table); i++) { if (trans_table[i].cur_state == current_state && trans_table[i].event == ev) { if (trans_table[i].action) trans_table[i].action(); current_state = trans_table[i].next_state; state_changed = 1; log_state_change(current_state); return; } } // 表格里没匹配到,默认保持状态不变 }这套调度逻辑看起来很简练,但运行起来效果不错。我特别建议保留state_changed和log_state_change,哪怕只是往串口发一个状态编号,后面调试都会轻松很多。实际测试时我会在串口上用示波器观察状态跳变瞬间,再配合逻辑分析仪看PWM输出,问题基本能定位到是哪一次转移出了问题。
3.3 传感器、电机与状态机的配合
状态机本身的代码只关心状态和事件,但事件从哪来、动作怎么落地,还得靠外设驱动。这里重点说两个容易出错的地方。
第一个是超声波测距。HC-SR04的触发脚需要至少10微秒的高电平脉冲,然后等待Echo脚返回高电平,高电平持续时间就是声波往返时间。我建议用定时器输入捕获来测量,而不是用delay_us死等。原因很简单:死等期间状态机完全无法响应其他事件,如果这时候左边突然撞上来一个东西,程序还在等Echo,避障就失效了。输入捕获模式下,Echo上升沿捕获计数值,下降沿再捕获一次,差值就是脉宽,测距期间主循环还能照常跑。
uint32_t echo_us = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); if (echo_state == 0) { TIM_SetCounter(TIM2, 0); echo_state = 1; } else { echo_us = TIM_GetCapture1(TIM2); echo_state = 0; dist_ready = 1; } } }第二个是电机驱动。我用的驱动模块是常用的L298N或者TB6612,PWM频率设置在10kHz左右可以避开人耳噪音。转向状态不要直接用HAL_GPIO_WritePin把一路电机设成全速、另一路设成停,那样不仅转弯半径大,而且容易把小车甩偏。我在转向状态里对内侧电机给一个30%的占空比,外侧给60%,实测下来转向稳定很多。这个参数要根据你自己的底盘微调,我建议在调试阶段把PWM值做成宏,然后在主循环里用一个简单变量实时改,边跑边调。
4. 避障策略进阶:从简单避让到动态路径规划
4.1 超声波测距的数据处理
直接拿超声波的单次测量结果去喂状态机,很容易出问题。HC-SR04在倾斜表面或者近距离小物体上容易丢回波,测出来一个超大值;在移动过程中测距值又会剧烈跳动。如果不处理,状态机会在S_FORWARD和S_CHECK之间来回抖,小车看起来像抽搐。
我的做法是做两层滤波。第一层是合理性过滤:距离小于2厘米或者大于400厘米的数据直接丢弃,这种数据在正常场景下不可能是真实障碍。第二层是滑动平均:连续取5次有效数据,去掉最大最小后求平均。代价是响应会慢几十毫秒,但小车速度不高,完全够用。如果做快速小车,我会把滑动窗口从5降到3,优先级是响应速度大于平滑度。
4.2 三种避障策略的代码写法
很多教程里的所谓“避障”,其实是“撞到之前的条件反射”。我把常见的做法分成三层,你在项目里可以按需选用。
第一层是阈值避让,最简单也最常用。检测到前方小于20厘米就停车,左右探测后选一个更宽敞的方向转。这个策略的局限在于,如果左右两侧都小于阈值,小车会左右横跳。所以我在S_BACK状态里加了倒车逻辑,倒车0.8秒后再重新检测,实测能脱离大部分死角。
第二层是沿墙走。这在“走迷宫”场景下很实用:设定一个期望的右墙距离,比如15厘米,如果实际右墙距离大于15厘米,就稍微向右转靠近墙;小于15厘米,就稍微向左转远离墙。这个策略本质是一个比例控制器,状态机只需要维护S_FORWARD、S_ADJUST_LEFT、S_ADJUST_RIGHT三个状态,事件就是“距离偏大”“距离偏小”,非常适合让小车沿着障碍边缘稳定巡航。
第三层是动态避障路径规划。不少人一听到“动态避障小车路径规划”就想到A星、Dijkstra这些全局算法,但在stm32这种资源有限的MCU上跑完整地图规划不太现实。我的做法是把全局规划拆成“局部代价评估”:在S_CHECK状态里不止测左右两个方向,而是用舵机以15度为步进扫5个角度,每个角度测一次距离,然后选一条“离障碍最远且离当前朝向夹角最小”的方向转过去。这就是一个简化版的动态窗口法,虽然计算量不大,但避免了只测左右两个点导致“盲区撞墙”的问题。代码上就是在S_CHECK内部多存一个长度5的数组,测完用贪心策略选出目标角度。
4.3 状态机与动态规划怎么融合而不破坏结构
有人会担心,加了动态规划以后状态机会不会爆炸。我的经验是:规划算法是状态内部的算法,不是新状态。状态机永远只负责“现在该干什么”,至于“怎么干”,是动作函数内部的事。比如S_CHECK状态,动作函数里可以先用一阵扫描得到5个方向的障碍距离,再在函数内部计算最优方向,把结果写到全局变量,最后才触发EV_CHECK_DONE。状态机的显式状态并没有增加,复杂度全部封装在动作函数里。
这种分层设计还有个好处:你想把避障策略从简单阈值换成动态规划,只需要重写act_stop_and_scan和转向动作的函数体,状态表和调度器一行都不用动。我后来把同一个状态机框架用在了遥控避障小车和红外循迹小车上,都是只改事件采集和动作实现,状态结构复用率非常高。这算是状态机设计里最值回票价的一个好处。
5. 状态机调试与常见问题排查
5.1 状态机“卡死”了怎么查
小车跑到一半突然不动,是我被问得最多的问题。遇到这种情况,我第一反应不是去看电机,而是去看当前卡在哪个状态。我的项目里常驻一个OLED状态显示,用一个大数字显示当前状态编号,同时在串口把最近10次状态变化打印出来。如果看到状态停在S_TURN_LEFT,那就去查转向超时事件有没有正常产生;如果停在S_CHECK,那十有八九是左右距离测量没完成,事件没发出来。
这里要特别提醒一个坑:事件丢失。我的状态机调度是单线程的,如果handle_event执行过程中某个动作函数里出现了阻塞等待,比如串口发送时用了while(USART_GetFlagStatus(...) == RESET)这种写法,那么在这个等待期间进来的事件全都会被丢。小车就会出现“明明超声波已经报警了,但状态根本没切走”。解决办法是:动作函数里严禁阻塞等待,所有IO操作都改成查询加超时,或者干脆用DMA和中断。
5.2 状态跳变抖动怎么治
抖动最常见的原因是事件源不稳定。前面说的超声波原始数据抖动,会让S_FORWARD在“有障碍”和“无障碍”之间反复横跳。除了数据滤波,我还会在状态机外面加一个“事件去抖”模块:同一个事件必须连续出现3个节拍(也就是30毫秒)才真正生效。具体实现是给每个事件配一个计数器和阈值,事件连续有效就加1,无效就清零,计数值达到阈值才返回这个事件。
这样做的代价是响应延迟30毫秒,但换来的稳定性非常值。尤其是在S_CHECK转向完成以后,前方距离刚好在阈值附近时,没有去抖的话小车会反复进入S_CHECK再退出,看起来就像原地犹豫。加了去抖以后,车辆行为明显“果断”了很多。
5.3 调试工具与手段的选择
调试状态机,我个人最推荐的组合是“OLED状态显示+串口日志+逻辑分析仪”。OLED显示当前状态用于现场观察,串口日志记录完整的“状态+事件+时间戳”序列用于事后回放,逻辑分析仪则用来核对PWM和超声波Echo的时序。如果你手头只有示波器也没关系,把Echo引脚和电机PWM引出来看,基本能判断是“传感器没测到”还是“电机没执行”。
另外一个我强烈建议养成的习惯:在串口日志里加上“状态变化时刻”和“转移矩阵命中行号”。比如[1245ms] S_FORWARD -> S_CHECK, table_row=3, dist=18cm。这样一旦出现问题,你直接看日志就能知道是哪个条件触发了异常跳转,不用再靠肉眼去盯小车跑。早期我嫌麻烦没加,后来发现一次问题查半天,加了这行日志以后,很多bug在电脑上就能直接定位到代码行。
6. 状态机的更多形态:不止STM32里能用
6.1 事件驱动状态机与QP框架
做嵌入式避障小车用自写的状态表已经足够,但如果你接触更复杂的stm32项目,比如带以太网、多任务、多传感器融合的设备,手工状态机会慢慢变得难以控制。这时候可以看看QP状态机框架。QP是一款开源的事件驱动状态机框架,支持层次状态机(HSM),也就是说一个状态可以嵌套另一个状态,公共的转移逻辑写在父状态里,子状态只管自己特殊的部分。
我在一个带串口远程控制的项目里用过QP,感受最深的是层次状态机能省掉大量重复表项。比如“通信异常”这个事件,不管当前在哪个状态都要响应,层次状态机里写在根状态就行了;用平铺状态表的话,每一行都要重复写一遍跳转,表格容易膨胀。不过对于stm32小车避障这种规模,自写状态机反而更灵活、更可控,引入框架会增加学习成本,需要按项目复杂度权衡。
6.2 Verilog三段式状态机
状态机并不只是软件概念,FPGA里同样大量使用,Verilog三段式状态机就是最典型的写法。所谓三段式,就是把状态机拆成三个always块:第一段负责状态寄存器的时序更新;第二段组合逻辑根据当前状态和输入产生下一状态;第三段根据状态输出控制信号。这种写法的优势跟嵌入式里的状态表一样,逻辑清晰、便于维护、不易产生竞争冒险。
我刚接触FPGA的时候也觉得三段式繁琐,但熟练以后发现它跟stm32状态机的思想完全一致:状态寄存器就是“当前状态变量”,组合逻辑就是“事件采集+状态转移表”,输出逻辑就是“动作函数”。如果你在stm32项目里已经理解了转移表和事件驱动,再看Verilog三段式状态机基本没有障碍。反过来也成立,很多会写Verilog的人去写嵌入式状态机,上手也很快。
6.3 PLC与Java业务里的状态机
往更宽泛的领域看,状态机的应用远不止单片机和FPGA。工业界的PLC编程里就有专门的状态机写法,通常是使用ST语言或者SFC顺序功能图,把设备运行流程分解成“初始化”“运行”“报警”“急停”等状态,事件来自传感器和上位机指令。这种写法的好处是设备故障时能直接看到卡在哪一步,维护电工也能很快上手,所以在产线设备里非常流行。
软件领域就更常见了。关于“java状态机能否由用户灵活定义”这个问题,答案是肯定的,而且很多开源框架已经做得很成熟,比如Spring StateMachine。我做过一个工单审批系统,就是用状态机来管理工单的状态流转:草稿、待审批、审批通过、打回、关闭,每个事件对应一个用户操作。业务状态机和嵌入式状态机设计思路一脉相承,区别只在于事件来源是传感器还是用户,动作函数是控制电机还是调用数据库接口。理解了这一点,你会发现状态机是一种跨越软硬件的通用方法论。
7. 最后的经验之谈
这台stm32小车避障项目我从裸奔while循环改成状态机,前后花了一个晚上,但换来的收益持续了后面所有项目。最直观的改变是:以前写逻辑是“边想边堆代码”,状态多了自己都会乱;现在是“先画转移表,再填代码”,哪怕隔一个礼拜回来改,打开表看一眼就能续上。这个习惯让我在写stm32其他项目时也受益匪浅。
如果你刚接触状态机,建议不要一上来就追求复杂的层次状态机或者框架,先把这个小车项目用最简单的状态表跑通,感受一下“事件驱动”和“状态互斥”带来的稳定性,再慢慢尝试QP、层次状态机这些进阶玩法。调车的时候多留一点耐心,把状态日志、OLED显示这些调试手段配齐,踩坑是避免不了的,但有了记录就能快速爬出来。说到底,状态机不是炫技,它只是让程序的每一个瞬间都明确知道自己该做什么。这套思想,值得你在下一个项目里用起来。