news 2026/10/21 6:34:49

STM32+OpenMV电赛巡线小车:从看懂到敢改的完整工程拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32+OpenMV电赛巡线小车:从看懂到敢改的完整工程拆解

简介:这份资源是2024年全国大学生电子设计竞赛H题巡线小车的完整工程,面向备赛电赛控制类赛题的学生与嵌入式爱好者,主控采用STM32,配合OpenMV摄像头实现循迹方案。由于作者缺少官方器材,工程以摄像头巡线替代灰度传感器演示,但核心控制思路一致——通过黑线与中心线的偏差关系调节转向,灰度方案点位更准、数据相对离散,用于控制同样可行。压缩包共1053个文件,约27.45MB,以570个C源文件、256个头文件、51个汇编文件及若干工程配置与库文件为主,包含完整的Keil工程、OpenMV脚本与可烧录固件。目前已有4259人学习下载。工程展示了ABCD四点间丝滑衔接的完整控制逻辑,并指出MPU6050零漂、初始摆放角度、统一速度可改为变速等优化方向,适合读者对照复现、理解偏差控制原理并进一步调参提速。

1. 从一堆“能跑但不敢改”的工程说起

如果你手头正好有一份 2024 电赛 H 题巡线小车的 STM32+OpenMV 完整工程,打开压缩包的第一反应大概率是:文件不少,但不知道从哪下手。这个项目就是冲着这个痛点来的——它把电赛现场那套“感知—决策—执行”的链路完整落到了一个可编译、可烧录、可改参数的工程里。STM32 负责底盘运动控制和实时响应,OpenMV 负责图像采集与巡线偏差解算,两者通过串口交换数据。适合两类人:一是准备电赛但还没搭出完整框架的,二是拿到类似工程想二次开发却怕改崩的。下面我按“先看懂、再跑通、最后敢改”的顺序拆一遍。

2. 系统架构与目录结构:先搞清楚谁在管什么

2.1 双核分工:STM32 管实时,OpenMV 管视觉

这个工程最核心的设计思路是任务分离。STM32 不碰图像,OpenMV 不碰电机,两边各干各的擅长的事。STM32 这边跑的是裸机主循环加定时器中断的经典结构,主循环处理串口协议解析、状态机切换和调试输出,定时器中断里跑 PID 计算和 PWM 更新。OpenMV 那边跑的是 MicroPython 脚本,循环执行sensor.snapshot()取图、色块或线检测、计算偏差、打包成固定帧格式通过 UART 发出。

为什么这么分?因为巡线小车的控制周期和图像处理周期完全不是一个量级。电机控制需要 5ms 甚至更短的响应,而图像处理一帧动辄 20-30ms。如果硬塞进一颗芯片,要么图像掉帧,要么控制抖动。分开之后,STM32 只管“收到偏差就调”,OpenMV 只管“看到什么就报”,耦合度低,调试也方便——你可以单独用串口助手模拟 OpenMV 发数据来测 STM32 的控制逻辑。

常见做法是两边约定一个简单的帧协议,比如帧头 0xAA 0x55、数据长度、偏差值、校验和。这个工程用的就是类似结构,后面代码部分会展开。

2.2 目录结构:每个文件夹对应一个明确职责

拿到工程先别急着打开 main.c,先把目录树扫一遍。典型的 STM32+OpenMV 工程目录大概长这样:

Project/ ├── STM32/ │ ├── Core/ │ │ ├── Inc/ # 头文件:pid.h、motor.h、uart_protocol.h │ │ └── Src/ # 源文件:main.c、pid.c、motor.c、uart_protocol.c │ ├── Drivers/ │ │ ├── STM32F1xx_HAL_Driver/ # HAL 库 │ │ └── CMSIS/ # 内核支持 │ └── MDK-ARM/ # Keil 工程文件,.uvprojx ├── OpenMV/ │ ├── main.py # 主脚本:图像处理 + 串口发送 │ └── config.py # 阈值、ROI、串口波特率等参数 └── Doc/ └── protocol.md # 串口帧格式说明

这个结构的好处是边界清晰。STM32 的 Core/Inc 和 Core/Src 只放业务代码,HAL 库和 CMSIS 不动。OpenMV 的 config.py 把所有可调参数集中管理,改阈值不用翻 main.py。Doc 里放协议说明,队友之间对接不用靠嘴传。

我一般会建议在 Core/Inc 里再加一个board_config.h,把引脚定义、串口选择、波特率这些硬件相关的东西集中起来。这样换板子或者改接线的时候只动一个文件,不用全局搜索替换。

2.3 数据流:从摄像头到轮子的完整链路

把数据流画清楚,后面看代码就不会迷路:

  1. OpenMV 取一帧图像,在 ROI 区域内做线检测或色块检测
  2. 计算偏差值(比如线中心与图像中心的像素差),归一化到 -100 到 +100
  3. 按帧格式打包,通过 UART 发出
  4. STM32 串口中断接收,存入缓冲区
  5. 主循环解析帧,提取偏差值,更新目标值
  6. 定时器中断触发 PID 计算,输出 PWM 占空比
  7. PWM 驱动电机,小车转向

整条链路里,最容易出问题的是第 3 和第 5 步——帧格式对不上、校验失败、数据错位,这些后面避坑章节会细说。

3. 核心模块拆解:串口协议、PID 和电机控制

3.1 串口帧协议:两边约定好的“暗号”

先看协议定义。这个工程用的帧结构是:

字节位置内容说明
00xAA帧头1
10x55帧头2
2数据长度固定为2
3偏差高字节int16 高8位
4偏差低字节int16 低8位
5校验和前5字节异或

STM32 侧的解析代码大概长这样:

// uart_protocol.c #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_LEN 6 typedef struct { uint8_t buf[FRAME_LEN]; uint8_t idx; int16_t offset; // 解析出的偏差值 uint8_t ready; // 一帧完整数据就绪标志 } UartFrame_t; UartFrame_t g_frame; // 串口中断回调,逐字节喂入 void Uart_RxCallback(uint8_t byte) { // 状态机:先找帧头,再收数据 if (g_frame.idx == 0 && byte != FRAME_HEAD1) return; if (g_frame.idx == 1 && byte != FRAME_HEAD2) { g_frame.idx = 0; // 帧头不对,重置 return; } g_frame.buf[g_frame.idx++] = byte; if (g_frame.idx >= FRAME_LEN) { // 校验:前5字节异或 uint8_t sum = 0; for (int i = 0; i < 5; i++) sum ^= g_frame.buf[i]; if (sum == g_frame.buf[5]) { g_frame.offset = (int16_t)((g_frame.buf[3] << 8) | g_frame.buf[4]); g_frame.ready = 1; } g_frame.idx = 0; // 无论校验是否通过都重置,准备下一帧 } }

这段代码的关键点有三个。第一,状态机逐字节解析,不依赖 HAL 的 IDLE 中断或 DMA,简单可靠,适合新手理解。第二,校验失败直接丢弃,不做重传,因为巡线场景下丢一帧数据影响不大,下一帧很快就来。第三,g_frame.ready标志由主循环消费,中断里只做解析不做控制,避免在中断里跑 PID。

参数说明:FRAME_LEN固定为 6,如果协议要加数据(比如加一个速度值),改这个宏和校验循环长度即可。offset是 int16,范围 -32768 到 32767,实际用的时候 OpenMV 那边会限制在 -100 到 +100。

3.2 PID 控制器:位置式还是增量式

巡线小车的转向控制本质是一个单环 PID:输入是偏差,输出是转向修正量。这个工程用的是位置式 PID,代码在定时器中断里跑:

// pid.c typedef struct { float Kp, Ki, Kd; float integral; float prev_error; float output_max; // 输出限幅 } PID_t; float PID_Calc(PID_t *pid, float error, float dt) { // 比例项 float p = pid->Kp * error; // 积分项,带限幅防饱和 pid->integral += error * dt; if (pid->integral > pid->output_max) pid->integral = pid->output_max; if (pid->integral < -pid->output_max) pid->integral = -pid->output_max; float i = pid->Ki * pid->integral; // 微分项 float d = pid->Kd * (error - pid->prev_error) / dt; pid->prev_error = error; // 求和并限幅 float out = p + i + d; if (out > pid->output_max) out = pid->output_max; if (out < -pid->output_max) out = -pid->output_max; return out; }

为什么用位置式而不是增量式?因为巡线场景下积分项需要明确限幅。位置式 PID 的积分累加值可以直接钳位,防止小车在丢线时积分饱和导致猛打方向。增量式虽然天然抗饱和,但输出是增量,调试时不如位置式直观。

参数整定的血泪经验:先调 Kp,让小车能基本跟着线走但有点抖;再加 Kd,把抖动压下去;Ki 最后加,而且给很小,主要是消除稳态误差。dt是定时器中断周期,这个工程里是 5ms,也就是 0.005 秒。如果改定时器频率,dt必须同步改,否则微分项会算错。

3.3 电机控制:PWM 差速转向

PID 算出来的输出值最终要变成左右轮的 PWM 占空比。这个工程用的是差速转向:

// motor.c #define BASE_SPEED 3000 // 基础速度,对应PWM比较值 #define MAX_PWM 7200 // 定时器自动重装载值 void Motor_Update(float pid_out) { int16_t left = BASE_SPEED + (int16_t)pid_out; int16_t right = BASE_SPEED - (int16_t)pid_out; // 限幅,防止超出PWM范围 if (left > MAX_PWM) left = MAX_PWM; if (left < 0) left = 0; if (right > MAX_PWM) right = MAX_PWM; if (right < 0) right = 0; __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, left); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, right); }

逻辑很直白:PID 输出为正时,左轮加速右轮减速,小车右转;为负时反过来。BASE_SPEED决定了直道速度,MAX_PWM由定时器的 ARR 寄存器决定。如果电机转向反了,交换左右轮的通道或者取反pid_out都行,但建议在硬件接线层面解决,代码里保持逻辑一致。

注意pid_out是浮点数,强转 int16 会截断小数部分。对巡线来说精度够用,但如果你的 PID 输出很小(比如 Kp 调得很低),截断可能导致低速时响应迟钝。解决办法是把BASE_SPEED和 PID 输出都放大一个倍数,最后再除回去。

4. 跑通工程:编译、烧录、联调三步走

4.1 STM32 侧:Keil 编译与烧录

打开MDK-ARM目录下的.uvprojx文件,先确认三件事:芯片型号选对了没有、下载器配置对不对、串口波特率是否和 OpenMV 一致。这个工程默认用的是 115200,在uart_protocol.h里改。

编译之前检查一下board_config.h(如果有的话)里的引脚定义,特别是串口引脚和电机 PWM 引脚。不同板子的引脚映射不一样,直接烧录可能没反应。编译通过后烧录,用串口助手打开对应的 COM 口,波特率 115200,应该能看到 STM32 周期性发出的调试信息(如果工程里加了的话)。

如果没有调试输出,先别怀疑代码,拿示波器或者逻辑分析仪看串口 TX 引脚有没有波形。没有波形就是引脚配错了或者串口没初始化。

4.2 OpenMV 侧:脚本部署与阈值调试

OpenMV 这边用 IDE 连接摄像头,把main.py和config.py拷进去。先别急着跑完整逻辑,打开 IDE 的帧缓冲区,单独调阈值。巡线场景下,线的颜色和背景的对比度决定了阈值好不好调。

# config.py THRESHOLD = (0, 100, -20, 20, -10, 30) # 根据实际环境调 ROI = (0, 80, 320, 80) # 只看图像下半部分 UART_BAUD = 115200

THRESHOLD是 LAB 色彩空间的阈值,ROI限定检测区域减少计算量。调阈值的时候把 IDE 的阈值编辑器打开,拖动滑块直到线被稳定框出来。ROI 的高度和位置根据摄像头安装角度调整,一般只看图像下半部分,因为远处线太细容易丢。

调好之后跑main.py,用 IDE 的串口终端看有没有数据发出。如果 OpenMV 这边数据正常但 STM32 没反应,检查两边波特率、数据位、停止位是否一致。

4.3 联调:先静态测协议,再动态调 PID

联调分两步。第一步静态测协议:把小车架起来轮子悬空,OpenMV 对着线,STM32 烧录一个只解析串口并打印偏差的固件。看打印出来的偏差值是否随线的移动而变化,变化方向对不对。这一步能排除 90% 的协议问题。

第二步动态调 PID:小车放地上,先给很小的 Kp,让小车能慢慢跟着线走。如果直接冲出去,说明 Kp 太大或者电机转向反了。确认方向正确后再逐步加大 Kp,直到出现小幅抖动,然后加 Kd 压抖动。Ki 最后加,给 0.01 级别的小值。

提示:调 PID 的时候把电池电量保持充足,电压下降会导致电机转速变化,PID 参数在低电量下可能表现不一样。

5. 避坑与排查:那些年巡线小车翻过的车

5.1 串口数据错位,偏差值乱跳

现象:STM32 收到的偏差值偶尔变成很大的数,小车突然猛打方向。

原因:OpenMV 发送频率和 STM32 接收处理频率不匹配,或者帧头字节出现在数据段里导致状态机误判。比如偏差值低字节刚好是 0xAA,状态机可能把它当成新帧头。

解决:在帧头前加一个空闲间隔,或者用长度字段加校验双重保护。更稳妥的做法是状态机在收到完整帧后强制重置,不依赖帧头字节的偶然匹配。这个工程的g_frame.idx = 0在每帧结束后都执行,就是为了这个。

5.2 电机不动或者只往一个方向转

现象:烧录后电机没反应,或者只有一边轮子转。

原因:PWM 定时器通道配置错误、引脚复用没开、或者电机驱动模块的使能引脚没拉高。

解决:先量电机驱动模块的输入引脚有没有 PWM 波形。没有就查定时器初始化和 GPIO 复用配置;有波形但电机不转,查驱动模块的使能引脚和电源。只往一个方向转通常是另一个通道的比较值没更新,检查Motor_Update里两个通道是否都调用了__HAL_TIM_SET_COMPARE。

5.3 OpenMV 帧率太低导致控制延迟

现象:小车反应迟钝,线都拐过去了才转向。

原因:ROI 区域太大或者图像分辨率太高,OpenMV 处理一帧的时间超过 50ms。

解决:把分辨率降到 QQVGA(160x120),ROI 只保留图像底部 1/3。如果还慢,把色块检测换成更简单的线性回归或者只取几个采样点。帧率上去了,控制延迟自然下来。

5.4 PID 参数换场地就失效

现象:在实验室调好的参数,换到比赛场地就不行了。

原因:场地光线、地面摩擦、线的颜色都和实验室不一样,导致 OpenMV 输出的偏差值范围变了,PID 的等效增益也跟着变。

解决:在 OpenMV 侧做归一化,把偏差值固定映射到 -100 到 +100,不管实际像素差是多少。这样 PID 参数对场地的敏感度会降低。另外准备两组参数,一组保守一组激进,现场根据情况切换。

5.5 电池电压下降后小车行为异常

现象:刚充满电跑得好好的,跑几分钟后开始画龙。

原因:电池电压下降导致电机转速降低,同样的 PWM 占空比对应的实际速度变了,PID 的等效控制效果变弱。

解决:要么加电压检测做 PWM 补偿,要么在代码里留一个速度微调接口,比赛前根据电量手动补偿。最简单的办法是带两块电池,一块跑一块备。

6. 二次开发:从能跑到好用的改造思路

拿到这个工程之后,最值钱的改造方向不是重写,而是在关键节点上加东西。我一般会从三个地方下手。

第一个是串口协议加字段。现在的帧只传偏差值,你可以加一个字节传 OpenMV 的置信度或者线宽。STM32 收到低置信度的时候降低速度或者进入丢线保护逻辑。改协议的时候记得同步改两边的校验计算,不然校验永远不过。具体做法是在FRAME_LEN上加 1,校验循环长度从 5 改成 6,OpenMV 那边打包的时候多塞一个字节。

第二个是PID 输出加前馈。纯反馈 PID 在弯道入口总是慢半拍,因为偏差出现之后才开始修正。如果你能从 OpenMV 那边拿到线的曲率或者偏差的变化率,直接前馈到电机输出上,弯道响应会快很多。实现方式是在协议里加一个“偏差变化率”字段,STM32 侧在 PID 输出上叠加一个Kf * delta_offset。

第三个是状态机替换主循环。现在的代码是主循环里 if-else 堆逻辑,功能一多就乱。改成状态机之后,每个状态只关心自己的事。比如:

typedef enum { STATE_IDLE, STATE_LINE_FOLLOW, STATE_LOST_LINE, STATE_STOP } CarState_t; void Car_Update(void) { switch (g_state) { case STATE_LINE_FOLLOW: if (g_frame.ready) { float out = PID_Calc(&g_pid, g_frame.offset, 0.005f); Motor_Update(out); g_frame.ready = 0; } if (Line_Lost()) g_state = STATE_LOST_LINE; break; case STATE_LOST_LINE: Motor_Update(0); // 停车或者原地找线 if (Line_Found()) g_state = STATE_LINE_FOLLOW; break; default: break; } }

这样加新状态(比如避障、路口识别)的时候不用动已有的逻辑,插一个 case 就行。

验证改造效果的方法很简单:先离线仿真,再半实物,最后整机。串口协议改了先用串口助手模拟发数据,确认 STM32 解析正确;PID 改了先把小车架起来看轮子响应;状态机改了先打印状态切换日志,确认切换条件符合预期。每一步都确认了再上整机,比直接烧录然后看着小车乱跑强得多。

从那以后我每次改这个工程,都强制走一遍“串口助手模拟—悬空测试—低速整机”的流程,哪怕只改了一个参数。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 6:34:48

基于OpenCVSharp的角点检测实现工业平面度测量

做工业检测的人对“平整度”三个字应该都不陌生。我之前接了一个项目&#xff0c;要给一批金属面板做平面度初筛&#xff0c;预算有限、又不能上激光轮廓仪&#xff0c;最后是拿一台普通工业相机加OpenCVSharp的角点检测把这活儿干的。当时最头疼的是网上资料大多在讲相机标定、…

作者头像 李华
网站建设 2026/10/21 6:34:10

八种排序算法深度解析:从原理到实战场景选择指南

1. 为什么我要把排序算法重新梳理一遍做开发这些年&#xff0c;排序算法这东西就像厨房里的盐——平时炒菜不一定想起来&#xff0c;但真到用的时候&#xff0c;手边没有或者用错了&#xff0c;整道菜就废了。我最初学排序是大二的数据结构课&#xff0c;那时候背代码、记复杂度…

作者头像 李华
网站建设 2026/10/21 6:34:03

用vi配置DuckDB PostgreSQL插件连接Kingbase数据库实操详解

有阵子没折腾跨库连接这种事了&#xff0c;直到一个项目里需要把Kingbase的数据抽到本地做分析。手头正好有DuckDB&#xff0c;想着能不能直接用它连&#xff0c;结果发现事情比想象中简单——DuckDB有个PostgreSQL插件&#xff0c;而Kingbase恰好兼容PostgreSQL协议。更没想到…

作者头像 李华
网站建设 2026/10/21 6:33:49

QFtp老工程实战:源码集成、API使用、任务链封装与避坑

简介&#xff1a;面向需要在Qt 5.x及以上版本中使用QFtp类的开发者&#xff0c;资源从Qt 4.8.7中提取QFtp源码并封装为QFtp.h和QFtp.cpp&#xff0c;配套完整可运行的测试工程。解决了Qt 5移除QFtp后FTP客户端功能难以直接复用的问题&#xff0c;适合在迁移旧项目或需要纯Qt实现…

作者头像 李华
网站建设 2026/10/21 6:33:15

Windows本地提权原理与防御:从权限边界到系统加固实践

聊到Windows系统的攻击方式&#xff0c;本地提权是绕不开的一环。大多数安全事件里&#xff0c;攻击者最初拿到的权限并不高&#xff0c;可能只是一个Web应用池账户、一个普通域账号&#xff0c;真正让防守方头疼的往往是后续的提权动作。一旦本地提权成功&#xff0c;低权限进…

作者头像 李华
网站建设 2026/10/21 6:32:44

Mediapipe三维实时人体关键点检测与追踪:从二维到三维的实操指南

1. 从二维到三维&#xff1a;为什么我们需要空间关键点很多人第一次接触人体姿态估计&#xff0c;都是从二维关键点开始的。摄像头拍一张图&#xff0c;模型在画面里标出肩膀、手肘、手腕这些点的像素坐标&#xff0c;看起来已经挺像那么回事了。但真正做过动作分析、体感交互或…

作者头像 李华