简介:这份资源是2024年全国大学生电子设计竞赛H题巡线小车的完整工程,面向备赛电赛控制类赛题的学生与嵌入式爱好者。方案以STM32为主控、OpenMV摄像头实现巡线,核心思路是计算黑线与中心线的偏差进行转向控制,与官方要求的灰度传感器方案原理一致,便于理解偏差控制与四点丝滑衔接的难点。压缩包共1053个文件,约27.45MB,以570个c源文件、256个h头文件、51个汇编文件为主,另含工程配置、库文件与少量Python脚本,构成可直接编译的完整工程。目前已有4259人学习下载。读者可从中获取完整的小车控制代码框架、巡线偏差处理逻辑与工程目录组织方式,并参考作者关于统一速度、MPU6050零漂、初始摆放角度及四轮同步微调等优化思路,用于二次开发与稳定性提升。
1. 从一道巡线题说起:为什么这套方案值得直接参考
每年电赛控制类题目里,巡线小车都是最经典的载体。2024年H题把赛道元素做得更复杂,十字交叉、断线、弧线、锐角弯交替出现,单纯靠红外对管或者灰度传感器已经很难稳定跑完全程。很多队伍卡在同一个地方:车能跑,但一进弯道就丢线,一上十字就误判,速度提上去之后整个控制环路开始震荡。这套STM32+OpenMV的完整工程,解决的就是“感知层不够用、控制层调不动”这两个核心问题。它适合正在准备电赛控制题的学生、需要交巡线小车课程设计的同学,以及想参考一套完整视觉巡线架构的嵌入式开发者。方案本身不依赖特定赛道,换场地只需要重新标定阈值和PID参数,工程结构可以直接复用。
2. 需求拆解与技术选型:为什么是STM32加OpenMV
2.1 赛道元素分析与感知需求
先要把题目拆清楚。2024年H题的赛道通常包含以下几类元素:直道、S弯、十字交叉、断线区、弧线弯道,部分赛区还会加入坡道或者障碍物。不同元素对感知的要求完全不同。直道和缓弯只需要知道中线偏移量,十字交叉需要判断“前方是十字还是正常弯道”,断线区需要靠惯性或者编码器推算继续走,锐角弯则要求感知层提前给出足够大的转向预判量。
如果用传统灰度传感器阵列,十字交叉和断线区几乎无法区分,因为两者在传感器读数上都表现为“全部检测到黑线”或者“全部检测到白底”。这就是为什么这套方案选择OpenMV做视觉感知:摄像头能拿到二维图像,可以通过区域分割和特征判断区分十字、断线和正常弯道。STM32负责底层电机控制、编码器读取和PID运算,OpenMV负责图像处理和赛道元素识别,两者通过串口通信交换数据。分工明确,各干各擅长的事。
2.2 主控与视觉模块的职责划分
STM32这边通常选F103C8T6或者F407系列,F103足够跑电机PID和串口协议,F407则在需要更高控制频率或者多路PWM时更从容。OpenMV这边用H7或者M7版本都可以,H7帧率更高,适合高速巡线。职责划分上,STM32负责:
- 读取编码器脉冲,计算实时速度
- 读取陀螺仪(如果有)做转向补偿
- 接收OpenMV发来的中线偏移量和元素类型
- 执行PID运算,输出PWM给电机驱动
- 处理按键、OLED显示和蜂鸣器提示
OpenMV负责:
- 采集图像并做二值化或者边缘检测
- 提取赛道中线,计算偏移量
- 识别十字、断线、弯道等元素
- 通过串口把偏移量和元素标志发给STM32
这种划分的好处是:视觉处理耗时较长,放在OpenMV上不会阻塞STM32的控制循环;STM32的控制频率可以稳定在100Hz以上,保证电机响应及时。
2.3 通信协议与数据帧设计
串口通信是这套方案的血管。协议设计不好,会出现数据错位、丢包、延迟抖动等问题。我一般会用固定帧头加校验的方式,帧结构如下:
// STM32端接收解析示例 // 帧格式: 0xAA 0x55 | offset_h offset_l | element | checksum // offset为int16_t, element为uint8_t, checksum为前4字节异或 typedef struct { int16_t offset; // 中线偏移量, 范围-160~160 uint8_t element; // 0:正常 1:十字 2:断线 3:左弯 4:右弯 } VisionData_t; VisionData_t vision_data; uint8_t rx_buf[7]; uint8_t rx_index = 0; void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); // 状态机解析, 先找帧头0xAA 0x55 if(rx_index == 0 && ch == 0xAA) { rx_buf[rx_index++] = ch; } else if(rx_index == 1 && ch == 0x55) { rx_buf[rx_index++] = ch; } else if(rx_index >= 2 && rx_index < 7) { rx_buf[rx_index++] = ch; if(rx_index == 7) { uint8_t checksum = rx_buf[2]^rx_buf[3]^rx_buf[4]^rx_buf[5]; if(checksum == rx_buf[6]) { vision_data.offset = (int16_t)((rx_buf[2]<<8) | rx_buf[3]); vision_data.element = rx_buf[4]; } rx_index = 0; } } else { rx_index = 0; // 帧错误, 重置状态机 } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }这段代码的关键点在于状态机解析:只有按顺序收到0xAA、0x55之后才开始填充数据区,校验失败直接丢弃整帧。offset用int16_t是因为中线偏移可能是负数,element用单字节枚举表示赛道元素类型。校验用异或足够轻量,不需要上CRC16。波特率一般设115200,OpenMV端每帧发送间隔控制在10ms左右,STM32接收中断优先级要高于电机控制定时器中断,避免丢帧。
注意:串口线一定要用屏蔽线或者双绞线,电机驱动的地线和信号地要单点共地,否则电机启动瞬间的干扰会让串口误码率飙升。
3. 总体架构设计:三层结构与四个核心模块
3.1 感知层、决策层、执行层的划分
整套系统按三层架构组织。感知层是OpenMV和编码器,负责把赛道信息和车速信息数字化;决策层是STM32上的控制逻辑,包括元素判断、PID运算和状态机切换;执行层是电机驱动和舵机,负责把控制量变成物理动作。
感知层的关键指标是刷新率和准确率。OpenMV在QQVGA分辨率下可以跑到50fps以上,足够覆盖高速巡线需求。编码器用正交解码模式,STM32的定时器编码器接口可以直接读取,不需要额外中断开销。决策层的核心是一个状态机,根据当前元素类型切换控制策略:直道用位置式PID,弯道用增量式PID加前馈,十字和断线用开环保持或者陀螺仪辅助。执行层用PWM驱动电机,频率一般设10kHz到20kHz,避免人耳听到啸叫。
3.2 视觉处理模块:ROI划分与中线提取
OpenMV端的图像处理流程决定了整个系统的上限。我一般会按以下步骤做:
# OpenMV端中线提取核心逻辑 import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) # 160x120 sensor.skip_frames(time=2000) uart = UART(3, 115200) # ROI划分: 将图像分成远中近三个区域 ROI_FAR = (0, 20, 160, 20) # 远处, 用于预判 ROI_MID = (0, 50, 160, 30) # 中距离, 主控制区 ROI_NEAR = (0, 90, 160, 30) # 近处, 用于确认当前位置 def get_offset(roi): blobs = img.find_blobs([(0, 64)], roi=roi, merge=True) if blobs: # 取最大色块的中心x坐标 max_blob = max(blobs, key=lambda b: b.pixels()) return max_blob.cx() - 80 # 80是图像中心 return None while True: img = sensor.snapshot() # 二值化, 阈值根据现场光照调整 img.binary([(0, 64)]) offset_far = get_offset(ROI_FAR) offset_mid = get_offset(ROI_MID) offset_near = get_offset(ROI_NEAR) # 元素判断逻辑 element = 0 if offset_far is None and offset_mid is not None: element = 2 # 断线 elif offset_far is not None and offset_mid is not None: # 判断十字: 远处和近处都有线但中间区域宽度异常 pass # 加权融合, 远处权重低, 近处权重高 offset = 0 if offset_near is not None: offset = offset_near * 0.6 + (offset_mid or 0) * 0.3 + (offset_far or 0) * 0.1 # 发送数据帧 uart.write(bytearray([0xAA, 0x55, (offset >> 8) & 0xFF, offset & 0xFF, element, 0x00])) time.sleep_ms(10)ROI划分是视觉巡线的核心技巧。远处ROI用来提前发现弯道,中距离ROI是主控制信号来源,近处ROI用来确认车当前是否在线上。三个区域的偏移量按权重融合,远处权重低是为了避免噪声干扰,近处权重高是因为它最接近车当前姿态。二值化阈值需要根据现场光照调整,一般用OpenMV的阈值编辑器现场标定。
3.3 控制模块:串级PID与元素状态机
STM32端的控制逻辑用串级PID:外环是位置环,输入是OpenMV发来的中线偏移量,输出是目标转向角;内环是速度环,输入是编码器反馈的实际速度,输出是PWM占空比。位置环用PD控制就够了,积分项容易在弯道积累导致过冲。速度环用PI控制,保证直道匀速、弯道减速。
元素状态机是另一个核心。正常巡线时状态为LINE_FOLLOW,收到十字标志后切换到CROSS状态,保持直行固定时间或者固定距离,然后切回LINE_FOLLOW。收到断线标志后切换到LOST状态,用陀螺仪维持航向或者用编码器推算继续走,直到重新检测到线。状态切换要有超时保护,避免卡在某个状态出不来。
// 元素状态机核心逻辑 typedef enum { STATE_LINE_FOLLOW, STATE_CROSS, STATE_LOST, STATE_STOP } CarState_t; CarState_t car_state = STATE_LINE_FOLLOW; uint32_t state_timer = 0; void state_machine_update(void) { switch(car_state) { case STATE_LINE_FOLLOW: if(vision_data.element == 1) { // 十字 car_state = STATE_CROSS; state_timer = 0; } else if(vision_data.element == 2) { // 断线 car_state = STATE_LOST; state_timer = 0; } else { // 正常PID控制 pid_update(vision_data.offset); } break; case STATE_CROSS: state_timer++; // 十字保持直行, 固定PWM set_motor_pwm(BASE_SPEED, BASE_SPEED); if(state_timer > CROSS_HOLD_MS) { car_state = STATE_LINE_FOLLOW; } break; case STATE_LOST: state_timer++; // 断线保持上一转向或者直行 set_motor_pwm(BASE_SPEED, BASE_SPEED); if(vision_data.element == 0 || state_timer > LOST_TIMEOUT_MS) { car_state = STATE_LINE_FOLLOW; } break; default: car_state = STATE_STOP; break; } }状态机的关键是超时保护。CROSS_HOLD_MS一般设200到400ms,取决于车速和十字宽度。LOST_TIMEOUT_MS设500ms左右,超过就强制切回巡线状态,避免车一直往前冲。BASE_SPEED要根据电池电压动态调整,否则电量下降后车速变慢,十字保持距离就不够了。
3.4 执行模块:电机驱动与舵机响应
电机驱动用TB6612或者DRV8833都行,关键是PWM频率和死区设置。PWM频率设10kHz以上,避免电机发出刺耳啸叫。死区要实测:给最小占空比电机不转的那个值就是死区,代码里要把死区补偿加上,否则低速时控制精度很差。
舵机响应速度直接影响弯道性能。普通模拟舵机响应慢,数字舵机快但耗电大。如果赛道弯道急,建议用数字舵机,并且把舵机供电和主控供电分开,避免舵机堵转时拉低主控电压导致复位。转向控制量要限幅,一般限制在±30度以内,防止舵机打满后卡死。
4. 关键实现细节:从图像阈值到PID参数整定
4.1 图像二值化阈值现场标定方法
阈值不是固定值,不同场地光照差异很大。我一般会在OpenMV IDE里用阈值编辑器现场取点:把车放在赛道上,看串口终端输出的灰度值,黑线一般在0到40之间,白底在150到255之间。取中间值80到120作为阈值。如果场地反光严重,可以加偏振片或者调整摄像头曝光时间。
# 阈值标定辅助代码 import sensor, image sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(time=2000) while True: img = sensor.snapshot() # 在图像中心画十字, 读取该点灰度值 cx, cy = img.width()//2, img.height()//2 gray = img.get_pixel(cx, cy) print("中心灰度值:", gray) # 同时输出直方图统计 hist = img.get_histogram() print("灰度均值:", hist.mean())现场标定时,把车放在黑线上,看中心灰度值;再放到白底上,看中心灰度值。两者取中间值作为二值化阈值。如果黑线灰度值偏高(比如超过80),说明曝光过度,要降低曝光时间或者加遮光罩。
4.2 串口数据帧解析与校验
前面给了STM32端的解析代码,这里补充OpenMV端的发送逻辑。发送频率不能太高,否则STM32中断负担重;也不能太低,否则控制延迟大。10ms一帧,即100Hz,是比较平衡的选择。发送前要确保数据打包正确,offset是int16_t,拆成高低字节发送。
# OpenMV端数据打包 def send_data(offset, element): # offset范围-160~160, 转为无符号 offset_u = offset & 0xFFFF frame = bytearray([ 0xAA, 0x55, (offset_u >> 8) & 0xFF, offset_u & 0xFF, element, 0x00 # 预留字节 ]) # 计算校验 checksum = 0 for i in range(2, 6): checksum ^= frame[i] frame.append(checksum) uart.write(frame)校验用异或,计算量小,OpenMV和STM32都容易实现。如果现场干扰严重,可以升级到CRC8,但异或一般够用。关键是接收端的状态机要能处理帧错误,收到错误帧直接丢弃,不要影响后续解析。
4.3 PID参数整定步骤与经验值
PID整定是巡线小车最玄学的部分。我一般按以下步骤来:
第一步,只调速度环。把车架起来,轮子悬空,给固定PWM,看编码器读数是否稳定。速度环用PI,P设0.5到2.0,I设0.1到0.5,具体看电机减速比和编码器线数。
第二步,调位置环的P。把车放赛道上,速度设很低,只给P控制。从小往大加,直到车开始震荡,然后退回震荡值的60%。这个值就是临界P。
第三步,加D。D用来抑制震荡,一般设P的5到10倍。D太大会引入噪声,因为OpenMV的offset本身有抖动。可以在STM32端对offset做滑动平均滤波,窗口大小5到10。
第四步,加前馈。弯道时给一个与offset变化率相关的额外转向量,提前打舵。前馈系数一般设0.1到0.3,需要实测。
经验值参考:位置环P=0.8,D=6.0,前馈=0.15;速度环P=1.2,I=0.3。这套参数在QQVGA分辨率、10ms控制周期下比较稳。换赛道要重新微调,但不会偏离太远。
4.4 十字与断线的特殊处理逻辑
十字和断线是丢线的高发区。十字的处理逻辑是:当OpenMV检测到远处和近处都有线,但中间区域线宽异常时,判定为十字。STM32收到十字标志后,保持直行固定时间,同时把位置环的积分项清零,避免十字结束后积分饱和导致猛打舵。
断线的处理更麻烦。如果OpenMV完全看不到线,说明车已经冲出赛道或者进入断线区。这时候不能继续用位置环,要切换到开环保持。如果有陀螺仪,用陀螺仪维持航向;没有陀螺仪就用编码器差速推算,保持左右轮速一致,让车直行。断线区结束后重新检测到线,再切回位置环。
注意:十字和断线的判断阈值要现场调,不同赛道宽度和摄像头高度都会影响。建议在OpenMV IDE里把ROI区域画出来,实时看二值化图像,确认判断逻辑是否准确。
5. 避坑与常见问题:调试中最容易翻车的五个点
5.1 图像阈值漂移导致巡线不稳
现象:上午调好的阈值,下午跑就丢线,或者车在某个区域突然左右摆。
原因:场地光照随时间变化,窗户边尤其明显。或者摄像头自动曝光没关,OpenMV根据环境亮度自动调整,导致二值化结果不一致。
解决:关闭OpenMV自动曝光和自动增益,手动设固定值。现场标定阈值后,把阈值写死在代码里。如果场地光照变化大,加遮光罩或者补光灯。我一般会在车底加一圈LED灯带,给赛道补光,减少环境光影响。
5.2 串口丢帧导致控制抖动
现象:车跑起来一顿一顿的,或者突然猛打舵。
原因:串口波特率不够、中断优先级冲突、电机干扰。最常见的是STM32接收中断被电机控制定时器中断打断,导致数据丢失。
解决:提高波特率到115200以上;把串口接收中断优先级设到最高;电机驱动电源和主控电源分开,加磁珠或者电感隔离;串口线用屏蔽线,屏蔽层单端接地。如果还丢帧,在协议里加帧计数,STM32端检测到帧号不连续就丢弃该帧,避免用错数据。
5.3 PID积分饱和导致弯道过冲
现象:车进弯道时转向不足,出弯道时突然猛打舵,甚至冲出赛道。
原因:位置环积分项在弯道积累过大,出弯时积分项还在输出,导致过冲。或者速度环积分饱和,弯道减速后加速不及时。
解决:位置环用PD不用PID,或者加积分限幅和积分分离。积分分离的思路是:当offset绝对值大于某个阈值时,不累加积分;小于阈值时才累加。速度环积分限幅设最大PWM的20%到30%。
5.4 电机死区未补偿导致低速失控
现象:低速时车不动,加大PWM后突然窜出去。
原因:电机有启动死区,占空比低于某个值时电机不转。PID输出在这个区间内时,车要么不动要么猛动,控制精度极差。
解决:实测死区值,在PWM输出时做补偿。比如死区是15%,PID输出为10%时,实际输出给25%。补偿方向要判断正负,正转加死区,反转减死区。补偿后低速控制会平滑很多。
5.5 电源噪声导致OpenMV复位
现象:车跑着跑着OpenMV重启,串口数据中断。
原因:电机启动瞬间电流大,电池电压跌落,OpenMV供电不足复位。或者电机驱动的地线噪声串到OpenMV电源上。
解决:电池选放电倍率高的,比如2S锂电20C以上;OpenMV供电加LC滤波,电感选10uH以上,电容选100uF以上;电机驱动和OpenMV分别从电池取电,不要串联。如果还复位,在OpenMV电源输入端加TVS管吸收浪涌。
6. 验收与答辩:怎么证明这套方案能跑
6.1 分阶段验收指标
验收不能只看“能跑”,要有量化指标。我一般分三个阶段:
第一阶段,静态测试。车放在赛道上不动,看OpenMV输出的offset是否稳定,波动范围应该在±5以内。串口连续发1000帧,丢帧率低于0.1%。
第二阶段,低速巡线。速度设0.3m/s,跑完整圈,记录丢线次数。合格标准是丢线不超过2次,且每次都能自动恢复。
第三阶段,高速巡线。速度提到0.8m/s以上,跑完整圈,记录圈速和丢线次数。合格标准是圈速比低速快30%以上,丢线不超过3次。
验收表格可以这样设计:
| 测试项 | 测试条件 | 合格标准 | 实测结果 |
|---|---|---|---|
| 静态offset波动 | 车静止在直道 | ±5以内 | |
| 串口丢帧率 | 连续1000帧 | <0.1% | |
| 低速丢线次数 | 0.3m/s跑一圈 | ≤2次 | |
| 高速圈速 | 0.8m/s跑一圈 | 比低速快30% | |
| 十字通过率 | 连续过5个十字 | 100%通过 | |
| 断线恢复率 | 连续过3个断线 | 100%恢复 |
6.2 答辩演示的流程设计
答辩演示时间通常只有几分钟,要提前设计好流程。我一般会按以下顺序:
先展示静态标定过程,打开OpenMV IDE,让评委看到二值化图像和ROI划分,说明阈值是怎么现场调的。然后低速跑一圈,展示基本巡线功能。接着高速跑一圈,展示速度和控制稳定性。最后单独演示十字和断线通过,用慢动作回放或者串口打印的状态机日志证明元素识别逻辑在工作。
如果时间允许,可以准备一个“故障注入”演示:用手挡住摄像头模拟断线,看车是否能自动恢复。这个环节很加分,能证明系统的鲁棒性。
6.3 从验收反推设计改进
验收不是终点,是下一轮迭代的起点。如果高速丢线多,说明视觉刷新率不够或者PID响应慢,可以考虑换更高帧率的摄像头或者优化ROI划分。如果十字通过率低,说明元素判断逻辑有漏洞,可以加更多ROI区域或者用神经网络做分类。如果断线恢复慢,说明开环保持策略不够好,可以加陀螺仪或者里程计辅助。
我自己的习惯是:每次跑完车,把串口日志存下来,用Excel画offset曲线和PWM曲线,看哪里震荡、哪里延迟。数据不会骗人,曲线一看就知道问题在哪。从那以后我每次调车都强制走一遍“静态标定、低速验证、高速冲刺、故障注入”四步流程,少一步都不敢上赛道。希望这套方案能帮到你,少走一些我当年踩过的弯路。
本文还有配套的精品资源,点击获取