简介:2022年电子设计竞赛C题江苏省赛区一等奖作品完整工程包,主控采用MSP432,内含MSP封装库,面向备战电赛的本科生及嵌入式开发者。项目以主从双车协同为核心,主车配备OpenMV完成寻迹、停止线识别与障碍物测距,从车搭载同款视觉模块与蜂鸣器,通过串口通信实现指令传递,替代传统超声波和光电传感器,在硬件成本与系统集成度上做了明显优化。软件层面覆盖嵌入式C编程、OpenMV MicroPython图像处理、UART通信协议、电源管理及算法优化等关键知识点。资源共1026个文件,以642个C源码和172个头文件为主,同时包含编译产物(o/a/axf/map)、工程配置文件(ccsproject/uvprojx)、MSP封装库及少量Python脚本,整体约18.45MB,目录结构完整,便于直接导入CCS或IAR进行学习复现。目前已有3079人学习,适合需要完整赛题方案、底层驱动参考和图像处理思路的参赛者参考。
1. 为什么 2022 年 C 题小车大家都卡在“能跑”而不是“跑赢”
2022 年电子设计竞赛的 C 题,核心是一辆带视觉寻迹的小车,要求它沿着未知赛道完成循迹、避障、定点停车这几件事。很多队伍的问题是车能跑起来,但跑不稳:过弯掉线、光线一变就丢线、停车位置偏差超过十公分。这套拿了江苏省赛区一等奖的资源,主控是 MSP432,视觉部分配合 OpenMV,代码里还附带了一套完整的 MSP 封装库。它解决的不只是“让车动起来”,而是把“怎么调参、怎么排错、怎么在比赛现场快速改代码”这条完整链路整理了出来。适合正在备赛、手里有 MSP432 开发板、想在寻迹稳定性上突破的队伍参考。这篇笔记我会把工程结构、封装库用法、PID 整定和现场排错一起拆开讲。
2. 先看懂 2022 年 C 题的完整任务拆解:从赛题到模块映射
2.1 赛题的功能要求与评分点还原
2022 年 C 题的任务描述可以归纳成四个核心动作:小车上电后沿赛道黑线行驶,途中遇到障碍物需要绕行,经过指定标志点需要停车,最后由裁判测量停车位置与目标点的误差。评分点主要看三块:寻迹是否稳定、避障是否成功、停车精度有多高。很多队伍只盯着“寻迹”做,忽略了避障和停车,导致总分上不去。
从工程实现的角度,我把任务拆成了五个子模块:底盘驱动模块、寻迹传感模块、视觉辅助模块、避障决策模块、停车控制模块。底盘驱动负责 PWM 输出和电机方向控制,寻迹传感负责采集赛道灰度信息,OpenMV 负责识别障碍物和标志点,避障决策负责判断绕行方向,停车控制负责减速和精确制动。
2.2 主控选型:为什么是 MSP432 而不是 STM32
MSP432 是一颗基于 ARM Cortex-M4F 内核的 MCU,主频可以跑到 48MHz,带硬件浮点单元,内置 12 位 ADC 和多个定时器。和 STM32F103 相比,它的优势主要在 ADC 精度和低功耗特性上,不过比赛里大家更看重的其实是它的开发调试体验。MSP432 用 CCS 或 Keil 都可以开发,调试时能实时查看变量和内存,这对调 PID 参数非常重要。
一个容易被忽略的细节是 MSP432 的 ADC 是 12 位分辨率,读取灰度传感器的原始值时,能区分出更多灰度层次。实际测试中,同样的传感器在 STM32 上读到的是“白 2800 / 黑 400”,在 MSP432 上能读到“白 2900 / 黑 350”,黑线的分辨率更高,寻迹算法就不容易丢线。
2.3 各模块的引脚分配与资源配置表
我建议在写代码之前,先把引脚分配做成一张表格,贴在电脑前面。这套资源里的工程就是按这个思路做的,直接把引脚映射写进了封装库头文件。下面是我根据这个工程整理的分配方式:
| 模块 | 接口 | MSP432 引脚 | 说明 |
|---|---|---|---|
| 电机 PWM | Timer A 输出 | P2.0 / P2.1 / P2.2 / P2.3 | 电机驱动板 IN1~IN4 |
| 电机方向 | GPIO 输出 | P1.0 / P1.1 | 前进 / 后退方向切换 |
| 灰度传感器 | ADC 输入 | P4.0 / P4.1 / P4.2 / P4.3 / P4.4 | 五路灰度探头 |
| OpenMV 通信 | UART 接收 | P3.0 (RX) | 接收 OpenMV 发送的视觉数据 |
| 编码器输入 | Timer 输入捕获 | P5.0 / P5.1 | 读取电机转速用于速度闭环 |
引脚分配的原则是:PWM 通道尽量集中在同一个定时器上,避免多个定时器互相干扰;ADC 通道尽量连续排列,方便用 DMA 批量读取。这套工程的封装库把引脚定义都集中在msp_pin_config.h里,改引脚时不用到处翻代码。
2.4 封装库的结构解读与移植方法
拿到资源后,第一件事不是看主函数,而是先看封装库的目录结构。这个工程的 MSP 封装库分成了bsp和driver两层。bsp层负责芯片底层初始化,包括sys_clk.c做时钟配置、gpio.c做引脚复用设置;driver层负责外设功能封装,比如motor.c、adc.c、uart.c。
如果你用的是同一块 MSP432P401R 开发板,可以直接把整个工程文件夹复制过去编译。如果板子型号不同,重点检查三个地方:系统时钟频率配置、引脚复用功能表、中断向量表。最常见的移植错误是换了板子之后忘了改sys_clk.c里的外部晶振配置,导致 UART 波特率全部漂移,OpenMV 的数据解析全乱。
3. 工程骨架与封装库内部机制:从初始化到中断响应的完整链路
3.1 系统时钟链路:为什么必须从 SystemInit 开始看
MSP432 上电后,代码首先执行的是SystemInit()函数,这一步决定 CPU 主频和各个外设的时钟源。这个工程把时钟配置在sys_clk.c文件里,先用 DCO 跑内部振荡器,再切换到外部晶振,最后配置MCLK = 48MHz、SMCLK = 12MHz、ACLK = 32.768kHz。
// sys_clk.c 核心片段 void SystemInit(void) { // 等待外部晶振稳定,超时则退回 DCO CSCTL0 = CSKEY; // 先解锁时钟模块 CSCTL1 = DCOFSEL_6; // DCO 频率选择档位 6,约 48MHz while (CSCTL1 & DCOERR); // 等待 DCO 锁定 CSCTL3 = DIVS_2; // SMCLK = 48MHz / 2 = 24MHz CSCTL2 = SELM_0 | SELS_0; // MCLK 与 SMCLK 均选择 DCO 作为时钟源 }这段代码的逻辑是先把 CS 模块解锁,再设定 DCO 频率,最后配置分频系数。注意CSCTL0 = CSKEY这一句,MSP432 的时钟模块默认是锁定状态,不先写正确的密钥直接配置后面的寄存器是无效的。参数方面,DCOFSEL_6对应 48MHz 档位,DIVS_2表示 2 分频。如果你的电机驱动芯片需要更高频率的 PWM,可以在DIVS_x档位上调整,比如改成DIVS_1就是不分频,SMCLK 会跑到 48MHz。
3.2 电机 PWM 封装的实现:死区、占空比与反向逻辑
电机控制是这个工程里封装得最顺手的一块。它把 PWM 频率、占空比、正反转方向全部封装在一个结构体里,调用方只需要关心“左轮速度多少、右轮速度多少”。
// motor.c 核心驱动 void Motor_SetSpeed(Motor_Type *motor, int16_t speed) { // speed 为正表示正转,为负表示反转 if (speed > 0) { GPIO_setOutputHighOnPin(motor->dirPort, motor->dirPinA); GPIO_setOutputLowOnPin(motor->dirPort, motor->dirPinB); Timer_A_setCompareValue(TIMER_A0_BASE, motor->pwmTimer, speed); } else if (speed < 0) { GPIO_setOutputLowOnPin(motor->dirPort, motor->dirPinA); GPIO_setOutputHighOnPin(motor->dirPort, motor->dirPinB); Timer_A_setCompareValue(TIMER_A0_BASE, motor->pwmTimer, -speed); } else { // 速度为零时需要同时拉低两路方向引脚,防止电机锁死 GPIO_setOutputLowOnPin(motor->dirPort, motor->dirPinA); GPIO_setOutputLowOnPin(motor->dirPort, motor->dirPinB); Timer_A_setCompareValue(TIMER_A0_BASE, motor->pwmTimer, 0); } }封装库里把speed定义成有符号整型,负数直接代表反转。这个设计的好处是上层 PID 控制器计算时,输出负值直接丢给这个函数就能正确转向,不需要额外判断方向状态。这里有一个实战经验:当速度为零时,一定要把两个方向引脚都拉低,而不是只把 PWM 占空比设为 0。否则驱动器会保持在原来的导通状态,电机会因为反电动势产生抖动,车看起来就像在“哆嗦”。
3.3 ADC 多通道扫描与 DMA:五路灰度一次性读完
灰度传感器的读取频率直接影响寻迹的响应速度。这个工程用了 ADC 多通道序列采样加 DMA 搬运的方式,一轮五路灰度采集只需要几十微秒。初始化时,先把 ADC 配置成 Sequence 模式,把 P4.0 到 P4.4 映射为通道 0 到 4,然后启动 DMA 循环搬运。
// adc_dma.c 灰度采集初始化 void ADC_Init_With_DMA(void) { // 开启 ADC 模块时钟 ADC14->CTL0 = ADC14_CTL0_SHS_0 | ADC14_CTL0_CONSEQ_1; ADC14->CTL1 = ADC14_CTL1_RES_2; // 14 位分辨率 ADC14->MCTL[0] = ADC14_MCTLN_INCH_0; // 通道 0 接 P4.0 ADC14->MCTL[1] = ADC14_MCTLN_INCH_1; ADC14->MCTL[2] = ADC14_MCTLN_INCH_2; ADC14->MCTL[3] = ADC14_MCTLN_INCH_3; ADC14->MCTL[4] = ADC14_MCTLN_INCH_4 | ADC14_MCTLN_EOS; // EOS 标志要加在最后一个通道上,表示序列结束 DMA0->CTL = DMA_CTL_ENA | DMA_CTL_TRIG_ADC14; DMA0->SZ = 5; // 一次搬动 5 个结果 }参数里的CONSEQ_1表示序列重复模式,采完一轮五路数据后自动开始下一轮,不需要 CPU 干预。DMA_CTL_TRIG_ADC14把 DMA 的触发源设为 ADC 转换完成事件,转换完一个通道就搬一次数据。这里的坑是 DMA 寄存器里要提前预留 5 个uint16_t的空间,并且缓冲区大小必须和 ADC 通道数一致,少一个字节都会导致内存越界,程序跑着跑着就跳进 HardFault。
3.4 UART 中断协议解析:OpenMV 上报数据包的边界识别
MSP432 与 OpenMV 的通信是这个工程的核心环节,OpenMV 识别到障碍物或标志点后,会通过串口发送一帧结构化数据。工程里用的协议格式是固定的帧头加数据体的方式:
帧头:0xAA 0x55 数据:0x01 障碍物距离 / 0x02 标志点编号 / …… 帧尾:0x0D 0x0AMSP432 侧用 UART 接收中断配合环形缓冲区解析。下面是工程里解析逻辑的精简版:
// uart_parser.c 接收并解析 OpenMV 数据 void UART_RX_Handler(void) { rxByte = UART_getChar(EUSCI_A0_BASE); if (rxByte == 0xAA) { rxState = 0; // 等待 0x55 } else if (rxState == 0 && rxByte == 0x55) { rxState = 1; // 帧头校验完成 rxIndex = 0; } else if (rxState == 1) { rxBuffer[rxIndex++] = rxByte; if (rxIndex >= DATA_LEN) rxState = 2; } else if (rxState == 2) { if (rxByte == 0x0D) rxState = 3; } else if (rxState == 3 && rxByte == 0x0A) { // 收到完整帧,开始按协议解析 Parse_Visual_Frame(rxBuffer); rxState = 0; } }状态机从0xAA开始,第二个字节必须是0x55才算帧头有效,接着按长度接收数据区,最后校验回车换行。这种方式的防错效果比“直接按字节顺序读取”好很多,因为比赛现场电磁环境复杂,偶尔会有干扰字节。只要你收到0xAA后第二个字节不是0x55,整个状态机就回到初始状态重新等待,不会把后面的数据当成有效帧。
4. 寻迹闭环搭建:传感选型、PID 参数整定与 OpenMV 通信
4.1 五路灰度传感器的布局与阈值校准
灰度传感器的小车寻迹,本质上是在“找黑线和白地的分界线”。工程资源里推荐的是五路灰度探头,中间一路对准赛道中心线,左右各两路作为偏移检测。安装高度建议控制在 10 到 15mm,这个距离能平衡环境光干扰和地面反光差异。
代码里做了一组校准函数:上电后先读取纯白地面和纯黑胶带下的 ADC 原始值,分别存在whiteValue[5]和blackValue[5]数组里,之后每次读取的阈值由两者取中值得到。
// 计算每路传感器的二值化阈值 void Calibrate_Threshold(void) { for (i = 0; i < 5; i++) { threshold[i] = (whiteValue[i] + blackValue[i]) / 2; } }这个做法的优势是自适应:不同赛场的灯光色温不同、地面材质不同,老式的固定阈值需要现场反复试参数,而这个校准函数在每轮比赛发车前跑一遍就行。实战中要留意的是黑胶带的宽度和反光程度,有些赛道的黑线是喷漆画的,反光比胶带更强,压线时 ADC 值变化幅度会变小,这时可以把探头高度降低一两毫米,让传感器更贴近地面。
4.2 PID 方向控制的参数整定顺序
寻迹小车的方向控制,工程里用的是位置式 PD 控制:偏差来自五路灰度探头加权计算出的“偏移量”,输出是左右轮的差速。计算公式是:
// pid.c 方向 PD 控制器 int16_t steering = Kp * deviation + Kd * (deviation - lastDeviation); lastDeviation = deviation; leftSpeed = baseSpeed + steering; rightSpeed = baseSpeed - steering; Motor_SetSpeed(&leftMotor, leftSpeed); Motor_SetSpeed(&rightMotor, rightSpeed);deviation的计算方式是五路探头状态的加权和,比如中间探头为 0,右边第一路为 +1,右边第二路为 +2,左边对称取负。baseSpeed是基础车速,steering是转向补偿量。至于 Kp 和 Kd 的整定顺序,我一般先调 Kp:从 0.5 开始小幅增加,观察车在直道和弯道的表现。如果直道蛇形严重就是 Kp 太大,弯道切弯不够就是 Kp 太小。Kp 调稳后再加 Kd,用来抑制过弯时的超调,通常从 1 到 3 之间试起。
4.3 OpenMV 视觉协同:障碍物识别与标志点检测的串口协议
OpenMV 在这套资源里承担的是“远视”功能。灰度传感器只能看到车底下的十几毫米,OpenMV 可以提前看到前方三十到五十厘米的障碍物。代码里用色块识别函数做障碍物检测,用形状识别做标志点检测。识别结果格式化后通过串口按前面说的协议发送给 MSP432:
# OpenMV_side/main.py 障碍物识别与发送 import sensor, image, time, ustruct from pyb import UART uart = UART(3, 115200, timeout_char=1000) sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) threshold_obstacle = (20, 60, 30, 80, -10, 40) while True: img = sensor.snapshot() blobs = img.find_blobs([threshold_obstacle], pixels_threshold=200, area_threshold=200) if blobs: b = blobs[0] x_center = b.cx() distance = int((320 - x_center) * 0.5) frame = b'' frame += b'\xAA\x55' frame += b'\x01' + ustruct.pack('>H', distance) frame += b'\x0D\x0A' uart.write(frame)这里的关键参数是pixels_threshold和area_threshold,它们决定色块识别的最小面积。数值太小会把远处的小反光点当成障碍物误报,数值太大又可能漏掉真实障碍。我建议以摄像头画面里障碍物占比约十分之一为基准去调,算下来面积阈值在 200 到 400 之间比较合适。另外,set_auto_whitebal(False)这行很重要,比赛现场灯光会频繁变化,如果不开自动白平衡,障碍物颜色在图像里的偏移会很大。
4.4 速度闭环:编码器反馈让差速控制不再靠玄学
很多队伍调差速时是直接改 PWM 占空比,这个方法在小车空载时看着没问题,电池电压一降、电机负载一变,速度就跟着漂。这套资源里加了编码器速度闭环,用 Timer 输入捕获模式读取霍尔编码器的脉冲数,每 20ms 计算一次实际速度,再用一个 PI 控制器修正 PWM 输出。
// speed_loop.c 每 20ms 执行的周期任务 void Speed_Loop_Task(void) { int16_t leftActual = Encoder_GetCount(&leftEncoder); int16_t leftError = leftTarget - leftActual; leftPwmOut += Kp_speed * leftError; if (leftPwmOut > PWM_MAX) leftPwmOut = PWM_MAX; if (leftPwmOut < 0) leftPwmOut = 0; Motor_SetRawPWM(&leftMotor, leftPwmOut); }加了这一层之后,车的直线行驶稳定性明显好转,同一个 PID 方向参数在低电量和满电量下表现几乎一致。这里的Kp_speed不建议调大,一般 0.2 到 0.5 就够了,过大会在启动瞬间产生明显的前冲顿挫感,电流也容易超,把电机驱动板烧掉。
5. 竞赛现场避坑:这 5 个雷区至少炸过三支队伍
5.1 误区一:电池电压变化导致“第一圈好好的,第三圈突然跑偏”
现象:比赛时车在刚开始的几圈寻迹正常,跑到第三圈或电池电量下降后,直道开始蛇形,弯道冲线。
原因:电池电压降低后,驱动板所能输出的最大占空比对应的实际电压也降低了,速度闭环的目标值没有变,但电机的输出能力变化了,导致 PID 控制器输出饱和或者速度波动。
解决:检查电调的限幅参数,把速度闭环的目标值和 PID 输出上限之间留出 15% 的余量。具体做法是把PWM_MAX从 1000 降到 850,留 150 的调节空间。同时让车在每一轮比赛前先以固定 PWM 空跑五秒钟,再进入寻迹模式。
5.2 误区二:OpenMV 画面里障碍物颜色和赛道环境色混淆
现象:障碍物明明是橙色的,但OpenMV 频繁误触发识别,把地面的反光点也当作障碍物。
原因:OpenMV 的 LAB 阈值设置时只看了实验室的灯光,没有考虑比赛现场不同色温的灯光对颜色分量的影响。
解决:现场裁判允许测试时,把摄像头对准赛道地面和障碍物各拍五张图,用img.get_statistics()分别查看 LAB 分量的分布范围,把threshold_obstacle的阈值范围扩大 10% 到 20%,保证在光线变化下仍然有足够容差。
5.3 误区三:UART 数据帧偶尔解析失败,车偶尔停顿一下
现象:小车在正常行驶中偶发一个急停动作,然后又恢复,停下时 OpenMV 的串口数据明显异常。
原因:串口中断里收到的字节被处理得太慢,UART 接收缓冲区溢出后丢失了帧尾字节,导致状态机一直等待0x0A,后面的数据全部作废。
解决:把串口接收逻辑改成“接收中断只放进环形缓冲区,主循环里再做解析”,中断里尽量少做处理。另外把串口波特率从 9600 提升到 115200,缩短每一帧的传输时间,降低溢出概率。
5.4 误区四:灰度探头在强光下全部输出“白”,丢线后疯狂摇头
现象:小车经过窗户边或者灯光直射区域时,五路灰度值整体漂移,二值化结果全变成白色,车就失去方向。
原因:校准阈值时是在普通光照下做的,强光直射导致 ADC 底值抬高,原来的(white + black) / 2已经失效。
解决:校准阈值时把比赛场地最亮区域和最暗区域的采样都纳进来,或者在代码里做一个动态基线校正。具体做法是每一百毫秒重新读取中间一路传感器的 ADC 值,如果它持续高于 2800 就自动抬高所有阈值 15%,反之降低 15%。
5.5 误区五:跑完一圈后内存占用升高,最后程序卡死在 HardFault
现象:程序运行几分钟后表现异常,用调试器看发现堆栈溢出或数组越界。
原因:环形缓冲区没有处理好读写指针,数据读出来后没有及时清理,缓冲区被写满后覆盖了相邻内存区域。
解决:检查uart_parser.c里的读写指针自减逻辑。环形缓冲区在读操作结束后必须把读指针向前移动,并且检查写指针追尾的情况。建议在初始化时为 UART 接收缓冲区分配 128 字节,比最大数据帧长度大一倍,给解析留足余量。
6. 最终技巧:把现场调试参数固化到配置区,实现快速恢复
比赛现场最折腾人的是调试好的参数被别人碰了、或者自己改崩了没法还原。这套工程在config.h里定义了一组配置宏,把所有可能需要现场调整的参数集中在一起,包括灰度阈值、PID 三参数、基础速度、UART 数据帧长。比赛前我会把最终调好的参数写进 Flash 末端的保留扇区,上电时先读 Flash 里的配置,如果有值就用配置区的值,如果 Flash 为空或者校验失败,就用编译时的默认参数。
// config.h 集中管理可调参数 typedef struct { float kp; float kd; float kp_speed; uint16_t base_speed; uint8_t threshold_offset; } Tuning_Config; const Tuning_Config default_config = { .kp = 1.8f, .kd = 2.2f, .kp_speed = 0.35f, .base_speed = 520, .threshold_offset = 15, };我一般会在调试阶段把参数越权值写在代码里,比如 Kp 初始给 3.0,让它蛇形,然后逐步拉回 1.8 左右,这样每次修改后心里有数。配置区机制的另一个好处是现场临时调整时不用重新编译——OpenMV 端通过串口发一条带格式的指令,MSP432 接收到后写进 Flash,下次上电自动生效。一次集中调试后,把车关机再开机验证配置是否成功,确保固化过程没有写错地址。这个方法我用在模拟项目X上之后,现场调车时间缩短了至少三分之一,从那以后我每次去比赛之前都强制走一遍“全参数固化加断电重启验证”这套流程,调试阶段踩过的坑变少了,心里也踏实。希望这些拆解能帮你在备赛路上少走几步弯路。
本文还有配套的精品资源,点击获取