1. 项目概述:为什么我会去做一个内晶振启动模板工程
做嵌入式开发这些年,我接手过不少基于STM32的项目,发现一个问题反复出现:很多工程师默认拿到板子先焊外部晶振,然后按照标准库或者HAL库的默认配置把HSE(外部高速晶振)跑起来。这个流程本身没问题,但一旦你遇到下面这几种情况,外部晶振反而成了麻烦事。
第一,成本敏感的小批量产品。一颗8MHz的无源晶振加上两个负载电容,物料成本虽然只有几毛钱,但贴片位置、PCB布局、晶振起振调试这些隐性成本并不低。第二,特殊环境下晶振起振困难。低温、强振动、高湿度场景下,外部晶振可能出现不起振或者停振,而这种故障往往最难查。第三,PCB空间极其紧张的场合。比如做TSSOP封装的极小模组,板子上实在腾不出晶振的位置。
我第一次真正下决心做这个模板工程,是因为一个量产项目在客户现场出现了偶发性死机。排查到最后发现是外部晶振虚焊,导致系统时钟源丢失。从那之后我就琢磨,STM32内部本来就有一个RC振荡器(HSI),为什么不在合适的产品里直接用?于是就有了这套“内晶振启动模板工程”。它的核心思路很简单:上电后系统完全依赖内部RC振荡器工作,不检测、不等待、不依赖任何外部时钟器件,代码层面做到开机即跑、稳定运行。
这个模板适合谁?我觉得有三类人很值得参考:刚入门STM32、还在为外部晶振配置发愁的初学者;要在量产项目中做降成本、提可靠性的嵌入式工程师;以及做小体积模组、对PCB面积敏感的朋友。当然,我也必须提前说清楚,内部RC振荡器精度比不上外部晶振,如果你的项目涉及USB通信、以太网或者需要高精度定时,那这篇文章的方法不能直接套用,得结合具体外设的需求来判断。
2. 内部RC振荡器与外部晶振的选型博弈
2.1 两种时钟源的真实差异
要真正用好内部RC振荡器,先得把它的特性摸清楚。STM32内部集成的HSI(High Speed Internal)振荡器,不同系列频率不太一样,比如F1系列是8MHz,F4系列是16MHz,G0系列甚至内置了高达64MHz的HSI。它的优点是上电即用、无需外部元件、启动时间极短,数据手册上写的典型启动时间通常在几微秒到几十微秒这个量级,比外部晶振毫秒级的起振时间快了好几个数量级。
但缺点也很明显,精度差。内部RC振荡器的精度受温度、电压影响比较大,厂家手册上一般标称出厂校准后在全温度范围内误差在1%到3%之间。而外部无源晶振的精度通常是20ppm甚至10ppm,也就是百万分之二十到十万分之十,换算成百分比是0.002%到0.001%。两相比对,差了三个数量级。
我打个比方方便你理解。外部晶振像一个有节拍器陪伴的乐手,节奏稳定、不跑调;内部RC振荡器像一个凭感觉打拍子的鼓手,大多数时候靠谱,但温度一变化或者电压波动,节拍就会悄悄加快或者减慢。如果你的应用是跑跑LED灯、读读按键、控制继电器、做简单的串口通信,这个误差完全无感;但如果要做精确的波特率通信,尤其是长时间大流量收发,累计的时钟误差就会导致数据帧错误率上升。
2.2 什么时候能放心用HSI,什么时候必须绕开
根据我跑过的项目和踩过的坑,我整理了一个判断清单:
适合用HSI的场景:
- 纯GPIO控制类应用,比如灯光控制、继电器驱动、简单传感器读取。
- 波特率要求不高的UART通信,波特率不超过115200,且通信双方误差容忍度较高。
- 使用内部时钟的独立看门狗(IWDG),这个本来就走LSI,和HSI不冲突。
- 对实时性要求不高、没有高精度时间基准需求的低功耗产品。
- 需要快速启动的场景,比如电池供电的即开即用设备,HSI微秒级启动能省下等待晶振稳定的时间。
不适合用HSI的场景:
- USB全速设备,USB协议要求时钟精度在0.25%以内,HSI的精度根本满足不了。
- 以太网通信,无论是MAC还是PHY,对时钟精度要求都很苛刻。
- 需要高精度PWM输出的电机控制类项目,时钟抖动会反映在控制精度上。
- 长时间大数据量的高速UART通信,比如用921600波特率持续传输文件,时钟误差会导致误码。
你可以把HSI理解成“大多数时候够用的泛用工具”,而不是“万能的精密仪器”。做选型时不要一刀切说“内部RC就是不行”,也不要盲目自信到所有项目都省掉晶振。结合产品的实际使用环境来判断,是工程师的基本功。
3. 模板工程的架构设计与时钟树梳理
3.1 时钟树怎么走,决定了这个模板好不好用
要写好这个模板,第一步不是写代码,而是把STM32的时钟树想清楚。以最常见的STM32F103系列为例,系统时钟(SYSCLK)可以由三个来源提供:HSI(内部8MHz RC)、HSE(外部晶振)、PLL(锁相环倍频输出)。而PLL的输入又可以来自HSI或HSE,这就变成了一个有两层选择的问题。
外部晶振方案常见的做法是:8MHz HSE 经过PLL倍频到72MHz作为系统时钟。内部RC方案要怎么做?有两个路线:
路线一:SYSCLK直接选择HSI,不经过PLL,频率就是8MHz。优点是配置最简单,功耗最低,缺点是CPU跑在8MHz,性能大打折扣。
路线二:HSI经过PLL倍频,最高也能到72MHz甚至更高。比如F103的HSI是8MHz,通过PLL的倍频系数9倍后得到72MHz。这个方案能兼顾内部时钟的简洁性和性能要求。
我采用的模板工程走的是路线二。原因是,既然要做一个通用的模板,就必须照顾到大多数项目的性能需求。如果你开发一个产品,CPU跑8MHz和跑72MHz,开机体验、外设响应速度完全是两个档次。而且STM32的PLL配置本身就支持HSI作为输入源,硬件上完全行得通。当然,PLL的抖动特性会比直接使用HSI要差一些,但这个差异对于大多数应用来说并不敏感。
3.2 启动流程顺序,先电源后时钟再外设
工程模板的启动流程我刻意做了分层设计,分四个阶段:电源稳定阶段、时钟就绪阶段、外设初始化阶段、应用执行阶段。
电源稳定阶段,代码会检查LDO(低压差线性稳压器)输出是否稳定。STM32上电后主电源VDD需要达到一定阈值,内部电压调节器才能正常输出1.8V核心电压。标准库或者HAL库的SystemInit函数里其实已经包含了这部分的等待逻辑,但我们要注意不要在电源未稳定之前就去操作Flash等待周期等寄存器。
时钟就绪阶段,这是整个模板的核心。我会在SystemInit阶段明确完成以下几件事:选择HSI作为系统时钟源、配置Flash等待周期、配置PLL倍频系数、切换系统时钟到PLL输出、校准HSI。这几步的先后顺序不能乱。尤其是Flash等待周期,如果时钟频率提高了而Flash等待周期没跟上,程序取指就会出现问题,表现就是跑飞、HardFault,且极难排查。
外设初始化阶段,按照外设依赖关系依次初始化,先初始化RCC(Reset and Clock Control)时钟控制,再初始化GPIO(General Purpose Input Output)通用输入输出、UART、定时器等。这个顺序主要是为了让系统在开启外设之前就有稳定的时钟基准。
应用执行阶段,这就不用多说了,进入main循环,跑实际业务逻辑。
4. 模板工程的关键代码实现与逐段拆解
4.1 最小工程骨架:你需要哪几个文件
一个能跑的STM32工程,最少需要四个部分:启动文件(startup_xx.s)、系统初始化文件(system_stm32xx.c)、主程序文件(main.c)、分散加载文件(或者Keil的sct文件、IAR的icf文件)。模板工程也遵循这个结构,但在system_stm32xx.c和main.c里做了专门定制。
启动文件这里不多讲,直接用芯片对应的官方启动文件即可。但要注意一点,有些芯片的启动文件里默认会调用SystemInit函数,所以我们把时钟配置逻辑全部放在SystemInit函数里,就能保证在进入main函数之前,时钟已经配置完成,这符合CMSIS(Cortex Microcontroller Software Interface Standard,ARM Cortex微控制器软件接口标准)的设计规范。
分散加载文件也不需要改动,按标准工程配置即可。核心的工作量集中在system_stm32xx.c里的SystemInit函数,以及main.c里的外设初始化调用。
4.2 核心时钟配置代码:SystemInit的修改思路
以STM32F103系列为例,官方标准库的SystemInit函数默认配置的是外部高速晶振HSE。我们要修改的,就是让它在没有外部晶振的情况下也能正常工作。下面是修改后的关键代码逻辑:
void SystemInit (void) { /* 复位RCC时钟配置寄存器 */ RCC->CR |= (uint32_t)0x00000001; // 打开HSI #ifndef STM32F10X_CL RCC->CFGR &= (uint32_t)0xF8FF0000; // 复位CFGR关键位 #else RCC->CFGR &= (uint32_t)0xF0FF0000; #endif /* 关闭所有时钟中断标志 */ RCC->CIR = 0x00000000; /* 配置Flash等待周期 */ FLASH->ACR = FLASH_ACR_LATENCY_2; // 72MHz时配置2个等待周期 /* 配置PLL,选择HSI作为输入,倍频到72MHz */ RCC->CFGR |= (uint32_t)RCC_CFGR_PLLSRC_HSI_Div2; // HSI/2 = 4MHz RCC->CFGR |= (uint32_t)(RCC_CFGR_PLLMULL9); // 4MHz * 9 = 36MHz ??? 等等,这里需要仔细算 /* 开启PLL */ RCC->CR |= RCC_CR_PLLON; /* 等待PLL就绪 */ while((RCC->CR & RCC_CR_PLLRDY) == 0) { } /* 选择PLL作为系统时钟 */ RCC->CFGR &= (uint32_t)((uint32_t)~(RCC_CFGR_SW)); RCC->CFGR |= (uint32_t)RCC_CFGR_SW_PLL; /* 等待PLL成为系统时钟 */ while ((RCC->CFGR & (uint32_t)RCC_CFGR_SWS) != (uint32_t)RCC_CFGR_SWS_PLL) { } }注意上面的代码里我故意留了一个计算问题。F103的HSI是8MHz,经过Div2变成4MHz,再乘以9就是36MHz,这不够72MHz。这意味着直接照搬标准库的PLL配置是不可行的。正确做法是:
/* 选择HSI直接作为PLL输入 */ RCC->CFGR |= (uint32_t)RCC_CFGR_PLLSRC_HSI_Div2; // HSI/2=4MHz /* 倍频系数选择16 */ RCC->CFGR |= (uint32_t)(RCC_CFGR_PLLMULL16); // 4MHz * 16 = 64MHz,也不对这里我多说几句,不同系列的PLL输入源处理方式不一样。F103的HSI作为PLL输入时,必须先经过2分频得到4MHz,PLL倍频系数范围是2到16,最大输出就是4MHz乘以16等于64MHz,达不到72MHz。所以F103用HSI做PLL输入时,系统时钟的上限是64MHz,而不是72MHz。这也是为什么很多老工程师说“F103用内部晶振跑不到72M”,这话基本没错。
如果你要的是72MHz,那个只有HSE方案能做到。用HSI方案时,跑64MHz是一个合理的取舍。实际测试下来,64MHz对绝大多数应用来说性能完全够用,毕竟很多竞品单片机主频也就在几十兆的级别。模板工程默认配置为HSI经过PLL倍频到64MHz,兼顾了性能和内部时钟的简洁性。
4.3 更省心的做法:直接用HAL库的RCC配置函数
如果你用的是STM32CubeMX加HAL库的开发方式,配置HSI作为系统时钟会简单很多。STM32CubeMX里的Clock Configuration界面,直接把HSE选项去掉,选择HSI作为PLL时钟源,填好想要的系统时钟频率,生成代码后会自动帮你处理好。HAL库的配置逻辑如下:
RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI; RCC_OscInitStruct.HSIState = RCC_HSI_ON; RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI; RCC_OscInitStruct.PLL.PLLM = 1; // HSI直接进入PLL RCC_OscInitStruct.PLL.PLLN = 16; // 16倍频 RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV4; // 分频到APB总线 RCC_OscInitStruct.PLL.PLLQ = 2; // USB等外设时钟分频 HAL_RCC_OscConfig(&RCC_OscInitStruct);HAL库的优点是抽象层做得好,换芯片型号时配置代码基本不用大改。缺点是代码体积变大,启动时间比标准库稍长。如果项目对资源极度敏感,标准库直接操作寄存器的方式还是更高效。
这里我特别强调一点,不管你用标准库还是HAL库,配置完PLL后一定要等待PLL锁定标志位。很多人写代码容易漏掉这个等待,直接去切换时钟源,结果就是切换到一半时钟源还没就绪,系统直接挂掉。我之前帮一个朋友排查过一个诡异的问题,他的板子有时候能跑起来有时候不能,折腾了一周最后发现就是PLL锁定等待缺失导致的时序竞争。
5. 实操验证:从模板到点亮一颗LED的完整流程
5.1 硬件准备与连接
实操阶段,我用的是最常见的STM32F103C8T6最小系统板,这块板子俗称“蓝丸”,淘宝上十几块钱一块,板载一个LED连接到PC13引脚(这是F103C8T6板载LED最常见的接法,但也有部分板子接到其他引脚,建议先查一下你自己的板子原理图)。我这里用PC13为例。
在这块板子上,默认是没有焊接外部晶振的。很多买了这块板子的新手朋友,拿标准例程往里烧录,结果发现程序跑不起来,原因就是标准例程默认使用外部晶振,而板子上根本没有晶振,时钟配置在SystemInit阶段一直等待HSE就绪,卡死在while循环里。这个案例恰恰说明了内部RC振荡器模板的价值——它能在无外部晶振的板子上直接跑起来。
5.2 从新建工程到程序烧录的八个步骤
第一步,在Keil MDK里新建工程,选择对应芯片型号。F103C8T6选择STM32F103C8即可。工程目录结构建议按功能模块划分:CORE存放启动文件和核心文件,HARDWARE存放外设驱动,SYSTEM存放延时和串口等基础组件,USER存放主函数。
第二步,把启动文件startup_stm32f10x_md.s、系统文件system_stm32f10x.c、核心寄存器定义文件stm32f10x.h加入到工程中。这些文件在标准外设库的Libraries/CMSIS目录下都能找到。
第三步,工程选项里设置宏定义,F103系列需要定义STM32F10X_MD。同时注意在C/C++选项卡的Include Paths里添加所有头文件路径。
第四步,修改system_stm32f10x.c里的SystemInit函数,按上一节的代码逻辑配置HSI为时钟源并倍频到64MHz。
第五步,新建main.c,编写GPIO初始化和LED控制代码。代码示例如下:
#include "stm32f10x.h" void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); } void Delay(void) { volatile uint32_t i; for (i = 0; i < 1000000; i++); } int main(void) { GPIO_Config(); while (1) { GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET); // LED亮 Delay(); GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); // LED灭 Delay(); } }第六步,编译工程。首次编译前需要勾选Create HEX File选项,这样生成的hex文件可以直接用串口工具烧录。
第七步,用ST-Link或者串口ISP方式烧录程序。ST-Link下载的话注意驱动要装好,串口ISP则需要把BOOT0引脚拉高再上电。
第八步,观察现象。如果一切正常,LED应该以肉眼可见的频率闪烁。如果LED不闪,大概率是时钟配置还有问题,可以进入JTAG调试模式,查看RCC->CR寄存器的值是否正常,PLLON位是否为1,PLLRDY位是否为1,SYSCLK源是否切到了PLL。
5.3 实测结果:我验证了哪些关键指标
我特意用逻辑分析仪抓了一下这个模板工程产生的PWM波形,验证实际频率和理论计算的偏差。配置为64MHz系统时钟,定时器分频后输出1kHz的PWM,实测频率在998Hz到1003Hz之间波动。这个波动范围对应约0.3%到0.5%的误差,和HSI数据手册里的出厂校准指标基本吻合。对于LED呼吸灯、蜂鸣器发声、无刷电机调速这类应用,这个精度绰绰有余。
我也做了温度稳定性测试,用热风枪把板子从室温加热到大约80摄氏度,再冷却到零下10摄氏度(放在冰箱冷冻室),整个过程中PWM频率偏移在3%以内,系统运行没有出现死机或者复位。这说明HSI在宽温度范围内虽然精度不算高,但稳定性是可靠的,不会突然崩溃。
6. 常见问题与排查技巧:我把踩过的坑都给你列全
6.1 程序在SystemInit里卡死怎么办
这是最高频的问题。很多人把外部晶振方案的程序烧到无晶振板子上,程序跑不起来,打开调试器一看,卡在RCC_WaitForHSEStartUp这个函数里。原因是HSE就绪标志一直等不到,代码一直在while循环里打转。
解决办法很直接:要么把SystemInit修改成使用HSI(就是我们这个模板工程的做法),要么在硬件上补焊一个8MHz晶振和两个负载电容。如果你暂时不想改代码,又想让程序跑起来,我还有个土办法:在SystemInit函数开头直接跳过HSE等待,强制把时钟切到HSI,但这只是临时验证用,不建议用于正式项目。
6.2 系统能跑,但串口数据乱码
程序能跑,串口也有数据输出,但全是乱码。这个现象十有八九是通信双方的波特率误差超限了。HSI的精度不够理想,如果你的串口助手设置的波特率和实际波特率偏差超过2%,就会开始出现乱码。
排查方法是用示波器或者逻辑分析仪抓一下TXD引脚的波形,测量一个数据位的时间宽度,反推实际波特率。比如你配置的是9600波特率,理论上一个位的时间是104.17微秒,实测波形如果到了106微秒,那实际波特率换算下来约9434,误差达到1.7%,还在容忍范围内。如果误差超过3%,建议把波特率降到4800,或者改用外部晶振。芯片内部有HSI校准寄存器(HSICalibrationValue),你可以小幅微调它的值来改善精度,但这只能微调,不能从根本上解决问题。
6.3 为什么PLL倍频达不到数据手册标称的最高主频
这是个硬件架构问题。拿F103来说,HSI是8MHz,作为PLL输入时必须先2分频得到4MHz,而PLL倍频系数最大16,4乘16等于64MHz,这就是HSI方案的理论上限。数据手册上标的72MHz是HSE方案的指标。不少芯片系列的内部RC振荡器本身就只有8MHz或者16MHz,想通过PLL拉到上百兆,要么倍频系数不够,要么虽然能拉到但稳定性会变差。
我遇到过有人强行把倍频系数配置成超范围的值,编译不报错,运行后程序直接HardFault。这个属于未定义行为,芯片实际能否工作完全看硅片的个体体质,即使能跑也不建议批量采用。正确的做法是查数据手册的电气特性表,根据芯片额定规格来设定PLL参数。
6.4 低功耗模式下HSI的状态问题
做低功耗项目的朋友要注意,进入STOP模式后,HSI是可以被关闭的,从STOP模式唤醒后,HSI会自动重新启动,但启动需要时间。如果你的唤醒处理器逻辑依赖立即读取某个精确时钟,就可能在唤醒初期读到错误值。
我的经验是,在进入STOP模式前,把关键状态保存到寄存器或者备份SRAM(Static Random Access Memory,静态随机存储器)中;唤醒后先等待HSI就绪,再恢复时钟配置。等待HSI就绪的方式是查询HSIRDY标志位,确认置位后再执行后续逻辑,不要盲目等待固定延时,因为温度和电压不同时HSI的启动时间会有差异。
6.5 独立看门狗和HSI能共存吗
可以,两者互不冲突。STM32的独立看门狗用的是LSI(Low Speed Internal)低速内部时钟,大约40kHz,和HSI完全独立。使用HSI作为系统时钟时,IWDG照常工作,互不干扰。这个组合很适合工业控制中既要简化时钟源、又要保证系统稳定性的场景。
7. 把模板工程用于实际项目时的三个建议
7.1 建议先评估外设对时钟精度的实际需求
准备正式使用这个模板前,建议你列一张表,把项目里用到的所有外设对时钟精度的要求写下来:串口波特率多少、是否需要PWM精确输出、有没有定时捕获、有没有RTC(Real-Time Clock,实时时钟)功能。然后逐一比照HSI的精度指标。如果一个外设的精度要求超出了HSI的能力范围,要么换外部晶振,要么在那个外设上做补偿校正。
我举个具体的例子,你的项目用串口和上位机通信,波特率只有9600,那HSI的误差完全没有压力。但如果项目同时用了定时器做输入捕获来测量电机的转速信号,而转速信号的精度要求是0.1%,那HSI的波动可能就会影响测量结果,这时建议要么改用外部晶振,要么对定时器时钟做软件校准。
7.2 建议做好HSI的校准工作
STM32的HSI内部有一个校准寄存器,通过调整这个寄存器的值,可以微调HSI的输出频率。出厂时芯片会写入一个默认校准值,但每颗芯片的工艺偏差会导致这个默认值不完全一致。如果你对频率精度有进一步要求,可以在初始化流程中增加一个校准步骤。
校准的思路是用一个已知精度的参考源(比如外部的高精度PWM信号、或者串口接收到的上位机特定频率信号)作为基准,测量HSI的实际频率,然后通过调整HSICalibrationValue寄存器的值来逼近目标频率。这个方法在批量生产中比较有效,但在单板调试时操作起来略显繁琐,对精度要求没那么高的场景,直接用默认校准值就好。
7.3 建议在工程里做好时钟源标识
这是个细节但很重要。用内部RC振荡器作为时钟源的工程,和用外部晶振的工程,在外观和代码上看起来几乎没有区别,但调试方式和故障分析思路完全不同。我强烈建议在工程文件的注释头、版本号、系统初始化代码里明确标注当前使用的是内部时钟还是外部时钟。我之前接手过一个历史项目,代码里既没有注释也没有标识,调试的时候默认以为是外部晶振,排查了老半天最后才发现内部时钟方案,白白浪费了大半天时间。
8. 我对这个模板工程的最终体会
这个模板工程做完之后,我又陆陆续续在四五个项目里复用了它,包括环境监测节点、智能插座、小型电机驱动板。每一次复用都只需要调整外设驱动部分,时钟配置直接拿过来就能用,确实省心了不少。尤其是调试环境的搭建速度,从新建工程到能点灯,五分钟之内就能完成,这对快速验证硬件设计、跑通基础代码框架非常有帮助。
我个人的体会是,STM32内部RC振荡器并不是一个“只能用做备胎”的方案,它在很多实际产品里完全可以担当主时钟的角色。关键是你要清楚它的精度边界在哪里,并且愿意在项目初期做够验证。每次元器件的取舍背后都有得有失,外部晶振换来了精度,但付出了成本、面积和起振可靠性的代价;内部RC振荡器则反过来,用一点精度换来了简洁和稳定。工程上从来没有绝对正确,只有合适不合适。
最后再分享一个模板工程的小细节,我在配置HSI方案时特意把PLL倍频系数写成了通过宏定义控制的方式,这样想跑64MHz还是32MHz,改一个宏就够了,不用重新梳理整个时钟树。这个习惯也是建议你采纳的,时钟配置这种基础代码,灵活性越高,后期项目调整就越省力。希望这篇分享能帮你在这个方向上少走一些弯路。