资讯动态

无线智能小车软件设计:通信协议、速度闭环与传感器处理

发布时间:2026/10/24 5:46:58 来源:尧图企业网站定制
1. 先想清楚这台小车的软件到底在管哪几件事网上关于无线智能小车的资料一抓一大把但绝大多数是接个蓝牙模块、下载一个现成固件、手机APP里按两下的组装教程。真到自己写软件的时候很多人第一反应是找开源代码改一改结果改出一个四不像通信时好时坏电机要么不动要么乱窜超声波测距的数据飘得没法用。我做这台无线智能小车时把车载主控的软件从零写了一遍包括无线通信协议、速度闭环、传感器处理、主循环调度。做完之后最大的感受是硬件的坑其实不多软件的坑才是一个接一个。这篇文章把整套软件的设计思路和实现细节全部摊开来讲目标是让一个只会点灯、会用串口的基础嵌入式开发者也能照着把软件框架搭起来再逐步填上电机控制、通信协议这些核心模块。在动笔写代码之前我先把软件要承担的责任列了一张清单总共五件事功能域软件模块难度来源无线通信串口/SPI收发、帧解析、超时处理断帧、粘帧、丢包运动控制PWM输出、方向控制、速度闭环电机非线性、PID调参传感器接入超声波触发与回波测量、循迹采集时序敏感、数据抖动逻辑决策手动/自动模式切换、避障策略状态管理混乱运行安全看门狗、通信超时自动停车设计时最容易漏掉这张表看似简单但每一条展开都是一整块内容。后面我会按这个顺序逐个讲其中通信和运动控制占的篇幅最大因为这两个模块直接决定了小车能不能遥控和开得稳不稳。2. 硬件选型如何决定软件的写法很多人觉得软件和硬件是分开的先画原理图、再写代码。实际上对智能车这种软硬一体的项目硬件选型会直接决定你的代码能写多重、用什么架构、甚至踩什么坑。2.1 主控芯片的资源边界别拿8位机跑RTOS我在这个项目里主控用过两类一类是经典的8位单片机一类是Cortex-M内核的32位主流芯片。8位机主频通常在12MHz到24MHzRAM只有几百字节Flash几KB到十几KB。这种资源下浮点运算、大数组、复杂协议栈都得尽量避免。我见过有人硬要在8位机上跑一个带缓冲的JSON解析结果RAM爆了程序直接死循环。换成主流32位芯片之后资源焦虑小很多72MHz主频、20KB RAM、64KB Flash定时器、串口、SPI都是现成的。这时软件可以做得更讲究比如用定时器做编码器计数、用硬件串口收无线模块数据、用SysTick做调度时基。我的经验是如果只想做一个能动的遥控小车8位机够用但凡要上速度闭环、多传感器融合、自动避障直接选32位芯片省下的开发时间远超那几块钱成本差。2.2 无线模块的三条路线蓝牙、2.4G、WiFi无线方案的选择对软件的影响是最直接的因为不同模块的数据通路完全不一样。我把常见的三种方案放在一起对比方案链路特征软件要额外处理的事蓝牙串口模块串口透明传输一对一基本不用写协议驱动但连不上时没有明确反馈2.4G射频模块自定义数据包自带ACK/重传要写SPI驱动、通道配置、地址管理WiFi模块TCP/UDP网络传输要处理连接状态、延迟波动适合做上位机组网蓝牙方案最省事主控只需要把无线模块当串口用。但它的坑是透明二字你不知道数据到底有没有发出去也不知道对方是不是还在线所以应用层的超时判断一定要做。2.4G射频模块是经典组比赛套路它有自动应答和自动重传链路可靠性反而比蓝牙高。代价是驱动代码和工作量我第一次调SPI通信花了整整两天。WiFi适合做手机直连或平台组网延迟比前两种略高但可玩性最强后面也能扩展图传。2.3 电机驱动与编码器没有反馈就别谈闭环电机驱动芯片有两种常见选择一类是比较老牌的桥式驱动压降大、发热多但便宜大碗另一类是近几年常用的MOSFET驱动芯片效率高PWM频率可以打得很高。驱动芯片型号不同软件的PWM频率选择范围也不同这个后面电机控制一节会细讲。更关键的是编码器。如果底盘只有电机没有编码器反馈软件只能做开环控制给定一个PWM占空比至于轮子实际转多快全靠猜。负载一变、电池电压一降速度立刻漂。所以只要想做速度闭环必须选带霍尔编码器的减速电机。编码器输出的是A、B两相互差90度的脉冲主控用定时器编码器模式直接读脉冲数软件只需要知道每转一圈多少个脉冲就能换算出轮速。这一步硬件没选对后面PID写得再好也没用。3. 主控程序架构从暴力轮询到时间片调度这一节是整套软件的骨架。很多新手一上来就在主循环里写一个巨大的while(1)把读无线数据、处理指令、测距、控制电机全堆在里面结果每个模块互相拖累程序的时间感完全混乱。3.1 为什么delay是万恶之源我第一次写小车时也犯过这个错超声波测距用delay延时触发电机转向用delay等一段时间结果小车执行指令的时候无线数据全丢在串口缓冲区里没人管。原因很简单delay是阻塞的程序在延时期间做不了任何别的事。正确的思路是把所有操作都改成非阻塞。所谓非阻塞就是发起一个动作、记录状态、然后立刻返回主循环等条件满足时再来检查结果。丢掉了阻塞延时之后主循环才能在通信、测距、控制之间快速轮转。我通常用一个1ms的硬件定时器做时基主循环里根据不同的时间计数去执行不同周期的任务。3.2 状态机让小车知道自己处于什么状态小车这种设备天然适合用状态机建模因为它的行为可以清晰划分为几个互斥状态待机、手动遥控、自动避障、循迹、急停。如果没有状态机代码里全是if(flag1 mode2 ...)这种散装逻辑改一个功能就要动十几处。我用一个枚举定义所有状态再写一个状态迁移函数任何状态变化都必须经过它typedef enum { SM_IDLE 0, SM_MANUAL, SM_AVOID, SM_LINE_FOLLOW, SM_ESTOP } CarState_T; CarState_T car_state SM_IDLE; void CarState_Change(CarState_T new_state) { // 统一在状态切换处做初始化和退出处理 CarState_OnExit(car_state); car_state new_state; CarState_OnEnter(car_state); }实际写的时候每个状态里再放一个子状态比如超声波避障状态里至少要分正在测距和正在转向。这样程序的执行路径永远是线性的调试时只需要看当前状态值就知道小车下一步要干嘛比翻一堆标志位靠谱得多。3.3 控制周期怎么定5ms、20ms还是100ms智能车的软件是多周期任务系统不同功能的实时性要求不一样不能全用同一个节奏跑。我最后定的任务周期表如下任务周期周期设定理由速度环PID10ms轮子转速变化在几十毫秒量级太快增加CPU开销太慢控制不稳无线协议解析主循环连续命令尽早执行缩短操控延迟超声波测距20ms声波往返一次约几毫秒到十几毫秒测太频繁反而串扰状态心跳上报100ms上位机只需要低速刷新状态显示通信超时检测50ms掉线后50ms内自动停车避免失控这里有个通用原则任务周期要跟物理过程的响应时间匹配不是越快越好。速度环用10ms是因为减速电机加减速需要大约100ms才能明显变化10ms的控制周期足够平滑又不至于浪费CPU。只要把任务周期的分配想清楚后面的代码结构就会非常清爽。4. 无线协议设计让每一帧命令都掷地有声无线模块只是解决了字节怎么从A端到B端的问题真正决定命令是否可靠的是应用层协议。如果直接对收到的原始字节做判断大概率会栽在断帧和粘帧上。4.1 帧格式帧头、长度、命令字、数据、校验我给小车设计了一套很紧凑的帧格式总共就五个部分[0xAA] [0x55] [LEN] [CMD] [DATA...] [XOR]帧头固定两个字节0xAA 0x55用来让接收端找到一帧的起点LEN表示CMD加DATA的总长度CMD是命令字DATA是具体参数最后一个字节是全帧异或校验。校验的计算范围从帧头第二字节算到DATA最后一字节这样能覆盖大部分单字节翻转错误。命令字定义得很精简命令功能数据段内容0x01速度设置方向(1字节)左轮速度(2字节)右轮速度(2字节)0x02模式切换模式值(1字节)0x03急停无0x10状态上报当前状态左右轮速度(合计5字节)比如要让小车以左右轮各自30%和30%的速度前进一帧就是AA 55 05 01 01 1E 00 1E 00 C9。协议够用就好没必要一开始就上复杂的加密和压缩那只会增加调试难度。4.2 环形缓冲区与帧解析状态机无线模块的数据到达主控是串行字节流随时可能来所以接收端必须在中断里先存到一个环形缓冲区然后在主循环里慢慢解析。环形缓冲区的作用是解耦接收速度和处理速度防止串口数据还没被处理就被覆盖。别把协议解析放在中断里做解析里可能有循环等待会让其他中断任务的实时性崩掉。帧解析我用了一个小状态机核心逻辑是逐字节推进typedef enum { PARSE_WAIT_HEAD1 0, PARSE_WAIT_HEAD2, PARSE_WAIT_LEN, PARSE_WAIT_CMD, PARSE_WAIT_DATA, PARSE_WAIT_XOR } ParseState_T; uint8_t Parse_Byte(uint8_t byte) { static ParseState_T state PARSE_WAIT_HEAD1; static uint8_t len, data_cnt, xor_acc, buf[8]; switch (state) { case PARSE_WAIT_HEAD1: if (byte 0xAA) state PARSE_WAIT_HEAD2; break; case PARSE_WAIT_HEAD2: state (byte 0x55) ? PARSE_WAIT_LEN : PARSE_WAIT_HEAD1; break; case PARSE_WAIT_LEN: len byte; buf[0] byte; data_cnt 0; xor_acc 0x55 ^ byte; state (len 0) ? PARSE_WAIT_CMD : PARSE_WAIT_HEAD1; break; // ... 后续状态按字节累积并校验 } return 0; }这个解析器不依赖任何阻塞等待来了一个字节就推进一次。即使收到的是半个包下半个包到了也能继续解析不会把状态搞乱。4.3 丢帧、重传与延迟的取舍无线链路免不了丢帧。对于速度控制这种连续型指令丢一帧根本不用管因为100ms后下一帧会带着最新速度覆盖旧值。但对于急停这种事件型指令绝不能靠概率所以我做了两层保险上位机急停时会连续发送5帧主控收到第一帧就执行急停同时主控端有一个50ms的通信超时定时器只要超过设定时间没收到任何有效帧自动进入停车状态。延迟问题是另一个容易被忽视的点。在115200波特率下发一帧八字节的指令只要约0.7ms传输本身几乎不消耗时间。操控延迟主要来自三个地方上位机的发送周期、主循环的调度周期、无线模块的缓冲延迟。我实测下来上位机用20ms周期发指令整体操控体验没什么延迟感如果放到100ms就会明显觉得肉。提示设计协议时想清楚每条指令是状态型还是事件型这个区分能帮你少写一半的重传逻辑。5. 电机运动控制从PWM到增量式PID通信解决了大脑怎么传达指令运动控制解决指令怎么变成准确的速度。这一节是整个软件里调参最费时间的地方。5.1 PWM与方向引脚怎么配合电机驱动芯片一般有两个方向引脚加一个PWM输入引脚方向引脚决定正反转PWM占空比决定速度。软件的配合逻辑是先把方向引脚置好再更新PWM占空比。就这么简单但有一个容易踩的坑——死区。减速电机启动时存在静摩擦PWM占空比低于某个值我实测约8%时电机根本不转但代码里依然按比例输出导致低速段控制效果很差。我的做法是给输出做一段线性上的死区补偿将实际输出的0到100映射到8到100让最小占空比刚好跨过启动阈值。这里一定要实测自己的电机不同减速比和电压的启动阈值可能差一倍。PWM频率我选择了18kHz左右。这里是72MHz定时器设置预分频系数3、自动重装载值999算下来刚好是18kHz1000级占空比分辨率也足够平滑。频率太低会听到电机啸叫并且扭矩波动明显频率太高则驱动芯片开关损耗变大所以落在10kHz到20kHz之间比较合适。5.2 编码器测速与增量式PID落地我用的电机是13线霍尔编码器加1:30减速箱也就是说轮子转一圈编码器输出390个脉冲。配65mm直径的轮子周长约0.204米。跑1m/s时需要约4.9转/秒换算成脉冲率约1900个/s在10ms控制周期里大约能采到19个脉冲分辨率足够支撑速度闭环。速度采集用定时器编码器模式每10ms读一次计数值减去上次的计数除以周期得到脉冲速率再换算成m/s。换算系数可以写死在宏里#define ENC_PULSES_PER_REV 390 #define WHEEL_CIRCUM_M 0.204f #define SPEED_CONST (WHEEL_CIRCUM_M / ENC_PULSES_PER_REV / 0.01f)速度闭环我选了增量式PID因为它的输出是变化量天然不适合搞复杂的积分限幅逻辑写起来也简单。核心代码就这么多typedef struct { float kp, ki, kd; float err, err_last, err_last2; float out; } IncPID_T; float IncPID_Update(IncPID_T *pid, float target, float actual) { float err target - actual; float d_err err - pid-err_last; float d_err2 err - 2.0f * pid-err_last pid-err_last2; float delta pid-kp * d_err pid-ki * err pid-kd * d_err2; pid-err_last2 pid-err_last; pid-err_last err; pid-out delta; if (pid-out 100.0f) pid-out 100.0f; if (pid-out -100.0f) pid-out -100.0f; return pid-out; }调参我是从P开始先把Ki和Kd设成0Kp从小到大加看到轮子开始轻微振荡后回调到振荡幅度的70%左右然后加Ki消除静差Kp和Ki的组合很容易把系统调出振荡所以要小幅加Kd在这类小车系统上我最后基本没怎么用。一组能用的起始参考是Kp12、Ki0.3、Kd0具体数值跟速度量纲强相关别硬抄。5.3 差速转向与速度斜坡差速转向的实现非常简单给定一个基础速度和转向差值左轮速度等于基础值减差值右轮等于基础值加差值。麻烦的是加速度。直接给电机一个很高的速度目标轮子会因为抓地力不足而打滑软件看到的速率反馈会失真PID越调越乱。我给目标速度加了一个斜坡限制让目标值每次最多只能变化一个最大值float LimitAccel(float target, float current, float max_step) { if (target current max_step) return current max_step; if (target current - max_step) return current - max_step; return target; }这个函数在10ms控制周期里调用相当于给整辆车加了一层软件限速器起步和停车都会变得柔和冲出去那一刻的视觉效果和专业感完全不一样。6. 传感器接入超声波与循迹的软件细节如果你只想做个纯遥控车传感器可以不做。但智能二字通常就体现在这里避障和循迹是它的两种常见工作模式。6.1 超声波测距的非阻塞写法HC-SR04这类超声波模块的经典驱动是Trig拉高10us触发然后死等Echo引脚的电平变化测量高电平持续时间。这种写法在主循环里一堵就是几十毫秒配合前面说的时间片调度完全不能忍。我的做法是把测距过程拆成两个子状态触发态和等待回波态。触发态里拉高Trig引脚并在10us后拉低同时记录当前时间等待回波态里不断查询Echo引脚用微秒级时间戳计算脉宽typedef enum { SONAR_TRIG 0, SONAR_WAIT_ECHO } SonarState_T; void Sonar_Task(void) { static SonarState_T state SONAR_TRIG; static uint32_t trig_tick, echo_start; switch (state) { case SONAR_TRIG: GPIO_SetTrig(1); trig_tick micros(); state SONAR_WAIT_ECHO; break; case SONAR_WAIT_ECHO: if (micros() - trig_tick 10) GPIO_SetTrig(0); if (GPIO_ReadEcho()) { echo_start micros(); state SONAR_CAPTURE; // 简写示意 } else if (micros() - trig_tick 30000) { // 超时无回波按远距离处理 state SONAR_TRIG; } break; // ... 下降沿捕获后换算距离 } }这样超声波只是主循环里的一个普通任务再也不阻塞其他功能。距离换算用距离cm 脉宽us / 58这是声波往返一次的经验公式。6.2 循迹传感器的滤波与阈值自适应循迹传感器通常会输出多路模拟电压或数字电平。模拟量比数字量更有潜力因为可以做阈值自适应适应不同地面反光差异。我处理模拟量时先做中值滤波连续采样5次去掉最大最小值后取3次平均值能有效消除白线反光不均导致的毛刺。动态阈值是让传感器自己学习地面亮度持续跟踪近期的最大最小采样值阈值取两者中值。这样从浅色地板换到深色地板也不用重新标定程序会自动适应。循迹的控制逻辑我用的是偏差转向修正模型三路传感器分别对应左、中、右位置中间传感器压线时偏差为0左传感器压线时偏差为负值右传感器压线时为正值。把这个偏差输入到一个独立的转向PID里输出叠加到差速值上。这种组合在弯道半径不太小时表现很稳定真正的高速急弯需要再加预测逻辑但作为入门项目已经绰绰有余。7. 联调排错实录三个真实Bug的完整排查链路软件写完之后真正折磨人的是联调阶段。我记录三个印象最深的Bug每一个的解决过程都值得复述因为它们的排查思路可以迁移到很多嵌入式场景。7.1 Bug一电机啸叫和起步顿挫——PWM频率选错了现象很直观一通电电机发出刺耳的持续噪声低速起步时一顿一顿像在抽搐。我第一反应是驱动板供电不足赶紧量电源电压结果正常又怀疑是方向引脚没置好导致竞争代码检查了也没问题。后来用示波器去看PWM引脚波形才发现频率只有1kHz左右——这是初始化代码里用了某套现成库的默认参数。1kHz落在人耳最敏感的听域电感线圈会因此高频振动发出噪声低速顿挫则是因为1kHz频率下每个PWM周期时间太长电机感抗平滑不了电压波动扭矩输出忽大忽小。把PWM重装载值调整到产生18kHz输出后噪声立刻消失起步也顺滑了。这个Bug的教训是驱动芯片和电机对PWM频率都有要求初始化外设不能想当然最好用示波器确认实际波形而不是只看配置代码。7.2 Bug二遥控指令延迟半秒甚至完全失联——主循环被阻塞了现象是刚开始上电一切正常跑一会之后遥控越来越肉最后按什么都没反应但重新上电又好了。我先给无线数据加了一个串口打印发现主控确实还在收到数据可处理很慢接着在主循环里统计每轮耗时发现某几个周期会突然跳到50ms以上。顺着耗时查罪魁祸首是超声波测距里的阻塞等待我用了一个没有任何超时的循环等Echo引脚一旦超声波的首次回波被噪声干扰就没法退出主循环直接卡死近30ms。而无线模块数据靠串口中断接收虽然不会丢但解析处理在主循环里排队延迟自然越来越大。把超声波改成前面说的非阻塞状态机之后主循环又回到了1ms量级遥控延迟恢复正常。注意任何等待某个条件成立的死循环都要带超时退出。这个习惯能让你的程序在异常情况下不彻底死机。7.3 Bug三速度闭环越调越振荡——PID符号搞反了开环测试一切正常轮子能转能停一开PID闭环系统就开始振荡而且是那种幅度越来越大的发散振荡。一开始我以为是参数太大把Kp从12一路降到3振荡依旧只是频率变低了一点。这时候我想起一个原则排除法要一次只变量一个条件。于是我在控制周期里多打了一行日志打印目标速度、实际反馈速度、误差值。看到数据我愣住了目标20cm/s时反馈显示-18cm/s误差越算越大。根本原因不是PID参数而是编码器的A/B相输出方向和电机实际转向相反导致反馈是负的。把反馈取反后立刻稳定。后来我又踩过一个类似坑积分项没有限幅停车时误差一直被积分累积等再次起步时输出直接饱和车猛冲出去。增量式PID虽然没有绝对积分但输出累加量同样需要硬限幅我加了一个±100的输出钳制才解决。排查这个Bug的完整链路是先确认反馈方向正确再看参数是否合理最后才怀疑实现细节。顺序反了的话你会浪费一晚上在调一个根本不存在的最佳Kp上。8. 写在最后如果重来一遍我会怎么优化项目收尾复盘时我给自己列了几条经验也是想给后来者提个醒。第一开发顺序很重要。我一开始急着调电机结果通信没通每一条指令都要靠猜测来验证。正确的顺序应该是先写好无线收发和串口日志确认指令能稳定下发到主控再开始动电机和传感器。日志系统是调试的神器我在主控里用串口按周期打印状态机状态、目标速度、实际速度几乎所有疑难Bug都是靠日志而不是靠猜解决的。第二安全逻辑要提前设计。我现在养成的习惯是任何控制类设备必须有一个通信超时自动停车的兜底机制。这个机制只占十几行代码在实测失控时救过我好多次尤其是电池电压不足导致无线模块掉线的时候。第三如果重新做我会更早引入指令队列。目前的速度控制是最新指令覆盖旧指令够用但缺少精细度加入一个带优先级的指令队列可以把避障和手动控制放在不同优先级上避免逻辑互相打断。这算是这个项目留给下一个版本的小目标。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑