简介:面向2024年电子设计竞赛H题参赛者的完整代码包,聚焦信号处理与STM32微控制器硬件控制,适合具备C语言与嵌入式基础、正在备赛的高校电子与计算机类学生。压缩包约114.28MB,内容以C语言源码与STM32相关工程文件为主,围绕赛题核心模块展开,涵盖信号采集、滤波与频谱分析等功能的程序实现,以及基于STM32的外设驱动、控制逻辑与数据交互代码,可帮助理解从模拟输入到数字处理再到外设响应的完整链路。已有5720人学习下载,适合对照赛题要求进行代码研读、模块移植与在线调试。除主程序外,包内还包含独立的功能拆分文件,便于分模块梳理算法与硬件控制流程,减少底层驱动和信号处理框架的重复搭建,可显著提升备赛效率与方案完整性。对于希望在电赛H题中快速搭建原型、核实关键算法细节的读者,这套代码是一份可直接落地的实践参考资料。
1. 2024年电赛H题:这类视觉小车代码的核心不在代码,在闭环
2024年电赛H题的方向是自动行驶小车,要求小车在指定赛道上完成循迹、识别标记、停车等动作。很多同学拿到代码包第一件事就是烧录,结果车不走或者乱走,于是觉得“代码有问题”。实际上这类题的代码从来不是跑通的,是调通的。摄像头看到什么、主控怎么理解、电机怎么响应,这三者之间任何一环没对齐,车就会翻。这篇笔记适合正在备赛的参赛队,也适合想入门视觉小车的人。我会把一套能稳定跑完赛道的代码拆开讲,从图像处理、串口协议到PID控制,最后给出调车阶段的排查清单。这套写法不挑具体板子,OpenMV或者K210配STM32都能用,重点是你得知道每行代码在帮小车回答什么问题。
2. 赛题代码从哪里下手:先搭系统,再写代码
2.1 硬件选型决定了代码长什么样
视觉小车的代码结构,很大程度上是硬件方案决定的,不是你想怎么写就怎么写。常见的方案有两类:一类是单芯片方案,直接用K210或者树莓派Pico外加摄像头模块,所有逻辑写在一块板子上;另一类是双芯片方案,用OpenMV或者K210做视觉采集与识别,把结果通过串口发给STM32或者GD32做主控,由主控去驱动电机和舵机。
我在备赛时用的是后一种方案。理由很简单,2024年H题这类赛题不仅要循迹,还要判断岔路、识别停止线,甚至可能需要区分颜色标记。视觉任务越复杂,越需要把图像处理和实时控制分开。视觉芯片跑图像算法时,中断和时序会受很大影响,如果这时候还让它控制电机,出问题的概率会翻倍。双芯片方案里,主控只需要关心串口里收到的数字,然后调速、转向,逻辑非常干净,调试时也容易定位是视觉的问题还是控制的问题。
代码组织同样受这个方案影响。视觉端的代码只负责回答三个问题:赛道在哪里、车偏了多少、前面有没有特殊标记。主控端的代码也只回答两个问题:当前应该多快、应该往哪边转。你不要在一段代码里既做图像识别又做PID运算,那样后面没法调。
2.2 主控与外设的代码分层:感知、决策、执行
把代码分成三层,这是我从往届优秀方案里总结出来的经验,自己用也顺手。感知层是视觉代码,处理摄像头画面,输出偏差值和标记信息。决策层在主控里,根据感知数据决定目标速度和目标转向角。执行层再往下,用PID去驱动电机和舵机,让实际速度和转向逼近目标值。
分层的好处是在赛前调试时可以单独验证每一层。比如车不动,你先看执行层,手动给一个PWM值,看电机转不转;电机转了再看决策层,手动往串口发一个偏差值,看车轮有没有对应动作;最后才查视觉层。按这个顺序排查,十分钟就能定位问题,而不是像个黑匣子一样瞎试。很多队伍拿到别人的代码后,一上来就改PID参数,结果车还是不走,就是因为他们跳过了执行层和决策层的验证,直接在最高层调参。
2.3 最小可跑通的工程骨架
一个能跑的工程,文件不需要很多,但结构必须清晰。我一般会在视觉芯片里放一个main.py,里面包含初始化、主循环;主控端放一个main.c或者main.py,里面包含串口接收、PID计算、电机输出。下面是一个典型的主控端主循环骨架,你可以照着把工程搭起来。
int main(void) { // 初始化串口1,接收视觉模块的偏差数据 uart_init(UART1, 115200); // 初始化定时器,产生PWM控制电机和舵机 timer_init(TIM2, 1000); // 1kHz 控制频率 // 初始化OLED用于调试显示 oled_init(); while (1) { // 1. 从串口读取视觉数据,解析出偏差值 if (uart_receive_frame(&frame) == OK) { target_speed = calculate_speed(frame.deviation, frame.marker); target_steer = calculate_steer(frame.deviation); // 2. 把目标值交给PID控制器 pid_set_target(&speed_pid, target_speed); pid_set_target(&steer_pid, target_steer); } // 3. 跑一遍PID并更新PWM // 这一行在定时器中断里做也可以,效果差别不大 pid_process_1000hz(&speed_pid, motor_read_speed()); pid_process_1000hz(&steer_pid, servo_read_angle()); } }这个骨架的重点是主循环只做两件事:收数据、跑控制。任何延时、打印、按键扫描如果在这个循环里做太多,都会拖慢控制频率。比如你用延时函数加一个按键消抖,假设每次消抖20毫秒,控制频率就直接从1kHz掉到50Hz,小车高速过弯时必翻车。我一般把按键扫描放到定时器中断里,主循环保持纯净。
主控端的代码解释完了,但整个系统真正的难点在视觉端。下面这一章我们来处理视觉感知,这也是整个H题代码里最容易翻车、最需要反复调的部分。
3. 视觉感知模块:图像怎么变成赛道参数
3.1 用LAB阈值分割提取赛道边界
2024年H题的赛道背景通常是浅色地面,赛道线是深色或者彩色。视觉端第一步是把赛道从背景里分离出来,我用的方法是在LAB色彩空间做阈值分割。为什么不用RGB?RGB三个通道对光照变化特别敏感,同一段赛道在阳光下和阴影里颜色差异很大,阈值很难统一。LAB的L通道是亮度,A和B通道是色彩,赛道和背景通常在A通道或者B通道上有稳定的差异,把亮度干扰丢掉,阈值就好调很多。
OpenMV的find_blobs就是干这个的。下面这段代码里,我定义了深色赛道线的LAB阈值,然后提取所有符合阈值的色块,再从中挑出面积最大的一个作为赛道目标。
import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.RGB565) # RGB565比灰度图保留更多颜色信息 sensor.set_framesize(sensor.QVGA) # 320x240,够用且处理速度快 sensor.skip_frames(30) sensor.set_auto_whitebal(False) # 关闭白平衡,防止颜色漂移 # 赛道线阈值,LAB色彩空间的下限和上限 # 这个值需要在不同光照下反复采集标定 THRESHOLD = [(10, 40, -20, 30, -10, 20)] def find_track(): img = sensor.snapshot() blobs = img.find_blobs(THRESHOLD, pixels_threshold=100, area_threshold=100) if blobs: # 挑选面积最大的色块作为主赛道区域 max_blob = max(blobs, key=lambda b: b.area()) img.draw_rectangle(max_blob.rect()) return max_blob return None这段代码里有几个关键点。pixels_threshold和area_threshold用于过滤噪点,如果设得太小,画面里的杂散反光会被当成赛道;设得太大,远处的赛道就丢了,因为远处赛道的面积本来就小。我一般先用100起步,实测后图像里只有一处大色块,那就说明过滤条件合理。这些参数直接影响小车能不能看到远处弯道,需要在现场实际光线条件下标定,不是在实验室调好就完事的。
3.2 计算车体位置偏差与弯道方向
有了赛道色块,下一步是把它变成一个数——偏差值。偏差值代表小车中心线和赛道中心线之间的横向偏移量,单位用像素。OpenMV的色块对象提供了cx()和cy()方法,分别返回色块中心点的横纵坐标。用画面中心线的x坐标减去色块中心的x坐标,就得到了偏差。
但只用色块中心有个问题:车已经上了赛道,色块往往集中在画面下方,中心点反映的是近处赛道位置,对远处弯道不敏感。我一般改用色块的质心加权计算,把色块下半部分和上半部分分别处理,近处偏差和远处方向信息糅合在一起。具体做法是,把画面分成上下两半,分别计算色块中心,然后近处偏差占权重大,远处方向占权重小,得到一个合成偏差。
def compute_deviation(blob, img_width=320): # 画面下半部分占60%权重,上半部分占40% mid_y = blob.cy() # 色块中心y坐标 if blob.cy() < img_width * 0.4: # 赛道太远,偏差参考意义弱,放大返回 return (blob.cx() - img_width // 2) * 1.5 # 近处赛道,直接用中心偏差 return blob.cx() - img_width // 2这里乘1.5是为了让远处赛道出现偏差时,小车能提前做出反应。弯道识别太晚是这类视觉小车最常见的问题,总是在进弯的一瞬间才打方向,车身姿态已经救不回来了。提前放大偏差,相当于让转向的灵敏度在远处就开始生效。这个系数不是定死的,你的赛道如果弯道急,可以调到2.0;弯缓则调到1.2。我在调车时通常先在直道上确认偏差输出为0,再进弯道看偏差是否平滑变化,而不是跳变。
3.3 串口通信协议:发送什么、多久发一次
视觉端算出了偏差值,接下来要通过串口发给主控。通信协议我建议用固定帧格式,不要发裸数据。裸数据的坑在于,偏差值是多字节数字,主控端解析时一旦收到半截数据,后面的帧全错位。加了帧头、帧尾和校验之后,主控可以检测到错位并重新同步。
我常用的帧格式是:帧头0xAA,随后是数据长度、偏差值的高字节和低字节、特殊标记值,最后是校验和。偏差值范围是-100到100,用有符号16位整数表示,特殊标记用来告知主控是否识别到停车线或岔路标志。发送频率要稳定,一般每50毫秒发送一帧,也就是20Hz。太快会挤占串口缓冲,太慢主控控制跟不上。这一段代码我一般放在主循环里,不单独开线程。
# 组装串口数据帧:AA 03 [偏差高8位] [偏差低8位] [标记] def send_frame(uart, deviation, marker): dev_int = int(deviation) dev_high = (dev_int >> 8) & 0xFF dev_low = dev_int & 0xFF data = bytearray([0xAA, 0x03, dev_high, dev_low, marker]) # 最后一个字节是校验和,累加所有前序字节 checksum = sum(data) & 0xFF data.append(checksum) uart.write(data)marker的值可以预先约定好:0表示无标记,1表示停车线,2表示岔路,3表示终点。主控端根据marker决定是继续跑、减速还是停车。这里有个细节,视觉端识别到标记后不能只发一帧,因为主控可能刚好没收漏了。我一般的做法是持续发3到5帧同一个标记,直到主控确认收到。确认机制可以是在主控端收到标记后回传一个ACK字节,视觉端收到ACK才清掉当前状态。这个双向确认机制在比赛现场特别管用,能避免小车明明看到了停车线却不减速的情况。
4. 电机控制与PID参数:让小车跟着代码跑起来
4.1 差速转向控制的思路
小车转向方式决定了控制代码的复杂度。常见的H题小车底盘有两种:舵机转向和差速转向。舵机转向是前轮偏转,后轮驱动,控制模型简单,偏差直接映射到舵机角度就行。差速转向是两个后轮独立驱动,靠左右轮转速差实现转向,转弯半径可以非常小,甚至原地转圈。
我比较推荐差速底盘,因为赛道上复杂的S弯和U形弯,舵机底盘往往转向半径不够,差速底盘可以通过一个轮正转一个轮反转完成小半径掉头。代价是控制代码复杂一些,需要同时管理两个电机的速度和转向。下面这套代码是我常用的差速PID结构,速度环和转向环分离。速度环控制整体前进速度,转向环控制左右轮速度差。两个环互不干扰,调参时可以分开整定。
4.2 速度环与转向环的分离式PID实现
实际写代码时,转向环的输出是一个修正量,叠加到基础速度上。左轮速度等于基础速度减去转向修正,右轮速度等于基础速度加上转向修正。这样小车直行时偏差为0,两边速度一样;偏差变大时,一边加速一边减速,自然形成转向效果。
我这里用了一个简单的增量式PID,输出是修正量而不是绝对值。增量式的好处是,突然拔掉电源再上电时,不会有巨大的积分冲击,车不会猛地弹出去。
class IncrementalPID: def __init__(self, kp, ki, kd, integral_limit=50): self.kp = kp self.ki = ki self.kd = kd self.last_error = 0 self.integral = 0 self.integral_limit = integral_limit def update(self, error, dt=0.02): # 增量式PID:输出是本次修正量,叠加到目标值上 p_term = self.kp * error self.integral += error * dt # 积分限幅,防止积分饱和导致过冲 if self.integral > self.integral_limit: self.integral = self.integral_limit elif self.integral < -self.integral_limit: self.integral = -self.integral_limit i_term = self.ki * self.integral d_term = self.kd * (error - self.last_error) / dt self.last_error = error return p_term + i_term + d_term # 转向修正量 steer_pid = IncrementalPID(kp=1.2, ki=0.05, kd=0.08) # 基础速度,单位是编码器每秒计数值,需要根据底盘实测 base_speed = 400 # 偏差从视觉端来,正数代表偏右,负数代表偏左 deviation = get_deviation_from_serial() steer_correction = steer_pid.update(deviation) left_speed = base_speed - steer_correction right_speed = base_speed + steer_correction这段代码里,dt默认0.02秒,对应控制频率50Hz。如果主循环里用了任何有可能超过20毫秒的操作,这个频控就不准了。我一般会在主循环开头记录时间戳,每次计算时用实际的时间差,而不是固定值。另外,左右轮速度是编码器目标值,实际给到电机的PWM还要经过一个电流环或速度环,这个取决于你的电机驱动板。如果驱动板自带速度闭环,你只需要写PWM占空比就行。
4.3 三个必调的参数:Kp、Ki、Kd的整定顺序
PID参数整定是所有调车环节里最玄学的部分,但很多队伍翻车不是因为不会调,而是因为上来就调Ki。我的整定顺序从来都是Kp优先,Ki其次,Kd最后。
第一步,把Ki和Kd都设成0,只调Kp。从0.5开始,让小车在直道上跑,看偏差波动。如果小车左右摆动越来越厉害,说明Kp太大,减小到0.3再试。如果小车反应迟钝,冲出赛道才打方向,说明Kp太小,加大到0.8。第二步,加入Ki,主要解决稳态偏差——车始终偏向一边的固定偏移,抵消底盘重心不对称或电机装配误差。Ki从0.02起步,每次加0.01。第三步,加Kd抑制超调,Kd相当于提前踩刹车,弯道出口处如果车身回摆明显,就加大Kd。Kd从0.05起步,注意Kd过大在直道上会引发高频抖动。
这套顺序听起来简单,实际调车时每一轮都要跑完一整圈才有效果。抄别人的PID参数是不能用的,因为底盘、轮胎摩擦力、电池电压全都不一样。电池电量下降时,同样的PWM跑出来的实际速度会变低,你的PID参数在满电时刚好,低电量时可能变得很迟钝。所以正式比赛前,我会把电池充到额定电压再调参,并且调完就用同一批电池。
5. 调车阶段的避坑与排查:代码没错车不走怎么办
5.1 现象:阈值调了一下午,赛道提取还是花屏
这是视觉端最常见的坑。表现是画面里识别出来的色块一会大一会小,有时把反光的白色地面也当成赛道,有时真正的赛道反而丢了。原因大概率是白平衡没有关闭。OpenMV和K210的摄像头默认会自动调整白平衡,画面颜色会跟着环境光线实时变化,你上午标定的阈值,下午换了光照环境就不适用了。
解决方法是初始化时显式关闭白平衡和自动曝光,sensor.set_auto_whitebal(False)和sensor.set_auto_exposure(False)。然后重新在比赛现场实际光照下标定阈值。如果现场光线确实变化大,可以多做几组阈值,在代码里设定一个按键切换逻辑,现场按一下切换到合适的组。我之前干过一件事,把三组阈值存成数组,用小车上一个按键轮询切换,然后OLED上显示当前是第几组,现场调车效率提升非常明显。
5.2 现象:串口有数据,电机没反应
这类问题往往是主控端串口解析的锅,不是电机的问题。你先用USB转TTL接在主控串口上,手动往里面发一帧数,看电机动不动。如果动了,说明执行层没问题,再看串口配置。常见原因是波特率不匹配,视觉端是115200,主控端初始化为9600,数据全是乱码。还有一个我踩过的坑是串口空闲中断配置错误,导致一帧数据要等超时才能被处理,主控制器反应慢半拍,车看起来就像“轴”一样。
解决方法是先确认波特率一致,然后打印主控收到的原始数据,看是否和视觉端发送的字节一致。看着不对再逐字节排查帧格式,看是不是帧头被前面多余字符挤掉了。
5.3 现象:直道稳、弯道冲出去
这是控制参数的时间尺度问题。直道稳定说明偏差小的时候反馈控制没问题,弯道冲出赛道往往是控制频率太低,或者转向修正量太小。先用示波器或者调试输出看进弯时偏差值变化,正常情况下弯道偏差应该平滑增大,如果偏差阶跃变化,说明视觉端丢帧,主控收到的是上一次的旧数据。
再说控制频率,如果你的主控主循环里有延时函数或者OLED刷新,控制频率可能掉到10Hz以下,弯道里反馈一遍要100毫秒,车早就飞出去了。解决方法是把OLED刷新放到一个低频分支里,比如每100次主循环刷新一次,控制代码保持高频执行。转向修正量太小也好解决,把基础速度从400降到300跑一圈看效果,如果弯道能过,说明是修正量不够,加大转向环的Kp系数,而不是硬撑速度。
5.4 现象:重启后参数全丢
某些参数如果存在主控的RAM里,断电就清零。比赛现场有个常见的场景:车跑完一圈回来,接线碰掉又重新插上,结果发现原来调好的PID参数变成了出厂值,车又开始鬼畜摆动。这种情况说明你的参数是硬编码在初始化函数里的,每次上电都重置。
解决办法是把参数存到芯片的Flash或者EEPROM里。也可以更简单,把关键参数定义在一个单独的头文件里,每次重新编译烧录就能固化。如果你用的是开源的固件或者别人的代码,先确认参数存储位置,别改完参数不保存就上电。
5.5 现象:停车线识别到了,但车不减速
这类问题在2024年H题里特别多,因为停车线不是一个纯色块,它是横向的条纹区域,和赛道线颜色相似但方向不同。很多队伍沿用找赛道线的方法找停车线,找到的是一根斜线甚至一块光斑,色块面积不够大直接被过滤器丢了。
解决办法是用两次判断。第一次用宽的阈值找大面积浅色区域,判断画面下边缘是否有横向宽带;第二次在带内检测多条纵向暗线,同时出现3条以上才算停车线。识别逻辑并不复杂,关键是你得把“停车线”单独建模,不要指望一个find_blobs能同时处理赛道和停车线。这个判断一旦误触发,会导致小车在赛道中间突然刹车,比不刹车更糟。
6. 验证方法:让小车“每次都跑出同一圈”才算调完
调车到最后,很多队伍陷入了玄学——这次跑完了,下次又不行,也不知道改了什么。我最常做的一件事是给小车建立“重复性基线”:同一赛道、同一电量、同一阈值组,连续跑五圈,记录每圈完成时间和是否冲出赛道。如果五圈结果基本一致,说明系统是可控的。如果忽快忽慢、这次过弯下次冲出,说明还有偶发问题没解决,这时候不要继续调参数,回去查硬件连接和代码时序。
进阶方向上,如果基础循迹已经稳定,可以试着加入两点增强。第一点是动态阈值,根据画面上半部分的平均亮度实时调整阈值边界,这能显著提升阳光移动时的适应性。第二点是前瞻距离调整,把偏差计算从当前帧改成前瞻几帧的加权平均,相当于让小车“看更远”,过S弯时提前量更足。这两个功能不要同时加,先加一个,跑一版,验证没问题再加另一个。
我的习惯是每调一次参数就备份一个版本,命名里带日期和效果摘要,比如“baseline_0518_fast5laps”或者“turn_better_lowbatt”。比赛前夜绝不改大逻辑,只微调阈值和PID。这套习惯帮我避免过很多次“昨天还好好的,今天全乱了”的情况。希望帮到你。
本文还有配套的精品资源,点击获取