1. 为什么STM32F1至今仍是嵌入式入门的首选平台
如果你在电子论坛或者技术群里问一句“新手学单片机选什么”,十有八九会有人回你“STM32F1”。这个答案从十年前一直延续到现在,哪怕Cortex-M0、M3、M4甚至M33的新品层出不穷,STM32F103系列依然牢牢占据着入门和中小型项目的主力位置。我自己从最早用寄存器操作点亮第一颗LED,到后来用标准外设库做工业采集板,再到现在偶尔拿它做快速原型验证,前前后后经手过几十个项目,对这颗芯片的脾气算是摸得比较透。
STM32F1系列是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族,主打型号包括STM32F103C8T6、STM32F103RCT6、STM32F103ZET6等。它的核心频率最高72MHz,内置Flash从16KB到1MB不等,SRAM从6KB到96KB,外设覆盖GPIO、USART、SPI、I2C、ADC、DAC、定时器、CAN、USB等,几乎你能想到的常用接口它都有。关键是价格便宜,一片STM32F103C8T6核心板不到十块钱,资料铺天盖地,社区活跃度极高,出了问题随便一搜就有答案。
但“资料多”这件事本身也是双刃剑。网上关于STM32F1的内容质量参差不齐,很多教程停留在“复制粘贴代码能跑就行”的层面,真正讲清楚配置逻辑、时钟树关系、中断优先级分组、DMA与外设配合的内容反而被淹没。更麻烦的是,很多新手直接拿别人的工程改,遇到问题不知道从哪查起,最后卡在某个看似简单的环节上耗掉好几天。所以这篇文章不打算再写一份“从零开始点灯”的流水账,而是围绕STM32F1在实际项目中最容易踩坑的几个核心环节,把原理、配置逻辑和实操经验串起来讲清楚。
这篇文章适合三类人:第一类是刚接触STM32F1、能看懂C语言但还没建立起完整工程概念的新手;第二类是用过F1但一直停留在“改改例程”层面、想真正搞懂底层机制的中级开发者;第三类是需要快速选型和搭建采集类项目的工程师,尤其是涉及DHT11温湿度传感器这类经典外设的场景。我会从时钟系统这个最容易被忽视但最关键的环节讲起,然后依次展开GPIO配置、定时器与延时、串口通信、DHT11单总线驱动、ADC采集以及低功耗与调试技巧,每个部分都给出可复现的配置思路和实测中总结的注意事项。
2. 时钟树没搞懂,后面全是玄学问题
2.1 为什么你的串口波特率总是对不上
很多人第一次用STM32F1串口通信时会遇到一个诡异现象:代码里明明写的波特率是9600,但串口助手收到的全是乱码,换个波特率偶尔能好,但换个芯片又不行。这个问题的根源几乎都指向同一个地方——时钟配置。STM32F1的时钟系统比51单片机复杂得多,它有多条时钟源和分频路径,最终送到各个外设的时钟频率决定了该外设的工作节奏。串口的波特率是由APB总线时钟经过分频得到的,如果APB时钟和你计算时假设的不一致,波特率自然就偏了。
STM32F1的时钟源主要有四个:HSI(内部高速RC,8MHz)、HSE(外部高速晶振,通常8MHz)、LSI(内部低速RC,约40kHz)、LSE(外部低速晶振,32.768kHz)。系统时钟SYSCLK可以来自HSI、HSE或者PLL倍频输出。以最常见的STM32F103C8T6最小系统板为例,板子上焊的是8MHz晶振,标准做法是HSE经PLL九倍频得到72MHz作为SYSCLK。然后SYSCLK经过AHB分频器(通常不分频)得到HCLK=72MHz,HCLK再经过APB1分频器(通常2分频)得到PCLK1=36MHz,经过APB2分频器(通常不分频)得到PCLK2=72MHz。
这里有个关键细节:APB1上挂的是USART2/3/4/5、TIM2/3/4/5/6/7、I2C1/2、SPI2等低速外设,APB2上挂的是USART1、TIM1、TIM8、SPI1、ADC1/2、GPIO等高速外设。如果你用USART1,它的时钟是PCLK2=72MHz;如果用USART2,时钟是PCLK1=36MHz。很多例程默认用USART1,你改成USART2时如果没注意这个差异,波特率寄存器算出来的值就会错一倍。
注意:STM32F1的定时器还有一个特殊规则——当APB分频系数不为1时,定时器的时钟会是APB时钟的两倍。也就是说TIM2挂在APB1上,PCLK1=36MHz,但TIM2实际计数时钟是72MHz。这个规则在计算定时器预分频值时必须考虑,否则定时时间会差一倍。
2.2 用标准库配置72MHz时钟的完整逻辑
虽然现在HAL库和LL库很流行,但STM32F1的标准外设库依然是很多老项目的首选,而且标准库的时钟配置代码更直观地展示了时钟树的运作过程。下面这段代码是SystemInit函数的核心逻辑,我把它拆开解释每一步在做什么。
// 使能HSE并等待就绪 RCC->CR |= RCC_CR_HSEON; while(!(RCC->CR & RCC_CR_HSERDY)); // 配置Flash预取和等待周期 FLASH->ACR |= FLASH_ACR_PRFTBE; FLASH->ACR &= ~FLASH_ACR_LATENCY; FLASH->ACR |= FLASH_ACR_LATENCY_2; // 72MHz需要2个等待周期 // 配置PLL:HSE作为输入,9倍频 RCC->CFGR &= ~RCC_CFGR_PLLSRC; RCC->CFGR |= RCC_CFGR_PLLSRC; RCC->CFGR &= ~RCC_CFGR_PLLMULL; RCC->CFGR |= RCC_CFGR_PLLMULL9; // 使能PLL并等待锁定 RCC->CR |= RCC_CR_PLLON; while(!(RCC->CR & RCC_CR_PLLRDY)); // 配置AHB、APB1、APB2分频 RCC->CFGR &= ~RCC_CFGR_HPRE; RCC->CFGR |= RCC_CFGR_HPRE_DIV1; // AHB = SYSCLK RCC->CFGR &= ~RCC_CFGR_PPRE1; RCC->CFGR |= RCC_CFGR_PPRE1_DIV2; // APB1 = HCLK/2 RCC->CFGR &= ~RCC_CFGR_PPRE2; RCC->CFGR |= RCC_CFGR_PPRE2_DIV1; // APB2 = HCLK // 切换系统时钟到PLL RCC->CFGR &= ~RCC_CFGR_SW; RCC->CFGR |= RCC_CFGR_SW_PLL; while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);这段代码里有两个地方新手容易忽略。第一是Flash等待周期,72MHz下必须设置2个等待周期,否则取指会出错,表现为程序跑飞或者偶尔死机。第二是切换系统时钟前必须确保目标时钟源已经稳定,代码里用while循环等待HSERDY和PLLRDY就是干这个的。如果你自己写时钟初始化,漏掉任何一个等待环节,都可能出现“大部分时候正常、偶尔启动失败”的玄学问题。
2.3 时钟安全系统与实测中的异常排查
STM32F1有一个时钟安全系统(CSS),当HSE失效时会自动切换到HSI并产生中断。这个功能在工业现场很有用,因为晶振受震动或温度影响可能停振。但CSS默认是关闭的,需要手动使能。我遇到过一块板子在电机启动瞬间程序跑飞,后来查出来是电机干扰导致晶振短暂停振,使能CSS后至少能保证系统不死机,可以在中断里做安全处理。
排查时钟问题有一个很实用的方法:在系统启动后把各个时钟频率通过串口打印出来。标准库提供了RCC_GetClocksFreq函数,可以获取SYSCLK、HCLK、PCLK1、PCLK2的实际值。如果你怀疑时钟配置有问题,先打印这几个值,和预期对比,能快速定位是哪个分频环节出了偏差。另外,用示波器测量MCO引脚(PA8)输出的时钟信号也是验证系统时钟的直观手段,通过配置RCC_CFGR的MCO位可以把SYSCLK或HSE分频后输出到PA8。
3. GPIO的八种模式到底该怎么选
3.1 推挽、开漏、上拉、下拉的实际区别
STM32F1的每个GPIO都有八种可配置模式:输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用、推挽复用。很多教程只列个表格就过去了,但实际项目中选错模式会导致各种奇怪现象。我用几个具体场景来说明。
推挽输出是最常用的模式,它可以主动输出高电平和低电平,驱动能力强,适合直接驱动LED、继电器控制脚等。开漏输出只能主动拉低,高电平需要外部上拉电阻提供,它的好处是可以做电平转换和线与逻辑。比如你用3.3V的STM32去驱动5V的逻辑电路,开漏输出加上拉到5V就能安全实现,推挽输出直接接5V可能会损坏芯片。
输入浮空模式下的引脚阻抗很高,如果外部没有明确的驱动源,引脚电平会随机漂移。我见过一个案例:按键接在浮空输入引脚上,没按的时候读到的值一直在跳,程序里做按键检测时误触发不断。改成输入上拉后问题消失。所以按键输入一般用上拉或下拉模式,具体取决于按键另一端接的是GND还是VCC。
模拟输入模式用于ADC采集,此时引脚的数字输入功能被关闭,信号直接进入模拟通道。如果你用ADC采集但GPIO配成了浮空输入,读出来的值会不准,因为输入施密特触发器还在工作,会对模拟信号产生干扰。
3.2 复用功能与重映射的配置顺序
STM32F1的很多外设引脚可以通过AFIO的重映射功能切换到其他引脚。比如USART1默认在PA9/PA10,但可以重映射到PB6/PB7。重映射的配置有一个严格的顺序要求:必须先使能AFIO时钟,再配置重映射寄存器,最后配置GPIO模式。如果顺序反了,重映射不生效。
// 正确顺序:先开AFIO时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); // 再配置重映射 GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE); // 最后配置GPIO RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_Init(...);还有一个容易踩的坑:JTAG引脚复用。STM32F1的PA13、PA14、PA15、PB3、PB4默认是JTAG调试接口,如果你想把它们当普通GPIO用,需要先关闭JTAG功能。标准库提供了GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)来只保留SWD调试。这个操作在引脚紧张的项目里很常见,但忘记关闭的话,配置GPIO时怎么都不生效。
3.3 输出速度设置对EMI的影响
GPIO输出速度有2MHz、10MHz、50MHz三档。速度越高,引脚翻转时的边沿越陡,EMI辐射也越大。很多新手随手就选50MHz,结果产品做EMC测试时辐射超标。正确的做法是根据实际信号频率选择:LED控制、继电器驱动这类低速信号用2MHz就够了;SPI、USART等中速信号用10MHz;只有需要高速翻转的场合才用50MHz。我在一个工业采集板上把所有GPIO默认设成50MHz,后来整改EMC时逐个降到10MHz和2MHz,辐射余量立刻上来了。
4. 定时器与延时的正确打开方式
4.1 SysTick和通用定时器的分工
STM32F1有一个SysTick系统定时器,属于Cortex-M3内核的一部分,通常用来做系统时基。标准库的delay函数就是基于SysTick实现的毫秒级延时。但SysTick只有一个,如果项目中需要多个不同精度的延时或者需要PWM输出,就得用通用定时器TIM2到TIM5。
SysTick的配置很简单,它有一个24位的递减计数器,时钟源可以是HCLK或HCLK/8。标准库默认用HCLK/8,也就是72MHz/8=9MHz,然后根据要延时的毫秒数计算重装载值。这里有一个细节:SysTick的重装载值最大是2的24次方减一,约1677万,在9MHz时钟下最大延时约1.86秒。如果你需要更长的延时,得在delay函数里循环调用。
通用定时器的功能就丰富多了,除了基本定时,还支持输入捕获、输出比较、PWM、编码器接口等。TIM2到TIM5是通用定时器,TIM1和TIM8是高级定时器,支持互补输出和死区插入,适合驱动电机。TIM6和TIM7是基本定时器,只能做定时,没有输入输出通道。
4.2 定时器预分频和重装载值的计算
定时器的定时时间由三个参数决定:时钟频率、预分频系数(PSC)、重装载值(ARR)。公式是:定时时间 = (PSC+1) × (ARR+1) / 定时器时钟频率。注意PSC和ARR都要加一,因为寄存器值是从0开始计数的。
假设TIM2的时钟是72MHz,我想定时1毫秒。先选一个合适的预分频系数,比如72-1,这样定时器计数时钟变成72MHz/72=1MHz,每个计数周期1微秒。然后ARR设为1000-1,总共计数1000次,就是1毫秒。这个计算过程看起来简单,但实际配置时容易忘记定时器时钟是APB时钟的两倍这个规则。如果你以为TIM2的时钟是36MHz,算出来的定时时间就会差一倍。
void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); }4.3 中断优先级分组对实时性的影响
Cortex-M3的中断优先级分为抢占优先级和响应优先级,STM32F1支持5种分组方式。分组决定了多少个位用于抢占优先级、多少个位用于响应优先级。抢占优先级高的中断可以打断正在执行的低抢占优先级中断,响应优先级只在同时挂起时决定谁先执行,不能打断。
很多项目里中断响应不及时,根源就是优先级分组没设对。比如串口接收中断和定时器中断同时存在,如果串口中断的抢占优先级低于定时器中断,而定时器中断服务程序又比较长,串口数据就会丢失。我的习惯是在系统初始化时统一设置分组为NVIC_PriorityGroup_2,即2位抢占优先级、2位响应优先级,这样抢占优先级有0到3共4级,响应优先级也有4级,对大多数项目够用了。
提示:中断服务程序里尽量不要做耗时操作,比如浮点运算、长循环、串口打印。如果确实需要处理大量数据,用标志位在主循环里处理,中断里只做数据搬运和标志置位。
5. 串口通信从能用到好用
5.1 波特率误差的来源与容忍范围
串口通信对波特率误差有一定容忍度,一般要求在2%以内,超过这个范围就可能出现误码。STM32F1的USART波特率寄存器是一个16位的整数部分加4位小数部分,计算公式是:USARTDIV = 外设时钟 / (16 × 波特率)。以USART1、PCLK2=72MHz、波特率115200为例,USARTDIV = 72000000 / (16 × 115200) = 39.0625。整数部分是39,小数部分是0.0625×16=1,所以寄存器值应该是39.1。实际写入时整数部分39,小数部分1。
如果外设时钟不是72MHz而是36MHz,USARTDIV = 36000000 / (16 × 115200) = 19.53125,整数19,小数0.53125×16=8.5,取整后误差就大了。所以用USART2时如果没注意时钟差异,115200的波特率误差可能超过容忍范围,导致通信不稳定。
5.2 接收中断与空闲中断的配合使用
串口接收不定长数据是一个经典问题。常见做法是用接收中断,每收到一个字节进一次中断,把数据存入缓冲区。但这样每字节一次中断,在高波特率下CPU开销很大。更好的方案是配合DMA和空闲中断:DMA负责把接收到的数据自动搬到缓冲区,空闲中断在总线空闲时触发,表示一帧数据接收完毕。
STM32F1的USART支持空闲中断,当RX线上检测到一个字节时间的空闲电平时触发。配置步骤是:使能USART的DMA接收请求,配置DMA通道从USART数据寄存器搬到内存缓冲区,然后使能空闲中断。在空闲中断服务程序里,读取DMA剩余传输数量就能知道这一帧收了多少字节,处理完后重新设置DMA计数器准备下一帧。
void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 读DR清空闲标志 u16 len = BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // len就是本帧接收到的字节数 ProcessFrame(rxBuffer, len); // 重新配置DMA准备下一帧 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }这个方案我在多个工业采集项目里用过,115200波特率下连续接收几百字节不丢数据,CPU占用率极低。
5.3 printf重定向与调试输出
把printf重定向到串口是调试利器,但标准库的printf默认输出到调试器,需要重写fputc函数。在Keil MDK环境下,还需要勾选Use MicroLIB,否则printf可能无法正常工作。重定向代码如下:
int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }这里用TC标志而不是TXE标志,是因为TXE只表示发送数据寄存器空,但移位寄存器可能还在发送。用TC能确保上一个字节完全发完再发下一个,避免输出乱序。不过用TC会稍微降低吞吐量,如果对速度要求高可以用TXE加适当延时。
6. DHT11温湿度传感器的单总线驱动实战
6.1 单总线时序的微秒级要求
DHT11是一款经典的温湿度传感器,它用一根数据线完成通信,协议是单总线。虽然接线简单,但时序要求比较严格,特别是微秒级的延时必须准确。DHT11的通信过程是:主机拉低总线至少18毫秒作为起始信号,然后释放总线,DHT11响应拉低80微秒、再拉高80微秒,之后开始传输40位数据。每一位数据以50微秒低电平开始,然后高电平持续26到28微秒表示0,持续70微秒表示1。
问题来了:STM32F1用SysTick做微秒级延时时,如果系统主频是72MHz,一个微秒是72个时钟周期。用循环做微秒延时需要精确计算循环次数,而且编译器优化等级不同,循环被优化掉的风险很大。我的做法是用定时器做微秒延时,或者用DWT(数据观察点与跟踪)单元里的CYCCNT计数器,它能精确计数CPU周期,不受中断影响。
// 用DWT实现微秒延时 void DWT_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t cycles = us * (SystemCoreClock / 1000000); while((DWT->CYCCNT - start) < cycles); }DWT需要先使能:在CoreDebug->DEMCR寄存器里置位TRCENA,然后清零DWT->CYCCNT,最后使能DWT->CTRL的CYCCNTENA位。这个延时方法精度很高,而且不受编译器优化影响。
6.2 读取DHT11的完整流程与校验
DHT11的数据格式是:8位湿度整数、8位湿度小数(通常为0)、8位温度整数、8位温度小数(通常为0)、8位校验和。校验和等于前四个字节相加的低8位。读取流程如下:
- 主机拉低总线至少18ms,然后拉高20-40us,切换到输入模式等待DHT11响应。
- 等待DHT11拉低80us,再拉高80us,之后进入数据传输。
- 循环40次,每次等待50us低电平结束,然后延时30us左右采样总线电平,高电平为1,低电平为0。
- 收完40位后释放总线,DHT11会自动拉低50us再释放。
- 校验数据,如果校验和不匹配则丢弃本次数据。
实际调试时最容易出问题的地方是等待响应信号。如果DHT11没有接上拉电阻,总线在释放后可能无法回到高电平,导致一直等待超时。DHT11的数据线需要接一个4.7k到10k的上拉电阻到VCC。另外,DHT11的采样周期不能太快,两次读取之间至少间隔1秒,否则读出来的数据可能不更新。
6.3 在FreeRTOS任务中读取DHT11的注意事项
如果项目用了FreeRTOS,在任务里读取DHT11要特别注意临界区保护。因为DHT11的时序是微秒级的,如果读取过程中被任务调度打断,时序就乱了。解决办法是在读取DHT11的整个过程中关闭任务调度器,或者把DHT11读取放在高优先级任务里,并确保没有其他高优先级任务会抢占。
void DHT11_Task(void *pvParameters) { while(1) { taskENTER_CRITICAL(); // 进入临界区,关闭中断 DHT11_Read(&temp, &humi); taskEXIT_CRITICAL(); // 退出临界区 vTaskDelay(pdMS_TO_TICKS(2000)); } }不过关闭中断会影响系统实时性,如果DHT11读取耗时较长(完整读取约4-5毫秒),可以考虑只关闭任务调度而不关中断,用vTaskSuspendAll和xTaskResumeAll。但这样如果中断服务程序里有时序敏感操作,还是可能干扰。最稳妥的方案是用硬件定时器配合状态机来解码DHT11,把微秒级时序交给硬件处理,CPU只负责状态切换。
7. ADC采集中的精度陷阱与软件滤波
7.1 参考电压与采样时间的选择
STM32F1的ADC是12位逐次逼近型,参考电压通常接VDDA,也就是3.3V。理论分辨率是3.3V/4096≈0.8mV。但实际精度受很多因素影响,其中采样时间是一个关键参数。ADC的采样时间可配置为1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5个ADC时钟周期。采样时间越长,采样电容充电越充分,转换结果越准确,但转换速度越慢。
如果被测信号源内阻较大,比如用电阻分压测量电池电压,分压电阻取值较大时,必须用较长的采样时间。我见过一个案例:用100k和100k分压测12V电池,ADC配置成1.5周期采样,读出来的值一直偏低且跳动。后来把采样时间改成239.5周期,读数立刻稳定了。原因是分压电阻和ADC采样电容构成了RC充电电路,时间常数太大,短采样时间下电容还没充到稳定值就开始转换了。
7.2 多重采样与中值滤波的代码实现
即使采样时间设置合理,ADC读数仍然会有几个LSB的跳动,这是正常现象。在工业采集场景里,通常需要做软件滤波。最简单的是多次采样取平均,但平均对脉冲干扰的抑制效果不好。更好的方案是中值滤波加平均:连续采样N次,去掉最大值和最小值,剩下的取平均。
#define SAMPLE_COUNT 11 u16 ADC_GetFilteredValue(u8 channel) { u16 samples[SAMPLE_COUNT]; u16 temp; u32 sum = 0; u8 i, j; for(i = 0; i < SAMPLE_COUNT; i++) { samples[i] = ADC_GetConversionValue(channel); } // 冒泡排序 for(i = 0; i < SAMPLE_COUNT - 1; i++) { for(j = 0; j < SAMPLE_COUNT - 1 - i; j++) { if(samples[j] > samples[j+1]) { temp = samples[j]; samples[j] = samples[j+1]; samples[j+1] = temp; } } } // 去掉最大最小各两个,中间7个取平均 for(i = 2; i < SAMPLE_COUNT - 2; i++) { sum += samples[i]; } return sum / (SAMPLE_COUNT - 4); }这个滤波方案在温度采集、电压监测等场景里效果很好,能把跳动从十几个LSB压到两三个LSB以内。代价是需要采样11次,转换时间乘以11,对采样率要求高的场合要权衡。
7.3 ADC与DMA配合实现自动扫描
当需要同时采集多个通道时,用DMA自动搬运ADC数据可以大大减轻CPU负担。配置步骤是:把ADC设置成扫描模式,配置规则组的通道顺序和采样时间,使能ADC的DMA请求,配置DMA通道从ADC数据寄存器搬到内存数组。这样ADC按顺序转换完所有通道后,DMA自动把结果存到数组里,CPU只需要在需要的时候读数组就行。
// ADC多通道DMA配置核心代码 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 = 3; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_239Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE);DMA配置时要注意数据宽度匹配:ADC数据寄存器是16位的,内存数组也要定义成u16类型,DMA外设数据宽度和内存数据宽度都设成HalfWord。另外DMA模式要设成循环模式,这样ADC连续转换时DMA会自动覆盖旧数据,不会因为传输完成而停止。
8. 低功耗设计与调试接口的取舍
8.1 睡眠、停止、待机三种模式的唤醒源
STM32F1支持三种低功耗模式:睡眠、停止、待机。睡眠模式只关闭CPU时钟,外设继续运行,任何中断都能唤醒,功耗降低有限。停止模式关闭所有时钟,保留SRAM和寄存器内容,功耗降到几十微安,可以通过外部中断、RTC闹钟、独立看门狗等唤醒。待机模式功耗最低,只有几微安,但SRAM内容丢失,唤醒后相当于复位,只有WKUP引脚、RTC闹钟、独立看门狗能唤醒。
选择哪种模式取决于应用场景。如果是电池供电的采集节点,每隔几分钟采集一次数据然后休眠,用停止模式比较合适,因为唤醒后不需要重新初始化外设,程序从休眠处继续执行。如果是长期待机的设备,偶尔被按键唤醒,可以用待机模式,但唤醒后要重新走完整初始化流程。
进入停止模式的代码很简单:调用PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI)。唤醒后系统时钟会自动切回HSI,需要重新配置时钟到72MHz。这个细节很容易被忽略,导致唤醒后串口波特率不对、定时器变慢。我的做法是在唤醒后立即调用SystemInit重新配置时钟,然后再继续执行。
8.2 调试接口对低功耗的影响
在调试低功耗项目时,SWD调试接口本身会消耗电流。如果测量到的休眠电流比数据手册标称值大很多,先检查调试器是否还连着。断开调试器后电流通常会降下来。另外,GPIO在休眠前要配置成合适的状态:悬空的输入引脚会导致输入级振荡耗电,应该配置成模拟输入或者带上拉/下拉的输入;输出引脚要根据外部电路决定输出高还是低,避免形成电流通路。
我做过一个温湿度采集节点,用DHT11加STM32F103C8T6,两节AA电池供电。最初休眠电流测出来有2mA,远高于预期的几十微安。排查后发现三个问题:一是调试器没拔,二是DHT11数据引脚在休眠时仍被驱动为高电平,三是有一个LED指示灯忘了关。逐个解决后休眠电流降到30微安左右,两节电池理论上能撑一年多。
8.3 看门狗与低功耗的配合
独立看门狗(IWDG)由LSI驱动,在停止模式下仍然运行,可以用来定时唤醒或者作为系统保护。但IWDG一旦启动就不能关闭,只能通过复位来停止。如果项目需要在低功耗和看门狗之间灵活切换,可以用窗口看门狗(WWDG),它由APB1时钟驱动,停止模式下会停止计数。
在停止模式下用IWDG唤醒时,要注意IWDG的超时时间由LSI频率决定,而LSI频率在30kHz到60kHz之间波动,精度不高。如果对唤醒时间精度要求高,还是用RTC闹钟更靠谱。RTC可以由LSE驱动,32.768kHz晶振精度高得多,配合预分频器可以实现精确的秒级、分钟级定时。
9. 从工程模板到量产固件的几个经验点
9.1 启动文件与链接脚本的匹配
STM32F1的启动文件有多个版本,对应不同容量的芯片。startup_stm32f10x_ld.s对应小容量(16-32KB Flash),md对应中容量(64-128KB),hd对应大容量(256-512KB),xl对应超大容量(768KB-1MB)。选错启动文件会导致栈顶地址和中断向量表位置不对,程序可能下载后不运行或者一进中断就死。
链接脚本里的ROM和RAM大小也要和实际芯片匹配。STM32F103C8T6有64KB Flash和20KB RAM,如果链接脚本里写的RAM是64KB,编译能过但运行时变量会跑到不存在的地址,表现为随机死机。在Keil里可以在Target选项卡里设置ROM和RAM的起始地址和大小,IAR则在.icf文件里配置。
9.2 固件升级方案的选择
量产产品通常需要预留固件升级接口。STM32F1支持在应用编程(IAP),通过串口、CAN、USB等接口接收新固件并写入Flash。实现IAP需要把Flash分成Bootloader区和应用程序区,Bootloader负责接收和烧写,应用程序通过修改中断向量表偏移来运行。
中断向量表偏移通过SCB->VTOR寄存器设置,应用程序在main函数开头调用NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x8000)把向量表指向自己的起始地址。这个偏移量必须是0x200的整数倍,因为Cortex-M3要求向量表对齐。Bootloader跳转到应用程序前要关闭所有中断和外设,设置主栈指针为应用程序向量表的第一个字,然后跳转到第二个字指向的复位处理函数。
9.3 批量生产时的选项字节配置
STM32F1的选项字节控制着读写保护、硬件看门狗、复位后启动模式等。批量生产时通常需要设置读保护防止固件被读取,但读保护一旦设置,再次烧写就需要先解除保护,而解除保护会自动擦除整个Flash。所以产线流程要设计好:先烧写固件,再设置读保护,后续如果要返修升级,得先解除保护再重新烧写。
写保护可以按页设置,把Bootloader所在的页设为写保护,防止应用程序意外擦除Bootloader。选项字节的编程可以通过ST-Link Utility或者在自己的Bootloader里用Flash编程接口实现。需要注意的是,选项字节的修改需要先解锁Flash,修改后系统复位才生效。
10. 个人在实际项目中的几点体会
STM32F1这颗芯片我用了很多年,从学生时代的课程设计到后来的工业产品,它几乎陪伴了我整个嵌入式开发生涯。如果说有什么最深的体会,那就是“基础不牢,地动山摇”。时钟树、中断优先级、GPIO模式这些看似基础的东西,一旦理解不到位,后面做再复杂的功能都是在沙子上盖楼。我见过太多人急着跑通例程、急着做项目,结果遇到问题只能靠猜和试,效率极低。
另一个体会是,不要迷信“最新”的库。HAL库抽象程度高,跨系列移植方便,但在STM32F1这种资源有限的芯片上,标准库的代码效率和直观性反而更好。很多老工程师坚持用标准库不是守旧,而是因为标准库的每一行代码你都能看到它在操作哪个寄存器,出了问题能直接定位到硬件层面。当然,新项目用LL库也是不错的选择,它在抽象和效率之间取得了比较好的平衡。
最后说一个调试习惯:手边常备一个逻辑分析仪。串口通信、I2C、SPI、单总线这些时序相关的问题,用逻辑分析仪抓一下波形,比盯着代码看半天有效得多。一个几十块钱的八通道逻辑分析仪,配合开源软件,能解决嵌入式开发中大半的通信问题。我甚至用它抓过DHT11的时序,一眼就看出是延时不够导致采样点落在了电平跳变沿上。工具不贵,但能省下的时间远超它的价格。