刚把循迹和蓝牙遥控在同一个底板上调通那会儿说实话我是松了一口气的。前两篇我们把环境搭好、电机转起来了蓝牙透传也跑通了但那些都是单条线。真正让人头大的是让这两套东西在同一辆STM32小车里互不打架——按遥控器的时候循迹别出来抢方向盘跑循迹的时候蓝牙消息又不能把控制逻辑搞乱。这篇“三”重点就讲这个怎么设计系统架构、怎么定通信协议、怎么处理循迹和遥控的模式切换以及实测中真正踩过的坑。适合已经跑通过基础电机和蓝牙通信的玩家也适合准备做课程设计或比赛作品的人拿来对照参考。1. 整体方案两条功能线怎么装进同一辆车1.1 先想清楚循迹和蓝牙控制共用的是什么很多新手拿到这个题目就直接分模块写代码循迹一套逻辑、蓝牙一套逻辑最后拼在一起发现完全没法用。问题不在于代码量而在于这两条功能线共享的东西太多了。先说硬件层面。不管走循迹还是被遥控最终执行的都是两个直流电机。电机驱动模块我用的是常见的小型双路驱动板也可以用成品模块只有两路PWM和两路方向IO你不可能让循迹逻辑和蓝牙逻辑同时去写这几根引脚。电源也是共用的蓝牙模块、灰度传感器、单片机、电机驱动全部挂在同一块电池上再加上电机启停的大电流冲击电源噪声会直接影响灰度传感器的ADC读数。软件层面就更不用说了PID计算周期、蓝牙接收中断、PWM输出更新这些都在抢同一个CPU。所以第一步不是写代码而是画一张数据流图。我习惯把系统拆成三层感知层灰度传感器、决策层主控逻辑决定了当前处于什么模式、该往哪走、执行层电机驱动。蓝牙指令进来之后它不是直接控制电机而是先进入决策层告诉决策层“我现在想切换模式”或者“我现在想往左转”。循迹传感器也是一样的它的数据交给决策层处理而不是自己直接去改PWM。这样设计之后底层电机驱动只需要面对一个“我该输出什么”的接口上层逻辑再花哨也不怕。1.2 工作模式从状态机开始设计我用一个枚举变量管整个小车的状态代码里叫car_mode。目前定义了四个模式待机模式、手动遥控模式、循迹模式、参数整定模式。待机模式就是上电后的初始状态两侧轮子不动蓝牙模块等待连接。手动遥控模式下蓝牙字节流解析出来的指令直接决定两轮PWM目标值。循迹模式下灰度传感器每10ms采一次样算误差、跑PID输出也是PWM目标值。参数整定模式比较特殊是用蓝牙临时修改PID参数用的这个后面细说。状态机最关键的一点是切换条件的唯一性。手动模式切循迹模式只能通过蓝牙指令完成循迹模式切回手动也必须有明确的指令。不能让“小车自己判断要不要从循迹切到手动”那样逻辑就会乱掉。我用了一个很简单的做法所有模式切换都走同一个mode_switch(new_mode)函数在这个函数里统一处理“切换前该做的事”和“切换后该做的事”。void mode_switch(uint8_t new_mode) { // 退出当前模式前的清理工作 if (car_mode MODE_MANUAL) { set_motor_stop(); // 防止切换瞬间电机继续跑 } // 切换 car_mode new_mode; // 进入新模式后的初始化 if (car_mode MODE_LINE) { pid_reset(); // PID 积分/微分历史清零 line_err 0; } }这个函数里的pid_reset()很重要。如果从手动模式直接切进循迹模式PID控制器还带着上次的误差历史上车瞬间就可能给一个奇怪的转向值小车直接歪掉。清零之后让它重新累积会平稳很多。1.3 系统硬件清单与连接规划列一下我这辆车上的实际配置不一定是最优解但都是验证过能稳定跑的方案模块型号/规格说明主控STM32F103C8T6性价比高资料多够用电机驱动双路H桥驱动模块TB6612类比L298N轻效率高压降小电机带编码器N20减速电机编码器这次没用但后续扩展速度闭环有用灰度传感器五路数字量输出模块单排一字布局蓝牙模块HC-05主从一体蓝牙透传模块经典方案手机连它很方便电源7.4V 2S锂电池经降压模块给主控和传感器供电连接上SPI/I2C我这次都没用全部走GPIO和串口。电机驱动PWM引脚接主控的TIM1或TIM2通道灰度传感器五路接普通GPIO输入蓝牙模块接USART1的TX/RX。注意电机驱动的地线一定要和单片机共地。很多人蓝牙连不上或者传感器读数飘查半天发现是模块之间地电位不一致信号根本就没法正确传输。2. 循迹模块从灰度传感器到动态转向2.1 五路灰度传感器布局与阈值标定灰度传感器的原理其实很简单红外发射管发光红外接收管接收反射回来的光强黑色赛道吸收大部分红外光白色地面反射很强传感器输出的电压就会明显不同。市面上买的五路灰度模块普遍是数字量输出模块上自带比较器阈值通过电位器调。布局上有讲究。我一开始把五个传感器等距排开间距设成了1.5cm后来发现跑起来反应太迟钝。原因在于小车的前轮距和赛道黑线宽度是有匹配关系的。一般比赛或课设的黑线宽度在1.5cm到2cm之间如果传感器间距太小过弯时中间三路全在黑线上误差变化不线性转向会一顿一顿的。我最后用的间距是2cm这样小车在直道上只有中间一路压线左右偏移时旁边两路能迅速给出反馈。标定阈值是很多人的第一道坎。数字量传感器模块上有个电位器顺时针拧是抬高比较阈值逆时针是降低。我的标定方法是这样的先把小车放到场地的白色区域用万用表或者直接看板载LED调整电位器直到当前输出为0白纸不触发然后手动把传感器挪到黑线上方确认输出变为1。这块要多试几个位置保证整条赛道上所有传感器都能可靠地分辨黑白。如果你用的是模拟量输出的版本那就需要读ADC记录白底和黑线上的稳定值把阈值设在两者中间附近。2.2 误差计算与PD控制参数整定五路传感器从左到右我标记为S1到S5把它们映射成一组位置权重。这里用加权平均的思想S1对应-2S2对应-1S3对应0S4对应1S5对应2。当前误差line_err等于所有触发的传感器对应权重的加权和除以触发数量。举个例子小车稍微偏左触发了S1和S2误差就是(-2-1)/2 -1.5一个负值说明车身相对黑线往左偏了此时需要往右打方向。PID这里我最终用的是PD控制器没用积分项。原因是赛道循迹是一个动态跟随过程积分项很容易饱和尤其在连续大弯上会产生严重的超调。而且灰度传感器本身有噪声积分把历史误差累起来反而会放大抖动。代码里我直接用增量式PD输出的是一个转向修正量int16_t line_pd_control(int16_t err) { static int16_t last_err 0; int16_t output; output KP_LINE * err KD_LINE * (err - last_err); last_err err; return output; }调参顺序很重要。先把KD设为0KP从0.1开始往上加。放到直道上观察如果小车左右摆动幅度越来越大说明KP偏大如果出弯后回正很慢则KP偏小。等KP调好之后再加KDKD的作用是在误差变化大的时候提前减速转向变化抑制过冲。我的车最终KP在0.35左右KD在0.5左右这个数值和你车的轴距、轮距、电机响应都有关系只能做参考不能照抄。转向输出最终映射为左右轮的差速。比如输出值为正说明需要右转传感器告诉我们偏左那就左轮PWM加到基础值、右轮PWM降到基础值的某个比例上。要注意的是转向幅度必须限幅两个轮子的PWM差值不能超过一个安全范围否则过弯速度一快外侧轮的PWM瞬间拉满内侧轮甚至反转会导致小车原地打转。2.3 弯道和十字路口策略比PID更重要PID参数再好遇到十字路口、急转弯也一样会翻车这时候策略的重要性就出来了。十字路口是最常见的翻车点。五路传感器全部压线时如果不做特殊处理PID算出来的误差是0控制器认为自己在直道上小车会直直冲过去。如果你的赛道设计里十字路口必须左转或右转的话靠PID本身是解决不了的。我的处理方案是加一个路口状态判断当五路传感器全部触发并持续超过50ms判定为进入十字路口。此时如果下一段赛道需要转弯直接强制让小车按预定方向转并在规定时间内停止这个强制转向把小车的控制权交还给PID。急弯的处理则是另一个思路。小车在入弯前会先出现“线从视野边缘消失”的情况比如左急弯时最左侧的传感器先压线然后S1、S2同时触发继续转弯后S3也开始触发。观察误差导数可以估算出弯道曲率。如果误差在短时间内急剧变大说明是急弯这时候需要主动减速而不是继续保持基础速度。我是在控制器里加了一个动态速度因子误差绝对值大于1.5时基础PWM往下修正20%左右这样入弯速度降下来出弯之后再恢复。3. 蓝牙控制链路Android端到STM32到底传了什么3.1 协议帧设计不是随便发字符串前两篇很多人做蓝牙控制就是手机端发个字符“1”代表前进“2”代表后退单片机上用switch-case处理。这种写法在demo阶段没问题但到了要正经控制的时候你会发现字符串解析不可靠发一个“10”里面既有“1”又有“0”万一App端逻辑写得不严谨小车就会同时执行前进和后退。我这次定了统一的帧格式每一帧固定5个字节AA 55 CMD DATA CHECK。开头两个字节AA 55是帧头用于接收端识别一帧数据的开始。第三个字节CMD是命令字。第四个字节DATA是数据域放数值或参数。最后一个字节CHECK是校验字节取值是前四个字节的累加和。拿控制指令举例CMD0x01表示方向控制DATA定义为0x00停止、0x01前进、0x02后退、0x03左转、0x04右转。CMD0x02表示速度设置DATA直接填目标占空比百分比比如50就是50%的PWM占空比。CMD0x03表示模式切换DATA填要切换到的模式编号。CMD0x10是PID参数整定指令DOG0x10DATA前两个字节是参数类型1表示Kp2表示Ki3表示Kd后面再跟两个字节的参数值这种情况帧长度会变成6字节但帧头、命令字、校验的整体思路不变。校验字节千万别省。蓝牙传输偶尔会丢字节或出错没有校验的话一个CRC错的数据可能让小车误判成“左转最大幅度”现场直接表演漂移很吓人。3.2 Android端App的数据发送设计Android端我用的是标准的BluetoothSocket连接UUID用的是Android蓝牙串口服务通用的那个00001101-0000-1000-8000-00805F9B34FBHC-05就是模拟串口所以这个UUID通用性很高。App界面不需要复杂一个连接按钮、四个方向按钮加一个停止按钮就够用。方向按钮用setOnTouchListener而不是setOnClickListener按下时连续发送方向指令松开时自动发送停止指令。这里有一个非常重要的细节发送频率要控制。我之前直接在触摸事件里发数据系统回调频率不稳定有时候一秒钟发了四五十条指令蓝牙模块缓冲区直接满掉后面几条数据被丢弃或错位。后来我在发送线程里加了一个限流逻辑每50ms最多发送一帧数据如果触摸事件产生的指令时间间隔小于50ms就不予发送只记录最新状态。这样既保证了控制连续性又不会把串口打爆。private var lastSendTime 0L private val sendInterval 50L fun sendCommand(cmd: ByteArray) { val now System.currentTimeMillis() if (now - lastSendTime sendInterval) return lastSendTime now try { bluetoothSocket?.outputStream?.write(cmd) bluetoothSocket?.outputStream?.flush() } catch (e: IOException) { // 连接断开处理 } }3.3 STM32端接收解析与PWM映射STM32端接收蓝牙数据用的是串口中断加环形缓冲区。很多新手喜欢在中断回调里直接处理业务逻辑这是噩梦的开始。串口中断应该只做一件事把收到的字节扔进环形缓冲区然后尽快退出。主循环里再定期从环形缓冲区取数据做帧解析。帧解析用状态机从左到右扫描字节流遇到0xAA先不急着确认要确认下一个字节是0x55才算真正的帧头。一套简化的解析流程如下void parse_byte(uint8_t byte) { static uint8_t state 0; static uint8_t buf[6]; static uint8_t idx 0; static uint8_t sum 0; switch (state) { case 0: if (byte 0xAA) { buf[0] 0xAA; sum 0xAA; idx 1; state 1; } break; case 1: if (byte 0x55) { buf[1] 0x55; sum 0x55; idx 2; state 2; } else state 0; // 帧头不匹配重新等 break; case 2: buf[2] byte; sum byte; idx 3; // 根据 buf[2] 的 CMD 判断后续长度 state (CMD_IS_EXTERN(buf[2])) ? 3 : 4; break; // ... 后续 DATA 和 CHECK 的处理 } }解析出完整一帧之后先校验和校验通过再执行对应的命令操作。执行操作又走回上一节说的模式切换函数手动模式下直接把DATA里的方向值映射到两个轮子的PWM目标值上。方向到PWM的映射我加了一个转向斜坡函数目标值不是瞬间拉满而是以每50ms增加5%的斜率渐变过去。这样遥控时小车不会突然窜出去手感也柔和很多。4. 模式切换与循迹遥控融合的工程细节4.1 模式切换状态机实现状态机前面提了一下这里展开讲具体实现。我定义了一个主循环任务10ms跑一次。每次先读蓝牙帧解析结果看是否有新的模式切换请求如果没有请求就按当前模式执行对应的控制逻辑。模式切换时有个容易被忽略的坑切换瞬间的PWM突变。举个例子循迹模式下因为正以80%的PWM左转这帧蓝牙命令说“切手动”如果切换函数只是改了模式标志位电机仍然保持80%的左转输出手动模式下第一次发停止指令前小车会继续歪着跑一小段反应快的还好反应慢的直接撞墙。所以mode_switch里必须处理“退出清理”先把两路PWM的目标值归零再改变模式。这样即使手机端还没来得及发新指令小车也是安全停住的。另外无论什么模式下收到停止指令0x00都应该直接停车并清空所有历史状态。这是安全的底线。我在代码里单独处理了这个例外因为它不依赖模式任何时候都应当有效。4.2 手动模式下的循迹软辅助这个功能是我在玩的时候自己加上的。手动遥控模式下如果你把车推到黑线上或者遥控方向没掌握好让车压线了其实可以做一个很轻的辅助修正让小车不那么容易跑出赛道。实现方式不复杂手动模式下也持续读取灰度传感器但不改变遥控指令的优先权。只有当用户没有发方向指令比如一直发“停止”或者松手了并且传感器检测到偏差时才把循迹PD输出以20%的权重叠加到当前PWM上。这种“软辅助”不会和用户意图打架因为用户一旦按下方向键遥控指令的权重就覆盖掉辅助PD。这个功能实际用起来很有意思。它相当于给小车的遥控驾驶加了一个“电子围栏”手残玩家也能稳稳地把车开在赛道里比赛的时候观感提升巨大。但这个功能也有代价就是代码里多了一个模式状态的判断分支逻辑复杂度上来了。如果想快速完成课设可以不做这个功能先把基础模式切换跑通。4.3 用蓝牙远程调PID调试方式的升级说到选型我觉得你必须知道为什么不能直接接管。很多人拿到循迹小车第一反应就是“我直接读五路传感器然后写死几个if判断不就行了”确实可以但那种实现方式是在“硬编规则”它只能应对你写if时想到的场景。而PID控制是连续输出直道、缓弯、急弯、S弯都能平滑过渡if判断则是一段一段的切换感很明显跑快一点就甩出去了。实测下来PID方案在小车跑60%以上基础速度时依然可控if方案往往在快一点的时候就开始左右横跳。到这一步PID的三个参数是通过蓝牙实时写的不用重新烧录程序效率非常高。调试的时候把小车架空拿在手里手机蓝牙连上发一组参数进去观察电机响应不行就再发一组几轮就能找到一个比较合适的点。这也是为什么我把参数整定单独设计成一个模式它在工程上太实用了。5. 实测记录与常见问题排查手册5.1 循迹跑飞的经典原因第一常见的是阈值没标定好。传感器模块的电位器稍微偏一点点白底误触发或者黑线上不触发小车跑起来就是一路歪。尤其是光线变化的时候下午三点的阳光和晚上开灯的环境反射强度完全不一样。解决了这个问题你才会发现原来小车能跑得这么直。第二常见的是PID参数过大导致震荡。现象是直道上来回摆幅度越来越大最后冲出去。这种问题非常好判断把KP和KD都调小再试。我给新手一个经验值范围基础速度控制在40%PWM左右KP从0.2到0.5之间试KD从0.3到0.8之间试大部分小车都能收敛。第三是物理层面的问题轮胎打滑。我原来用的轮子是普通的硬塑料轮跑得快一点过弯时抓地力不够传感器明明已经检测到偏了转向指令也输出了但车身因为惯性还是往外滑看起来就像“失控”。换了软硅胶轮之后改善非常明显。这块容易被忽略但它的重要性不比算法低。5.2 蓝牙偶发失效的处理蓝牙连接不稳定的案例我见过太多了。大部分人第一反应是换模块或者换手机其实问题经常出在供电和地线上。HC-05模块正常工作需要3.3V供电有些人直接拿单片机的3.3V引脚给它供电。单片机的3.3V一般是LDO芯片从5V或电池电压转出来的本身电流余量不大电机一转电压被拉低蓝牙就会断连或者重启。正确的做法是给蓝牙单独用一个3.3V稳压模块或者确保你的3.3V供电能力足够强。我实测中把小车的电机驱动电源、传感器电源、蓝牙电源分开走线之后蓝牙连接稳定了很多。还有一个低级但常见的坑蓝牙模块的TXD接单片机的RXDRXD接单片机的TXD这种交叉连接很多人会接反。焊上去之后发现收不到数据第一反应是代码问题查半天才发现连线反了。排查的时候先量一下蓝牙模块有没有正常输出再用串口调试助手在电脑上单独测模块等模块本身确认没问题了再往单片机这边查。5.3 电机响应不一致的排查有时候小车在一侧打方向盘之后回不正或者直道上跑着跑着自动偏向一边这很可能是两个电机特性不一致造成的。即使标称同型号的N20电机空载转速和扭矩也会有差异再加上左右轮子的摩擦阻力不同、安装偏心不同必然导致同样的PWM占空比下实际速度不一样。我这次没有用编码器做速度闭环硬件上有软件没写所以是通过软件校准来补偿的。具体做法是在手动模式下发送一个固定的速度指令目测或用量具测量左右轮转速然后给慢的那个轮子的PWM目标值乘一个补偿系数。比如测量发现左轮慢5%就把所有发送到左轮的PWM值乘以1.05。这个系数简单粗暴但有效能解决大部分“车总是往一个方向偏”的问题。如果你做了速度闭环那就完全不需要这个补偿系数了。编码器反馈会实时修正每个轮子的速度这也是后续升级的方向。5.4 问题故障速查表现象可能原因处理办法循迹直道上来回摆振幅越来越大PID参数B过倍调小KP和KD先减KD五路传感器全压线但车不转弯阈值误判为直道检查阈值标定确认五路都能可靠触发蓝牙连不上供电不足或连线接反独立供电核对TX/RX交叉连接蓝牙连上但收不到数据波特率不匹配STM32和蓝牙模块波特率设为一致常见9600或115200手动模式下遥控转向过度转向映射限幅过小增大差速比例或减小PWM变化斜率小车从循迹切手动瞬间乱跑切换前PWM状态未清理在模式切换函数中先停电机过弯打滑冲出去轮胎抓地力不足换软质轮胎或降低急弯入弯速度6. 代码结构参考与复现建议把整套代码的目录结构梳理一下方便你们照着搭项目。我用的是STM32标准外设库工程结构如下User/ main.c // 主循环10ms任务调度 car_mode.c/h // 状态机、模式切换 line_follow.c/h // 灰度采样、误差计算、PD控制 bluetooth.c/h // 串口中断、环形缓冲、帧解析 motor.c/h // PWM初始化、差速输出、斜坡函数 pid_tune.c/h // 蓝牙远程调参支持main.c里的主循环是核心调度器所有模块都是被调度的角色不搞中断里处理业务那一套。任务调度的周期我在前面说过是10ms这对于循迹控制和蓝牙遥控来说足够及时了。如果以后要做高速小车可能需要把控制频率提到5ms但那就需要仔细评估每个模块的执行时间。代码的可移植性还不错如果你之前用的是HAL库把外设初始化部分换成HAL的写法业务逻辑部分基本可以直接搬。灰度传感器用的是GPIO库函数读取蓝牙是串口中断这些在各个型号的STM32上差异不大。Android端我是在Android Studio里写的原生蓝牙App代码结构上分了三块蓝牙连接管理、指令发送线程、UI事件绑定。蓝牙连接管理负责扫描设备、配对、建立Socket指令发送线程就是我前面提到的限流控制逻辑UI事件绑定把按钮的触摸事件和发送函数关联起来。整体代码量不大核心逻辑集中在指令发送的限流和帧构造上。7. 实操心得与一个小建议这个项目做到第三篇我最大的体会是单片机和Android之间真正费时间的不是代码而是通信协议的设计。只要协议定得清晰帧头、命令字、数据、校验每一位都有明确含义排查问题就会快很多。反过来如果协议糊里糊涂你在现场会同时面临“不知道是单片机错了还是手机错了”的死局。最后分享一个我一直在用的小技巧在代码里保留一个调试用的串口输出通道把蓝牙收到的每一帧原始字节都打印出来拿串口调试助手在电脑上记录。每次小车出现奇怪的“自主行动”时翻记录看手机到单片机之间到底传了什么基本能第一时间定位问题。这个习惯让我的排查速度至少快了一倍。调试蓝牙和循迹融在一起的真车现场手忙脚乱是常态有一个能追溯数据的手段会让你稳很多。