☰
2024电赛STM32+OpenMV巡线小车完整工程:分层架构与PID调参避坑指南
2026/10/21 6:34:48 网站建设 项目流程

简介:这份资源是2024年全国大学生电子设计竞赛H题巡线小车的完整工程,面向备赛电赛控制类赛题的学生与嵌入式爱好者,主控采用STM32,配合OpenMV摄像头实现循迹方案。由于作者缺少官方器材,方案以摄像头巡线替代灰度传感器演示,但核心控制思路一致——通过检测黑线与中心线的偏差进行转向调节,灰度方案点位更准、摄像头数据更连续,两者可互相参考。压缩包共1053个文件,约27.45MB,以570个C源文件、256个头文件及51个汇编文件为主体,另含工程配置、库文件与少量Python脚本,构成可直接编译的完整工程。目前已有4259人学习下载。读者可从中获取ABCD四点间丝滑衔接的控制逻辑、统一速度行驶的调试经验,以及MPU6050零漂、初始摆放角度对转向影响等排错思路,并可在其基础上尝试变速策略与四轮同步微调,进一步提升稳定性与整体耗时表现。

1. 从一堆“能跑但不敢改”的工程说起:这套STM32+OpenMV方案到底解决了什么

去年帮一个学弟看电赛备赛工程,他电脑里躺着七八个版本的巡线小车代码,文件夹命名从“final”到“final_真final”,但没一个敢动。现象很典型:OpenMV那边一改阈值,STM32这边就疯跑;STM32调完PID,摄像头又识别不到线。两边像两个闹别扭的部门,各自都能跑,合起来就翻车。这套2024电赛STM32+OpenMV版完整工程,核心价值就在于把“视觉识别”和“运动控制”这两件事的边界划清楚了——OpenMV只负责把赛道线变成一串坐标和偏差值,STM32只负责根据偏差值做决策和电机输出,中间用一套约定好的串口协议通信。它适合正在准备电赛H题巡线小车、或者想拿一套能跑通的视觉闭环控制框架做二次开发的人。你拿到手不是一堆散装代码,而是一个已经分好层的工程:视觉层、通信层、控制层、执行层,每层都能单独替换和调试。这也是为什么我建议先别急着改参数,先把这套分层逻辑看懂,后面调车会省掉大量玄学时间。

2. 工程架构拆解:OpenMV与STM32各自管什么

2.1 视觉端:OpenMV只做三件事

OpenMV在这套方案里的角色非常克制,它不参与任何控制决策,只做三件事:找线、算偏差、发数据。找线用的是灰度阈值加ROI区域扫描,常见做法是在画面里取几行感兴趣区域,每行找黑色线的中心点,然后把这些中心点拟合成一条直线或者直接取最近点的横向偏移。算偏差就是拿线中心跟画面中心做差,得到一个带符号的误差值。发数据通过UART串口,把误差值、线宽、标志位打包成固定长度的帧发出去。

# OpenMV端核心逻辑(简化示意) import sensor, image, time from pyb import UART uart = UART(3, 115200) sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(time=2000) # ROI区域:只扫描画面下半部分,减少计算量 ROI = (0, 60, 160, 60) while True: img = sensor.snapshot() # 在ROI内找灰度值低于阈值的像素,即黑线 blobs = img.find_blobs([(0, 80)], roi=ROI, pixels_threshold=50) if blobs: # 取最大的色块作为赛道线 line = max(blobs, key=lambda b: b.pixels()) # 计算线中心与画面中心的偏差 error = line.cx() - img.width() // 2 # 打包发送:帧头0xAA 0xBB + error高八位 + error低八位 + 线宽 + 帧尾0xCC uart.write(bytearray([0xAA, 0xBB, (error >> 8) & 0xFF, error & 0xFF, line.w() & 0xFF, 0xCC])) time.sleep_ms(10)

这段代码里最值得关注的是ROI的选取和帧格式的设计。ROI只取画面下半部分,是因为巡线场景里赛道线基本出现在近处,上半部分全是远处干扰,砍掉能显著降低OpenMV的运算压力。帧格式用了双字节帧头加单字节帧尾,中间误差值用两个字节表示有符号整数,范围能覆盖正负32767,实际巡线偏差也就正负80以内,完全够用。线宽字段是给STM32做辅助判断用的,比如线宽突然变大可能意味着到了十字路口或者停车线。

2.2 控制端:STM32的接收与决策链路

STM32这边要干的事比OpenMV多得多。它要解析串口数据、做滤波、跑PID、输出PWM、处理按键和OLED显示,还要留出调试接口。这套工程里串口接收用的是空闲中断加DMA的方式,不是每来一个字节进一次中断。常见做法是开一个接收缓冲区,DMA搬完一帧数据后触发空闲中断,在中断里置一个标志位,主循环里再解析。这样CPU不用被串口频繁打断,能把时间留给控制算法。

// STM32端串口接收与解析(基于HAL库,简化示意) #define RX_BUF_SIZE 32 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint8_t rx_flag = 0; volatile uint16_t rx_len = 0; // 空闲中断回调:一帧数据接收完成 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { rx_len = Size; rx_flag = 1; // 通知主循环有完整帧待解析 // 重新开启DMA接收,准备下一帧 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, RX_BUF_SIZE); } } // 主循环中的解析函数 int16_t parse_error(uint8_t *buf, uint16_t len) { // 检查帧头和帧尾 if (len < 6 || buf[0] != 0xAA || buf[1] != 0xBB || buf[len-1] != 0xCC) return 0; // 帧不合法,返回0偏差 // 还原有符号误差值 int16_t err = (int16_t)((buf[2] << 8) | buf[3]); return err; }

解析函数里做了帧头帧尾校验,这是血泪经验——串口在电机干扰下很容易丢字节或者多字节,没有校验的话STM32会拿错位的数据去跑PID,车直接原地转圈。误差值还原成有符号整数后,一般还会做一个滑动平均滤波,把连续几帧的误差取平均再送给PID,避免摄像头识别抖动导致舵机抽搐。

2.3 控制层:位置式PID与电机输出

PID部分用的是位置式,输入是滤波后的偏差,输出是舵机转向角和电机速度补偿。巡线小车一般用两个环:一个转向环控制舵机,一个速度环控制电机。转向环的Kp给大一点让车快速回正,Ki给小一点消除稳态误差,Kd用来抑制过冲。速度环通常只给P或者PI,因为巡线场景对速度精度要求没那么高,反而响应速度更重要。

// 位置式PID结构体与计算函数 typedef struct { float Kp, Ki, Kd; float integral; float prev_error; float output_max; } PID; float pid_calc(PID *pid, float error, float dt) { pid->integral += error * dt; // 积分限幅,防止积分饱和导致回正过慢 if (pid->integral > 100.0f) pid->integral = 100.0f; if (pid->integral < -100.0f) pid->integral = -100.0f; float derivative = (error - pid->prev_error) / dt; pid->prev_error = error; float out = pid->Kp * error + pid->Ki * pid->integral + pid->Kd * derivative; // 输出限幅 if (out > pid->output_max) out = pid->output_max; if (out < -pid->output_max) out = -pid->output_max; return out; }

这里dt的取值很关键,一般跟控制周期一致,比如5ms或10ms。如果dt用错,Kd项会直接算出一个离谱的值,车会像被踢了一脚一样猛打方向。积分限幅也是必须的,不然车在直道上跑久了积分项累积到很大,一进弯道就回正过头,直接冲出赛道。

3. 从零跑通:编译、烧录与联调步骤

3.1 环境准备与工程目录说明

这套工程分两个部分:OpenMV端是一个Python脚本,用OpenMV IDE直接运行;STM32端是一个完整的Keil或者STM32CubeIDE工程,里面包含了HAL库、外设初始化代码和用户逻辑。拿到工程后先别急着编译,花两分钟看一下目录结构。通常会有Core文件夹放主逻辑,Drivers放HAL库,Hardware放电机、舵机、OLED的驱动,Middleware放PID和滤波算法。如果你用的是CubeMX生成的工程,还会有一个.ioc文件,双击能打开图形化配置界面,改引脚和时钟很方便。

提示:OpenMV的脚本和STM32的工程要分开烧录,先烧STM32再运行OpenMV脚本,顺序反了的话STM32会一直等不到数据,串口接收标志位永远不置位。

3.2 编译烧录与串口联调

STM32端编译之前检查三个地方:一是串口波特率跟OpenMV那边是否一致,这套工程默认115200;二是电机和舵机的PWM引脚是否跟你的硬件接线匹配,不同板子引脚可能不一样;三是中断优先级,串口空闲中断的优先级不要设得太高,不然会打断电机控制定时器。编译通过后用ST-Link或者串口下载器烧录,烧录完先别装车,把车架空让轮子悬空,上电看OLED有没有显示,串口助手能不能收到OpenMV发来的数据帧。

# 用串口助手查看OpenMV发来的原始数据(Linux下用minicom或screen) # 波特率115200,8数据位,无校验,1停止位 screen /dev/ttyUSB0 115200 # 正常应该看到类似这样的十六进制流: # AA BB FF F0 0A CC AA BB 00 15 0B CC # 其中 FF F0 表示偏差 -16,00 15 表示偏差 +21

如果串口助手收到的数据帧头不是AABB,或者帧尾不是CC,说明OpenMV那边发送逻辑有问题,或者波特率不匹配。如果数据帧看起来正常但STM32没反应,检查DMA接收是否重新开启,很多人在空闲中断回调里忘了重新调用接收函数,导致只能收到第一帧。

3.3 电机与舵机的中位校准

车架空之后,先做中位校准。舵机中位决定了车直线行驶时前轮的角度,电机中位决定了两个电机在相同PWM下转速是否一致。常见做法是给舵机发一个1.5ms脉宽的PWM,看前轮是否朝正前方,如果偏了就调整机械安装或者改代码里的中位值。电机这边给两个电机相同的PWM占空比,看轮子转速是否一样,不一样的话要么换电机,要么在代码里给一个补偿系数。

// 舵机中位与电机补偿(简化示意) #define SERVO_MID 1500 // 1.5ms脉宽对应中位 #define MOTOR_L_COMP 1.0f #define MOTOR_R_COMP 0.95f // 右电机稍弱,给一个补偿 void set_servo(int16_t offset) { // offset范围-500到500,对应脉宽1000到2000 uint16_t pulse = SERVO_MID + offset; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulse); } void set_motor(float left, float right) { // left和right范围-100到100 int16_t pwm_l = (int16_t)(left * MOTOR_L_COMP * 10); int16_t pwm_r = (int16_t)(right * MOTOR_R_COMP * 10); // 设置PWM比较值,具体取决于定时器配置 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, abs(pwm_l)); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, abs(pwm_r)); // 设置方向引脚 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, pwm_l >= 0 ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, pwm_r >= 0 ? GPIO_PIN_SET : GPIO_PIN_RESET); }

中位校准这步千万别省,我见过太多人直接上赛道跑,结果车一直往一边偏,以为是PID没调好,其实是舵机中位差了50个脉宽。校准完之后把车放地上,用手推着走一遍赛道,看OpenMV的识别效果和STM32收到的偏差值是否合理,再开始调PID。

4. 避坑与常见问题:调车时最容易翻车的几个点

4.1 串口数据错位导致车疯跑

现象是车在直道上突然猛打方向或者原地转圈,串口助手看数据帧偶尔出现帧头不对。原因是电机PWM线跟串口线捆在一起走线,电机换向时产生的电磁干扰串到了串口线上,导致STM32收到的字节错位。解决办法有两个:一是串口线跟电机线分开走,尽量远离;二是在解析函数里加严格的帧头帧尾校验,校验不过直接丢弃这一帧,用上一帧的偏差值顶替。我一般还会在串口接收引脚上加一个100欧姆的电阻和100pF电容做低通滤波,成本很低但效果明显。

4.2 PID参数整定顺序搞反

现象是车要么反应迟钝冲出赛道,要么高频抖动像得了帕金森。原因是很多人先调Kp再调Ki最后调Kd,但巡线场景里Kd对抑制过冲非常关键,应该先把Kp和Kd一起调到一个临界状态,再加Ki。具体做法是先把Ki和Kd置零,慢慢增大Kp直到车开始等幅振荡,然后固定Kp,增大Kd直到振荡消失,最后加一点点Ki消除直道上的稳态误差。注意Kd对噪声非常敏感,如果OpenMV的偏差值抖动大,Kd要慎用,或者先加强滤波。

4.3 OpenMV阈值随光照变化失效

现象是上午调好的阈值下午就跑不了,或者室内能跑室外就识别不到线。原因是灰度阈值对光照强度非常敏感,环境光一变,黑线和白底的灰度值都跟着变。解决办法是不要用固定阈值,改成动态阈值。常见做法是每帧先算ROI区域的平均灰度,然后取平均灰度乘以一个系数作为阈值,这样光照变暗时阈值自动降低。另外可以在赛道旁边放一个白色参考块,OpenMV定期读一下参考块的灰度值来校准。

4.4 电机启动瞬间复位

现象是车一给油门STM32就重启,OLED黑屏。原因是电机启动电流太大,把电池电压拉低到STM32的欠压复位阈值以下。解决办法是电机电源和STM32电源分开供电,或者至少在电机电源端并一个大电容(比如470uF)储能。如果用的是锂电池,检查电池放电能力是否足够,有些小容量电池带两个电机确实吃力。软件上也可以在电机启动时加一个斜坡,让PWM占空比慢慢加上去,不要一上来就给满。

4.5 定时器中断优先级冲突

现象是串口能收到数据但PID不执行,或者PID执行了但串口数据不更新。原因是串口空闲中断和PID控制定时器中断的优先级设置不当,高优先级中断一直抢占低优先级,导致低优先级中断进不去。解决办法是把PID控制定时器的中断优先级设成最高,串口接收中断设成次高,其他外设再往后排。在CubeMX里可以直接拖拽调整,注意不要设成同一个抢占优先级,否则会互相打断。

5. 二次开发:换传感器、改算法、加功能

5.1 把OpenMV换成灰度传感器阵列

如果你觉得OpenMV成本高或者光照适应性差,可以把视觉端换成灰度传感器阵列。这时候STM32端的串口解析代码要改,因为灰度传感器一般输出的是模拟量或者I2C数字量,不是串口帧。常见做法是写一个传感器抽象层,上层PID只关心偏差值,底层具体是OpenMV还是灰度阵列不影响控制逻辑。这样换传感器只需要改底层驱动,PID和电机控制代码一行不用动。

// 传感器抽象层接口 typedef struct { int16_t (*get_error)(void); // 返回偏差值 uint8_t (*is_valid)(void); // 返回数据是否有效 } SensorInterface; // OpenMV实现 int16_t openmv_get_error(void) { return parse_error(rx_buf, rx_len); } uint8_t openmv_is_valid(void) { return rx_flag; } // 灰度阵列实现 int16_t gray_get_error(void) { /* 读ADC或I2C,算偏差 */ } uint8_t gray_is_valid(void) { /* 检查数据是否在合理范围 */ } // 上层控制逻辑只依赖接口 SensorInterface sensor = { openmv_get_error, openmv_is_valid };

这种抽象层写法在电赛里特别实用,因为赛题可能临时要求换传感器,有抽象层的话半天就能改完,没有的话要动整个工程。

5.2 从位置式PID换成增量式或模糊PID

位置式PID在巡线场景够用,但如果你想让车在弯道里跑得更快更稳,可以试试增量式PID或者模糊PID。增量式PID的输出是上一次输出加上增量,对积分饱和不敏感,适合电机这种需要频繁换向的场景。模糊PID则是根据偏差和偏差变化率动态调整Kp、Ki、Kd,弯道给大Kp快速响应,直道给小Kp减少抖动。改的时候注意保持接口一致,pid_calc函数的输入输出不变,只换内部实现,这样上层代码不用动。

5.3 加OLED调试页面和按键调参

这套工程一般会留一个OLED和一个按键,用来在车上直接调PID参数,不用每次插电脑改代码。常见做法是定义一个参数结构体,按键短按切换参数项,长按进入编辑模式,旋转编码器或者加减按键改数值,改完自动保存到Flash。这样在赛道边调车效率极高,不用来回插拔下载线。

// 参数保存到Flash(简化示意) typedef struct { float Kp, Ki, Kd; uint16_t servo_mid; uint8_t magic; // 用于判断Flash是否已初始化 } CarParams; CarParams params; void save_params(void) { HAL_FLASH_Unlock(); // 擦除对应扇区后写入 // ...具体擦写逻辑取决于芯片型号 HAL_FLASH_Lock(); } void load_params(void) { // 从Flash读取,如果magic不对则加载默认值 if (params.magic != 0x5A) { params.Kp = 1.0f; params.Ki = 0.0f; params.Kd = 0.5f; params.servo_mid = 1500; params.magic = 0x5A; } }

5.4 验证改造是否成功的三个方法

改完代码别急着上赛道,先用三个方法验证。第一,架空车看轮子转向和舵机方向是否跟偏差值符号一致,偏差为正时舵机应该往左打还是往右打,这个逻辑错了车会反向跑。第二,用手推着车沿赛道走,看OLED上显示的偏差值是否连续变化,如果跳变很大说明滤波不够或者传感器安装有问题。第三,用示波器或者逻辑分析仪看PWM输出是否平滑,如果PWM占空比突变说明PID输出限幅没做好。

从那以后我每次改完控制代码,都强制走一遍“架空看方向、手推看数据、示波器看波形”这三步,再也不敢直接上赛道。希望这套工程和这些踩坑记录能帮你少走点弯路,顺利把车跑起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询