简介:在嵌入式系统开发中,机器视觉与运动控制的协同是实现智能小车自主运行的关键基础。通常,视觉感知负责获取目标位置,而运动控制则负责执行精准驱动,二者通过串口通信与协议解析实现高效联动。这种分层架构不仅降低了单片机端的算法负担,还提升了系统的可维护性与调试效率,在电赛、智能物流车及无人驾驶教学等场景中应用广泛。其中,PID控制算法结合PWM调速为小车提供了稳定的转向与速度闭环,而可靠的数据帧设计则保证了图像坐标传输的准确性。本文基于OpenMV与STM32的组合方案,围绕串口协议、图像阈值识别、PID参数整定以及实际联调中的常见问题,系统梳理了一套从硬件分工到工程落地的完整控制流程,适合备赛团队与嵌入式爱好者参考复用。
1. 2024电赛小车控制方案,为什么OpenMV加STM32是稳妥组合
电赛备赛到最后一个星期,时间最紧张的不是写算法,而是把一堆外设焊在一起之后发现小车不走直线。用OpenMV加STM32做小车控制,本质上就是把“看得见”和“走得动”分开:OpenMV负责找目标、算坐标,STM32负责解析指令、驱动电机。这个分工在2024电赛的智能小车题目里非常实用,因为大部分赛题考察的是“能否稳定识别并控制”,不是让你在单片机上跑神经网络。而且这套方案最大的优势是调试速度快——OpenMV端用MicroPython改阈值、改ROI,几乎是热改热用;STM32端只需要把串口协议和PID调稳。适合备赛时间不足、又想快速拿出一套能演示的完整工程的队伍,也适合后续想往无人车、物流小车方向做课程设计的开发者直接复用。
2. STM32与OpenMV的系统分工:先定通信协议,再写控制逻辑
2.1 为什么不让STM32直接处理图像
很多人一开始会纠结:STM32性能也不差,为什么非要外挂OpenMV?原因很简单:电赛题目的核心是“控制”,不是“图像处理”。让STM32做图像识别,意味着要移植算法、调内存、处理帧率,这些工作放在赛场上风险极高。而OpenMV自带MicroPython环境,摄像头驱动、图像滤波、色块识别都是封装好的,拿到手就能跑。
另一个理由是从评审角度考虑的。电赛答辩时,评审更看重“你如何设计系统架构”和“如何解决实际问题”。STM32做运动控制、OpenMV做视觉感知,这个分工本身就是一种清晰的架构设计。相比之下,如果把所有逻辑都塞进STM32,代码量上去了,调试时间翻倍,最后反而很难讲清楚。
我一般的做法是:OpenMV只做三件事——采集图像、提取目标坐标、通过串口发送坐标帧;STM32做四件事——解析串口帧、计算转向误差、输出PWM、处理急停和复位。两边的职责边界越清晰,联调的时候就越少互相甩锅。
2.2 串口协议设计:帧头、帧尾、校验位一个不能少
OpenMV和STM32之间用串口通信是公认最稳妥的方式。但“直接用uart.write()发一个坐标”这种写法在赛场上必翻车:接收端不知道什么时候开始算数据,一个字节错位,后面全乱。所以协议必须有帧头、数据长度、数据、校验位和帧尾。
| 字段 | 字节数 | 值 | 用途 |
|---|---|---|---|
| 帧头 | 2 | 0xAA 0x55 | 标记一帧数据开始 |
| 数据类型 | 1 | 0x01 | 表示坐标数据帧 |
| X坐标高字节 | 1 | 0~255 | 目标物中心X |
| X坐标低字节 | 1 | 0~255 | 目标物中心X |
| Y坐标高字节 | 1 | 0~255 | 目标物中心Y |
| Y坐标低字节 | 1 | 0~255 | 目标物中心Y |
| 帧尾 | 1 | 0x5A | 标记一帧数据结束 |
这个协议一共8个字节。之所以把X和Y拆成高字节和低字节,是因为OpenMV的cx()和cy()返回值是0到319、0到239,用两个字节拼成一个16位整数才有余量,也可以直接发单个字节——如果你确定分辨率不超过255像素的话。但为了以后换分辨率不用改协议,我还是建议直接发16位。
// STM32端协议结构体定义 typedef struct { uint8_t head1; // 固定 0xAA uint8_t head2; // 固定 0x55 uint8_t type; // 数据类型 uint8_t data[4]; // X高、X低、Y高、Y低 uint8_t tail; // 固定 0x5A } Frame_TypeDef;这段代码定义了接收端的缓冲区结构。注意data[4]固定4字节,意味着两边约定“一帧数据长度固定为8字节”。如果你在OpenMV端加了新的数据字段(比如颜色编号、目标面积),一定要同步改这个结构体,否则长度匹配不上,状态机永远收不到完整帧。
2.3 波特率、丢帧和联调约定
波特率我建议用115200。这个速率在OpenMV和STM32的HAL库默认配置下都比较稳,而且OpenMV端发送8字节一帧,按30帧算,每秒只有240字节,115200波特率下绰绰有余。如果为了“更保险”降到9600,问题反而多——OpenMV默认的串口初始化如果设置不当,低波特率下更容易出现时序不匹配。
联调时有一个约定必须提前定死:OpenMV每隔多少毫秒发一帧。常见做法是让它每帧图像处理完就发一次,也就是随帧率走。但如果图像处理的耗时波动大,STM32端接收间隔也会忽快忽慢,容易在缓冲区内残留半帧数据。我习惯的做法是OpenMV主循环里加一个节流延时,比如每50ms才发一次,保证STM32有充足的时间处理。
# OpenMV端发送节流示例 import time last_send_time = time.ticks_ms() while True: img = sensor.snapshot() blobs = find_target(img) if blobs and time.ticks_ms() - last_send_time > 50: uart.write(build_frame(blobs)) last_send_time = time.ticks_ms()代码逻辑很简单:色块存在时才发,而且距离上一次发送不足50ms就跳过。这个节流的意义在于——如果某帧图像里同时找到多个色块,处理时间变长,下一帧拖到80ms后才来,STM32端不会因为“等待下一帧”而把上一帧的重复数据误算成新目标。
3. OpenMV端图像识别:二值化阈值与色块坐标提取全流程
3.1 摄像头初始化与色块阈值调参
OpenMV端的工作量主要集中在摄像头初始化和阈值选择。初始化代码是每次上电都跑的,必须先关掉自动白平衡和自动曝光,否则颜色阈值在光线变化时会漂移,这是在赛场上最常见的“上午还能跑,下午就瞎了”的原因。
import sensor import image from pyb import UART # 摄像头初始化 sensor.reset() sensor.set_pixformat(sensor.RGB565) # RGB565 色彩模式 sensor.set_framesize(sensor.QVGA) # 320x240,帧率与精度的平衡点 sensor.skip_frames(10) # 跳过前10帧,等感光稳定 sensor.set_auto_whitebal(False) # 关闭自动白平衡 sensor.set_auto_exposure(False) # 关闭自动曝光每行都有讲究。RGB565比灰度图多了颜色信息,识别有色目标时必须用它;set_framesize(sensor.QVGA)选320x240不选更高分辨率,是因为色块识别在QVGA下已经够用,而且帧率更高——720p下帧率可能只有15帧,但QVGA能稳定在30帧以上;skip_frames(10)让摄像头在刚开始上电的自动增益阶段过去后再工作。
3.2 阈值参数怎么理解
find_blobs的阈值参数是一个六元组,对应LAB色彩空间下的L、A、B分量的上下限。绝大多数人第一次调阈值时喜欢凭感觉写数字,然后发现目标色块时有时无。我习惯的做法是:先用OpenMV IDE的“帧缓冲区”工具,把当前画面里目标区域框选出来,工具会自动生成一组阈值,再手动微调。
# 以红色为例,LAB阈值范围 red_threshold = (30, 100, 20, 80, -10, 30) blobs = img.find_blobs( [red_threshold], pixels_threshold=200, # 最小像素面积,过滤噪点 area_threshold=200, # 最小色块面积,过滤小碎片 merge=True # 合并重叠色块 )pixels_threshold和area_threshold是过滤静态噪点的关键。如果赛场上光线偏暗,噪点像素会变多,这时候可以把pixels_threshold调高到300;如果目标本身很小,调低到100,但代价是可能把远处的干扰光源也当成目标。merge=True会把相邻的色块合并成一个大色块,防止目标被分割成几块后丢失。
3.3 主循环里输出目标坐标
识别到色块后,最常见的选择是取最大色块的中心坐标作为目标位置。取最大而不是取最近,是因为在赛场上目标物通常是唯一的大色块,最大面积天然有抗干扰能力。
while True: img = sensor.snapshot() blobs = img.find_blobs([red_threshold], pixels_threshold=200, area_threshold=200, merge=True) if blobs: # 选面积最大的色块作为目标 max_blob = max(blobs, key=lambda b: b.area()) x = max_blob.cx() # 目标中心X坐标 y = max_blob.cy() # 目标中心Y坐标 # 组帧发送:0xAA 0x55 0x01 XH XL YH YL 0x5A frame = bytearray([0xAA, 0x55, 0x01, x >> 8, x & 0xFF, y >> 8, y & 0xFF, 0x5A]) uart.write(frame) else: # 没找到目标,发一个特殊帧告诉STM32“目标丢失” frame = bytearray([0xAA, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A]) uart.write(frame)这段代码里有一个容易被忽略的细节:没找到目标时也发一帧,而不是什么都不发。这是因为STM32端需要区分“目标在视野左边”和“目标暂时丢失”这两种情况——如果什么都不发,STM32只能保持上一个状态,小车会直直冲出去,大概率出赛道。类型字段0x00就是告诉STM32:这一帧不是坐标,是没有目标。
4. STM32端小车控制:串口解析、PID转向与PWM输出
4.1 串口状态机:接收8字节帧的可靠写法
STM32端的核心不是电机驱动,而是串口接收的可靠性。直接用HAL库的HAL_UART_Receive()阻塞接收,在电赛这种需要同时处理PWM和按键的场景下会卡死主循环。正确的做法是用中断接收,每收到一个字节就进入一次回调,在回调里用状态机拼帧。
// 串口中断接收回调 uint8_t rx_buffer = 0; volatile uint8_t frame_state = 0; volatile uint8_t frame_data[6]; volatile uint8_t frame_ready = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { switch (frame_state) { case 0: if (rx_buffer == 0xAA) frame_state = 1; break; // 找帧头1 case 1: if (rx_buffer == 0x55) frame_state = 2; else frame_state = 0; break; // 找帧头2 case 2: if (rx_buffer == 0x01) frame_state = 3; else frame_state = 0; break; // 类型判断 case 3: frame_data[0] = rx_buffer; frame_state = 4; break; // X高字节 case 4: frame_data[1] = rx_buffer; frame_state = 5; break; // X低字节 case 5: frame_data[2] = rx_buffer; frame_state = 6; break; // Y高字节 case 6: frame_data[3] = rx_buffer; frame_state = 7; break; // Y低字节 case 7: if (rx_buffer == 0x5A) frame_ready = 1; frame_state = 0; break; // 帧尾 } HAL_UART_Receive_IT(&huart1, &rx_buffer, 1); // 继续接收下一字节 } }这段状态机代码的核心思路是:不信任任何单个字节,只有连续匹配到0xAA 0x55 0x01才开始收数据,最后用0x5A确认帧尾。即使中间任何一个字节错位,状态机会自动回到s=0重新找帧头,所以不会出现永久错位的死锁。frame_ready置1后,主循环里就能拿到这一帧的坐标。
一个需要特别注意的点:HAL_UART_Receive_IT()必须在每次回调结束前重新调用。如果漏了这句,串口只收一次就罢工了。很多同学拿到代码发现“只收到了第一帧数据”,九成是这个原因。
4.2 PID转向控制:让小车朝目标坐标走
得到目标坐标之后,控制策略有很多种写法。最简单的就是纯比例控制——用目标中心X坐标与画面中心X坐标的偏差,直接换算成左右轮速度差。但纯比例控制在赛道上会有一个明显问题:如果偏差一直存在,小车会一直往一个方向偏,形成S形摆动。加一个微分项可以抑制摆动,加一个积分项可以消除稳态误差。
// PID控制器,用于转向环 int pid_control(int target, int current, float kp, float ki, float kd) { static float integral = 0; static int last_error = 0; int error = target - current; integral += error; // 积分限幅,防止长时间偏差导致积分饱和 if (integral > 200) integral = 200; if (integral < -200) integral = -200; // 微分项直接用误差变化量 int derivative = error - last_error; last_error = error; // 输出限幅到 -1000 ~ 1000,对应PWM占空比范围 int output = (int)(kp * error + ki * integral + kd * derivative); if (output > 1000) output = 1000; if (output < -1000) output = -1000; return output; }参数说明:target是画面中心X坐标(QVGA下固定为160),current是OpenMV发来的目标X坐标。output正数代表目标在右边,左轮加速、右轮减速;负数相反。kp初始值我建议从0.5开始试,ki从0.01,kd从0.1。这三个值不是一次就能调对的,需要在小车实际跑起来之后观察摆动趋势再微调。
// 主循环中计算转向并设置电机PWM while (1) { if (frame_ready) { uint16_t target_x = (frame_data[0] << 8) | frame_data[1]; int steer = pid_control(160, target_x, 0.5, 0.01, 0.1); left_speed = base_speed + steer; // 左轮 = 基础速度 + 转向量 right_speed = base_speed - steer; // 右轮 = 基础速度 - 转向量 set_motor_speed(MOTOR_LEFT, left_speed); set_motor_speed(MOTOR_RIGHT, right_speed); frame_ready = 0; } }这里的base_speed是前进的基础速度,比如600(对应PWM占空比60%)。steer叠加在基础速度上,转向时一个轮子加速、一个轮子减速,实现差速转向。注意:如果steer大于base_speed,一个轮子的速度会变成负数,也就是反转。这时要确认电机驱动芯片是否支持正反转——使用TB6612时,需要同时切换IN1/IN2两个引脚的方向,不能只靠PWM占空比。
4.3 PWM初始化与电机驱动接线表
STM32产生PWM通常用定时器的PWM输出模式。我常用TIM1的CH1和CH2来驱动左轮和右轮,频率设在20kHz,这样电机运行噪音小、不会啸叫。
// TIM1 PWM初始化代码(HAL库) void Motor_PWM_Init(void) { GPIO_InitTypeDef gpio_init; TIM_HandleTypeDef htim1; TIM_OC_InitTypeDef oc_init; __HAL_RCC_TIM1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); gpio_init.Pin = GPIO_PIN_8 | GPIO_PIN_9; gpio_init.Mode = GPIO_MODE_AF_PP; gpio_init.Pull = GPIO_NOPULL; gpio_init.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio_init); htim1.Instance = TIM1; htim1.Init.Period = 1000 - 1; // 计数值1000 htim1.Init.Prescaler = 84 - 1; // 84MHz/84=1MHz,PWM频率1kHz htim1.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim1); oc_init.OCMode = TIM_OCMODE_PWM1; oc_init.Pulse = 0; oc_init.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim1, &oc_init, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim1, &oc_init, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_2); }Period = 1000 - 1和Prescaler = 84 - 1的组合意味着PWM频率是1kHz,Period设成1000是为了让set_motor_speed()里的占空比参数可以直观地写作0到1000的整数。如果想让PWM频率更高,把Prescaler调小到42,频率就变成2kHz,但Period也要相应调整。
整个系统的接线方式,我整理了一个常用的表格,方便现场查线:
| OpenMV引脚 | STM32引脚 | 说明 |
|---|---|---|
| UART_TX(P4) | USART1_RX(PA10) | 数据发送到STM32 |
| UART_RX(P5) | USART1_TX(PA9) | 备用反向通道 |
| GND | GND | 必须共地,否则串口乱码 |
| 5V | 5V | OpenMV供电,不能接3.3V |
5. 联调避坑指南:2024电赛小车最常见的五个现场翻车点
5.1 现象:STM32串口收到一堆乱码
联调第一天大概率会遇到这个问题:用串口助手看STM32发上来的数据,发现打印出来的全是0xFF、0x00或者随机字节。
原因主要有两个:一是OpenMV和STM32的USB口没有共地,两边信号电平参考地不同;二是波特率配置不一致——pyb.UART初始化用了115200,STM32端CubeMX里配的却是9600。
解决方法是:把所有模块的GND统一接到同一个电源地,再检查两边波特率是否一致,最好在协议里加一个固定字节的测试帧,比如让OpenMV上电先发一串0xAA 0x55 0x55 0xAA,STM32能收到完整序列再继续调后面的流程。
5.2 现象:OpenMV帧率突然掉到10帧以下
跑赛道的同学经常遇到:刚上电时帧率30多帧,跑了半分钟之后OpenMV开始卡顿,小车的转向反应明显变慢。
原因大概率不是代码逻辑,而是OpenMV的微控制器过热或者内存碎片化。另一个常见原因是把find_blobs的roi设成了全画面,每次扫描320x240的像素区域,在目标移动时计算量没有优化。
解决方法是给find_blobs手动设置ROI区域。比如知道目标只出现在画面中央的200x200范围,就把ROI从(0, 0, 320, 240)缩小到(60, 20, 200, 200),扫描面积减少近一半,帧率能回升不少。如果还卡,降低到QQVGA(160x120)也是一个办法,丢一点精度但换来稳定性。
5.3 现象:电机一启动,STM32直接复位
这个问题的现场表现是:把小车放到地上,一加速就重启;或者用仿真器调试时,一开电机,调试器直接断开。
这是典型的电源跌落问题。电机启动瞬间电流可以达到稳定运行时的3到5倍,如果STM32和电机共用一个电源,电压会被瞬间拉到欠压阈值以下。
解决方法是电源分层:电机用独立的电池或电源模块供电,STM32和OpenMV用另一路稳压后的电源;如果只能共用一组电池,在电机驱动板的电源输入端并联一个470uF到1000uF的电解电容,小电流瞬间能扛一下。另外检查一下STM32的NRST引脚——如果被干扰拉低,也会复位,可以串一个10k电阻到3.3V。
5.4 现象:小车左右蛇形走位,就是不走直线
如果PID调了一段时间,小车还是左右画龙,问题不一定在PID——先检查两个电机速度是否分别匹配。我用过一个简单的验证方法:让左右轮各以同样占空比空转,拿测速码盘看实际转速,如果两边转速差超过10%,说明是机械差异,这需要调整左右轮的基础速度偏移量,也就是给两轮一个固定的补偿值。
如果机械没问题,那就是PID参数问题。先去掉积分项,只调P和D。P从小往大加,直到小车开始出现轻微振荡,再往回退一点;然后加D,让振荡收敛。最后再加回积分项来消除残余的稳态偏差。这个流程说起来简单,实际操作至少需要两小时,建议在比赛前一天的下午做,别留到赛前两小时。
5.5 现象:OpenMV发送频率太快,STM32偶尔漏帧
现场偶发“小车没反应”,检查代码发现数据明明已经发出去了。这种丢帧不是通信断掉,而是STM32主循环里处理其他逻辑占用了时间,来不及在下一帧来之前把frame_ready消费掉。
解决方法是在OpenMV端做发送节流,这个前面已经说过;另外在STM32端,如果检测到连续3秒没收到新帧,就让小车停下来而不是继续按旧数据行驶,这样就算漏帧也不会冲出赛道。
// 目标丢失保护逻辑:超时停车 if (HAL_GetTick() - last_frame_time > 3000) { left_speed = 0; right_speed = 0; set_motor_speed(MOTOR_LEFT, left_speed); set_motor_speed(MOTOR_RIGHT, right_speed); }这段代码在主循环里跑,last_frame_time在每次收到帧后更新。如果3秒没有新帧,说明OpenMV死机、通信断开或者目标长时间消失,这时候让小车停下是最稳妥的行为。
6. 让控制方案在答辩现场更稳的进阶验证技巧
这套方案跑通之后,很多队伍会卡在“能用”和“稳定”之间。这里分享三个我从多个模拟项目里总结出来的进阶技巧,都是答辩和正式比赛时真正能拉开差距的点。
第一个是目标丢失后的恢复策略。前面提到OpenMV发送type=0x00的丢失帧,但STM32收到后具体怎么行动,是需要单独调试的。我建议的处理方式是:丢失后先减速原地等待,同时让OpenMV端执行“扫描模式”——不只在固定ROI里找色块,而是重新在全画面里搜索一次。这样目标如果只是暂时被遮挡,重新出现后小车能继续追踪,而不是直接停车。
# OpenMV端目标丢失后扩大搜索范围 if len(blobs) == 0: # 扩大ROI到全屏重新检测 blobs = img.find_blobs([threshold], roi=(0, 0, 320, 240)) if blobs: max_blob = max(blobs, key=lambda b: b.area()) # 恢复到常规ROI pass第二是用串口数据分析的方式来验证PID是否真的调好了。让小车在原地转圈,记录每一帧的目标X坐标和PID输出,导出数据后画成曲线。正常情况下,目标X坐标应该在画面中心附近小幅波动;如果曲线出现大幅振荡,说明P太大;如果响应缓慢、偏差长时间不回中,说明P太小或者积分太弱。这个验证方法看起来多了一步,但是比肉眼调车靠谱得多——肉眼看不出0.5和0.8的P值区别,曲线却能一眼看出来。
第三是准备一个“手动遥控模式”作为兜底。不管视觉和自动控制写得多好,总有可能在临场时因为光线问题导致识别失效。在我的工程里,我会留一个按键中断:按下后自动切换到串口遥控模式,通过另一个无线模块或者直接用STM32自带的按键控制小车前进后退左右转。画面里识别失败的时候,至少还能手动把小乔挪回正确位置,比在现场硬着头皮修阈值体面得多。
最后说一个电赛老选手的习惯:比赛前一天的傍晚,把所有参数都烧进固件里,然后把小车放地上跑一次完整的流程,关掉调试器、拔掉仿真下载线、只接电池,验证一遍独立运行。这个动作能发现所有“接着仿真器好好的、一拔线就翻车”的隐藏问题,通常和电源干扰、调试口电平浮空有关。我每次带队赛前调试的最后一步都是它,翻车概率能降一半。希望这个方案能帮你在2024电赛的赛场上少走几步弯路。
本文还有配套的精品资源,点击获取