ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32寄存器开发:RCC时钟配置从8MHz倍频到72MHz实战

STM32寄存器开发:RCC时钟配置从8MHz倍频到72MHz实战

上一章我们把“寄存器地址、偏移量、位操作”这三板斧捋了一遍,至少看到芯片手册里那一堆表格,心里不那么怵了。这一章按理说该接GPIO了,但我考虑了一下,决定先把时钟(RCC)拎出来单独讲透。不是因为我偏爱时钟,而是在实际带新人的过程中,我见过太多次“板子不工作”的求助,最后一查,超过一半都落在同一个原因上:外设的时钟根本没使能。这一篇就是“STM32寄存器白话手册”的第二章,主角是RCC寄存器,目标很直接——让你亲手写出一个把系统时钟从8MHz整到72MHz的初始化函数,再把板载LED点亮。

1. 为什么第二章就碰时钟:寄存器的“总闸”和“红绿灯”

1.1 一块“不亮”的板子:从A同学的问题说起

大概半年前,有个刚入门的朋友,就叫他A同学吧。他拿着一块常见的STM32F103C8T6核心板,照着网上的教程写GPIO控制代码,逻辑看着全对:引脚配成推挽输出、输出寄存器拉高拉低,甚至复位寄存器也用了。下载到板子以后,LED就是不亮。

他折腾了一个晚上,把GPIO相关的寄存器翻来覆去地改,什么复用功能、上下拉、输出速度都试了一遍,毫无反应。最后我让他查一件事:RCC的APB2ENR寄存器里,GPIOC的时钟使能位置1了没有?他补了一句“RCC->APB2ENR |= (1 << 4);”,LED瞬间亮了。

这个案例特别典型,因为它说明一个核心问题:STM32的外设在上电之后,默认是“断电”状态。只有先把对应外设的时钟使能,外设寄存器里的配置才会真正生效。

1.2 时钟到底是什么:从“心跳”到“电网”的生活类比

把时钟理解成“心跳”不太准确,我更喜欢把它类比成电网。

MCU内部有CPU,有Flash,有GPIO、串口、定时器这些外设。CPU要干活,外设也要干活,但所有动作都需要一个统一的节拍来驱动,这个节拍就是时钟。你给GPIO写输出高电平,本质上是在一个时钟脉冲的触发下,让输出锁存器更新状态;定时器计数,也是每个时钟脉冲加一;串口发送数据,更是按时钟节拍一位一位地往外送位流。

如果某个外设的时钟没有打开,相当于你买了个电器却没合闸。电器插着电、电线接好了、开关也按了,但电源总闸没推上去,它当然不工作。GPIO、串口、定时器、ADC、SPI,每一个外设都挂在一条时钟总线上,总闸就在RCC模块里。

1.3 为什么第二章就讲它:后面每一章都绕不开

很多教材把RCC放在系统初始化那一大段里,用启动文件一带而过,导致初学者对RCC的印象停留在“别管,初始化代码里全配好了”。但我们是寄存器路线,不能跳过。

后面写GPIO、USART、定时器时,每一步几乎都逃不开“使能时钟”这一步。如果你不理解RCC里每个位的含义,遇到外设不工作就只能瞎猜:是不是模式配错了?是不是寄存器地址不对?其实八成只是忘了合闸。因此这一章把RCC拆开讲透,往后的代码才有地气。

2. 时钟树白话拆解:四个“供货商”和两条大马路

2.1 四个时钟源各有分工:HSI、HSE、LSI、LSE

STM32的时钟源一共有四个来源,很多人被“时钟树”那张大图吓住,感觉枝枝杈杈全是线。其实你不用背整棵树,你只需要认识这四个源头:

  • HSI:内部高速RC振荡器,频率8MHz,芯片上电默认就是用它跑。好处是啥都不用接、上电就能用,坏处是精度一般,温度一变化频率会漂。
  • HSE:外部高速晶振,开发板上通常接8MHz。精度高,适合给系统提供稳定主频。
  • LSI:内部低速RC,大约40kHz,主要给独立看门狗和RTC备用时钟用。
  • LSE:外部32.768kHz晶振,RTC专用,低功耗场景下靠它走时。

四个时钟源里,真正跟系统主频相关的就是HSI和HSE。LSI、LSE一般只在RTC、看门狗那种地方出现,平时写业务代码基本不会碰。

我把它们的特点整理成一个表,方便对比:

时钟源来源典型频率主要用途特点
HSI芯片内部RC8MHz上电默认主时钟、备用时钟启动快、精度一般
HSE外部晶振8MHz(板载)系统主时钟来源精度高、需要起振时间
LSI芯片内部RC约40kHz独立看门狗、RTC备用超低功耗
LSE外部低速晶振32.768kHzRTC精确计时频率低、极省电

2.2 PLL锁相环:把8MHz“翻倍”到72MHz的变压器

时钟源直接拿来当系统主频行不行?也行,比如直接用HSI 8MHz,系统就能跑。但问题在于,STM32F103最高可以跑到72MHz,只拿8MHz当主频,性能就浪费了一大截。

这时候就需要PLL(锁相环)。你可以把它理解成一个变压器:输入一个低频信号,通过内部倍频逻辑,输出一个更高频的时钟。比如HSE是8MHz,PLL倍频系数设成9,输出就是8×9=72MHz。

注意,PLL不是想翻几倍就翻几倍。F103的PLL倍频系数有明确范围,同时PLL的输入频率也有上下限。所以配置的时候,你先得确认自己的晶振频率是多少,再算倍频系数。常见做法就是8MHz晶振倍频9,得到72MHz。

2.3 AHB、APB1、APB2三条“大马路”:时钟怎么分配下去

PLL输出72MHz之后,时钟信号不会直接灌给所有外设,中间还要经过三条总线:

  • AHB:给CPU、Flash、DMA这些核心部件提供时钟,之后再用预分频器分出APB1和APB2。
  • APB1:低速总线,挂的是USART2/3、TIM2~7、I2C、SPI2/3等外设。F103上APB1最高36MHz。
  • APB2:高速总线,挂的是GPIO、USART1、TIM1、SPI1、ADC等外设。F103上APB2最高72MHz。

为什么要分一条慢一条快?因为不同外设的工作频率上限不同。有些外设设计上就跑不了那么快,硬喂72MHz反而容易不稳定。于是一方面把速度高的外设放在APB2,另一方面用分频器把APB1压到一半。

用文字把整个链路表示出来就是这样:

HSE 8MHz → PLL×9 → 72MHz SYSCLK → AHB不分频 → 72MHz ├→ APB2不分频 → 72MHz(GPIO、USART1...) └→ APB1÷2 → 36MHz(USART2/3、TIM2...)

看到没,核心就是“一个来源,两个马路”。你不需要背什么复杂图表,抓到这条主线,RCC配置就成功了一半。

3. 把RCC寄存器逐个翻出来:CR、CFGR与使能位

3.1 CR时钟控制寄存器:每个“开关”在哪一位

RCC控制寄存器的基地址是0x40021000,CR寄存器就在偏移0x00处。它的作用说白了就是一堆开关和状态灯。

CR里真正常用的位有这么几个:

  • bit0 HSION:HSI开关,写1打开HSI。
  • bit1 HSIRDY:HSI就绪标志,硬件自动置1,表示HSI已经稳定。
  • bit16 HSEON:HSE开关,写1打开HSE。
  • bit17 HSERDY:HSE就绪标志。
  • bit18 HSEBYP:HSE旁路模式,当前不是用晶振振荡,而是由外部时钟信号直接输入时才置1。普通开发板不碰它。
  • bit19 CSSON:时钟安全系统开关,打开后一旦HSE失效会自动切回HSI。
  • bit24 PLLON:PLL开关。
  • bit25 PLLRDY:PLL就绪标志。

这个寄存器没什么花活,就是“给位、置位、查标志”。打开时钟后,你不能马上就去用,必须等对应的RDY标志变成1,因为晶振起振和PLL锁定都是要花时间的。很多初学者不查就绪位,结果系统启动时有时正常有时跑飞,那就是时序上出了问题。

3.2 CFGR配置寄存器:PLL选谁、倍频多少、总线分频怎么填

CFGR寄存器在偏移0x04处,负责的是“怎么算”这件事。其中你迟早要用的位段有:

  • bit1:0 SW:系统时钟切换,00选HSI,01选HSE,10选PLL。
  • bit3:2 SWS:系统时钟状态回读,告诉你当前实际跑在哪个时钟上。
  • bit7:4 HPRE:AHB预分频,0000表示不分频。
  • bit10:8 PPRE1:APB1预分频,100表示/2,101是/4,110是/8,111是/16。
  • bit13:11 PPRE2:APB2预分频,000表示不分频。
  • bit16 PLLSRC:PLL输入源选择,0选HSI/2,1选HSE。
  • bit21:18 PLLMULL:PLL倍频系数,比如0111表示×9。

CFGR这组位段,几乎可以看作一路“组合逻辑”。你告诉芯片:PLL吃的是HSE,倍9倍,APB1分2,APB2不分频。芯片就按这套参数把时钟算出来。

这里有个细节值得注意:SW、PLLMULL这些多bit字段,修改前最好先清掉旧值。如果原值是别的倍频,你直接按位或上一个新值,可能会残留旧位,配置就乱了。正确做法是先用“&= ~(mask)”把这一整段清零,再写入新值。

3.3 APB1ENR与APB2ENR:各个外设的“电源开关”

如果说CR和CFGR决定系统时钟怎么产生,那APB1ENR和APB2ENR就决定这个时钟分给谁。

它们的偏移地址是固定的:

  • APB2ENR,偏移0x18,主管APB2总线上的外设时钟,GPIO、USART1、ADC、TIM1都在这里。
  • APB1ENR,偏移0x1C,主管APB1总线上的外设时钟,USART2/3、TIM2~7、I2C、SPI2/3、CAN都在这里。

使用方式极其简单,把对应位置1即可。比如:

RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 使能GPIOC时钟 RCC->APB1ENR |= RCC_APB1ENR_USART2EN; // 使能USART2时钟

别小看这一步,外设不工作、寄存器写了没反应,十有八九就是漏了它。

3.4 常用外设时钟使能位速查表

以F103为例,把最常见的外设和它对应的使能位整理出来。这张表我建议你存起来,写代码时随时翻:

外设所在总线使能寄存器使能位
GPIOAAPB2APB2ENRbit2
GPIOBAPB2APB2ENRbit3
GPIOCAPB2APB2ENRbit4
USART1APB2APB2ENRbit14
TIM1APB2APB2ENRbit11
ADC1APB2APB2ENRbit9
TIM2APB1APB1ENRbit0
TIM3APB1APB1ENRbit1
USART2APB1APB1ENRbit17
USART3APB1APB1ENRbit18
I2C1APB1APB1ENRbit21
SPI2APB1APB1ENRbit14

不同型号的STM32,GPIO挂的总线可能不一样。比如F4系列,GPIO的时钟使能位在AHB1ENR里;F1系列则放在APB2ENR。写代码之前,先确认自己用的是哪个系列,别把F4的例子往F1上硬套。

4. 从0写一个时钟初始化:8MHz晶振到72MHz全流程

4.1 先开HSE,再等它“就绪”

正式的时钟初始化,第一步不是配PLL,而是先把HSE打开、确认它起振。晶振上电后需要一点时间稳定输出,所以必须查HSERDY标志。

// 打开外部高速晶振 RCC->CR |= RCC_CR_HSEON; // 等待HSE就绪,超时保护可以加上,这里先写最简形式 while ((RCC->CR & RCC_CR_HSERDY) == 0) { }

这个循环看起来简单,很多人不理解“为什么要等”。你可以这样想:刚给晶振通电,它不可能瞬间输出稳定波形,就跟开灯后灯管要闪几下才稳定一样。HSERDY位就是硬件给你的“灯已亮稳”信号。

4.2 配置PLL和总线分频:关键一步必须算清楚

HSE稳定之后,就可以配置PLL和总线分频了。这一步的“算法”就是前文的链路图:8MHz HSE→PLL×9→72MHz系统时钟→AHB不分频→APB2不分频→APB1分2。

对应到CFGR寄存器,就是四件事:选PLL输入源为HSE、倍频系数设为9、APB1分频为2、APB2不分频。

// 配置PLL输入源为HSE RCC->CFGR |= RCC_CFGR_PLLSRC; // 配置PLL倍频为9(8MHz × 9 = 72MHz) RCC->CFGR &= ~RCC_CFGR_PLLMULL; // 清空倍频字段 RCC->CFGR |= RCC_CFGR_PLLMULL9; // PLLMULL = 0111,对应×9 // 配置APB1分频为2,APB2不分频 RCC->CFGR |= RCC_CFGR_PPRE1_DIV2; // APB1 = 72MHz / 2 = 36MHz RCC->CFGR &= ~RCC_CFGR_PPRE2; // APB2不分频,保持72MHz

为什么APB1要分频?因为F103的APB1外设最高只能承受36MHz。如果你把APB1也配成72MHz,串口和定时器可能表现异常,比如波特率换算出来的分频值不对,或者通信偶发错误。这是很多人配完时钟后遇到“怪问题”的根源之一。

4.3 FLASH等待周期:最容易被忽略的“隐藏关卡”

配置完总线分频,还不能急着切系统时钟。有一个坑几乎所有初学者都会踩:系统时钟提高到72MHz后,Flash读取速度跟不上CPU访问速度,程序会随机跑飞、进硬件错误。

解决方法是配置Flash接口的等待周期。F103的Flash接口寄存器地址是0x40022000,ACR寄存器偏移0x00,其中bit2:0表示等待周期数。72MHz主频下,需要设置2个等待周期。

FLASH->ACR = 0x32; // 等待周期=2(bit2:0 = 010),同时开启预取缓冲(bit4 = 1)

为什么是2而不是3?这是芯片手册给出的硬性规定:主频在48MHz到72MHz范围内,Flash等待周期必须为2。低于48MHz时可以是1,更高频率才需要更多。这条如果你查手册会发现写得很清楚,但很多示例代码不解释,导致学习者只抄了PLL配置、漏掉了Flash配置。

4.4 打开PLL、等待锁定、切换系统时钟

万事俱备,最后一步就是打开PLL,等PLLRDY标志,然后把系统时钟切换到PLL输出。这三步不能省,顺序也不能乱。

// 打开PLL RCC->CR |= RCC_CR_PLLON; // 等待PLL锁定 while ((RCC->CR & RCC_CR_PLLRDY) == 0) { } // 切换系统时钟到PLL RCC->CFGR |= RCC_CFGR_SW_PLL; // 确认切换完成:SWS位会回读当前系统时钟状态 while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) { }

为什么要读SWS?因为SW只是写“我想切到PLL”,切换动作本身是硬件完成的,需要时间。你不等SWS变成PLL状态就去操作外设,系统时钟可能还悬在HSI上,配置用了72MHz的参数,实际却是8MHz的节奏,外设算出来的分频值全错。调试器里看到串口乱码,往往就是这个原因。

到这里,时钟初始化函数完整版如下:

void Clock_Init_To_72MHz(void) { // 1. 打开HSE并等待就绪 RCC->CR |= RCC_CR_HSEON; while ((RCC->CR & RCC_CR_HSERDY) == 0) { } // 2. 配置Flash等待周期为2,开启预取缓冲 FLASH->ACR = 0x32; // 3. 配置PLL:HSE 8MHz × 9 = 72MHz RCC->CFGR |= RCC_CFGR_PLLSRC; // PLL输入 = HSE RCC->CFGR &= ~RCC_CFGR_PLLMULL; // 清倍频字段 RCC->CFGR |= RCC_CFGR_PLLMULL9; // ×9 // 4. 配置总线分频:APB1 /2,APB2不分频 RCC->CFGR |= RCC_CFGR_PPRE1_DIV2; RCC->CFGR &= ~RCC_CFGR_PPRE2; // 5. 打开PLL并等待锁定 RCC->CR |= RCC_CR_PLLON; while ((RCC->CR & RCC_CR_PLLRDY) == 0) { } // 6. 切换到PLL时钟 RCC->CFGR |= RCC_CFGR_SW_PLL; while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) { } }

你把这个函数放在main函数最开始调用,系统就跑在72MHz上了。

5. 点亮PC13:让时钟配合GPIO完成首次实战

5.1 外设的第一步永远是“上电”

时钟初始化完成之后,接下来用一个最经典的动作来验证:点亮核心板上的LED。

大多数核心板把LED接在PC13这颗引脚上,而且通常是低电平点亮。要操作GPIOC,第一件事还是那句老话:先开GPIOC时钟。

// 使能GPIOC的时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 相当于 (1 << 4)

这一句你能真正看懂了:APB2ENR的bit4是GPIOC的时钟开关。很多初学朋友把GPIO初始化代码从头到尾背下来,却不知道第一句到底在干嘛,现在应该清楚了。

5.2 把PC13配成推挽输出

GPIO端口本身有一堆控制寄存器。F103的GPIOC基地址是0x40011000,PC13属于高8位引脚,配置寄存器要用CRH而不是CRL。

PC13对应的4位是CRH的bit23:20。在这4位里,低2位是MODE(输出模式/速度),高2位是CNF(输出配置)。写成代码:

// 清空PC13相关4位:MODE13和CNF13都归零 GPIOC->CRH &= ~(0x0F << 20); // MODE13 = 01:输出模式,最大速度10MHz // CNF13 = 00:通用推挽输出 GPIOC->CRH |= (0x01 << 20);

为什么用10MHz而不是50MHz?驱动LED只需要很低的翻转速度,输出速度设太高会增加EMI和功耗。能用低的就用低的,这是实践中总结出来的经验,不是越高速越好。

5.3 拉低电平,让LED真正亮起来

PC13板载LED一般低电平点亮,所以要把ODR寄存器bit13清0:

GPIOC->ODR &= ~(1U << 13); // OD13 = 0,输出低电平

全流程代码连起来:

int main(void) { Clock_Init_To_72MHz(); // 使能GPIOC时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 配置PC13为推挽输出 GPIOC->CRH &= ~(0x0F << 20); GPIOC->CRH |= (0x01 << 20); // 输出低电平,点亮LED GPIOC->ODR &= ~(1U << 13); while (1) { } }

到这里,你就拥有了一套“时钟初始化 + GPIO操作”的完整寄存器代码。这段代码虽然短,但它走完了整个链路:时钟源选择、倍频、总线分频、外设时钟使能、引脚模式、电平输出。后面写串口、定时器,思路是一模一样的。

6. 时钟路上最容易翻车的四个坑:现象、根因与排查

6.1 现象一:代码复制了一遍,LED就是不亮

最经典的问题。很多人拿到例程,改了GPIO引脚号,下载后没反应,就开始怀疑GPIO配置。其实问题往往在时钟使能上:可能漏写了GPIOCEN,或者使能的是GPIOA,实际用的却是GPIOC。

排查方法很简单:打开调试器,看RCC->APB2ENR的值,确认对应引脚所在端口的使能位是否已经置1。没置1,其他一切都是白搭。

6.2 现象二:改了PLL倍频之后,程序直接跑飞或进硬件错误

常见原因有两个。第一个是倍频结果超过了芯片上限,比如用16MHz晶振配×9,得到144MHz,F103根本跑不到。第二个是系统时钟提高到72MHz后,Flash等待周期没跟着调,CPU取指速度跟不上。

第二个原因尤其隐蔽,因为代码本身没有任何错误,程序却时好时坏,看起来像“玄学问题”。遇到这种,第一反应就去检查FLASH->ACR的等待周期是不是2。这也是我把Flash配置放在时钟初始化里的原因,这一环不能省。

6.3 现象三:串口输出乱码,波特率明明没配错

串口乱码往往是APB分频和波特率分频的联动问题。如果你把APB1配置成了72MHz而不是36MHz,USART2/3的时钟频率就变成了两倍,但波特率寄存器算出来的分频值是按36MHz算的,最终发出去的波特率就偏了。

还有一种情况是用了HSI做主时钟。HSI精度一般,温度一高频率就漂,串口波特率也会跟着偏。所以只要板上有晶振,就尽量用HSE,通信稳定不是玄学,而是时钟源本身要准。

6.4 怎么判断当前系统时钟到底是多少

有时候你怀疑系统时钟没切过去,又懒得用示波器测MCO引脚,那就直接读CFGR的SWS字段。

// 读SWS字段,判断当前系统时钟来源 uint32_t sws = (RCC->CFGR & RCC_CFGR_SWS) >> 2; // 0:HSI 1:HSE 2:PLL

如果SWS显示0,说明系统还在HSI上跑。这时候就去检查PLLON有没有置1,PLLRDY有没有变1,SW位有没有写对。按这个顺序排查,几分钟就能定位问题。

在实际调试中,我还习惯在初始化函数里临时加上一个“超时保护”,防止外部晶振损坏或者接线虚焊时函数死循环卡死。做法很简单,设一个计数变量,循环超过一定次数就强制跳到HSI模式继续运行。生产环境不一定需要,但调试阶段能帮你快速判断是不是晶振没起振,而不是对着一块黑屏的板子发呆。

时钟这部分内容就这些了,但它的影响会贯穿整本书。下一篇开始,我们就可以放心地拿着这套时钟配置,去摆弄GPIO的高电平、低电平、按键输入、推挽开漏这些真正“看得见”的操作了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进