STM32与OpenMV双MCU协作的物流小车控制系统解析
2026/9/12 6:10:51 网站建设 项目流程

简介:针对2019年上海市高校智能物流机器人选拔赛,这份参赛代码包基于STM32与OpenMV构建了一套完整的智能物流机器人系统,覆盖自主定位、移动避障、二维码读取、物料识别与抓取、路径规划及任务显示等核心环节,适合嵌入式爱好者、机器人竞赛选手及相关课程设计参考。压缩包共1048个文件,约31.3MB,以C语言源文件610个、头文件310个为主,辅以启动文件、链接脚本、工程配置及说明文档,代码结构清晰,便于直接阅读与二次开发。工程内还包含ARM数学库等优化文件,可支撑运动控制与视觉处理中的复杂运算。项目同时附有docx说明文档和txt说明文件,有助于理解系统整体设计思路与运行方式。目前已有62人学习下载,对于希望深入研究多传感器融合与视觉导航控制方案的学习者,这份真实赛题代码具有较强的参考价值。

1. 为什么一台物流小车要拆成 STM32 和 OpenMV 两块板子

在自动化仓库里跑运输的小车,最怕的不是路径长,而是视觉识别突然卡几十毫秒,把底层的电机控制周期带乱。2019 年上海市高校智能物流机器人选拔赛上,多数参赛队都选择了双片架构,STM32 负责底盘电机、编码器和任务状态机,OpenMV 负责二维码读取和物料识别这类图像处理。这套命名为 MHR_v1.0 的参赛代码就是这种架构的完整实现,覆盖了自主定位、移动避障、二维码识别、物料抓取、路径规划与任务显示,工程包里还一并放进了 IAR 工程文件、CMSIS-DSP 数学库和配套说明文档。对想复现比赛车功能、做毕业设计或课程设计的读者,把这份代码当作双 MCU 协作加视觉伺服的完整参考来拆,比单看某一款驱动例程更有价值。

2. STM32 实时控制、OpenMV 视觉与串口帧协议

2.1 双片分工的边界怎么划

先说明一个容易被忽略的事实:OpenMV 的内核其实也是 STM32F427,主频并不低,为什么还要额外挂一块 STM32F4 做控制?关键在实时性。OpenMV 跑的是 MicroPython 解释器,图像采集、色块检测、二维码解码的耗时不稳定,没法保证毫秒级的中断延迟;而 STM32 侧的速度环通常 5~10ms 就要刷新一次,编码器中断更不能被拖。让两块芯片各干各的,控制周期稳定,视觉部分又能单独用 OpenMV IDE 调参,不至于一改阈值就得重新烧录整个主控工程。

从这套代码的目录结构也能看出分工。libarm_cortexM4lf_math.aiar_cortexM4lf_math.a这类链接库编译后给 STM32 主控侧使用,属于 CMSIS-DSP 数学库;MHR_v1.0-master目录里才是完整源码,arm_common_tables.carm_dct4_init_f32.carm_linear_interp_data.c是 DSP 库自带的查表数据。它们被链接进工程并不是为了做 FFT,典型用途是调用arm_linear_interp_f32()做路径点的线性插值,把折线路径平滑成连续的速度指令,避免电机速度出现阶梯跳变。

通信层面,STM32 与 OpenMV 之间最稳妥的连接是 UART,占用引脚少,调试也直观。STM32 侧用空闲中断加 DMA 接收,OpenMV 侧用串口定时上报视觉结果。接线时必须两边共地,否则长时间运行后会出现偶发乱码,这个问题在实际比赛里比很多人预想的更常见。

2.2 串口帧格式:不只发数据,还要带类型和校验

很多新手第一版代码是直接往串口丢结构体,这在两个单片机之间能跑,但一旦加上视觉数据、心跳和不同帧类型,解析就乱了。比赛里更稳的做法是定义统一的帧协议,帧头、类型、长度、数据、校验五个部分固定下来:

// bsp_uart.c —— STM32 侧串口发送帧封装 typedef struct { uint8_t head[2]; // 0xAA 0x55 帧头 uint8_t type; // 0x01 坐标, 0x02 二维码, 0x03 物料, 0x04 心跳 uint8_t len; // data 有效长度 uint8_t data[16]; // 具体数据,比如二维码 ID 和像素偏移 uint8_t crc; // 累加异或校验 } comm_frame_t; uint8_t comm_calc_crc(const uint8_t *buf, uint8_t len) { uint8_t crc = 0; for (uint8_t i = 0; i < len; i++) { crc ^= buf[i]; } return crc; } void comm_send_frame(UART_HandleTypeDef *huart, uint8_t type, uint8_t *data, uint8_t len) { uint8_t tx[24] = {0}; tx[0] = 0xAA; tx[1] = 0x55; tx[2] = type; tx[3] = len; memcpy(&tx[4], data, len); tx[4 + len] = comm_calc_crc(&tx[4], len); HAL_UART_Transmit(huart, tx, len + 5, 100); }

帧头固定为0xAA 0x55,接收端先匹配帧头再继续解析,噪声数据无法进入后续流程。type 字段用来区分四类信息,内容见下表:

type含义data 内容
0x01全局坐标x 低字节、y 低字节
0x02二维码识别ID、dx、dy 偏移
0x03物料识别中心点 cx、cy、颜色类型
0x04心跳无业务数据

crc 用累加异或实现,长度短、算得快,现场用串口助手人工验算也方便。另一个关键是心跳机制:OpenMV 死机或排线松动时,STM32 如果不做超时判断,会一直拿上一次的二维码坐标去跑路径,小车最终会直直撞上货架。我习惯在串口接收里加一个 500ms 看门狗计数,超过 500ms 没收到任何帧就切到安全停车状态,这个视觉丢失后的降级策略,在仓库落地场景里比识别算法本身更决定安全性。

data 段建议只传可见的偏移量。OpenMV 图像坐标系原点在左上角,STM32 里程计坐标系原点在场地某个角落,两者还有旋转关系,直接在 OpenMV 里算全局坐标很容易错。让 OpenMV 上报二维码中心相对图像中心的 dx、dy 以及二维码 ID,由 STM32 结合场地地图换算绝对坐标,后续改场地布局只需要动主控侧表格。波特率我用 115200,线长控制在 30cm 以内;如果现场电机 PWM 干扰导致误码率高,降到 57600 并把心跳频率保持为 50Hz,误码率基本可以忽略。

2.3 CMSIS-DSP 库与 IAR 工程的关联

工程里MHR_v1.0.uvguix.Administrator是 IAR 的窗口布局文件,记录界面怎么排列,不参与编译。真正决定编译行为的是同级的.eww工作区和.ewp工程,在工程选项的 FPU 设置里能看到浮点相关配置。libarm_cortexM4lf_math.a对应带 FPU、小端模式的 Cortex-M4F;libarm_cortexM4l_math.a对应不带 FPU 的小端 M4;iar_cortexM4bf_math.a是大端版本,一般用不上。链接库选错的典型现象是一堆浮点函数 undefined symbol,只需要按芯片内核型号重新选择即可。

压缩包里的附赠资源.docx说明文件.txt,前者通常装着接线说明、调参记录,后者是部署步骤,对跑通代码很有帮助。这类比赛工程能凑齐源码、库文件和文档三件套的不多,先读文档再看代码,顺序不要反。这套主从架构也不限于物流小车,凡是几个控制器之间做数据交互的场景,比如两个 STM32 组网、Linux 板卡加 MCU,都可以沿用同样的帧协议思路。

3. STM32 运动控制:编码器四倍频、PID 速度环与里程计校准

3.1 定时器编码器模式与四倍频计数

多数比赛底盘是双驱动轮差速结构,两个直流减速电机各带一个编码器。STM32 定时器的编码器接口模式可以直接把 A/B 两相接到定时器通道上,硬件自动完成四倍频计数:每个上升沿和下降沿都计一次,13 线编码器接 30:1 减速箱时,输出轴转一圈的计数是 13×4×30。

// encoder.c —— TIM3 编码器接口读右轮,PA6/PA7 复用为 CH1/CH2 void encoder_tim3_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Alternate = GPIO_AF2_TIM3; HAL_GPIO_Init(GPIOA, &gpio); TIM_Encoder_InitTypeDef enc = {0}; enc.EncoderMode = TIM_ENCODERMODE_TI12; // 双沿计数,四倍频 enc.IC1Polarity = TIM_ICPOLARITY_RISING; enc.IC2Polarity = TIM_ICPOLARITY_RISING; HAL_TIM_Encoder_Init(&htim3, &enc); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); }

TIM_ENCODERMODE_TI12让定时器同时捕获 TI1 和 TI2 两个通道的边沿,一个机械周期计 4 次。如果现场脉冲毛刺明显,可以给IC1Filter设置滤波器系数,系数越大抗干扰越强,但也会衰减高速脉冲,需要平衡。读取计数时用int16_t强转,正反转动会自然带符号,直接参与差速计算。

首次上路前必须确认左右轮编码器计数方向:两个驱动轮位于车身中轴两侧,同向前进时在各自编码器里计数值可能一正一负。程序侧统一成“前进时左右计数值都为正”,左轮计数值按需取反,否则里程计模型的航向角会直接算反,车会在原地打转。

3.2 PID 速度环与 PWM 输出

运动控制分两层:外环发位置指令,内环做速度闭环。速度环每 5ms 执行一次,增量式 PID 实现如下:

// pid.c —— 增量式 PID 速度环 typedef struct { float kp, ki, kd; float target, last_err, integ; float out; } pid_t; void pid_speed_update(pid_t *pid, float current, float dt) { float err = pid->target - current; pid->integ += err * dt; // 积分限幅:防止机械臂下压堵转时积分饱和,起步猛窜 if (pid->integ > 200.0f) pid->integ = 200.0f; if (pid->integ < -200.0f) pid->integ = -200.0f; float deriv = (err - pid->last_err) / dt; pid->out = pid->kp * err + pid->ki * pid->integ + pid->kd * deriv; pid->last_err = err; }

积分限幅这一段在抓取任务里非常重要。机械臂下压取料时车体受力突然增大,轮子可能瞬间卡住,编码器读到的误差飙升,没有限幅的话积分项会在几十毫秒内饱和,等机械臂抬起后小车猛地冲出去。PWM 输出同样要做上下限幅,再映射到定时器比较寄存器,形成完整保护。

速度环整定顺序是先 P 后 I 再微调 D,参数含义和现场调整方向如下表:

参数作用现场调整方向
kp决定响应速度起步顿挫、车体抖动时调小
ki消除稳态误差长距离匀速跑偏时调大
kd抑制超调停车时来回振荡可微调,过大则 PWM 在 0 附近跳变

12V 供电、空载 0.4m/s 的典型底盘,P 取 20~40,I 取 1~5,D 取 0.1~0.5。P 过大时底盘高频振荡,听电机声音能明显感觉到“嗡嗡”声,此时 P 砍半再看阶跃超调。D 对编码器噪声敏感,D 过大反而让 PWM 频繁跳变,电机发热明显。

提示:比赛现场如果只允许保留一种调试手段,建议把 PID 参数和速度标定值放到 Flash 固定地址,开机自动加载,改参数只需要串口命令,不用反复烧录主控固件。

3.3 里程计模型与二维码绝对定位校正

差速底盘里程计递推公式是标准形式:

delta_s = (left_dist + right_dist) / 2 theta += (right_dist - left_dist) / wheel_track x += delta_s * cos(theta) y += delta_s * sin(theta)

实现时注意 theta 是弧度制,坐标系 Y 轴方向必须与 OpenMV 上报的二维码地图一致,否则全局路径规划出来的坐标会整体翻转。里程计靠编码器积分推算,长时间运行必然漂移,单靠轮子精度满足不了抓取时 2cm 的定位需求,所以这套代码里加入了二维码绝对校正:OpenMV 上报识别到 ID 为 3 的二维码,STM32 查表得到该二维码在场地里的绝对坐标,然后把里程计的 x、y、theta 直接改写为该坐标,顺带清空最近 20ms 的累积误差。里程计为主、二维码为辅的组合,比赛场景和真实仓库里都比单一方案可靠。

赛前还要做一轮轮距和轮径校准。最简单的办法是把车开直线 3m,用卷尺量实际位移,反推左右轮的脉冲系数和真实轮距。左右轮直径细微差异在长距离路径上会被持续放大,这一步不做,路径规划再好也会偏出抓取窗口。

4. OpenMV 感知侧:二维码识别、物料检测与 STM32 握手

4.1 用 find_qrcodes() 做场地定位,附带运动模糊处理

OpenMV 的find_qrcodes()是底层成熟的二维码解码实现,可以直接用。但比赛场景有两个典型问题:日光灯直射下哑光贴纸会反光,白色反光区域让解码失败;小车行驶中的运动模糊会让帧率掉一半。代码层面这样处理:

# main_openmv.py —— 二维码识别与偏移量发送 import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_windowing((160, 120)) # 只保留中央 ROI,减少解码耗时 sensor.skip_frames(30) uart = UART(1, 115200, timeout_char=100) def send_frame(type_, payload): data = bytes([type_, len(payload)]) + payload crc = 0 for b in data: crc ^= b uart.write(b'\xAA\x55' + data + bytes([crc])) while True: img = sensor.snapshot() codes = img.find_qrcodes() if codes: qr = max(codes, key=lambda c: c.w() * c.h()) # 取面积最大的二维码 dx = qr.cx() - img.width() // 2 dy = qr.cy() - img.height() // 2 # dx/dy 统一加 100 后再发低字节,STM32 侧减 100 还原,规避负数串口传输问题 send_frame(0x02, bytes([qr.payload() & 0xFF, (dx + 100) & 0xFF, (dy + 100) & 0xFF])) time.sleep_ms(20)

这里有两个关键处理。第一,set_windowing缩小了解码区域,视觉耗时明显下降,但二维码刚进入边缘时不会被解出。更好的做法是大窗口巡检加小窗口解码,先在大窗口找到疑似二维码的矩形区域,再用该区域做二次定位。第二,运动模糊可以通过缩短曝光时间缓解,skip_frames(30)让传感器先稳定曝光再进入主循环,这个初始化很多人会漏掉,结果一开机前三秒频繁误识别。dx、dy 加 100 再取低字节,是规避串口负数的常用手段,STM32 解析时减 100 恢复真实偏移即可。

4.2 物料识别:LAB 颜色阈值与抓取点计算

物料识别围绕find_blobs()展开,比赛物料通常是红、蓝、绿三色圆柱或方块。用 OpenMV IDE 的阈值编辑器可以直接从画面框出目标颜色并导出一组 LAB 阈值,比如红色的典型阈值:

THRESHOLD_RED = (85, 100, 20, 80, 0, 60) # L、A、B 三个通道的最小/最大值 blobs = img.find_blobs([THRESHOLD_RED], pixels_threshold=15, area_threshold=15, merge=True) if blobs: b = max(blobs, key=lambda b: b.area()) # 发回物料中心像素坐标,颜色类型固定为 1 send_frame(0x03, bytes([(b.cx() + 100) & 0xFF, (b.cy() + 100) & 0xFF, 1]))

为什么选 LAB 而不是 RGB?因为日光灯色温偏移会让 RGB 三个通道整体漂移,换个场地就要重新调一组阈值;LAB 把亮度和色度分离,L 只管明暗,A 通道管红绿,B 通道管黄蓝,光照变化时通常只动 L 通道就够了。调阈值时把现场环境光固定成比赛当天状态,再加像素面积过滤,把地面上微小反光噪点滤掉。若物料表面本身有反光,把 A、B 通道范围适当收窄,优先保证误检率最低。

物料像素坐标不等于机械臂抓取坐标。OpenMV 和机械臂在车体上的安装位置有几何偏移,STM32 拿到 b.cx() 和 b.cy() 后要减去这个固定偏移,再换算到机械臂基座坐标系。比赛代码里通常把这个偏移写成宏定义,换一次结构件就要同步改一次,我拆这套代码时看到它的偏移定义集中在一处,这个习惯值得保留,改起来不需要全文搜索。

4.3 STM32 与 OpenMV 的双向握手与串口接收

单向发送只能支撑“视觉上报、主控决策”,但机械臂动作需要“主控下发指令、OpenMV 回报结果”的闭环。工程里用一组轻量握手命令实现:

命令字含义OpenMV 响应
0xA1已到达抓取点,请求二次识别重新拍摄并返回精确物料坐标
0xA2开始抓取暂停视觉任务,等待主控释放
0xA3抓取完成,恢复导航恢复心跳和二维码上报

STM32 收到第一遍物料识别结果后先停下车身,再发 0xA1,OpenMV 收到指令后才对物料做精细识别,返回更精确的坐标。这个二次识别规避的是“行驶中拍到画面模糊导致的抓取点跳动”,比在 OpenMV 端加滤波更直接。

STM32 侧串口接收采用空闲中断加 DMA:

// uart_rx.c —— 空闲中断加 DMA 接收,收到完整一帧后触发回调 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { comm_parse_frame(usart2_rxbuf, Size); HAL_UARTEx_Receive_DMA(&huart2, usart2_rxbuf, RX_BUF_SIZE); } }

用 DMA 的原因在于接收帧长不固定,如果逐字节中断接收,一次通信要进几十次中断,会频繁打断 PID 计算;DMA 加空闲中断只在帧接收完成时处理一次,控制回路稳定得多。解析时第一个字节对不上帧头就直接丢弃整帧,防止和噪声数据错位后整帧错乱。

注意:OpenMV 和 STM32 的地线必须相连,且两边尽量用同一路稳压源供电。板卡分开供电时,一旦断开共地,第一帧通信往往正常,第二帧以后出现随机错位的概率非常高。

5. 路径规划与状态机:全局路径点、避障降速与任务显示

5.1 全局路径规划:从栅格地图到路径点表

比赛场地通常是 3m×4m 的半结构化场地,地面用黑色胶带划分区域,这种环境用不上完整的 RRT 或混合 A*,最常见且够用的方案是栅格法。把场地按 10cm 步长离散成网格,障碍物所在格子标记为不可走,用 A* 求全局路径,得到一串路径点。STM32 算力有限,A* 可以在上位机或 OpenMV 上提前算好,把路径点以数组形式编译进主控,运行时只做路径点跟踪:

// plan.c —— 路径点跟踪,按顺序行驶到所有路径点 typedef struct { float x; float y; } waypoint_t; static const waypoint_t route[] = { { 0.0f, 0.0f}, // 起点 { 0.0f, 1.2f}, // 中途二维码附近 { 0.8f, 1.2f}, { 0.8f, 2.0f}, // 物料区 { 1.6f, 2.0f}, // 放货区 }; void track_waypoint(robot_pose_t *pose, uint8_t *idx) { if (*idx >= sizeof(route) / sizeof(waypoint_t)) { motor_stop(); return; } waypoint_t wp = route[*idx]; float dx = wp.x - pose->x; float dy = wp.y - pose->y; if (dx * dx + dy * dy < 0.05f * 0.05f) { *idx += 1; // 距离小于 5cm,切换下一个目标点 return; } float target_angle = atan2f(dy, dx); // 覆盖四个象限,不会反向转 motion_turn_to(target_angle); motion_set_speed(0.4f); }

为什么用atan2f而不是atanatan2f能直接覆盖四个象限,目标点在左后方时不会出现往右前方转半圈的额外摆动;dx*dx + dy*dy和开方等效,但省去一次sqrtf调用,在 5ms 周期里积少成多。路径点跟踪还要做提前切换,距离小于 5cm 就换点,否则每个路径点都会出现明显过冲。

路径点设置上有个容易忽略的原则:绕开物料区周边的高频活动区域。比赛时裁判和工作人员经常站在场地边缘,路径点贴障碍物太近容易与人群交叠。每个路径点与障碍物边缘至少留出 1 个车宽约 30cm 的余量,这是给动态避障保留的缓冲空间。

5.2 动态避障与分级降速策略

结构化场地里常见的动态障碍是其他比赛车和临时放置的箱子。超声波测距模块装在车头保险杠,测距间隔 50ms。不建议检测到障碍就急刹车,急刹会让机械臂结构受冲击,物料也容易被甩飞,更好的做法是分级降速:

距离 d (cm)策略动作
d ≥ 40正常路径跟踪不干预
20 ≤ d < 40减速 50%速度环目标降一半,保留转向能力
d < 20停车并后退 10cm电机反转 200ms,重新规划
// obstacle.c —— 分级避障,由定时器轮询调用 void obstacle_handle(float dist_cm) { if (dist_cm >= 40.0f) return; if (dist_cm >= 20.0f) { motion_set_speed(0.2f); // 目标速度从 0.4 降到 0.2 return; } motion_stop(); motion_back(0.1f, 5); // 后退 10cm 后重新进入规划 }

超声波触发放在定时器回调而不是主循环,主循环被状态机和路径跟踪占用时,避障响应依然稳定。后退动作的时长是按当前车速标定的,如果整车速度调整过,这个时间参数也要跟着改,否则每次后退距离都会偏离预期。

5.3 任务状态机与 OLED 任务显示

“任务显示”要求在车体屏幕上实时展示当前阶段,裁判能一眼看懂小车在做什么。状态流转用如下结构描述:

状态进入条件动作OLED 显示
IDLE上电等待开始信号Ready
NAV_TO_PICK收到开始指令路径跟踪加二维码校正Going to Pick
PICK到达取料点且识别到物料机械臂下压抓取Picking
NAV_TO_PLACE机械臂抬起完成路径跟踪Going to Place
PLACE到达放料点机械臂放下Done

状态机用 switch-case 实现,关键约束是禁止状态自由跳变。进入 PICK 前必须同时满足到位标志和机械臂停止标志,否则车还在滑行就下压机械臂,轻则抓偏,重则损坏结构件。OLED 显示推荐 I2C 接口的 SSD1306,驱动代码短,比赛不要求复杂界面,一个纯文本状态行足够现场观察。把状态切换统一收敛到一个debug_log()函数,负责 OLED 刷新和串口打印双路输出,调参时看日志比盯着车跑高效得多。

6. 调试与验证:从 IAR 工程文件到上车前的最后检查

6.1 用 IAR 实时调试观测 PID 内部变量

用 IAR EWARM 打开.eww工程后,在pid_speed_update里设断点,把pid->outpid->integerr三个变量拉进 Live Watch,跑一段直线就能看到速度环动态过程。不要用 printf 做速度环调试,串口打印一帧的耗时与 DMA 接收互相干扰,不如直接在 IAR 的寄存器窗口里看定时器计数。想实时改参数的话,把 PID 系数声明为volatile,配合 Live Watch 直接改值,不用反复烧录。这个习惯对任何走闭环控制的 STM32 项目都适用。

6.2 上车前的五项物理检查

  • 编码器计数方向:低速空转左轮,确认两个编码器计数值符号一致,默认前进时都为正,方向反了就对调 A/B 相线或在代码里取反。
  • 车轮抬空旋转:把车架起来转动左右轮,确认四倍频读数在额定转速下没被输入滤波吃掉,计数丢失表现为速度反馈周期性偏小。
  • OpenMV 通信回环:STM32 发一帧 0x55,OpenMV 原样返回,对比数据内容并验证 crc 实现是否一致,这一步能排除大部分接线错误。
  • 二维码解码距离标定:从 20cm 到 80cm 每隔 10cm 测一次解码成功率,把停车参考点固定在成功率最高的距离区间。
  • 机械臂安装预紧:移动机械臂时 OpenMV 支架可能产生形变,抓取偏移跟着漂,重新标定偏移宏之前先确认螺丝预紧力一致。

这五项检查每次代码改动后重跑一遍,二十分钟内能全部完成。再多的路径规划优化,都不如现场少一次因接线失误导致的整局失败。

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

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

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

立即咨询