
1. 项目概述为什么我会去做一个内晶振启动模板工程做嵌入式开发这些年我接手过不少基于STM32的项目发现一个问题反复出现很多工程师默认拿到板子先焊外部晶振然后按照标准库或者HAL库的默认配置把HSE外部高速晶振跑起来。这个流程本身没问题但一旦你遇到下面这几种情况外部晶振反而成了麻烦事。第一成本敏感的小批量产品。一颗8MHz的无源晶振加上两个负载电容物料成本虽然只有几毛钱但贴片位置、PCB布局、晶振起振调试这些隐性成本并不低。第二特殊环境下晶振起振困难。低温、强振动、高湿度场景下外部晶振可能出现不起振或者停振而这种故障往往最难查。第三PCB空间极其紧张的场合。比如做TSSOP封装的极小模组板子上实在腾不出晶振的位置。我第一次真正下决心做这个模板工程是因为一个量产项目在客户现场出现了偶发性死机。排查到最后发现是外部晶振虚焊导致系统时钟源丢失。从那之后我就琢磨STM32内部本来就有一个RC振荡器HSI为什么不在合适的产品里直接用于是就有了这套“内晶振启动模板工程”。它的核心思路很简单上电后系统完全依赖内部RC振荡器工作不检测、不等待、不依赖任何外部时钟器件代码层面做到开机即跑、稳定运行。这个模板适合谁我觉得有三类人很值得参考刚入门STM32、还在为外部晶振配置发愁的初学者要在量产项目中做降成本、提可靠性的嵌入式工程师以及做小体积模组、对PCB面积敏感的朋友。当然我也必须提前说清楚内部RC振荡器精度比不上外部晶振如果你的项目涉及USB通信、以太网或者需要高精度定时那这篇文章的方法不能直接套用得结合具体外设的需求来判断。2. 内部RC振荡器与外部晶振的选型博弈2.1 两种时钟源的真实差异要真正用好内部RC振荡器先得把它的特性摸清楚。STM32内部集成的HSIHigh Speed Internal振荡器不同系列频率不太一样比如F1系列是8MHzF4系列是16MHzG0系列甚至内置了高达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且极难排查。外设初始化阶段按照外设依赖关系依次初始化先初始化RCCReset and Clock Control时钟控制再初始化GPIOGeneral 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函数之前时钟已经配置完成这符合CMSISCortex Microcontroller Software Interface StandardARM 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/24MHz /* 倍频系数选择16 */ RCC-CFGR | (uint32_t)(RCC_CFGR_PLLMULL16); // 4MHz * 16 64MHz也不对这里我多说几句不同系列的PLL输入源处理方式不一样。F103的HSI作为PLL输入时必须先经过2分频得到4MHzPLL倍频系数范围是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位是否为1PLLRDY位是否为1SYSCLK源是否切到了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倍频系数最大164乘16等于64MHz这就是HSI方案的理论上限。数据手册上标的72MHz是HSE方案的指标。不少芯片系列的内部RC振荡器本身就只有8MHz或者16MHz想通过PLL拉到上百兆要么倍频系数不够要么虽然能拉到但稳定性会变差。我遇到过有人强行把倍频系数配置成超范围的值编译不报错运行后程序直接HardFault。这个属于未定义行为芯片实际能否工作完全看硅片的个体体质即使能跑也不建议批量采用。正确的做法是查数据手册的电气特性表根据芯片额定规格来设定PLL参数。6.4 低功耗模式下HSI的状态问题做低功耗项目的朋友要注意进入STOP模式后HSI是可以被关闭的从STOP模式唤醒后HSI会自动重新启动但启动需要时间。如果你的唤醒处理器逻辑依赖立即读取某个精确时钟就可能在唤醒初期读到错误值。我的经验是在进入STOP模式前把关键状态保存到寄存器或者备份SRAMStatic Random Access Memory静态随机存储器中唤醒后先等待HSI就绪再恢复时钟配置。等待HSI就绪的方式是查询HSIRDY标志位确认置位后再执行后续逻辑不要盲目等待固定延时因为温度和电压不同时HSI的启动时间会有差异。6.5 独立看门狗和HSI能共存吗可以两者互不冲突。STM32的独立看门狗用的是LSILow Speed Internal低速内部时钟大约40kHz和HSI完全独立。使用HSI作为系统时钟时IWDG照常工作互不干扰。这个组合很适合工业控制中既要简化时钟源、又要保证系统稳定性的场景。7. 把模板工程用于实际项目时的三个建议7.1 建议先评估外设对时钟精度的实际需求准备正式使用这个模板前建议你列一张表把项目里用到的所有外设对时钟精度的要求写下来串口波特率多少、是否需要PWM精确输出、有没有定时捕获、有没有RTCReal-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改一个宏就够了不用重新梳理整个时钟树。这个习惯也是建议你采纳的时钟配置这种基础代码灵活性越高后期项目调整就越省力。希望这篇分享能帮你在这个方向上少走一些弯路。