1.项目简介
本系统基于 FreeRTOS 实时操作系统架构构建。在感知层,利用 STM32F411 的 ADC 多路分时复用技术,结合 TIM2 定时器触发(100kHz) 与 DMA 直接存储器访问,实现了对双路光敏传感器信号的高频、低延迟同步采集。 在控制层,核心任务实时计算两路电压的差分信号作为误差反馈,通过带死区的比例控制算法动态调制 TIM3 的 PWM 占空比,驱动舵机执行机构实时修正姿态,从而构建了一个高响应速度的闭环光源追踪系统,实现对点光源的自动捕获与跟随。
**硬件环境:**stm32f411re单片机、嘉立创设计pcb板、SG90 舵机。
**核心技术点:**STM32F411 + FreeRTOS 系统调度、ADC 多通道 DMA 采集(100kHz 触发)、舵机 PWM 闭环控制、自定义 PCB 设计
核心原理
**物理感知:**利用光敏电阻在电路中的分压随光强变化的特性搭建检测前端。通过 STM32 内部 ADC 对传感器阵列(双路)进行高频电压采样,将光强信号转换为数字信号。
差分逻辑:采用差分电压比较算法。系统实时监测两路传感器的电压差值,当光源正对检测板时电压差为0;当光源移动导致照射角度偏移时,两路电压产生压差,该压差作为系统的误差反馈信号。
**闭环追踪:**基于误差信号驱动舵机执行机构。控制算法根据电压差的极性和大小,动态计算 PWM 脉宽,驱动舵机调整检测板角度,直至消除电压差,从而实现检测板始终正对光源的闭环追踪效果。
项目展示

2.系统整体设计

3.硬件设计
3.1原理图展示


3.2Pcb展示


3.3 PCB 设计细节与考量
(1)在电源输入端并联了 1000uF 电解电容 与 100nF 陶瓷电容
1000uF 电解电容 : 用于滤除低频纹波和进行储能。由于项目中包含舵机(感性负载),舵机启动瞬间电流需求极大,会导致电源电压瞬间跌落。1000uF 电容充当"蓄水池",在电压跌落时迅速放电补充电流,维持电压稳定,防止单片机复位
100nF 陶瓷电容 : 用于滤除高频噪声。大电容由于卷绕工艺,存在较大的等效串联电感(ESL) ,对高频噪声(如 MCU 内部时钟切换产生的开关噪声)阻抗很大,无法过滤。而 100nF 陶瓷电容 ESL 很小,能为高频干扰提供一条通往地(GND)的低阻抗路径。在 PCB 布局时,这个小电容必须尽可能靠近 MCU 的电源引脚放置,否则线路寄生电感会使其失效。
(2)传感器对称布局
算法核心是"差分比较"(Delta V = V_{left} - V_{right})。对称布局保证了两个传感器的物理基准一致 ****。****避免因物理位置偏差导致差分算法出现静态误差。
(3)双面铺铜接地
PCB 上的铜皮也是有电阻的。舵机工作时电流很大,如果地线很细(阻抗大),会在地线上产生电压降(地弹,Ground Bounce)。ADC 采集是参考地电压的。如果地电压因为舵机动作而跳变,采集到的光强数据就会剧烈抖动。大面积铺铜可以将地线阻抗降到最低,保证 0V 参考电位的纯净。同时,双面铜皮构成的屏蔽层也增强了系统抗环境电磁干扰的能力。
(4)顶层(Top)放传感器,底层(Bottom)放元件
光敏电阻独立放置于 Top 层,其余阻容元件与连接器放置于 Bottom 层。这种设计不仅避免了元件阴影对光路的遮挡,也便于后续安装遮光筒以提高追踪灵敏度
4.软件设计 ------ 核心代码与思路
4.1软件逻辑

4.2核心代码
(1)左转右转代码
cpp
void MotorTurnRight(uint16_t step)//应该 加
{
if(pulsewide <= 500 - step)
{
pulsewide += step;
}
else
{
pulsewide = 500; // 到达右极限
}
//******************************************************重置占空比
//__HAL_TIM_SET_COMPARE(&htim3,TIM_CHANNEL_1,pulsewide); 不需要这个,因为我已经在定时器设置的地方把那个常量改成pulsewide了
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulsewide);
/*MX_TIM3_Init();
HAL_TIM_Base_Start(&htim3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);*/
}
void MotorTurnLeft(uint16_t step)//应该减
{
// 逻辑:左转需要减小 pulsewide
// 边界检查:当前值是否比 (最小值 + 步长) 大
// 如果是,可以安全减去 step;否则直接设为最小值,防止越界
if(pulsewide >= 300 + step)
{
pulsewide -= step;
}
else
{
pulsewide = 300; // 到达左极限
}
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulsewide);
/*
MX_TIM3_Init() 会重新配置定时器硬件、重置计数器,这会导致 PWM 波形中断、频率重置,舵机会剧烈抽搐甚至不动作。
正确的做法: 只需要修改定时器的**比较寄存器(CCR)**的值即可。
MX_TIM3_Init();
HAL_TIM_Base_Start(&htim3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);*/
}
(2)获取电压代码
cpp
float Get_Channel1_ADC_Value()
{
flag=0;
//开启时钟tim2 和adc采样
HAL_TIM_Base_Start(&htim2);
HAL_ADC_Start_DMA(&hadc1,ADC_V,200);
while(flag==0)
{
osDelay(1);
}
ADC_1_ave=0;
ADC_2_ave=0;
for(int i=0;i<100;i++)
{
ADC_1_ave=(ADC_V[i*2]+ADC_1_ave);
ADC_2_ave=(ADC_V[i*2+1]+ADC_2_ave);
}
ADC_1_ave=ADC_1_ave/100;
ADC_2_ave=ADC_2_ave/100;
// HAL_ADC_Stop(&hadc1);
return ((float)ADC_1_ave*3.3f/4095.0f);
}
float Get_Channel2_ADC_Value()
{
return ((float)ADC_2_ave*3.3f/4095.0f);
}
(3)中控代码
cpp
void StartCoreTask(void *argument)
{
/* USER CODE BEGIN StartCoreTask */
/* Infinite loop */
for(;;)
{
adc_chanel1_current=Get_Channel1_ADC_Value();//采样第1通道电压
adc_chanel2_current=Get_Channel2_ADC_Value();//采样第2通道电压
delta_v_currtnt=adc_chanel2_current-adc_chanel1_current;
printf("adc_chanel1_current: %.3f \n",adc_chanel1_current); //传回第1通道电压
printf("adc_chanel2_current: %.3f \n",adc_chanel2_current); //传回第2通道电压
printf("delta_v: %.3f \n",delta_v_currtnt); //电压差目前
//*********************************************动态循环**********************************************************
if (fabs(delta_v_currtnt) > 0.01)
{
// 动态计算步长:电压差越大,转得越快
// 限制步长最大值,防止一次转动过大导致超调
step_temp = (uint16_t)(fabs(delta_v_currtnt) * 200);
if(step_temp > 50) step_temp = 50; // 增加一个限幅保护
if(step_temp < 1) step_temp = 1; // 保证至少动一下
// 判断方向
if (delta_v_currtnt > 0)
{
// 如果通道2电压 > 通道1电压,往左转(假设逻辑)
// 具体方向如果反了,把Left改成Right即可
MotorTurnLeft(step_temp);
printf("Turning Left, Step: %d\n", step_temp);
}
else
{
MotorTurnRight(step_temp);
printf("Turning Right, Step: %d\n", step_temp);
}
}
osDelay(20); // 给控制循环一个时间间隔,不要太快,伺服电机响应需要时间
}
/* USER CODE END StartCoreTask */
}
frtos代码作为后续开发开源---可利用二值信号量作为一个开锁,添加OLED任务,或者同步开启上下两个维度的光源追踪,实现三维追踪光源的效果
关键代码解析
(1)舵机驱动逻辑
SG90 模拟舵机通过 PWM(脉冲宽度调制) 信号控制角度。它对信号有两个硬性要求:
频率固定:PWM 信号的周期必须是 20ms(即频率 50Hz)。
占空比决定角度:高电平持续的时间(脉宽)决定了舵机的转角。
首先,Prescaler (预分频系数) = 500 - 1;Counter Period (自动重装载值 ARR) = 4000;使得信号周期达到20ms从。随后通过改变ccr的值去改变高电平时间从而达到控制舵机旋转的功效。
0.5ms-> 0度(或者 -90度,视厂家定义);1.5ms ->90度(中位);2.5ms-> 180度(或者 +90度)
(2)电压线性对应代码: (float)ADC_1_ave*3.3f/4095.0f
ADC 的分辨率(Resolution)与参考电压(Reference Voltage)的线性对应
STM32F411 内部集成的 ADC 是12位 逐次逼近型 ADC。能表示的数值范围是 0到 2^{12} - 1 = 4095。当输入电压为 0V 时,ADC 读数为 0;当输入电压为 3.3V 时,ADC 读数为 4095。
ADC 的工作本质是一个线性比例映射。假设当前采集到的数值为 X(即代码中的 ADC_1_ave),对应的实际电压为 V_{in}。加上 f 后缀(如 3.3f)强制编译器将其作为 float 类型(单精度,4字节)处理。对于 STM32F4 这类带有 FPU(浮点运算单元)的 MCU,使用 float 可以利用硬件加速,运算速度比 double 快几十倍。
(3)比例计算的舵机控制器----防止"帕金森抖动"
cpp
if (fabs(delta_v_currtnt) > 0.01)
{
// 动态计算步长:电压差越大,转得越快
// 限制步长最大值,防止一次转动过大导致超调
step_temp = (uint16_t)(fabs(delta_v_currtnt) * 200);
if(step_temp > 50) step_temp = 50; // 增加一个限幅保护
if(step_temp < 1) step_temp = 1; // 保证至少动一下
// 判断方向
if (delta_v_currtnt > 0)
{
// 如果通道2电压 > 通道1电压,往左转(假设逻辑)
// 具体方向如果反了,把Left改成Right即可
MotorTurnLeft(step_temp);
printf("Turning Left, Step: %d\n", step_temp);
}
else
{
MotorTurnRight(step_temp);
printf("Turning Right, Step: %d\n", step_temp);
}
}
只有当两个传感器的电压差绝对值超过 0.01V 时,才认为光照不平衡,开始调节。因为,ADC 采样总会有微小的底噪跳动(eg: 0.002V 的波动)。如果没有这行代码,舵机会因为这些噪点不停地微小抖动(像帕金森一样),不仅消耗电流,还会磨损齿轮。0.01V 就是系统的"容忍度"。在这个范围内,我们认为已经对准了。
步长 = 电压差 ×比例系数
delta_v 的单位是 伏特 (V) (通常在 0~3.3V 之间),而 step 的单位是 PWM 计数器数值 (Ticks)(需要 1~50 这样的整数)。
灵敏度调节 :200 是一个经验参数。它决定了系统的反应速度。
eg :假设电压差是 0.1V。如果不乘 200:step = 0.1 -> 取整为 0 -> 舵机不动。乘了 200:step = 0.1 * 200 = 20 -> 舵机一次转动 20 个单位,反应迅速。
参数调试心得 :如果这个数太小(比如 50):追光很慢,迟钝;如果这个数太大(比如 1000):步长太大,稍微一点电压差舵机就猛转,容易转过头(超调),导致在光源附近左右摇摆(震荡)。200 是我经过调试找到的最佳平衡点。
5.踩坑记录与注意事项
(1)转动不是到300而是到平均点
在代码中,pulsewide = 300 对应 PWM 脉宽 1.5ms。对于 SG90 舵机来说,这是物理上的正中间(90度), 这是一个固定的、预设的 位置。不能误以为"复位"就是让舵机回到 300。但在寻光系统中,我们的目的不是 让舵机回正,而是让它追光 。是一个动态的、未知的位置。
**"平均点"**是指两个光敏电阻接收到的光照强度相等的那个瞬间。反映在电路里,就是 Voltage_Left == Voltage_Right,或者说 Delta_V ≈ 0。它完全取决于手电筒(光源)在哪里。
(2)舵机疯狂抖动
在控制循环中重复初始化定时器,每次调用 MX_TIM3_Init() 都会重置定时器的预分频器(Prescaler)和计数器(Counter),导致 PWM 波形输出中断。对于需要连续平滑信号的舵机来说,这是致命的。从而导致了舵机的抽动。初始化只能在 main 函数开头做一次。在循环中,应使用宏直接修改 CCR 寄存器。
(3)舵机安全保护---舵机初始值
SG90 舵机的物理转动范围是 0°~180°。在我们的定时器配置下(周期 20ms,ARR=4000),这个物理范围对应 PWM 寄存器(CCR)数值范围是 100 到 500。
如果程序计算出的 CCR 值小于 100 或大于 500,舵机会试图转动到机械结构允许范围之外,导致电机堵转,电流激增,极易损坏舵机或烧毁单片机接口。
代码中实现了**"预判 + 钳位"**的双重保护逻辑:
当计算结果试图超出 [100, 500] 区间时,代码不仅阻止了越界,还执行了 else { pulsewide = Limit; }。这相当于在软件层面装了一堵墙。无论 PID 算法或差分算法算出多离谱的数值,最终输出给硬件的信号永远在安全区间内。
(4)pwm信号未开启