
做嵌入式项目这么多年我越来越觉得“状态机”这东西是被严重低估的。就拿最常见的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或者TB6612PWM频率设置在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_row3, dist18cm。这样一旦出现问题你直接看日志就能知道是哪个条件触发了异常跳转不用再靠肉眼去盯小车跑。早期我嫌麻烦没加后来发现一次问题查半天加了这行日志以后很多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显示这些调试手段配齐踩坑是避免不了的但有了记录就能快速爬出来。说到底状态机不是炫技它只是让程序的每一个瞬间都明确知道自己该做什么。这套思想值得你在下一个项目里用起来。