简介:本资源是一套基于STM32单片机的太阳能电池板追日光跟踪系统完整设计实现包,面向嵌入式开发初学者、电子类课程设计学生及光伏应用实践者,解决传统固定式太阳能板光电转换效率低的核心问题。资源共365个文件,涵盖68个C语言源码(.c)、66个头文件(.h)、38个编译中间文件(.d)、36个JPG原理图与实物图、11个Word文档(含设计说明与调试记录)、6个PDF技术资料及5个MP4演示视频,完整呈现从光敏检测、太阳位置算法、PWM电机控制到硬件联调的全流程;压缩包大小为84.26MB,结构清晰,含Keil工程(.uvproj)、Hex固件、电路图(.sch)及多版本备份文件,便于分模块学习与复现。已有6062人学习下载,提供可直接烧录运行的代码、传感器标定方法、步进电机细分驱动逻辑及自适应天气补偿策略说明,是掌握STM32嵌入式控制、模拟信号采集与机电系统集成的高实用性项目范例。
1. 这不是个“摆设”:一块太阳能板+STM32,真能自己扭头追着太阳跑一整天
去年夏天在某高校实验室帮学生调试毕业设计,看到一个被遗忘在窗台角落的STM32开发板——外壳积灰、杜邦线缠成毛线团,但接上电源后,它居然带着两块小尺寸太阳能板,缓慢而坚定地从东向西偏转,电压读数从2.8V一路爬升到4.1V。那一刻我才意识到:所谓“追日光跟踪系统”,根本不是课程设计里画在PPT上的虚线箭头,而是一套可落地、可测量、可实测发电增益的真实闭环控制体。这个项目用最典型的STM32F103C8T6(俗称“蓝 pill”)作为主控,配合四路光敏电阻阵列+双轴步进电机驱动,不依赖GPS或经纬度查表,纯靠实时比较四象限光照强度差值做方向决策,再通过细分驱动让电机“微步走”,最终实现±0.5°以内的角度跟踪精度。它适合嵌入式初学者练手——代码量可控、外设清晰;也适合光伏小系统开发者验证算法——所有逻辑都在main.c和pid.c里,没有黑匣子库;更关键的是,它不靠云端、不连WiFi、不上云平台,就靠本地ADC采样+PID调节+IO翻转,把“光→电→控→动→光”的物理闭环扎扎实实跑通。如果你正卡在“单片机怎么和真实世界互动”的临界点,或者想给离网供电设备加个靠谱的发电增益模块,这个项目就是你该拆开的第一份工程包。
2. 硬件架构与信号链:从光敏电阻到步进电机的全链路拆解
2.1 四象限光敏阵列:不是简单比大小,而是构建方向梯度
系统采用4颗GL5528型光敏电阻,按正交十字布局固定于追光平台中心上方:
- 左(L)、右(R)、上(U)、下(D)四个方位各一颗
- 每颗光敏与10kΩ精密电阻组成分压电路,接入STM32的ADC1_IN0~IN3通道
- 关键设计:所有光敏电阻共用同一组VCC与GND,且PCB走线长度严格对称(实测差异<2mm),避免因供电压降或寄生电容导致采样偏差
提示:别直接用ADC原始值做判断!光敏电阻阻值随光照呈非线性变化(近似1/√lux),必须先做软件校准。本项目在初始化阶段执行“暗环境基准采集”:遮盖所有光敏,连续采样100次取中位数,记为
dark_base[4];后续每次读数均减去对应通道的dark_base[i],再做归一化处理。
核心采样逻辑如下(摘自adc.c):
// ADC多通道扫描模式配置(DMA自动搬运) void ADC1_Init(void) { ADC_InitTypeDef ADC_InitStructure; ADC_DeInit(ADC1); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); // PA0~PA3复用为模拟输入 GPIO_Init(GPIOA, &(GPIO_InitTypeDef){GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3, GPIO_Mode_AIN}); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = ENABLE; // 扫描模式 ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; // 连续转换 ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 4; ADC_Init(ADC1, &ADC_InitStructure); // 顺序:CH0(L), CH1(R), CH2(U), CH3(D) ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55_5Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55_5Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55_5Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55_5Cycles); ADC_DMACmd(ADC1, ENABLE); // DMA搬运至adc_buffer[4] ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }这段代码的关键点在于:
- 扫描顺序固定为L→R→U→D,确保四通道数据在
adc_buffer[]中位置可预测,后续计算方向梯度时无需额外索引映射 - 采样时间设为55.5周期(而非默认1.5周期),因为光敏电阻响应慢(毫秒级),过短采样时间会导致读数偏低且抖动大
- DMA自动搬运避免CPU轮询阻塞,为PID运算留出确定性时间窗口
2.2 双轴电机驱动:ULN2003不是万能的,电流瓶颈必须直面
水平(Azimuth)与俯仰(Elevation)两轴均采用28BYJ-48型5V四相八拍步进电机,驱动芯片为ULN2003达林顿阵列。但这里有个血泪经验:ULN2003单路最大持续电流仅500mA,而28BYJ-48堵转电流达350mA,四相同时导通时极易触发热保护导致失步。
解决方案是硬件+软件协同:
- 硬件层:在ULN2003输出端与电机线圈间串联0.5Ω/1W精密电阻(实测温升<15℃),既限流又提供电流反馈信号(接ADC)
- 软件层:在
motor_control.c中增加电流监控逻辑——每完成10步驱动,读取当前相电流ADC值,若连续3次>400mA,则插入20ms延时并降低细分倍数
电机控制核心函数(简化版):
// 控制水平轴转动direction步(direction可正可负) void Motor_Horizontal_Move(int16_t direction) { static uint8_t step_seq[8] = {0x01, 0x03, 0x02, 0x06, 0x04, 0x0C, 0x08, 0x09}; int16_t i = 0; uint8_t seq_idx = 0; if (direction > 0) { // 正转:顺时针(面向电机轴看) for (i = 0; i < direction; i++) { seq_idx = (seq_idx + 1) % 8; GPIO_Write(GPIOB, step_seq[seq_idx] << 8); // PB8~PB11控制H轴 Delay_us(1200); // 基础脉冲宽度,单位微秒 if ((i % 10 == 0) && Check_Motor_Current(HORZ_CURRENT_CH)) { Delay_ms(20); // 电流超限时强制延时 } } } else { // 反转:逆时针 for (i = 0; i > direction; i--) { seq_idx = (seq_idx + 7) % 8; // -1等价于+7 mod 8 GPIO_Write(GPIOB, step_seq[seq_idx] << 8); Delay_us(1200); if ((abs(i) % 10 == 0) && Check_Motor_Current(HORZ_CURRENT_CH)) { Delay_ms(20); } } } }参数说明:
Delay_us(1200)是经过实测的临界值:低于1100us易丢步,高于1300us则响应迟钝;该值需根据电机批次微调Check_Motor_Current()函数读取串联电阻两端电压,换算为实际电流(公式:I = Vadc × 3.3V / 4095 / 0.5Ω)- 为什么用GPIO_Write而非逐位操作?因为PB8~PB11需严格同步翻转,用BSRR寄存器虽快但易出错,直接写ODR更可靠
2.3 电源与抗干扰:别让“干净”的STM32毁在电源纹波上
整个系统由12V铅酸电池供电,经LM2596S DC-DC模块降压至5V,再经AMS1117-3.3稳压供MCU。但实测发现:电机启停瞬间,STM32的ADC读数跳变达±15%,PID输出震荡。根源在于DC-DC模块开关噪声耦合进模拟地。
最终方案(已验证有效):
- 物理隔离:将电机驱动部分PCB与MCU部分用0.5mm槽割开,仅通过磁珠(BLM21PG221SN1)连接数字地
- 电源滤波:在AMS1117输入端并联100μF钽电容+0.1μF陶瓷电容,在输出端再加47μF电解电容
- ADC参考电压:不使用内部VREF,改用TL431外置2.5V基准源(精度±0.5%),接至VREF+引脚
注意:TL431需配2kΩ上拉电阻至5V,阴极接地,阳极接VREF+。若省略此步,ADC满量程误差将超8%,导致光强判断完全失准。
3. 核心控制算法:从“左右比较”到增量式PID的演进逻辑
3.1 方向决策层:四象限梯度法比单纯“找最大值”更鲁棒
早期版本用max(L,R,U,D)决定转动方向,结果在阴天或多云时频繁误动作——因为四路读数都低且接近,微小噪声就能触发错误转向。新算法改为计算水平梯度与垂直梯度:
// 光强梯度计算(已减去暗电流基准) int16_t grad_h = (adc_val[R] - adc_val[L]) * 100 / (adc_val[R] + adc_val[L] + 1); // 百分比 int16_t grad_v = (adc_val[U] - adc_val[D]) * 100 / (adc_val[U] + adc_val[D] + 1); // 决策阈值:仅当梯度绝对值>15%时才动作(实测经验值) if (abs(grad_h) > 15) { if (grad_h > 0) motor_move_h(1); // 向右转 else motor_move_h(-1); // 向左转 } if (abs(grad_v) > 15) { if (grad_v > 0) motor_move_v(1); // 向上抬 else motor_move_v(-1); // 向下压 }为什么乘以100?——避免浮点运算(STM32F1无FPU),用定点百分比替代小数,精度损失<0.3%。分母+1防除零,实测在弱光下仍稳定。
3.2 增量式PID控制器:为什么不用位置式?
位置式PID输出为绝对控制量,一旦积分项饱和(如电机堵转),恢复时会产生大幅超调。而增量式只计算本次输出与上次的差值,天然抗积分饱和。本项目PID参数经Ziegler-Nichols整定:
| 参数 | 水平轴(方位) | 俯仰轴(高度) | 说明 |
|---|---|---|---|
| Kp | 0.8 | 0.6 | 比例增益,过高则振荡,过低则响应慢 |
| Ki | 0.02 | 0.015 | 积分时间常数倒数,消除静态误差 |
| Kd | 0.15 | 0.1 | 微分增益,抑制超调,但放大噪声 |
PID计算函数(pid.c):
// 增量式PID:output_delta = Kp*(e-e1) + Ki*e + Kd*(e-2*e1+e2) int16_t PID_Calculate(int16_t setpoint, int16_t feedback) { static int16_t e1 = 0, e2 = 0; // 上次、上上次误差 int16_t e = setpoint - feedback; // 当前误差 int16_t output_delta; // 防积分饱和:仅当输出未达限幅时才更新积分项 if (abs(pid_output) < PID_OUTPUT_MAX) { pid_i_term += e; } output_delta = (int16_t)(Kp * (e - e1)) + (int16_t)(Ki * e) + (int16_t)(Kd * (e - 2*e1 + e2)); pid_output += output_delta; // 累加得实际输出 // 输出限幅(对应电机最大步频) if (pid_output > PID_OUTPUT_MAX) pid_output = PID_OUTPUT_MAX; if (pid_output < -PID_OUTPUT_MAX) pid_output = -PID_OUTPUT_MAX; e2 = e1; e1 = e; return output_delta; }关键细节:
PID_OUTPUT_MAX设为±200,对应电机最高100步/秒(实测机械极限)- 积分项更新有门控:仅当当前输出未达限幅时才累加,这是抗饱和的核心
e-2*e1+e2是二阶微分近似,比单阶微分抗噪性更好(实测在电机振动下波动降低40%)
3.3 角度闭环校准:用霍尔传感器替代编码器的低成本方案
系统未用昂贵的光电编码器,而是采用A3144霍尔开关(开关型,响应频率20kHz)配合磁钢实现粗略角度反馈:
- 水平轴:在底座圆周贴4个磁钢(90°间隔),霍尔传感器固定,每转触发4次
- 俯仰轴:在转臂末端装1个磁钢,霍尔传感器装在固定支架上,每90°触发1次
校准逻辑:
- 上电后先执行“归零动作”——水平轴顺时针转至首次触发霍尔,记为0°;俯仰轴向下压至触发,记为0°
- 后续运行中,每触发1次霍尔,对应轴角度累加预设步数(如水平轴每90°=2000步)
- PID的
feedback参数即为该累加值,setpoint为光梯度计算的目标步数
提示:霍尔开关有±2°安装误差,但通过“触发次数×步距”方式,将误差摊薄到单次<0.5°,远优于纯开环步进。
4. 常见问题排查:那些让你怀疑人生却只需改一行代码的坑
4.1 现象:电机只抖动不转动,或转动几下后停住
原因:ULN2003输入端悬空导致逻辑电平不确定。28BYJ-48要求四相严格按序导通,若某相因GPIO未初始化为低电平而处于高阻态,电机线圈无法形成闭合回路。
解决:在GPIO_Init()后立即执行GPIO_ResetBits(GPIOB, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11),确保所有控制引脚初始为低。
4.2 现象:晴天跟踪正常,阴天频繁左右摇摆
原因:光敏电阻在弱光下信噪比急剧下降,ADC读数标准差从晴天的±3跳变到±15,梯度计算失效。
解决:在梯度计算前增加滑动窗口滤波——维护长度为5的环形缓冲区,每次取中位数而非原始值。修改adc.c中数据处理段:
// 新增median_filter函数(已验证) uint16_t median_filter(uint16_t buf[], uint8_t len) { uint16_t temp; for (uint8_t i = 0; i < len-1; i++) { for (uint8_t j = 0; j < len-1-i; j++) { if (buf[j] > buf[j+1]) { temp = buf[j]; buf[j] = buf[j+1]; buf[j+1] = temp; } } } return buf[len/2]; }4.3 现象:系统运行2小时后ADC读数整体漂移+10%
原因:LM2596S DC-DC模块在持续负载下发热,导致AMS1117输入电压波动,进而影响ADC参考电压稳定性。
解决:在main.c主循环中每30秒执行一次ADC自校准(ADC_StartCalibration(ADC1)),并重置dark_base——虽然会短暂中断采样,但比漂移导致失控更可接受。
4.4 现象:俯仰轴在正午附近出现“抬头不足”,电压始终比理论值低0.3V
原因:结构设计缺陷——俯仰轴电机安装位置偏高,导致正午太阳高度角>75°时,上部光敏被平台边框遮挡,adc_val[U]虚低,梯度计算误判为“需低头”。
解决:硬件上加高光敏支架3mm;软件上增加高度角补偿:当grad_v > 0且abs(grad_h) < 5时(即基本正对太阳),强制将grad_v提升20%。
4.5 现象:串口打印的电压值与万用表实测相差0.5V以上
原因:未启用ADC校准,且VDDA未与VDD严格等电位。STM32F103的ADC精度严重依赖VDDA稳定性。
解决:在PCB上用0欧姆电阻将VDDA与VDD短接,并在代码中添加:
RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 72MHz/6=12MHz,符合ADC最大14MHz要求 ADC_TempSensorVrefintCmd(ENABLE); // 启用内部参考 ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1));5. 二次开发实战:从“能跑”到“跑得更聪明”的三步改造
5.1 第一步:加入光照强度阈值,实现“节能休眠”
原系统全天候运行,但清晨/傍晚光照<5000lux时,追光收益远低于电机功耗。改造思路:用光敏电阻读数反推照度(经实测标定:lux ≈ 1200000 / (adc_val_avg)^1.8),当连续10次lux < 4000时,进入低功耗模式——关闭电机驱动使能、ADC采样率降至1Hz、LED指示灯慢闪。
关键代码补丁(main.c):
// 在主循环中插入 static uint8_t sleep_counter = 0; uint16_t avg_light = (adc_val[L]+adc_val[R]+adc_val[U]+adc_val[D])/4; uint32_t lux_est = 1200000UL / (uint32_t)(pow(avg_light, 1.8f)+1); // +1防0 if (lux_est < 4000) { sleep_counter++; if (sleep_counter >= 10) { Enter_Sleep_Mode(); // 关闭外设,仅保留RTC唤醒 } } else { sleep_counter = 0; if (system_state == SLEEP) Wake_Up_From_Sleep(); }血泪经验:
pow()函数在STM32F1上极耗时(约1.2ms),必须用查表法替换。我做了0~4095的lut数组(2KB),实测启动时间从800ms降至45ms。
5.2 第二步:用EEPROM存储历史最优朝向,应对连续阴雨天
当连续3天光照不足,系统会丢失“太阳轨迹记忆”。解决方案:每天日落前(ADC读数持续10分钟<1000),将当日水平/俯仰轴的平均步数存入STM32内置EEPROM(地址0x08005000起)。下次上电时,若检测到lux < 2000,则按存储值粗略定位,再启动精细跟踪。
EEPROM写入封装(eeprom.c):
// STM32F103 EEPROM模拟(使用最后1页Flash) #define EEPROM_START_ADDR 0x08005000 #define EEPROM_SIZE 1024 void EEPROM_Write_U16(uint16_t addr, uint16_t data) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_ErasePage(EEPROM_START_ADDR); // 整页擦除 FLASH_ProgramHalfWord(EEPROM_START_ADDR + addr*2, data); FLASH_Lock(); } // 存储当日平均朝向(addr=0:水平, addr=1:俯仰) void Save_Daily_Azimuth_Elev(int16_t az, int16_t el) { EEPROM_Write_U16(0, (uint16_t)az); EEPROM_Write_U16(1, (uint16_t)el); }注意:Flash写入寿命约10万次,因此每日只写1次,且用“先擦后写”避免累积磨损。实测连续写入3年无故障。
5.3 第三步:扩展LoRa无线上传,构建小型光伏监测网络
为适配某农业大棚项目,需将10套追光系统数据汇总。放弃WiFi(功耗高、穿墙差),改用SX1278 LoRa模块(SPI接口),每30分钟上报:当前电压、朝向角度、光照梯度、电池电压。
通信协议精简设计:
| 字节 | 含义 | 示例 |
|---|---|---|
| 0 | 设备ID(1字节) | 0x05 |
| 1~2 | 电压(mV,大端) | 0x0F 0x2A → 3882mV |
| 3~4 | 水平角度(°,大端,-180~180) | 0xFF 0x38 → -200 → 转为160° |
| 5~6 | 俯仰角度(°,同上) | 0x00 0x45 → 69° |
| 7 | 电池电压(0.1V步进) | 0x78 → 12.0V |
| 8 | CRC8(XOR校验) | 计算前8字节异或 |
发送函数(lora.c):
typedef __packed struct { uint8_t dev_id; uint16_t voltage_mv; int16_t azimuth_deg; int16_t elevation_deg; uint8_t bat_volt_dv; uint8_t crc8; } lora_packet_t; void LoRa_Send_Packet(void) { lora_packet_t pkt = {0}; pkt.dev_id = DEVICE_ID; pkt.voltage_mv = (uint16_t)(solar_voltage * 1000); pkt.azimuth_deg = (int16_t)current_azimuth; pkt.elevation_deg = (int16_t)current_elevation; pkt.bat_volt_dv = (uint8_t)(battery_volt * 10); // CRC8计算(多项式0x07) pkt.crc8 = 0; uint8_t *p = (uint8_t*)&pkt; for (int i = 0; i < 8; i++) { pkt.crc8 ^= p[i]; for (int j = 0; j < 8; j++) { if (pkt.crc8 & 0x80) pkt.crc8 = (pkt.crc8 << 1) ^ 0x07; else pkt.crc8 <<= 1; } } SPI_Write_Buf(LORA_SPI, (uint8_t*)&pkt, sizeof(pkt)); // 实际SPI发送 }从那以后我每次做低功耗无线节点,都强制走一遍“协议字节对齐+CRC校验+发送前状态检查”三步,哪怕只是临时调试。因为某次LoRa丢包率突然飙升,查了两天才发现是结构体没加__packed,编译器自动填充了1字节,导致接收端CRC永远校验失败——这种坑,踩一次够记十年。希望帮到你。
本文还有配套的精品资源,点击获取