1. 项目缘起与整体设计思路
1.1 为什么会想到做一套鸽子驯养系统
养鸽子这件事,外行看热闹,内行看门道。我接触赛鸽和观赏鸽驯养差不多有六七年时间,最开始完全是靠人工:每天固定时间喂食、吹哨、记录归巢时间、观察状态。鸽子数量少的时候还行,一旦超过二三十只,光靠人盯根本忙不过来。尤其是做归巢训练的时候,你得精确记录每只鸽子进棚的时间,还要控制投喂量、监测棚内温湿度,稍有疏忽就会出现鸽子状态下滑甚至丢失的情况。
市面上确实有成熟的赛鸽计时系统,但那些设备要么价格高得离谱,要么功能单一,只解决计时问题,环境监测、自动投喂、远程查看这些需求根本覆盖不到。我就琢磨着,能不能用STM32做一套完整的智能驯养系统,把喂食、计时、环境监测、数据记录这几件事整合到一起。这个想法从2022年底开始落地,前后迭代了三版硬件,代码重构了两次,最终形成了一套相对稳定的方案。
这套系统适合谁参考?如果你是有一定单片机基础的电子爱好者,或者正在找毕业设计题目的学生,又或者是养鸽爱好者想自己动手做点东西,这套方案都能直接拿来用。硬件成本控制在三百元以内,软件全部开源思路,设计手册里连PCB布局注意事项都写清楚了。
1.2 系统整体架构是怎么拆的
做任何嵌入式项目,第一步永远是画框图。我见过太多人一上来就写代码,结果做到一半发现外设冲突、引脚不够用、电源撑不住,推倒重来的成本极高。这套鸽子驯养系统的架构我反复调整过,最终确定为主控+传感层+执行层+交互层+电源层五个部分。
主控选的是STM32F103C8T6,也就是大家常说的“蓝板”核心芯片。为什么不用F4或者G系列?因为这个项目的外设需求并不算重:几路定时器、两路ADC、一路USART、若干GPIO就够了。F103C8T6有72MHz主频、64KB Flash、20KB RAM,跑这套逻辑绰绰有余,而且价格便宜、资料丰富、社区支持好。你要是手头有F407或者G030,改一下时钟配置和引脚定义也能直接用,核心逻辑不变。
传感层包括DHT22温湿度传感器、BH1750光照传感器、HX711称重模块(用于料槽余量检测),以及一个红外对管组成的归巢检测通道。执行层有SG90舵机控制的投喂闸门、无源蜂鸣器做的声音召唤、以及一个5V继电器控制的补光灯。交互层是0.96寸OLED显示屏加三个按键,负责本地参数设置和状态查看。电源层用12V适配器输入,经过Buck电路降到5V,再经LDO降到3.3V给主控和传感器供电。
这里重点说一下电源设计。很多人做STM32项目习惯直接用USB供电,但鸽子棚里不可能一直插着电脑。我用的是12V/2A的适配器,经过MP1584构成的Buck电路降到5V,效率能到90%以上。5V再分两路:一路直接给舵机和继电器供电,另一路经过AMS1117-3.3降到3.3V给主控和传感器。为什么要分开?因为舵机启动瞬间电流能冲到1A以上,如果和主控共用一路LDO,电压会被拉低导致主控复位。这个坑我踩过,后来加了独立的Buck输出才解决。
1.3 方案选型背后的取舍逻辑
在传感器选型上我纠结了很久。温湿度传感器一开始用的是DHT11,便宜是便宜,但精度太差,±2℃的误差对于监测鸽子棚环境来说不够用。后来换成DHT22,精度提到±0.5℃,响应速度也快了不少。光照传感器选BH1750而不是光敏电阻,是因为BH1750直接输出数字量,不需要额外的ADC校准,而且量程宽、线性度好。
归巢检测这块我想过好几种方案:红外对管、超声波、RFID脚环。RFID方案最精确,但每只鸽子都要戴脚环,成本高而且鸽子可能不适应。超声波测距受环境影响大,鸽子棚里灰尘多,探头容易误触发。最后选了红外对管,在入口通道两侧各装一个,鸽子通过时遮挡光线产生脉冲,配合定时器输入捕获就能算出通过时间。这个方案成本低、可靠性高,唯一需要注意的是防抖处理,后面会详细讲。
投喂机构的设计也改过好几版。最开始用直流电机加限位开关,但直流电机停不准,每次投喂量不一致。后来换成SG90舵机带动一个旋转料盘,舵机转固定角度对应固定投喂量,重复精度能到±2克。舵机的控制信号是50Hz的PWM,高电平时间0.5ms到2.5ms对应0到180度,这个用STM32的定时器PWM模式很容易实现。
2. 核心电路设计与关键细节解析
2.1 STM32最小系统与时钟树配置
STM32最小系统看起来简单,但真要稳定运行,几个细节必须处理好。首先是复位电路,我用的是经典的RC复位加手动按键,10K上拉电阻配100nF电容,时间常数1ms,足够STM32完成上电复位。有些教程推荐用专用复位芯片,但对于这个项目来说没必要,RC复位完全够用。
晶振电路是另一个容易出问题的地方。STM32F103外部高速晶振是8MHz,配合内部PLL倍频到72MHz。负载电容我选的是20pF,这个值不是随便定的,要根据晶振的负载电容规格来算。公式是:CL = (C1 × C2) / (C1 + C2) + Cstray,其中Cstray是PCB走线寄生电容,一般取3到5pF。如果晶振规格书上写负载电容是12pF,那么C1和C2各取20pF左右比较合适。我实测过,负载电容偏差太大会导致起振困难或者频率偏移,进而影响串口波特率。
时钟树的配置在代码里体现为SystemInit函数和SystemCoreClockUpdate函数。很多人用CubeMX生成代码后就不管了,但实际上如果你要手动改时钟频率,必须同时修改这两个地方。我一般会在main函数开头加一句SystemCoreClockUpdate(),确保SystemCoreClock变量和实际时钟一致,否则延时函数会算错。
// 时钟配置关键代码片段 void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; // 使能HSE,配置PLL RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1; RCC_OscInitStruct.HSIState = RCC_HSI_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; // 8MHz * 9 = 72MHz HAL_RCC_OscConfig(&RCC_OscInitStruct); // 配置AHB、APB1、APB2分频 RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; // 72MHz RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; // 36MHz RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; // 72MHz HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2); }注意APB1的分频系数是2,所以挂在APB1上的定时器时钟是36MHz×2=72MHz,而APB2上的定时器是72MHz×1=72MHz。这个细节在计算定时器周期时经常被忽略,导致实际定时时间和预期不符。
2.2 电源电路:从Buck到LDO的完整链路
电源是整个系统的基础,我见过太多项目因为电源设计不当而出现各种玄学问题。这套系统的电源链路是:12V输入 → Buck降压到5V → LDO降压到3.3V。
Buck电路我选的是MP1584,输入电压范围4.5V到28V,输出电流最大3A,开关频率1.5MHz。外围元件很简单:一个10μH电感、两个22μF陶瓷电容、一个反馈电阻分压网络。输出电压由反馈电阻决定,公式是:Vout = 0.8V × (1 + R1/R2)。要得到5V输出,R1取51K,R2取10K,算下来是0.8 × (1 + 5.1) = 4.88V,实际测试在4.9V左右,完全够用。
PCB布局上,Buck电路的电感要尽量靠近芯片的SW引脚,反馈电阻要靠近FB引脚,输入输出电容要就近放置。这些走线越短越好,否则会引入噪声导致输出电压波动。我第一版PCB就是因为电感走线太长,导致5V输出上有200mV的纹波,后来重新布局才降到50mV以内。
LDO用的是AMS1117-3.3,这个芯片便宜好用,但有个缺点:压差要求至少1.1V。也就是说输入5V、输出3.3V,压差1.7V,满足要求。但要注意功耗:如果负载电流是200mA,那么LDO上的功耗就是(5-3.3)×0.2 = 0.34W,SOT-223封装加一小块铜皮散热就够了。如果负载电流更大,比如500mA,功耗就到0.85W,必须加散热片或者换用开关电源。
注意:AMS1117的输出电容不能省,至少接一个22μF钽电容或者100μF电解电容,否则容易自激振荡。我实测过,不加输出电容时输出电压会在3.0V到3.6V之间跳变,加上之后就稳定在3.3V。
2.3 归巢检测电路与抗干扰设计
归巢检测用的是红外对管,发射端是IR333,接收端是PT331C。发射端串联一个100Ω限流电阻接5V,接收端集电极接3.3V,发射极接10K下拉电阻到地,同时接到STM32的GPIO。当没有鸽子通过时,接收端收到红外光,光敏三极管导通,GPIO读到低电平;鸽子通过遮挡光线时,光敏三极管截止,GPIO读到高电平。
这个电路看起来简单,但实际用的时候会遇到两个问题:一是环境光干扰,二是抖动。环境光干扰的解决办法是在接收端加一个红外滤光片,只让850nm到950nm的光通过。抖动问题更麻烦,鸽子通过时可能产生多次边沿,如果直接用外部中断计数,会多计好几次。
我的解决方案是硬件RC滤波加软件消抖。硬件上在接收端输出和GPIO之间串一个10K电阻,再对地接一个100nF电容,构成低通滤波器,截止频率是1/(2π×10K×100nF) ≈ 160Hz。这个频率远高于鸽子通过产生的信号频率(大概10Hz到50Hz),所以有用信号能通过,高频噪声被滤掉。软件上在定时器中断里每隔1ms采样一次GPIO状态,连续三次相同才认为状态有效,这样能进一步消除抖动。
// 软件消抖状态机 typedef enum { IDLE, DETECTING, CONFIRMED } DetectState; DetectState state = IDLE; uint8_t stable_count = 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); uint8_t current = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); switch(state) { case IDLE: if (current == 1) { state = DETECTING; stable_count = 1; } break; case DETECTING: if (current == 1) { stable_count++; if (stable_count >= 3) { state = CONFIRMED; // 记录归巢事件 RecordHomingEvent(); } } else { state = IDLE; stable_count = 0; } break; case CONFIRMED: if (current == 0) { state = IDLE; stable_count = 0; } break; } } }2.4 无源蜂鸣器驱动与声音召唤电路
声音召唤是驯养系统的核心功能之一。鸽子对特定频率的声音有条件反射,训练时每次喂食前播放固定频率的声音,时间长了鸽子听到声音就会归巢。无源蜂鸣器需要方波驱动,频率决定音调,占空比决定响度。
驱动电路我用的是S8050三极管构成的共射极放大电路。STM32的GPIO输出3.3V方波,经过1K基极电阻驱动S8050,集电极接蜂鸣器负极,蜂鸣器正极接5V,同时反向并联一个1N4148续流二极管。为什么要加续流二极管?因为蜂鸣器内部有线圈,断电瞬间会产生反向电动势,不加二极管的话这个高压可能击穿三极管。
PWM频率我用的是4kHz,这个频率在鸽子的听觉范围内(鸽子能听到0.1Hz到10kHz),而且人耳听起来也不刺耳。占空比设50%,实测响度在10米外能清晰听到。如果你想让声音传得更远,可以把占空比提到70%到80%,但注意不要超过蜂鸣器的额定功率。
// 蜂鸣器PWM初始化 void Buzzer_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 18000 - 1; // 72MHz / 4kHz = 18000 TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 9000; // 50%占空比 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM4, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_Cmd(TIM4, ENABLE); }3. 实操过程与核心功能实现
3.1 开发环境搭建与芯片包安装
开发环境我用的是Keil MDK5,版本5.38。为什么不用VSCode加PlatformIO?因为这套项目涉及大量寄存器级操作和中断调试,Keil的调试器更直观,尤其是配合ST-Link Utility查看内存和寄存器很方便。当然如果你习惯VSCode,配置好Cortex-Debug插件也能用,但入门门槛稍高。
安装Keil5之后第一件事是装芯片包。STM32F1系列的芯片包是Keil.STM32F1xx_DFP,下载下来双击安装就行。注意版本要匹配,我用的2.4.0版本,太老的版本可能不支持某些新特性。装完芯片包后新建工程,选择STM32F103C8,然后勾选需要的库:RCC、GPIO、TIM、USART、ADC、I2C。
这里有个坑:Keil5默认不安装C51编译器,如果你之前装过Keil4的C51,想和Keil5共存,需要把C51的安装路径添加到Keil5的TOOLS.INI文件里。具体操作是在Keil5安装目录下找到TOOLS.INI,在[ARM]段落后添加[C51]段落,指向C51的安装路径。这样两个编译器就能共存了,切换工程时Keil会自动选择对应的工具链。
ST-Link Utility是烧录和调试的好帮手,尤其是当你需要批量烧录或者查看Flash内容时。安装好驱动后,用ST-Link V2连接STM32的SWD接口:SWCLK接PA14,SWDIO接PA13,GND接GND,3.3V接3.3V。注意不要接5V,STM32的IO耐压是3.3V,接5V可能烧芯片。
3.2 定时器配置与归巢时间精确测量
归巢时间的测量精度直接决定了这套系统的实用价值。赛鸽比赛的时间差可能只有零点几秒,所以定时器的配置必须精确。我用的是TIM2,配置成1MHz计数频率,也就是每微秒计数一次,32位定时器最大能计到4294秒,足够覆盖任何归巢时间。
TIM2的时钟源是APB1,前面说过APB1分频系数是2,所以TIM2的时钟是72MHz。要得到1MHz计数频率,预分频器设为71,这样计数频率就是72MHz/(71+1)=1MHz。自动重装载值设为0xFFFFFFFF,让定时器自由运行。
// TIM2初始化,用于精确计时 void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period = 0xFFFFFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz / 72 = 1MHz TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_Cmd(TIM2, ENABLE); } // 获取当前时间戳(微秒) uint32_t GetMicrosecond(void) { return TIM_GetCounter(TIM2); } // 计算两次事件的时间差 uint32_t GetTimeDiff(uint32_t start, uint32_t end) { if (end >= start) { return end - start; } else { return (0xFFFFFFFF - start) + end + 1; } }实际使用的时候,鸽子进入检测通道时记录一次时间戳,完全通过后再记录一次,两个时间戳的差值就是通过时间。通过时间可以用来判断鸽子是正常进入还是犹豫徘徊,如果通过时间超过2秒,说明鸽子可能在入口处犹豫,这个数据对驯养策略调整很有参考价值。
3.3 投喂机构控制与舵机角度校准
SG90舵机的控制信号是50Hz的PWM,周期20ms。高电平时间0.5ms对应0度,1.5ms对应90度,2.5ms对应180度。STM32的TIM3配置成PWM模式,预分频器设为71,自动重装载值设为19999,这样计数频率是1MHz,周期是20000个计数,正好20ms。
// TIM3初始化,用于舵机控制 void TIM3_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 19999; // 20ms周期 TIM_TimeBaseStructure.TIM_Prescaler = 71; // 1MHz计数 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 1500; // 初始位置90度 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC2Init(TIM3, &TIM_OCInitStructure); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); } // 设置舵机角度(0-180度) void Servo_SetAngle(uint8_t angle) { if (angle > 180) angle = 180; uint16_t pulse = 500 + (angle * 2000) / 180; // 500us到2500us TIM_SetCompare2(TIM3, pulse); }舵机角度和投喂量的对应关系需要实际校准。我的做法是:先把舵机转到0度,料盘开口完全关闭;然后每次增加10度,用电子秤称量投喂量,记录数据。实测下来,0度到30度是空行程,30度到150度线性对应0克到50克,150度到180度又是空行程。所以有效控制范围是30度到150度,对应投喂量0到50克。
校准的时候要注意,舵机上电瞬间会有一个抖动,这是正常现象。为了避免误投喂,我在初始化时先把舵机转到0度,等500ms稳定后再进入主循环。另外舵机的电源要和主控分开,前面电源部分已经讲过,这里再强调一次:舵机启动电流大,共用电源会导致主控复位。
3.4 温湿度采集与OLED显示
DHT22是单总线传感器,时序要求比较严格。起始信号是主机拉低至少1ms,然后释放,DHT22响应拉低80μs,再拉高80μs,然后开始传输40位数据。每一位数据以50μs低电平开始,高电平持续26μs到28μs表示0,持续70μs表示1。
// DHT22读取函数 uint8_t DHT22_Read(float *temp, float *humi) { uint8_t data[5] = {0}; uint8_t i, j; // 主机发送起始信号 DHT22_SetOutput(); DHT22_Low(); Delay_ms(1); DHT22_High(); Delay_us(30); DHT22_SetInput(); // 等待DHT22响应 if (DHT22_ReadPin() == 0) { while (DHT22_ReadPin() == 0); // 等待80us低电平结束 while (DHT22_ReadPin() == 1); // 等待80us高电平结束 // 读取40位数据 for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { while (DHT22_ReadPin() == 0); // 等待50us低电平结束 Delay_us(40); data[i] <<= 1; if (DHT22_ReadPin() == 1) { data[i] |= 1; while (DHT22_ReadPin() == 1); // 等待高电平结束 } } } // 校验 if (data[4] == (data[0] + data[1] + data[2] + data[3])) { *humi = (data[0] << 8 | data[1]) / 10.0; *temp = (data[2] << 8 | data[3]) / 10.0; return 1; } } return 0; }OLED用的是SSD1306驱动,I2C接口。显示内容分三行:第一行显示当前时间和温度,第二行显示湿度和光照强度,第三行显示今日归巢次数和料槽余量。刷新率不用太高,每秒刷一次就够了,太频繁反而影响可读性。
I2C通信要注意上拉电阻,SSD1306模块一般自带4.7K上拉,如果没有的话需要在SDA和SCL上各接一个4.7K到3.3V。我遇到过因为上拉电阻太大导致通信失败的情况,后来换成2.2K就稳定了。另外I2C的时钟频率不要超过400kHz,SSD1306最高支持400kHz,但实际用100kHz更稳定。
3.5 数据记录与串口输出
系统运行过程中会产生大量数据:每次归巢的时间、温湿度变化、投喂记录等。这些数据需要保存下来供后续分析。我用的是W25Q64 Flash芯片,8MB容量,通过SPI接口和STM32通信。数据按页写入,每页256字节,写满一页后触发擦除和写入操作。
数据格式我定义了一个结构体:
typedef struct { uint32_t timestamp; // 时间戳(秒) uint16_t homing_count; // 归巢次数 float temperature; // 温度 float humidity; // 湿度 uint16_t feed_amount; // 投喂量(克) uint8_t reserved[8]; // 保留字节 } Record_t;每条记录20字节,一页能存12条。8MB总共能存大约40万条记录,按每天100条算,能用10年以上。写入的时候要注意Flash的寿命,W25Q64每个扇区擦写次数是10万次,所以不能频繁擦写同一扇区。我的策略是顺序写入,写满整个芯片后再从头覆盖,这样每个扇区的擦写次数是均匀的。
串口输出用的是USART1,波特率115200。通过USB转TTL模块连接到电脑,可以用串口助手查看实时数据。如果你想让STM32直接通过USB发送数据,可以配置USB虚拟串口,但F103C8T6的USB和CAN共用引脚,配置起来稍麻烦。我建议还是用USART加外置USB转TTL模块,简单可靠。
// 串口初始化 void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // PA9 TX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA10 RX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baudrate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } // 重定向printf到串口 int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }4. 常见问题与排查技巧实录
4.1 系统复位与电源相关问题
系统频繁复位是嵌入式项目最常见的问题之一,尤其是在有电机、舵机这类大电流负载的系统中。我遇到过的复位原因主要有三种:电源跌落、看门狗误触发、堆栈溢出。
电源跌落是最常见的。舵机启动瞬间电流能到1A,如果电源内阻大或者走线细,5V电压可能瞬间跌到3V以下,导致LDO输出不稳,STM32复位。解决办法前面说过,舵机单独走一路Buck输出,并且加大电容储能。我在舵机电源端并了一个1000μF电解电容加一个100nF陶瓷电容,复位问题就再没出现过。
看门狗误触发通常是因为喂狗不及时。我用的是独立看门狗IWDG,超时时间设的是2秒。主循环里每500ms喂一次狗,正常情况下不会超时。但如果某个函数执行时间过长,比如Flash擦写需要几百毫秒,就可能来不及喂狗。解决办法是在耗时操作中插入喂狗语句,或者把看门狗超时时间设长一点。
堆栈溢出比较隐蔽,通常表现为程序跑飞或者进入HardFault。STM32F103C8T6的默认堆栈大小是1KB,如果局部变量太大或者递归太深,就会溢出。我建议在启动文件里把堆栈改成2KB,并且避免在中断里定义大数组。
排查技巧:如果系统复位,可以先读RCC_CSR寄存器的复位标志位,判断是上电复位、看门狗复位还是软件复位。这个寄存器在调试的时候非常有用。
4.2 传感器数据异常与通信故障
DHT22读不出数据是新手最常遇到的问题。我总结了几种情况:一是时序不对,二是上拉电阻缺失,三是传感器损坏。DHT22的数据线需要接一个4.7K到10K的上拉电阻到3.3V,如果没有上拉,数据线一直是低电平,读出来全是0。时序方面,起始信号的低电平时间不能少于1ms,但也不能太长,否则传感器会进入休眠。
I2C通信失败通常表现为OLED不亮或者BH1750读数为0。先检查上拉电阻,再用示波器看SDA和SCL波形。如果波形上升沿很缓,说明上拉电阻太大;如果波形有振铃,说明走线太长或者没有匹配电阻。我遇到过因为I2C走线过长导致通信不稳定的情况,后来把OLED和BH1750的走线缩短到5cm以内就解决了。
红外对管误触发是另一个常见问题。除了前面说的RC滤波和软件消抖,还要注意发射端和接收端的对准。如果两个管子没有正对,接收端收到的红外光强度不够,输出电平会在阈值附近跳动。我的做法是用手机摄像头看红外发射管是否亮(手机摄像头能拍到红外光),然后微调接收管的角度,直到输出电平稳定。
4.3 定时器与中断优先级冲突
STM32的中断优先级配置不当会导致各种奇怪问题。比如串口接收中断和定时器中断同时触发,如果优先级设置不合理,可能出现数据丢失或者定时不准。我的原则是:实时性要求高的中断优先级高,比如归巢检测的定时器中断;通信类中断优先级低,比如串口接收中断。
NVIC的优先级分组我设的是Group 2,也就是2位抢占优先级、2位响应优先级。抢占优先级高的中断可以打断抢占优先级低的中断,响应优先级只在同时触发时决定谁先执行。归巢检测定时器中断抢占优先级设1,串口中断设2,这样归巢检测永远不会被串口打断。
// 中断优先级配置 void NVIC_Configuration(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 归巢检测定时器中断,抢占优先级1 NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 串口中断,抢占优先级2 NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_Init(&NVIC_InitStructure); }还有一个容易忽略的点:中断服务函数里不要做耗时操作。我见过有人在串口中断里解析协议、在定时器中断里写Flash,结果导致其他中断响应不及时。正确的做法是在中断里只做标记,把耗时操作放到主循环里处理。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 系统频繁复位 | 电源跌落 | 示波器看5V和3.3V波形 | 加大电容,舵机独立供电 |
| DHT22读数全0 | 上拉电阻缺失 | 万用表测数据线电压 | 加4.7K上拉电阻 |
| OLED不亮 | I2C通信失败 | 示波器看SDA/SCL波形 | 检查上拉电阻和地址 |
| 红外误触发 | 环境光干扰 | 遮挡环境光测试 | 加滤光片和RC滤波 |
| 舵机抖动 | 电源干扰 | 示波器看舵机电源 | 独立供电,加去耦电容 |
| 定时器不准 | 时钟配置错误 | 示波器测PWM频率 | 检查APB分频和预分频 |
| 串口乱码 | 波特率不匹配 | 检查时钟和波特率 | 确认SystemCoreClock |
| Flash写入失败 | 扇区未擦除 | 读回数据对比 | 先擦除再写入 |
4.5 几个容易被忽略的实操心得
第一个心得是关于PCB布局的。STM32的晶振要尽量靠近芯片,走线要短且对称,晶振下方不要走其他信号线。我第一版PCB把晶振放在板子边缘,结果起振不稳定,后来移到芯片旁边就好了。另外晶振外壳要接地,能减少干扰。
第二个心得是关于代码结构的。不要把所有代码都堆在main.c里,按功能分模块:传感器驱动一个文件、执行器控制一个文件、数据处理一个文件、主逻辑一个文件。这样调试的时候容易定位问题,后期维护也方便。我习惯用#ifdef DEBUG包裹调试代码,发布的时候直接注释掉宏定义就行。
第三个心得是关于参数保存的。系统运行参数(比如投喂时间、投喂量、声音频率)需要掉电保存,我用的是STM32内部的Flash模拟EEPROM。注意内部Flash的擦写次数只有1万次,不能频繁写。我的策略是参数改变时先写到RAM,每隔10分钟或者检测到掉电信号时才写入Flash。
第四个心得是关于看门狗的。独立看门狗IWDG用的是内部40kHz RC振荡器,精度不高,超时时间有±30%的误差。如果对时间要求严格,可以用窗口看门狗WWDG,它用的是APB1时钟,精度高得多。但WWDG的配置稍复杂,需要设置窗口值和计数器值。
第五个心得是关于调试的。ST-Link Utility可以实时查看变量值,但需要把变量定义成全局变量并且不要被编译器优化掉。我习惯在调试时给关键变量加volatile关键字,防止编译器优化导致看不到值。另外SWD接口的SWCLK和SWDIO不要接其他外设,否则会影响调试。
这套系统从最初的想法到最终稳定运行,前后花了差不多半年时间。中间踩过的坑、烧过的芯片、改过的板子,现在回头看都是宝贵的经验。硬件设计没有捷径,每一个电阻电容的值都有它的道理,每一根走线都要考虑信号完整性。软件方面,中断优先级、时钟配置、电源管理这些基础的东西,往往比花哨的算法更重要。
如果你打算复现这套系统,我的建议是先跑通最小系统,确认晶振起振、串口能打印、LED能闪烁,然后再一个一个添加外设。每加一个外设就测试一个,不要一次性全焊上去。调试的时候善用示波器和逻辑分析仪,很多问题看波形比看代码快得多。最后,鸽子的驯养是一个长期过程,系统只是辅助工具,真正重要的是你对鸽子的观察和耐心。