news 2026/9/10 8:20:28

STM32F103闭环目标追踪系统:PID控制与双路PWM同步实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103闭环目标追踪系统:PID控制与双路PWM同步实现

简介:本资源是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 初始化(右轮):参数同上,仅InstanceChannel变更。
关键参数说明:
参数含义电赛典型取值注意事项
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.cTIM 外设驱动✅ 必须
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_uart.cUART 中断与 DMA✅ 必须
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_rcc.c时钟使能(__HAL_RCC_TIMx_CLK_ENABLE✅ 必须
STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_tim_ex.cHAL_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_leftpwm_right的差值波动范围应 ≤±150(对应转向灵敏度),且error_x稳态波动 ≤±5px。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 8:17:43

前端手摸手跑路AI应用开发:从大模型API到SSE流式对话实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:15:14

LeetCode 110:平衡二叉树的递归后序遍历核心解法与工程启发

LeetCode 110这道平衡二叉树题目&#xff0c;我愿称它为“树结构入门体检单”。你去看大厂的笔试面试题单&#xff0c;几乎每一份都会把这道题放在二叉树板块的前十题里。它看起来就是判断一棵树是不是平衡的&#xff0c;但真正动手写的时候&#xff0c;你会发现不少人在递归边…

作者头像 李华
网站建设 2026/9/10 8:12:32

已读不回、语音条与撤回:即时通讯如何重塑我们的社交方式

你有没有过这样的瞬间&#xff1a;消息发出去了&#xff0c;屏幕显示“已读”&#xff0c;对方却像人间蒸发&#xff1b;手机明明满格信号&#xff0c;电话却怎么都拨不出去&#xff1b;对话框上方飘着一行“对方正在输入…”&#xff0c;几分钟后却一个字都没蹦出来。这些细碎…

作者头像 李华
网站建设 2026/9/10 8:06:51

愚人节课堂设计:用体验式教学提升中小学生信息辨别力

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:06:49

网站SEO问题快速诊断:从收录异常到排名下滑的排查思路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:06:48

DeepSeek V4接入Claude Code实操指南:配置步骤与避坑经验

最近后台和群里被同一个问题刷屏&#xff1a;“DeepSeek V4 已经能接进 Claude Code 了吗&#xff1f;”我一开始以为又是哪个营销号在炒冷饭&#xff0c;点进去才发现不光是新手&#xff0c;连不少老玩家都在问。大家的意思很明确&#xff1a;Claude Code 写代码确实爽&#x…

作者头像 李华