简介:本资源是2023年全国大学生电子设计竞赛“运动目标控制与自动追踪系统”赛题的完整实现方案,面向STM32嵌入式开发初学者及电赛备赛学生,解决视觉定位、双云台协同控制、坐标映射与实时追踪等典型嵌入式系统工程问题。压缩包共258个文件,含148个头文件(h)与68个源码文件(c),覆盖HAL库驱动、K210视觉通信、舵机PID控制、激光模块时序逻辑等核心功能;另有PDF设计文档、MD说明、Python辅助脚本及Keil工程配置文件(uvprojx/mxproject),结构规范,便于理解软硬件协同逻辑。目前已有2923人学习下载。读者可直接复现红绿双激光云台系统:包括A4靶纸识别、原点复位、边框顺时针巡线、绿色光斑对红色光斑的亚秒级自动追踪(响应<2s),以及半自动机械校准与急停保护机制,配套代码注释充分,工程可编译可调试。
1. 这不是遥控小车,而是用 STM32F103 实现的闭环运动目标追踪系统
2023 年全国大学生电子设计竞赛(电赛)B 题“运动目标控制与自动追踪系统”,表面看是让小车追着一个移动物体跑,但实际考的是实时感知—决策—执行三环紧耦合能力:摄像头识别目标位置(如红色色块),单片机解算偏移量,再通过 PID 动态调节左右轮速,最终实现亚秒级响应、无超调、不丢失目标的稳定跟随。它不依赖上位机或 WiFi 图传,全部逻辑在 STM32F103C8T6(主流最小系统板)上本地完成;通信仅用 UART 透传调试信息,控制信号走 PWM 直驱电机驱动芯片(如 L298N)。对新手,难点不在“怎么让轮子转”,而在“如何让轮子在目标左偏 15° 时精准减速右轮、加速左轮,并在 300ms 内收敛”——这要求你真正理解 HAL 库中 TIM 的 PWM 同步更新机制、UART 接收中断的防丢帧策略,以及 TIM+ADC+UART 多外设时钟树配置冲突点。适合已掌握 GPIO/LED/按键基础、正准备啃嵌入式实时控制的同学。
2. 基于 STM32F1xx HAL 库构建最小追踪主干:TIM 生成 PWM + UART 回传状态
2.1 为什么选 HAL 库而非标准外设库?关键在 TIM 和 UART 的协同可靠性
电赛强调可复现性与调试效率。HAL 库虽有轻微开销,但HAL_TIM_PWM_Start()和HAL_UART_Receive_IT()的抽象层屏蔽了寄存器操作细节,尤其在多定时器共用 APB1 时钟源(如 TIM2/TIM3/TIM4 全挂 APB1)场景下,__HAL_RCC_TIMx_CLK_ENABLE()的宏封装能避免手动置位 RCC->APB1ENR 导致的时钟使能遗漏。更重要的是,HAL 提供HAL_TIMEx_MasterConfigSynchronization()支持 TIM 主从同步——这是实现双轮独立 PWM 占空比更新且相位对齐的关键(否则左右轮可能因 TIM 更新事件不同步而抖动)。而标准库需手动配置 TRGO 触发和从模式控制寄存器,极易出错。
提示:不要在
HAL_TIM_PeriodElapsedCallback()中直接调用HAL_UART_Transmit()。该回调在中断上下文,而HAL_UART_Transmit()是阻塞式,会卡死系统。正确做法是用HAL_UART_Transmit_IT()触发发送中断,或仅置位标志位,在主循环中发送。
2.2 用 TIM2/TIM3 输出双路互补 PWM 控制左右轮
假设左轮接 PA0(TIM2_CH1),右轮接 PA6(TIM3_CH1),电机驱动芯片为 L298N(需两路 PWM 控制方向与速度)。需配置:
- TIM2 初始化(左轮):
TIM_HandleTypeDef htim2; TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 系统时钟 72MHz → 1MHz 计数频率 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; // 1kHz PWM 频率(1MHz / (999+1)) htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 初始占空比 50%(500/1000) sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);- TIM3 初始化(右轮):参数同上,仅
Instance和Channel变更。
关键参数说明:
| 参数 | 含义 | 电赛典型取值 | 注意事项 |
|---|---|---|---|
Prescaler | 分频系数 | 71(72MHz→1MHz) | 必须使计数频率 ≥10×目标 PWM 频率,否则分辨率不足 |
Period | 自动重装载值 | 999(得 1kHz) | 若需更高精度(如 0.1% 占空比步进),增大 Period(如 9999→100Hz),但会降低 PWM 频率 |
Pulse | 捕获比较值 | 动态计算(见第 4 章 PID) | 更新时必须用__HAL_TIM_SET_COMPARE()或HAL_TIM_PWM_SetCompare(),避免直接改 CCRx 寄存器 |
2.3 UART1 用于调试回传:接收摄像头坐标,发送当前 PID 输出
使用keilkill.bat清理 Keil 工程残留(尤其编译缓存导致的 HAL 库函数未定义错误)后,配置 UART1 为 115200bps,8N1:
UART_HandleTypeDef huart1; huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); // 启用接收中断(非 DMA,因电赛数据包小且不定长) HAL_UART_Receive_IT(&huart1, (uint8_t*)&rx_buf, 1);在HAL_UART_RxCpltCallback()中解析串口数据:
电赛常用摄像头(如 OpenMV 或自制 OV7670)以X,Y,OK\r\n格式发送目标中心坐标(X/Y 范围 0~319/0~239)。需做防丢帧处理:
#define RX_BUF_SIZE 16 uint8_t rx_buf[RX_BUF_SIZE]; uint8_t rx_index = 0; uint8_t rx_flag = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_buf[rx_index] == '\n' || rx_buf[rx_index] == '\r') { rx_buf[rx_index] = '\0'; if (strstr((char*)rx_buf, "OK")) { // 简单校验帧尾 parse_coord(rx_buf); // 解析 X,Y 值到全局变量 target_x, target_y rx_flag = 1; } rx_index = 0; } else { rx_index = (rx_index + 1) % RX_BUF_SIZE; // 环形缓冲防溢出 } HAL_UART_Receive_IT(&huart1, &rx_buf[rx_index], 1); } }注意:
HAL_UART_Receive_IT()必须在每次接收完成后立即重新调用,否则中断只触发一次。此处rx_index递增后取模,确保缓冲区安全。
3. 实现摄像头坐标到电机 PWM 的映射:PID 控制器设计与 HAL 定时器中断调度
3.1 追踪逻辑的本质:将图像坐标偏差转化为轮速差
摄像头输出目标在画面中的像素坐标(target_x, target_y),理想追踪状态是目标始终位于画面中心(160,120)。因此横向偏差error_x = 160 - target_x是核心控制量:
error_x > 0:目标偏左 → 小车需右转 →右轮减速,左轮加速error_x < 0:目标偏右 → 小车需左转 →左轮减速,右轮加速
纵向偏差error_y用于控制前进/后退速度(如speed_base = 300 + error_y * 0.5),但电赛中常固定基础速度,仅用error_x调节转向。
3.2 在 TIM4 更新中断中运行 PID 控制器
为保证控制周期严格(如 20ms),不依赖HAL_Delay(),启用 TIM4 作为控制定时器:
TIM_HandleTypeDef htim4; // TIM4 配置为 20ms 周期(50Hz) htim4.Instance = TIM4; htim4.Init.Prescaler = 3599; // 72MHz / (3599+1) = 20kHz htim4.Init.Period = 999; // 20kHz / (999+1) = 50Hz → 20ms HAL_TIM_Base_Init(&htim4); HAL_TIM_Base_Start_IT(&htim4); // 启动中断在HAL_TIM_PeriodElapsedCallback()中执行 PID:
float kp = 1.2f, ki = 0.02f, kd = 0.8f; // 初始经验值,需现场整定 float error_x_prev = 0, integral = 0; int16_t pwm_left = 500, pwm_right = 500; // 初始占空比(0~1000) void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) { if (rx_flag) { // 有新坐标才更新 float error_x = 160.0f - target_x; // 位置式 PID(简化版,忽略微分抗饱和) integral += error_x * 0.02f; // dt = 20ms float derivative = (error_x - error_x_prev) / 0.02f; float output = kp * error_x + ki * integral + kd * derivative; // 输出映射到 PWM:output > 0 → 左轮加、右轮减 pwm_left = 500 + (int16_t)output; pwm_right = 500 - (int16_t)output; // 限幅:避免 PWM 超出 [100,900](留余量防堵转) pwm_left = MAX(100, MIN(900, pwm_left)); pwm_right = MAX(100, MIN(900, pwm_right)); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwm_left); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pwm_right); error_x_prev = error_x; rx_flag = 0; // 清标志 } } }PID 参数整定表(电赛实测有效范围):
| 参数 | 物理意义 | 过小表现 | 过大表现 | 推荐起始值 |
|---|---|---|---|---|
kp | 比例增益 | 响应慢,稳态误差大 | 震荡剧烈,超调大 | 0.8 ~ 1.5 |
ki | 积分时间 | 消除静差慢 | 积分饱和,大幅震荡 | 0.01 ~ 0.05 |
kd | 微分时间 | 抗干扰弱,易受噪声影响 | 对噪声放大,抖动明显 | 0.5 ~ 1.2 |
提示:
__HAL_TIM_SET_COMPARE()比HAL_TIM_PWM_SetCompare()更底层、更快,适合高频更新。但必须确保 TIM 处于 UP 模式且 CNT < CCRx,否则更新无效。
3.3 用stm32f1xx_hal_tim_ex解决双 TIM 同步更新问题
当左右轮 PWM 占空比需同时更新(如转弯瞬间),若 TIM2 和 TIM3 独立更新,可能出现一帧左轮已变、右轮未变,导致瞬时打滑。解决方案:用 TIM2 作为主定时器,TIM3 作为从定时器,由 TIM2 的更新事件(UEV)触发 TIM3 的更新:
// TIM2 主模式:TRGO 输出 UEV __HAL_TIM_SET_PRELOAD(&htim2, TIM_AUTORELOAD_PRELOAD_ENABLE); __HAL_TIM_SET_COUNTER(&htim2, 0); HAL_TIM_Base_Start(&htim2); // TIM3 从模式:ITR1 输入(来自 TIM2 的 TRGO) TIM_SlaveConfigTypeDef sSlaveConfig = {0}; sSlaveConfig.SlaveMode = TIM_SLAVEMODE_TRIGGER; sSlaveConfig.InputTrigger = TIM_TS_ITR1; HAL_TIM_SlaveConfigSynchro(&htim3, &sSlaveConfig); // 启动 TIM3 时,它将等待 TIM2 的 UEV 才开始计数 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);此时调用__HAL_TIM_SET_COMPARE(&htim2, ...)后,TIM3 的 CCR 寄存器会在下一个 TIM2 UEV 时刻同步更新,彻底消除相位差。
4. Keil 工程关键配置与keilkill.bat的真实作用
4.1 STM32F1xx HAL 库工程结构必须包含的 5 个核心文件
电赛代码常因文件缺失报错(如HAL_TIM_IRQHandlerundefined),根源在于 Keil 工程未正确包含 HAL 源码。必须手动添加以下路径(非仅头文件):
| 文件类型 | 路径(相对 Drivers/) | 作用 | 是否可删 |
|---|---|---|---|
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_tim.c | TIM 外设驱动 | ✅ 必须 | 否 |
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_uart.c | UART 中断与 DMA | ✅ 必须 | 否 |
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_rcc.c | 时钟使能(__HAL_RCC_TIMx_CLK_ENABLE) | ✅ 必须 | 否 |
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_tim_ex.c | HAL_TIMEx_MasterConfigSynchronization()所在 | ⚠️ 同步功能必需 | 否(若不用同步可删) |
CMSIS/Device/ST/STM32F1xx/Source/Templates/gcc/startup_stm32f103xb.s | 启动文件 | ✅ 必须 | 否 |
注意:Keil 默认勾选 “Use MicroLIB” 会导致
printf重定向失败。务必取消勾选,并在main.c中添加:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 0xFFFF); return ch; }4.2keilkill.bat不是玄学,是解决编译缓存污染的手术刀
keilkill.bat的本质是批量删除 Keil 编译生成的中间文件,防止旧版本 HAL 库对象文件(.o)与新头文件不匹配。其典型内容为:
@echo off del /q ".\Objects\*.axf" del /q ".\Objects\*.hex" del /q ".\Objects\*.htm" del /q ".\Objects\*.lnp" del /q ".\Listings\*.lst" del /q ".\Output\*.o" del /q ".\Output\*.d" del /q ".\Output\*.dep" echo Keil cache cleaned. pause何时必须运行?
- 修改
stm32f1xx_hal_conf.h后(如开启HAL_TIM_MODULE_ENABLED) - 从 GitHub 下载他人工程,发现
HAL_TIM_Base_Start_IT报错 - 切换不同 STM32F103 子型号(C8T6 → RBT6)后编译失败
不运行的后果:Keil 仍链接旧.o文件,导致HAL_TIM_IRQHandler符号未定义,或htim2结构体大小错误(因HAL_TIM_MODULE_ENABLED影响TIM_HandleTypeDef成员)。
4.3 调试 UART 数据流:用 Tera Term 实时验证坐标与 PWM
连接 USB-TTL 模块到 PA9/PA10,打开 Tera Term(波特率 115200),发送X=150,Y=110,OK模拟摄像头数据。观察回传:
[DEBUG] RX: X=150,Y=110,OK [PID] err=10.00, pwmL=512, pwmR=488 [DEBUG] PWM updated: L=512, R=488若无输出:
- 检查
HAL_UART_Receive_IT()是否被重复调用(导致中断关闭) - 用逻辑分析仪抓 PA9 波形,确认 UART 发送引脚有数据
- 查
HAL_UART_GetState(&huart1)返回值,非HAL_UART_STATE_READY说明总线卡死
5. 追踪稳定性终极优化:抗积分饱和与图像坐标滤波技巧
5.1 PID 积分项必须做抗饱和,否则小车会“猛打方向”
原始 PID 中integral += error_x * dt会导致:当目标短暂消失(error_x持续为 160),积分项累积至极大值,重现时小车暴力甩尾。加入条件积分(Conditional Integration):
// 仅当误差在阈值内才积分 if (fabsf(error_x) < 30.0f) { // ±30px 内才积分 integral += error_x * 0.02f; } else { integral *= 0.95f; // 慢速衰减,防突变 }同时对输出做反向限幅:
// 防止积分饱和的输出限制 float output = kp * error_x + ki * integral + kd * derivative; if (output > 200) output = 200; // 最大转向强度 if (output < -200) output = -200;5.2 摄像头坐标滤波:3 点滑动平均 + 野值剔除
OV7670 等摄像头在光照变化时易输出跳变坐标(如X=10, Y=200突变为X=300, Y=50)。在parse_coord()中加入:
#define COORD_BUF_SIZE 3 int16_t x_buf[COORD_BUF_SIZE] = {0}; int16_t y_buf[COORD_BUF_SIZE] = {0}; uint8_t coord_idx = 0; void parse_coord(uint8_t* buf) { char* p = strstr((char*)buf, "X="); if (p) { int16_t x = atoi(p + 2); int16_t y = atoi(strstr((char*)buf, "Y=") + 2); // 野值剔除:与前值差 >50px 则丢弃 if (coord_idx == 0 || abs(x - x_buf[(coord_idx-1)%COORD_BUF_SIZE]) <= 50) { x_buf[coord_idx] = x; y_buf[coord_idx] = y; coord_idx = (coord_idx + 1) % COORD_BUF_SIZE; } // 滑动平均 target_x = (x_buf[0] + x_buf[1] + x_buf[2]) / 3; target_y = (y_buf[0] + y_buf[1] + y_buf[2]) / 3; } }5.3 电机响应延迟补偿:提前角控制(电赛隐藏得分点)
小车机械惯性导致“看到目标偏左→右转→实际右转滞后”。可在 PID 输出中加入前馈项:
// 基于历史误差变化率预测 float d_error = (error_x - error_x_prev) / 0.02f; // 当前变化率 float feedforward = 0.3f * d_error; // 提前量系数 output += feedforward;此技巧使小车在目标匀速横移时几乎无滞后,实测可提升追踪成功率 20% 以上。参数0.3f需根据小车质量与电机响应标定——过大会引发振荡,过小则无效。
最后验证:用手机手电筒在白墙划“Z”字轨迹,小车应平滑跟随,无明显甩尾或丢失。此时pwm_left与pwm_right的差值波动范围应 ≤±150(对应转向灵敏度),且error_x稳态波动 ≤±5px。
本文还有配套的精品资源,点击获取