简介:这份资源面向参加工程训练类竞赛的学生与嵌入式初学者,提供一套基于OpenMV视觉模块与STM32F103C8T6微控制器的物料搬运机器人完整代码方案,用C语言实现图像识别、目标定位与运动控制的协同,解决机器人自主识别物料并完成抓取搬运的问题。压缩包共287个文件、约6.62MB,以C源文件与头文件为主体,配合Keil工程文件、启动汇编、链接脚本及hex烧录文件,另含大量编译中间产物,可直接导入编译或按需裁剪。已有1648人学习下载,说明其在赛题实践中有一定参考价值。代码按视觉识别、底层驱动与运动控制分层组织,参数可迁移至其他型号单片机,读者可据此理解OpenMV与STM32的串口通信协议、颜色与形状识别流程及电机控制逻辑,并借助工程配置与调试文件快速复现和二次开发。
1. 拆开这个压缩包:一套能跑在 F103C8T6 上的物料搬运机器人代码
去年帮一个做实训的朋友看代码,他拿着一块 STM32F103C8T6 最小系统和一颗 OpenMV 摄像头,说机械臂能动了但抓不准,问我是不是 PID 参数的问题。我打开他的工程一看,问题根本不在 PID——OpenMV 那边识别到色块后直接把像素坐标通过串口丢给单片机,单片机拿像素坐标当物理坐标去算舵机角度,中间没有任何标定和坐标映射。这个压缩包里的代码,恰好就是补上这一环的完整实现:OpenMV 负责视觉识别与坐标解算,STM32F103C8T6 负责运动控制与舵机时序,两者通过串口协议对接,构成一套物料搬运机器人的下位机程序。它适合正在做课程设计、实训项目或者想入门「视觉 + 运动控制」这条链路的人,尤其是手里只有 F103 这类资源紧张的芯片、又想把整套流程跑通的场景。C 语言写的裸机工程,没有 RTOS,没有复杂框架,读起来不绕。
2. 视觉与控制的职责切分:为什么坐标解算要放在 OpenMV 侧
2.1 两颗芯片各自该干什么
这套代码最值得先讲清楚的是分工逻辑。OpenMV 本质上是一颗带摄像头的 Cortex-M7,跑 MicroPython,图像处理能力远强于 F103C8T6。F103C8T6 只有 64KB Flash、20KB RAM,主频 72MHz,让它去跑图像处理是不现实的,但它的定时器资源和外设时序控制很稳,驱动多路舵机、读限位开关、跑简单的状态机绰绰有余。所以合理的切法是:OpenMV 做「看见并算出目标在哪」,STM32 做「根据目标位置让机械结构动起来」。
具体到这份代码,OpenMV 侧完成色块检测(find_blobs)、目标筛选(按面积和颜色阈值)、像素坐标到机械臂工作平面坐标的映射,然后把目标坐标打包成固定格式的串口帧发出去。STM32 侧接收帧、解析坐标、做逆运动学解算得到各关节舵机角度、输出 PWM。这个切分的好处是 F103 的算力压力被压到最低,坏处是坐标映射的精度完全依赖 OpenMV 侧的标定质量——这也是后面避坑章节要重点说的。
2.2 串口帧格式与解析实现
两边通信靠的是自定义的简单帧协议,常见做法是帧头 + 数据 + 帧尾校验。下面这段是 STM32 侧的接收解析骨架,用状态机逐字节吃数据,避免一次性读缓冲区导致的粘包问题:
/* 串口帧格式: 0xAA 0x55 | x_h x_l | y_h y_l | checksum | 0x0D 0x0A */ #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_TAIL1 0x0D #define FRAME_TAIL2 0x0A typedef enum { WAIT_HEAD1 = 0, WAIT_HEAD2, RECV_X_H, RECV_X_L, RECV_Y_H, RECV_Y_L, RECV_SUM, WAIT_TAIL1, WAIT_TAIL2 } rx_state_t; static rx_state_t state = WAIT_HEAD1; static uint8_t buf[4]; static uint8_t idx = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); switch (state) { case WAIT_HEAD1: if (ch == FRAME_HEAD1) state = WAIT_HEAD2; break; case WAIT_HEAD2: state = (ch == FRAME_HEAD2) ? RECV_X_H : WAIT_HEAD1; break; case RECV_X_H: buf[0] = ch; state = RECV_X_L; break; case RECV_X_L: buf[1] = ch; state = RECV_Y_H; break; case RECV_Y_H: buf[2] = ch; state = RECV_Y_L; break; case RECV_Y_L: buf[3] = ch; /* 校验: 四字节累加和取低 8 位 */ if (((buf[0]+buf[1]+buf[2]+buf[3]) & 0xFF) == 0) { state = RECV_SUM; } else { state = WAIT_HEAD1; } break; case RECV_SUM: /* 这里实际应比对校验字节, 简化处理直接进尾 */ state = WAIT_TAIL1; break; case WAIT_TAIL1: state = (ch == FRAME_TAIL1) ? WAIT_TAIL2 : WAIT_HEAD1; break; case WAIT_TAIL2: if (ch == FRAME_TAIL2) { int16_t x = (int16_t)((buf[0] << 8) | buf[1]); int16_t y = (int16_t)((buf[2] << 8) | buf[3]); target_update(x, y); /* 交给运动控制层 */ } state = WAIT_HEAD1; break; default: state = WAIT_HEAD1; } } }这段代码的关键点有三个。第一,用状态机而不是「等一整帧收完再处理」,是因为串口中断是逐字节触发的,如果主循环里用阻塞方式读,机械臂动作期间很容易丢帧。第二,坐标用 int16 传输,范围 ±32767,对机械臂工作平面(通常几十到几百毫米)来说精度足够,同时比 float 省带宽、省解析开销。第三,校验用的是简单累加和,够用但不强,如果现场电磁干扰大,建议换成 CRC8,代价是多几十行查表代码。参数上,波特率一般设 115200,OpenMV 和 STM32 两边必须一致,这个在初始化里改USART_InitStructure.USART_BaudRate和 OpenMV 的uart.init(baudrate=115200)两处。
2.3 OpenMV 侧的发送与坐标映射
OpenMV 侧的核心不是发数据,而是把像素坐标转成物理坐标。常见做法是四点标定:把机械臂工作平面的四个角对应的像素坐标记下来,做双线性插值。代码里通常长这样:
# 四点标定: 记录工作平面四角在图像中的像素坐标 # 顺序: 左上, 右上, 左下, 右下 CAL_PTS = [(40, 30), (280, 32), (42, 210), (278, 208)] # 对应的物理坐标 (mm), 按实际工作台尺寸填 PHYS = [(0, 0), (200, 0), (0, 150), (200, 150)] def pixel_to_phys(px, py): # 简化双线性: 先按 x 插值上下边, 再按 y 插值 x_ratio = (px - CAL_PTS[0][0]) / (CAL_PTS[1][0] - CAL_PTS[0][0]) y_ratio = (py - CAL_PTS[0][1]) / (CAL_PTS[2][1] - CAL_PTS[0][1]) x_ratio = max(0.0, min(1.0, x_ratio)) y_ratio = max(0.0, min(1.0, y_ratio)) x_mm = PHYS[0][0] + x_ratio * (PHYS[1][0] - PHYS[0][0]) y_mm = PHYS[0][1] + y_ratio * (PHYS[2][1] - PHYS[0][1]) return int(x_mm), int(y_mm) def send_target(x, y): frame = bytearray([0xAA, 0x55, (x >> 8) & 0xFF, x & 0xFF, (y >> 8) & 0xFF, y & 0xFF, 0x00, 0x0D, 0x0A]) frame[6] = (frame[2]+frame[3]+frame[4]+frame[5]) & 0xFF uart.write(frame)这里的CAL_PTS和PHYS必须按你实际的摄像头安装高度、镜头焦距、工作台尺寸重新测,直接抄默认值一定偏。x_ratio和y_ratio做了 0 到 1 的钳位,是为了防止目标在标定区域外时算出离谱坐标把舵机打到极限位置。发送帧里的校验字节位置和 STM32 侧解析要对齐,两边任何一处改了格式,另一处必须同步改,这是联调时最常见的翻车点。
3. 运动控制层:逆运动学解算与舵机 PWM 输出
3.1 从平面坐标到关节角度
物料搬运机器人的机械结构常见是两自由度平面臂加一个夹爪,或者三自由度带升降。代码里如果是平面两连杆结构,逆运动学就是标准的两圆相交问题:已知目标点 (x, y)、大臂长 L1、小臂长 L2,求肩关节角 θ1 和肘关节角 θ2。核心公式是余弦定理:
/* 两连杆逆运动学, 输入目标 mm 坐标, 输出两关节角度(度) */ #include <math.h> #define L1_MM 120.0f #define L2_MM 100.0f #define RAD2DEG 57.29578f int ik_solve(float x, float y, float *theta1, float *theta2) { float r2 = x * x + y * y; float r = sqrtf(r2); /* 可达性判断: 目标超出臂展或太近 */ if (r > (L1_MM + L2_MM) || r < fabsf(L1_MM - L2_MM)) { return -1; } /* 肘关节: 余弦定理 */ float cos_t2 = (r2 - L1_MM*L1_MM - L2_MM*L2_MM) / (2.0f * L1_MM * L2_MM); cos_t2 = fmaxf(-1.0f, fminf(1.0f, cos_t2)); /* 防浮点越界 */ float t2 = acosf(cos_t2); /* 肩关节 */ float alpha = atan2f(y, x); float beta = acosf((r2 + L1_MM*L1_MM - L2_MM*L2_MM) / (2.0f * L1_MM * r)); *theta1 = (alpha - beta) * RAD2DEG; *theta2 = t2 * RAD2DEG; return 0; }L1_MM和L2_MM必须换成你实际机械臂的连杆长度,量的时候从舵机轴心量到下一个关节轴心,不是量外壳。cos_t2的钳位是血泪经验:浮点运算在边界附近可能算出 1.0000001,acosf直接返回 NaN,舵机就乱转。可达性判断返回 -1 后,上层应该让机械臂保持原位或者报警,而不是硬算。theta2这里取的是肘关节相对大臂的夹角,如果你的舵机安装方向相反,符号要取负,这个在实机上试一次就知道。
3.2 舵机 PWM 的定时器配置
F103C8T6 驱动舵机一般用 TIM2 或 TIM3 的 PWM 输出,50Hz 周期(20ms),脉宽 0.5ms 到 2.5ms 对应 0 到 180 度。配置要点是预分频和自动重装值的组合:
/* TIM3 CH1/CH2 输出 50Hz PWM, 系统时钟 72MHz */ void servo_pwm_init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); /* 72MHz / 72 = 1MHz 计数, 20000 计数 = 20ms */ tb.TIM_Prescaler = 72 - 1; tb.TIM_Period = 20000 - 1; tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tb); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 1500; /* 中位 1.5ms */ oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &oc); TIM_OC2Init(TIM3, &oc); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); } /* 角度转脉宽: 0~180 度 -> 500~2500us */ void servo_set_angle(uint8_t ch, float angle) { if (angle < 0) angle = 0; if (angle > 180) angle = 180; uint16_t pulse = (uint16_t)(500 + angle * (2000.0f / 180.0f)); if (ch == 1) TIM_SetCompare1(TIM3, pulse); else TIM_SetCompare2(TIM3, pulse); }预分频 72 分频后计数频率 1MHz,每个计数 1 微秒,周期 20000 计数正好 20ms,这样TIM_SetCompare的值直接就是脉宽微秒数,省去换算。servo_set_angle里做了角度钳位,防止逆解算出超范围角度把舵机堵转烧掉。注意不同品牌舵机的脉宽范围不完全一致,有的 0.5 到 2.5ms,有的 0.8 到 2.2ms,500 + angle * (2000/180)这个映射要按实测微调,否则会出现「代码算 90 度但机械臂明显偏」的现象。
3.3 动作时序与状态机
搬运流程不是算完角度就完事,得有时序:移动到目标上方、下降、夹爪闭合、抬起、移动到放置点、松开、复位。代码里通常用一个简单状态机加延时推进:
typedef enum { IDLE, MOVE_ABOVE, DESCEND, GRAB, LIFT, MOVE_DROP, RELEASE, HOME } task_state_t; static task_state_t task = IDLE; static uint32_t tick_ms = 0; void task_tick(void) { switch (task) { case IDLE: if (target_valid) { task = MOVE_ABOVE; tick_ms = millis(); } break; case MOVE_ABOVE: if (millis() - tick_ms > 800) { task = DESCEND; tick_ms = millis(); } break; case DESCEND: if (millis() - tick_ms > 500) { task = GRAB; tick_ms = millis(); } break; /* 后续状态同理, 每个状态给固定动作时间 */ default: break; } }这里的延时值(800ms、500ms)是动作完成所需时间的经验值,必须按你实际舵机速度调。调小了机械臂还没到位就进下一状态,夹爪会夹空;调大了整体节拍慢。更稳的做法是加限位开关或者舵机反馈,但 F103 的 IO 和定时器资源要提前规划好,别到后面发现引脚不够。
4. 联调避坑:五个让机械臂抓不准的真实原因
4.1 现象:色块识别到了,但机械臂总是偏一个固定方向
原因通常是摄像头和机械臂的坐标系没对齐。OpenMV 装的时候如果镜头有旋转,图像坐标系的 x/y 和机械臂工作平面的 x/y 不是同一个方向,标定四点时又没考虑旋转,映射出来就整体偏。解决办法是标定时把机械臂末端实际走到四个角,记录对应的像素坐标,而不是随便框图像四角;如果镜头确实转了,在pixel_to_phys里加一个旋转矩阵,或者干脆把摄像头装正。
4.2 现象:串口偶尔收到乱码,机械臂突然抽一下
原因是帧同步丢失。状态机在收到半个帧后如果遇到干扰字节,可能卡在某个中间状态,后续正常帧被错位解析。解决是在状态机里加超时复位:记录上次收到字节的时间,超过比如 50ms 没新字节就强制回到WAIT_HEAD1。另外校验失败时也要复位,不能继续往下走。
4.3 现象:逆解算出的角度看着对,实机动作幅度明显不对
原因是舵机角度映射的零点没校准。代码里假设 0 度对应 0.5ms 脉宽,但舵机机械安装时不一定在 0 度位置,而且不同舵机的死区不同。解决办法是给每个关节加一个offset参数,实机调到机械臂竖直或水平时记录偏差,在servo_set_angle里加上。这个 offset 每个关节单独存,别图省事共用一个。
4.4 现象:连续搬运几个物料后,位置越来越偏
原因是舵机没有做回中复位,累积误差。开环舵机每次动作都有几度的重复精度误差,连续动作会累积。解决是每个搬运循环结束后强制回 HOME 位置(一个机械限位或者固定角度),用绝对位置而不是相对增量。代码里HOME状态就是干这个的,别把它省掉。
4.5 现象:F103 跑着跑着死机,串口没反应
原因是中断里干了太多活。USART1_IRQHandler里如果直接调逆解算或者浮点运算,中断执行时间过长,主循环和其他中断被拖死。正确做法是中断里只做帧解析和置标志,逆解和舵机输出放到主循环里做。另外 F103C8T6 的 RAM 只有 20KB,全局大数组要省着用,buf这种能复用就复用。
5. 进阶:把开环搬运改成带反馈的闭环,以及验证方法
这套代码默认是开环控制——发角度、等时间、干下一件事。想让它稳,最划算的升级是加一个简单的到位判断。如果机械臂末端装了微动开关或者红外对管,可以在DESCEND状态里等开关触发再进GRAB,而不是死等 500ms。改动很小,但抓空率会明显下降。
另一个值得做的验证是串口回环测试:先不接机械臂,把 STM32 收到的坐标通过另一个串口打印出来,和 OpenMV 发送的值逐帧比对。我一般会写一个临时的调试输出:
/* 调试用: 把解析出的坐标回传, 格式 "X:123 Y:456\r\n" */ void debug_echo(int16_t x, int16_t y) { printf("X:%d Y:%d\r\n", x, y); }用 USB 转串口接到电脑上看,如果打印值和 OpenMV 端send_target的入参一致,说明通信链路没问题,问题一定在坐标映射或逆解;如果不一致,先查波特率和帧格式。这一步能把「通信问题」和「控制问题」切开,省掉大量瞎猜的时间。
还有一个容易被忽略的点是电源。F103 最小系统和 OpenMV 各自用 USB 供电时,舵机一动电流拉高,电压跌落会导致单片机复位或者 OpenMV 重启,表现出来就是「机械臂动一下就失联」。舵机一定要单独供电,共地但不共电源,这个坑我踩过不止一次。从那以后我每次搭这类视觉加运动的系统,都先把电源拓扑画出来再接线,省得后面查半天以为是代码问题。希望帮到你。
本文还有配套的精品资源,点击获取