简介:2022年电子设计竞赛江苏省赛区C题一等奖作品,主控采用MSP432超低功耗微控制器,实现主从双车协同寻迹与识别。面向电子竞赛参赛者、嵌入式与机器视觉学习者,资源提供完整工程源码、MSP432封装库和OpenMV视觉处理方案。全包共1026个文件,以642个C源文件和172个头文件为核心,辅以库文件、链接脚本、工程配置、Python脚本及编译产物,压缩包大小18.45MB,目录便于按模块查阅。主车与从车的外设分工、图像识别、通信协议与电源管理均有体现;主车OpenMV负责寻迹、停止线识别与障碍物距离计算,替代了传统超声波与光电传感器;资源内置MSP432封装库与常用外设驱动,硬件设计与软件调试均可直接参考。软件层面涉及嵌入式C编程、OpenMV机器视觉、任务调度与算法优化,配套工程可直接编译烧录,也可作为低功耗智能小车开发框架复用,已有3079人学习下载,适合作为电赛备赛与智能小车开发的参考案例。
1. 电子设计竞赛2022年C题某省赛区一等奖:MSP432主控加OpenMV寻迹小车,自带一套完整封装库
一提到电子设计竞赛里的寻迹小车,第一反应总是 OpenMV 识别黑线、算出偏差、发给 MSP432 输出 PWM。真到赛道上你就会发现,难点根本不在这条链路,而在阈值漂移、出线恢复和电机响应这几个地方。这套在 2022 年 C 题上拿到某省级赛区一等奖的作品,恰好把最难的那层补齐了:主控用 MSP432,视觉前端用 OpenMV,整个资源包里还内置了一份 MSP 封装库,等于把 GPIO、UART、定时器这些底层外设全部拧成了即插即用的模块。适合手上有 MSP432 板子和 OpenMV 镜头、想快速搭出一台能稳定寻迹的小车的开发者,也适合拿这套代码当骨架,二次改成电磁、视觉融合方案的进阶选手。
2. 技术选型拆解:MSP432 负责控制,OpenMV 负责看线,分工干净
2.1 题目场景逼出来的需求:弯道、断线和光线变化
2022 年 C 题的核心场景是快速寻迹,赛道一般由直线、直角弯、圆弧弯和少量十字路口组成。直线段可以全速冲,但进弯必须提前减速,出弯后还要加速回赛道,中间最怕的是断线、反光和突然闯入的光斑。如果只让主控直接处理摄像头原始图像,普通单片机会被二值化和坐标计算拖慢,中断响应也会跟着抖,车跑起来就变成一顿一顿的。
所以这套项目把“看”和“控”拆成两个独立硬件:OpenMV 负责图像采集、二值化、色块识别和坐标计算,MSP432 只负责串口接收、控制算法和 PWM 输出。这样做的直接收益是帧率可以稳定到 60fps 以上,而主控侧的控制中断还能保持在 1ms 级别。对于比赛来说,这个架构最大的好处是调试时可以分开排查:车乱跑不一定是算法问题,先看 OpenMV 画面里色块框有没有跟住线,再看串口输出数值是否正常,问题就被切成两半了。
2.2 为什么主控选 MSP432:低功耗、浮点、定时器资源
MSP432 不是电赛里最冷门的选择,但它很对这类控制场景的胃口。它内核带了浮点运算单元,跑 PID 这类浮点控制算法不需要软件模拟,运算误差也小。其次,它的定时器模块能同时输出多路 PWM,而且支持互补输出,配合 H 桥电机驱动芯片时可以直接做成带死区的控制信号,不用额外搭逻辑电路。
另一个容易被忽略的点是 ADC。MSP432 内部有 12 位 ADC,可以实时采集电池电压和电机电流。我一般在跑车之前都会先用它测一遍供电电压,如果发现重载时电压掉得厉害,就不敢把速度限幅设得太大。这套项目把电池电压采样也封装进了库,所以后期加低压保护、过流保护都只是改一个回调函数的事,不需要重新画板子。
还值得一提的是它的低功耗模式。小车在起跑线上等待出发指令时,主控可以进入低功耗状态,只保留串口接收唤醒和按键中断。比赛要求现场调试时频繁上下电,这种设计能减少电池损耗,也避免长时间待机导致硬件发热。
2.3 OpenMV 做视觉前端:把图像处理挡在摄像头端
如果让 MSP432 直接接摄像头芯片,图像数据量大不说,处理逻辑还要自己写。OpenMV 的价值在于它板载了完整的图像处理库,开箱就能做灰度二值化、色块搜索、边缘检测,还能在屏幕上实时显示色块框。项目里采用的方案是让 OpenMV 输出一个归一化偏差值,而不是把整幅图像传过来。
调试的时候只需要连上 OpenMV 配套 IDE,观察每一帧画面里色块框是否稳定贴合赛道线,就能快速判断视觉端是否正常。现场光线变化时,也只需要在 IDE 里直接调阈值,不用重新编译主控工程。这种前后端分离在比赛现场特别省时间,我见过不少队伍临场改主控逻辑,改完烧录又要等十几秒,而 OpenMV 这边改完阈值立刻能看到画面效果。
2.4 封装库的价值:从“能跑”到“可复用”
这份资源里最值钱的不是那一百多行控制代码,而是内附的 MSP 封装库。封装库做的事情很简单,就是把寄存器操作包成函数,例如 GPIO 初始化、UART 收发、定时器 PWM 启动、ADC 单次采样,全部变成模块化的接口。
它的价值体现在两个层面:第一,拿到手之后不需要从头翻数据手册,也能把外设初始化写对;第二,它把驱动层和应用层隔开了,后续改车时只动 PID 参数和状态机,底层硬件接口基本不动。比如想换一个电机驱动芯片,只需要改 motor.c 里 PWM 输出和方向引脚的定义,主控逻辑一行都不用碰。
3. 目录与封装库剖析:拿到压缩包后先认清哪些文件能动
3.1 资源目录一览:工程、库、文档各占一块
打开压缩包后不要急着编译,先把目录结构看一遍。项目的顶层目录大致是下面这个样子:
. ├── doc/ # 设计文档、引脚接线表、参数记录表 ├── openmv/ # OpenMV 端源码,main.py 与阈值配置文件 ├── project/ # MSP432 主控工程目录 │ ├── app/ # 应用层:状态机、控制逻辑、任务调度 │ ├── driver/ # 电机、编码器、RGB 灯的抽象驱动 │ ├── msplib/ # MSP 封装库头文件和源码 │ └── startup/ # 启动文件和系统时钟配置 └── readme.md # 快速上手说明doc 目录里一般会有引脚分配表和接线图。先把这张表打出来,对照着硬件板子核对一遍,能省掉很多低级错误。project/app 是应用逻辑的集中地,基本的寻迹算法和状态机都在这里;driver 目录负责把具体硬件封装成统一接口,比如电机正反转控制,即便底层驱动芯片不同,对外接口仍然是motor_set_speed(left, right)。
3.2 MSP 封装库的模块关系:GPIO/UART/定时器分开管
封装库内部按外设拆成了几个模块,每个模块对应一组头文件和源文件:
| 模块 | 对外接口 | 主要功能 |
|---|---|---|
| gpio | gpio_pin_init()、gpio_write() | 引脚模式配置、电平输出与读取 |
| uart | uart_init()、uart_send() | 串口波特率配置、中断接收回调、发送函数 |
| timer | timer_pwm_start()、timer_get_ticks() | 定时中断、PWM 输出、时间戳计数 |
| adc | adc_read_channel() | 电池电压、电位器电压采样 |
| motor | motor_set_speed() | 方向引脚和 PWM 信号的组合输出 |
这种拆法遵循了一个很朴素的原则:每个文件只干一件事。GPIO 模块不依赖 UART,UART 模块不关心电机怎么转。于是你在调试时如果发现串口接收有异常,可以直接去 uart.c 里查 FIFO 和中断配置,不会牵连到别的模块。
封装库里的 API 命名也保留了芯片手册的风格,例如uart_init(UART_NO, BAUDRATE, DATABITS, STOPBITS),看起来啰嗦,但实际上减少了歧义:到底是哪个串口、什么波特率、停位有多少,调用者一眼就能看明白。对竞赛项目来说,这种啰嗦反而能阻止临时抱佛脚式的瞎猜。
3.3 硬件抽象层与应用逻辑层的边界
很多初学者会犯一个错误:把控制逻辑和寄存器操作写在同一个函数里,看起来很短,但换一块板子就全部作废。这套项目刻意把两层分开:应用层只调用 driver 和 msplib 提供的函数,不直接操作寄存器。
具体到代码上,应用层关心的是“当前偏差是 32,目标速度和方向应该怎么变”,它不会去写UCTL这种寄存器。驱动层关心的是“把 left 电机设成 PWM 占空比 60%,方向为正”,它把定时器的比较寄存器和 GPIO 方向引脚组合封装起来。只要接口不变,把 MSP432 换成一个寄存器地址完全不同的芯片,也只需要重写 driver 和 msplib 两层,应用层代码可以直接平移。
4. 核心链路实战:从像素坐标到电机差速的完整通路
4.1 OpenMV 端:灰度二值化、找色块、算偏差
OpenMV 端采用的是最经典的寻线方式:把图像转成灰度,找出比背景更暗的黑色赛道线,然后计算最大色块的中心位置。为了让计算更快,代码里会把图像窗口缩到很窄,比如 80 像素宽、60 像素高,只保留视野中间的一小条区域。
import sensor, image, ustruct from machine import UART ROI_W = 80 ROI_H = 60 LINE_THRESHOLD = (0, 35) # 黑色线在灰度图中的阈值范围 sensor.reset() sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.set_windowing((ROI_W, ROI_H)) sensor.skip_frames(100) uart = UART(3, 115200, bits=8, parity=None, stop=1) uart.init() while True: img = sensor.snapshot() blobs = img.find_blobs([LINE_THRESHOLD], pixels_threshold=15, area_threshold=15) if blobs: largest = max(blobs, key=lambda b: b.pixels()) error = int((largest.cx() - ROI_W // 2) * 0.1) # 归一化到 -10~10 send_data = bytearray([0xAA, 0x55, error & 0xFF, 0x00]) else: send_data = bytearray([0xAA, 0x55, 0x80, 0x01]) # 丢线标志 uart.write(send_data)这段代码的重点在find_blobs这一步:它在整张灰度图里寻找符合阈值的连通域,然后取面积最大的那个色块。largest.cx()得到的是色块中心的横向坐标,用这个坐标减去图像中线,就知道当前赛道线在车的左边还是右边。乘 0.1 是对偏差做缩放,让串口传输和 PID 计算都更好处理。
参数方面,LINE_THRESHOLD决定了什么是“线”:在灰度图中 0 是全黑,255 是全白,如果赛道是白底黑线,阈值范围就应该放在靠近黑色的低端。pixels_threshold和area_threshold用来过滤噪点,太小的连通域直接忽略。这两个值设置过大,会漏掉细线;设置过小,地缝或阴影会被当成赛道。现场调试时我一般先把area_threshold放到最低,看画面上有没有多余的色块,再慢慢往上抬。
4.2 串口协议:一帧四个字节,主控怎么安全拆包
OpenMV 和 MSP432 之间通过 UART 通信,波特率设为 115200。简单计算一下,每帧 4 字节,每秒 60 帧,总共 240 字节,带宽完全够用。协议设计成固定长度帧,帧头是 0xAA 0x55,第三个字节是偏差值,第四个字节是丢线标志。
/* uart_handler.c: 主控 UART 中断接收解析 */ static uint8_t rx_buf[4]; static uint8_t rx_cnt = 0; static volatile int8_t line_error = 0; static volatile uint8_t line_lost = 0; void UART_RX_Handler(uint8_t byte) { rx_buf[rx_cnt++] = byte; if (rx_cnt < 4) return; /* 重新同步帧头 */ if (rx_buf[0] != 0xAA || rx_buf[1] != 0x55) { rx_cnt = 0; return; } line_error = (int8_t)rx_buf[2]; line_lost = (rx_buf[3] == 0x01); rx_cnt = 0; }这段代码的关键动作是“攒满一帧再解析”。中断里每收到一个字节就存进rx_buf,攒到 4 个字节才处理。如果帧头不对,说明丢帧了,直接把计数器清零重新开始,不要硬着头皮解析错位的数据。line_error用 int8_t 类型定义,这样 OpenMV 发的 0xFE 才能被正确解释成 -2 而不是 254。
实际比赛环境中串口很容易被电机干扰,偶尔丢掉一帧是正常的。好在控制侧是 1ms 级中断循环,丢一帧只会让控制量在上一次数值上多保持一毫秒,对车身影响很小。真正要防的是连续错帧,所以有的代码会加一个连续错误计数,超过一定次数就强制停车,这个可以参考后续状态机的设计。
4.3 PID 调速:位置式带积分限幅
拿到偏差之后,控制侧需要把它转化成左右电机的速度差。最稳定的做法是位置式 PID:偏差大时输出大,偏差小时输出小,偏差变化快时通过微分项提前压住转向。
typedef struct { float kp; float ki; float kd; float integral; int16_t last_error; int16_t out_limit; } pid_ctrl_t; int16_t pid_run(pid_ctrl_t *pid, int16_t error) { pid->integral += error; /* 积分限幅,防止出线时积分饱和 */ if (pid->integral > 200) pid->integral = 200; if (pid->integral < -200) pid->integral = -200; float p_out = pid->kp * error; float i_out = pid->ki * pid->integral; float d_out = pid->kd * (error - pid->last_error); pid->last_error = error; int16_t out = (int16_t)(p_out + i_out + d_out); /* 输出限幅,限制最大转向增量 */ if (out > pid->out_limit) out = pid->out_limit; if (out < -pid->out_limit) out = -pid->out_limit; return out; }这段代码里最容易忽略的是积分限幅。如果小车在弯道里连续丢线好几帧,偏差一直很大,积分项会快速累积到很夸张的值。等赛道线重新出现时,这个残留积分会导致猛打方向,车直接甩出去。限幅值设为 200 是经验值,具体要看电机响应和赛道弧度来调。
输出限幅也很关键。它限制的不是电机速度本身,而是 PID 输出的增量幅度。如果出限幅设得太小,转向响应会显得迟钝;设得太大,车身会出现明显摆动。调试时先把 Kp 放到很小,让车勉强能压着线走,再逐步加大 Kd 让曲线变得圆滑,最后加一点 Ki 消除稳态误差。
4.4 运行状态机:出线后的三档恢复策略
单纯的 PID 只能在有赛道线的时候工作。一旦小车冲出赛道,OpenMV 传回来的是丢线标志,这时候必须有一套状态机来决定车该干什么。
typedef enum { ST_IDLE, ST_TRACK, ST_LOST, ST_STOP } run_state_t; static run_state_t state = ST_IDLE; static uint16_t lost_cnt = 0; void task_run(void) { switch (state) { case ST_IDLE: if (start_flag) state = ST_TRACK; break; case ST_TRACK: if (line_lost) { lost_cnt = 0; state = ST_LOST; } else { int16_t turn = pid_run(&pid, line_error); motor_set_diff(turn); } break; case ST_LOST: lost_cnt++; if (lost_cnt > 30) { /* 超过 30ms 仍丢线,强制停车 */ motor_stop(); state = ST_STOP; } else { motor_set_diff(last_turn * 0.6); /* 低速循惯性继续走 */ } break; case ST_STOP: break; } }这套状态机把控制逻辑从“要不要转向”提升到了“是否需要救援”的层面。ST_TRACK 是正常巡线态,ST_LOST 是丢线恢复态,恢复态里不只是原地停车,而是以 60% 的转向输出继续低速前进一小段。这个设计很聪明:很多丢线只是瞬时遮挡,车头稍微再走一点就能重新看到线,如果立刻原地不动反而会卡死。
lost_cnt > 30是保护阈值。如果连续 30 毫秒都恢复不了,说明车已经彻底跑出赛道,此时必须停车。30 这个数字不是固定的,赛道越宽、车速越快,可以适当调大;场地狭窄时调小,否则车会撞到隔离物上。
5. 跑版本与避坑排查:烧录、标定、最常见的几个问题
5.1 烧录与启动流程:先烧 OpenMV 固件,再烧主控程序
拿到代码后的第一件事不是改车,而是先确认两套系统都能独立跑起来。OpenMV 端比较简单,连接 USB 后,在配套 IDE 里打开openmv/main.py,直接运行。如果画面窗口能正常显示灰度图像,说明摄像头驱动没有问题,气闸下一点要小心避免 USB 线和电机驱动同电源,否则一启动电机,OpenMV 就会掉线。
主控端的烧录走 SWD 接口,用常见的仿真器连接 MSP432 的调试引脚。烧录前先核对板子上的跳线帽,确认没有把调试引脚复用到别的功能上。烧录结束后板子会自动复位,此时先不要装电机,打开串口助手看主控是否在循环发送握手数据,确认基础外设正常后再装上车架。
整个过程我习惯按“先看画面,再看串口,最后看轮子”的顺序走。如果第一步发现 OpenMV 画面是花的,大概率是感光芯片排线没插好;如果画面正常但主控收不到数据,先检查 RX/TX 是否交叉连接,这是最常踩的低级错误。
5.2 参数标定顺序:把“玄学”变成一张表
寻迹车跑得好不好,80% 靠参数标定。现场最容易翻车的地方就是有人上来直接把速度拉满,然后车在第一个弯道就飞出去。最稳的标定顺序是先确定视觉阈值,再确定电机死区,最后调 PID 三个参数。
| 参数 | 位置 | 标定方法 |
|---|---|---|
| LINE_THRESHOLD | openmv/main.py | 在 IDE 里实时调阈值,观察色块框贴合程度 |
| PWM 频率 | driver/motor.c | 电机无明显啸叫即可 |
| Kp | app/pid.c | 直线段能跟上赛道,弯道不冲出 |
| Ki | app/pid.c | 稳态时偏差可以收敛,不抖动 |
| Kd | app/pid.c | 弯道入口车身不前后摆动 |
电机死区往往是最容易被忽略的一项。电机从 PWM 0% 到真正开始转动之间存在一个最小占空比,如果 PID 输出很小,电机根本不动,失误就会累积。常见做法是在motor_set_speed里加一个死区补偿:当目标速度大于 0 但小于某个下限时,直接输出那个下限值。死区补偿值不能设太大,否则车会在直线段左右扭来扭去。
PID 标定顺序不要乱。先调 Kp,让车能在直线上居中;再加 Kd 处理弯道手抖;最后加 Ki 消除细微偏差。如果试跑时发现车在直线上左右偏,但开不出赛道,一般是 Kp 过大;如果进弯就甩出去,通常是 Kd 不足或者速度限幅太高。
5.3 常见问题排查:现象、原因、解决
第一个高频问题:OpenMV 画面正常,主控串口助手却显示乱码。原因是 OpenMV 和 MSP432 之间的波特率不一致,或者接线松动。解决方法是先确保两边都设成 115200,再用示波器或串口助手量测接收端的实际波形,确认没有信号漂浮。
第二个高频问题:车在强光下突然丢线,但在室内正常。原因是LINE_THRESHOLD是固定值,阳光直射会让赛道线变成灰色的,和地面的对比度下降。解决方法是先固定曝光时间,把set_auto_gain(False)打开,然后在正午光线下重新标定阈值。如果现场光源不可控,建议把 OpenMV 的镜头加一段遮光罩,效果立竿见影。
第三个高频问题:跑了几圈后电机发热,速度越来越慢。原因是 PWM 频率太低,超出了电机内部功率管的开关带宽,导致电机额外发热。把timer_pwm_start的频率从 1kHz 提到 10kHz 通常能解决。如果频率已经到 20kHz 还发热,那就是负载电流太大,需要检查电池容量和减速齿轮箱。
第四个高频问题:主控烧录程序时偶尔失败,拔掉电机后恢复正常。原因是电机大电流造成了电源上的共地干扰,烧录器在高压跌落时无法保持握手。解决方案是把烧录器的地线单独接到主控板的电源地,不要在电机驱动的地线上共用;还有一种做法是烧录时断开电机供电,只给主控和烧录器供电。
第五个高频问题:丢线后车会原地转圈,而不是恢复。原因是ST_LOST状态里motor_set_diff(last_turn * 0.6)被注释掉了,代码根本没有执行低速继续走。打开代码检查一下状态迁移条件,确认丢线恢复态里不是直接调用motor_stop()。很多时候不是硬件问题,而是代码注释里的“临时测试”忘了还原。
6. 二次开发:改颜色识别、加入惯性传感、验证阈值与 PID
6.1 从黑线到彩色线:阈值配置的小改动
很多比赛场地不是白底黑线,而是浅蓝底深蓝线,或者红底白线。OpenMV 的find_blobs本身支持彩色阈值,把图像改成 RGB565 格式,再传三组阈值范围,就能识别彩色赛道线。
LINE_THRESHOLD = (0, 40, -20, 20, -20, 20) # LAB 色彩空间阈值 sensor.set_pixformat(sensor.RGB565)改动点只有两处:pixformat改成 RGB565,阈值从灰度阈值变成 LAB 分量范围。实际调试时不要凭经验写阈值,先拍一张赛道照片,用 IDE 里的“阈值编辑器”工具框选赛道颜色,让它自动生成 LAB 范围,再粘贴到代码里。这个方法能省下很多肉眼调参的时间。
6.2 加上惯性模块做出线补偿
如果原赛道里有连续 S 弯,光靠摄像头偏差很容易在切换方向时反应过慢。一个性价比很高的扩展是用六轴惯性模块计算出车头朝向角,把角速度融合到 PID 输出里。方法是当摄像头给出一个偏差值时,同时记录陀螺仪的 Z 轴角速度,如果角速度方向与弯道方向不一致,就适当减小转向输出。
这种扩展不需要改封装库,只需要在app/下新增一个imu.c,把角速度读出来,在task_run()里对 PID 输出做一次加权。注意惯性模块的零漂问题,上车前要静止采集 200ms 的偏移量,否则长时间运行后累积误差会让车偏向一侧。
6.3 验证阈值与 PID 的稳妥套路:参数白名单
我拿到这类资源后的处理习惯是先把关键参数集中到一个头文件里,做成参数白名单。比如把LINE_THRESHOLD、PWM_LIMIT、KP、KI、KD、LOST_THRESHOLD全部放进param.h,每次跑车前只改这个文件,不改应用逻辑。跑完一圈后把效果记录到表格里,下一版只动一个参数,永远不做两个以上的改动。
这个习惯是因为我在一次比赛前同时改了阈值和 PID,结果车在弯道里翻车,我甚至不知道是哪个改动造成的。从那以后,每次调试都强制自己走一遍“记录当前参数 → 改一个变量 → 跑一圈 → 记录结果”的流程,视觉阈值和控制参数之间不再互相甩锅。希望帮到你。
本文还有配套的精品资源,点击获取