【stm32】RCC 与时钟树

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


目录

  • [第一章 到底什么是"时钟"?](#第一章 到底什么是"时钟"?)
  • [第二章 时钟树:MCU 内部的"电力调度中心"](#第二章 时钟树:MCU 内部的"电力调度中心")
  • [第三章 时钟树、总线与外设是怎么配合的?](#第三章 时钟树、总线与外设是怎么配合的?)
  • [第四章 RCC:复位置位与时钟控制器](#第四章 RCC:复位置位与时钟控制器)
  • [第五章 动手实操:让 LED 的闪烁速度因时钟而改变](#第五章 动手实操:让 LED 的闪烁速度因时钟而改变)
  • [第六章 源码解析:HAL 库背后到底做了什么](#第六章 源码解析:HAL 库背后到底做了什么)
  • [附录一 RTC 与看门狗:时钟树的第二部分](#附录一 RTC 与看门狗:时钟树的第二部分)
  • [附录二 MCO:时钟树的第三部分](#附录二 MCO:时钟树的第三部分)
  • [附录三 世界为什么选择了"树"形结构](#附录三 世界为什么选择了"树"形结构)

第一章 到底什么是"时钟"?

1.1 CPU 也要被"催促"才能干活

很多人第一次听说"时钟"这个词,脑子里浮现的是墙上的挂钟。但在单片机(MCU)的世界里,时钟不是用来"看时间"的,而是用来"催干活"的

你有没有想过这样一个问题:

你的 ARM-CPU 能从 Flash(闪存)里读取代码并执行,可到底是谁让 CPU 去读指令的?是谁让 CPU 一刻不停地读指令的?

打个比方:警察很厉害,能抓坏人,可是------是谁让警察去抓坏人的? 警察自己不会没事就去抓人,他需要一个指令、一个安排。

CPU 也是一样的。CPU 非常聪明,它能"读指令、分析指令、执行指令",然后周而复始地循环。但 CPU 又非常"傻",它自己不会主动开始干活,必须在别人的催促下,才会机械地一步一步执行。

这里"催促" CPU 运行的硬件机制,就叫做 时钟(Clock)

把时钟想象成一个"节拍器":

  • 节拍器以固定的频率"哒、哒、哒"地响,催促大家踩着节奏干活;
  • 时钟以固定的频率给设备发送"电信号",催促目标设备开始工作。

核心结论:CPU 不会主动执行,它必须在时钟这个"外力"的驱使下,才能工作。

1.2 片上外设也一样:没有时钟,谁都不干活

在 MCU 内部,除了 CPU,还有一大堆"片上外设"(On-chip Peripheral),比如后面我们会用到的 GPIO、USART(串口)、I²C、SPI、定时器等等。

那么问题来了:这些外设自己能主动工作吗?

答案是不能。

  • 单片机内部,几乎所有外设都不会主动工作
  • 它们全部都要在时钟的驱使下,才能开始工作。

再往深一层说:在硬件层面上,所有设备------不管是 CPU 还是外设------都要在时钟的推动下,统一步调地工作。

就好比一支乐队:每个乐手都很会演奏,但如果没有指挥挥动指挥棒(时钟),大家就各吹各的,乱成一锅粥。有了指挥的节拍,大家才能整齐划一地演奏出乐曲。

1.3 时钟是怎么"催促"设备的?(时钟信号与时钟源)

为了把原理讲清楚,我们简化一下真实世界(真实情况复杂得多,但道理是通的):

  • 假设我们所有的设备,在硬件设计上,都是"收到高电平才工作"(事实会有差别,但不影响理解);
  • 我们生成的一串有高有低的电平序列(时序),就叫做 时钟信号(Clock Signal)
  • 产生这串高低电平时序的源头硬件,就叫做 时钟源(Clock Source)

可以这样理解:

复制代码
时钟源
  │  (持续输出高低电平,形成方波)
  ▼
┌──────────────────────────────┐
│  设备1  │  设备2  │  设备3    │
└──────────────────────────────┘

有了时钟,所有设备都会在统一时钟的节奏下,步调一致、有节奏地执行工作。

结论一:有了时钟,所有设备都能统一步调、有序工作。

1.4 成本敏感:为什么使用外设之前,必须先"使能时钟"?

接下来,让我们代入单片机设计者的视角,体会一段"心理路程":

我是一款单片机的设计者。我心里清楚,未来单片机上有几十种设备(CPU、各种外设)。但这些外设不一定都会被开发者同时用到。如果开发者根本用不到某个外设,我却还让它在时钟的催促下做着"无效工作",岂不是白白浪费电?

功耗一高,用我的单片机的成本就变高了,那我的单片机在市场上就没有竞争力了!

而且我还得满足用户"想用哪个外设就用哪个外设"的自由选择需求。

那怎么才能做到"功耗"和"功能"两者兼顾,既要又要呢?

答案:在设计和制造单片机时,给每个外设的时钟输入都加上一个"开关"(使能位)。

这样一来:

  • 我在使用任何外设之前,都要先使能它的时钟------本质就是"打开这个外设对时钟的受理开关";
  • 如果一个外设用不到,就不使能它,它的时钟开关保持断开,不耗电
  • 只有 CPU 例外:因为代码要一直执行,CPU 必须一直运行,所以它的时钟一般不需要你去手动使能(上电后就默认工作)。

这就是"我们使用任意一个片上外设之前,都需要先开启它的时钟"的根本原因。

补充一个小知识:所谓的时钟方波,本质就是高电平和低电平,画出来就是方波的样子。

1.5 时钟频率与时钟周期:时钟"快慢"如何衡量?

有了上面的基础,我们现在有个概念:时钟信号的表现形式,就是类似"方波"的时序图。

而不同的设备,有的在"上升沿"(电平从低变高的那一瞬间)工作,有的在"下降沿"(电平从高变低的那一瞬间)工作。这样,靠一个方波,就能让不同设备沿着一定节奏共同工作。

  • 方波排得 ,设备就工作得
  • 方波排得 ,设备就工作得

那么,怎么衡量一个时钟的快慢呢?这就引出了两个重要概念:时钟周期时钟频率

1.5.1 时钟周期(Period)

  • 周期 表示"重复出现的最小完整动作"所对应的时间单元。注意:周期是时间单位
  • 时钟周期 特指"一次完整波动"所需要的时间。一次波动 = 起始状态 → 变化 → 回到起始状态,表现形式上一定有一次高电平、一次低电平

不管一个周期画成什么样,一个周期描述的都是一次波动。一次波动 = 初始状态 → 变化 → 回到初始状态的过程。所以:

一个周期,必然有且只有:一个上升沿(变化)+ 一个下降沿(回到初始状态)。

1.5.2 时钟频率(Frequency)

假设输出一个周期需要花 1ms(毫秒),那么 1 秒钟内能输出多少个周期呢?

复制代码
1秒 ÷ 1ms = 1000 次

我们把"每秒输出多少个完整周期数 "叫做 时钟频率 ,次数的单位是 赫兹(Hz)

复制代码
时钟频率 = 1 / 时钟周期
例如:周期 = 1ms,频率 = 1 / 0.001s = 1000 Hz = 1 kHz
例如:周期 = 13.9ns(纳秒),频率 ≈ 72 MHz(72兆赫兹)

时钟频率是衡量时钟快慢的指标,单位是赫兹(Hz),它等于每秒输出的完整周期数。

1.5.3 小结记忆

概念 性质 一句话记忆
时钟周期 时间单位 一次完整波动花的时间
时钟频率 频率单位 每秒能输出多少个完整周期
一个周期 波形特征 必有一个上升沿 + 一个下降沿

1.6 倍频与分频:用同一个时钟源,服务不同频率的设备

前面为了方便,我们认为所有设备都工作在一个时钟源对应的一个节奏下。但事实上:

不同的设备,需要的工作频率是不同的。

比如(以 STM32F103ZET6 为例):

  • GPIO 总线 PCLK2 最高可以到 72 MHz;
  • APB1 上的低速外设(USART2/3、I²C、SPI2、TIM2~4 等)最高只能到 36 MHz;
  • RTC 只需要 32.768 kHz;
  • 独立看门狗只需要约 40 kHz......

为什么外设的工作频率会不一样?原因有很多,比如:

  • 硬件工艺:不同电路能承受的速度上限不一样;
  • 功耗与散热:频率越高,发热越大,低功耗设备不需要高频率;
  • 通信协议:某些协议本身就规定了时序要求;
  • 精度要求:时钟频率要能被精确整除,才能得到精确的波特率。

既然设备频率各不相同,那总不能让每个设备都配一个时钟源吧?------那样成本太高、功耗太大、设备间同步也会乱套。

于是,几乎所有的单片机都采用了"倍频 + 分频"的做法。

1.6.1 倍频器(Frequency Multiplier):把频率变高

倍频器是一个基础且重要的混合信号电路,核心功能是:

将一个输入的低频时钟信号,转换成一个频率更高、且波形节奏完全对应的输出时钟信号。

通俗理解:输入 1 个时钟周期,输出 n 个时钟周期。

常见的倍频器例子就是 PLL(Phase-Locked Loop,锁相环)。PLL 就像一台"时钟放大器",能根据你设定的倍数,把低频输入变成高频输出。

1.6.2 分频器(Frequency Divider):把频率变低

分频器是一个基础且重要的数字电路,核心功能是:

将一个输入的高频时钟信号,转换成一个频率更低、但波形完全同步的输出时钟信号。

通俗理解:输入 n 个时钟周期,输出 1 个时钟周期。

1.6.3 合起来看

有了倍频器和分频器,我们就可以用一个时钟控制源 ,控制不同的设备 ,让它们统一步调,却以不同的频率工作:

复制代码
         ┌──────────────┐   ×9 (PLL)   ┌─────────┐
时钟源 ──►│  分频/倍频电路  ├──────────►│ 系统时钟 │
(8MHz)   └──────────────┘              └────┬────┘
                                             │
                                    ┌────────┴────────┐
                                    │  分频器 / 分频器  │
                                    ▼                ▼
                                 APB1外设(36MHz)   APB2外设(72MHz)

关于时钟的本质,我们可以总结为:

  • 时钟,是"决定单片机片上外设什么时候工作"的设备;对单片机来讲,时钟是发起者
  • 其他片上外设,是执行者,大家在时钟的指挥棒下有序工作;
  • CPU(Cortex-M3)是未来执行代码的地方,但它也要在时钟的驱使下完成"取指令 → 分析指令 → 执行指令",在这个层面上,CPU 和片上外设并无本质区别

1.7 硬件时钟源:真实存在的晶振

前面说的"时钟源",在我们的 MCU 或者开发板上,是一个真真实实的硬件

在 STM32F103 的板子上,你能看到两类非常常见的晶振(晶体振荡器,英文 Crystal):

  1. 32.768 kHz 的低速晶振:频率低,专给 RTC(实时时钟)用。为什么偏偏是 32.768kHz?因为 32.768 kHz = 32768 Hz = 2¹⁵,可以非常方便地用计数器二分频出精确的 1 秒信号。
  2. 8 MHz 的高速晶振:作为 HSE(高速外部时钟)的来源,喂给 PLL 倍频出 72MHz。

⚠️ 细节:区分"时钟源"和"时钟信号"

  • 时钟源:是"产生或提供时钟"的硬件实体(比如那颗晶振);
  • 时钟信号:是这个实体最终输出到电路中的电信号波形。

也就是说,晶振是"水厂",时钟信号是水厂里流出来的"水"。


📌 本知识点总结:什么是时钟?

时钟 是单片机内部一个以固定频率产生高低电平方波的硬件机制,它像"节拍器"一样,持续不断地"催促"CPU 和各种片上外设工作。CPU 再聪明也不会主动执行指令,它必须在时钟的驱使下,才能完成"取指令→分析指令→执行指令"的循环;片上外设同样必须依靠时钟驱动才能工作。因此,在硬件层面上,MCU 内所有设备都依赖时钟统一步调、协调运行。

衡量时钟快慢的指标是 时钟频率 ,单位是赫兹(Hz),它表示每秒输出的完整周期数;时钟周期 则是完成一次完整波动(一个上升沿加一个下降沿)所需的时间,二者互为倒数。

由于不同外设工作频率不同,MCU 不会给每个外设单独配时钟源,而是采用 倍频(如 PLL 锁相环)和分频 的方式,从同一时钟源派生出各种频率的时钟。同时,为了兼顾功耗与功能,每个外设的时钟都带有"使能开关",使用外设之前必须先使能它的时钟,用不到的外设时钟保持关闭以省电。


第二章 时钟树:MCU 内部的"电力调度中心"

2.1 为什么需要"时钟树"?------像硬件工程师一样思考

有了关于时钟的宏观认识,我们现在站在单片机设计者的角度,做几个"大胆推测":

推测一:一个时钟源不够用。

如果只有一个时钟源,万一它因为极端情况(高温、线路老化等)而无法正常工作,那么整个系统都会瘫痪。

是的,所以 STM32F103 系列的单片机不可能只有一个时钟源,具体几个取决于设计。

推测二:多个时钟源之间,频率最好有差别。

  • 片上外设工作频率不同,虽然可以靠倍频/分频来满足要求;
  • 但是,不同时钟源自身频率有差别,就能覆盖更大的频率范围,还能在不需要高频的时候,选择低频时钟源来工作,从而降低功耗

就像修路:高速路修双向 8 车道能满足交通需求,可小区内部也修双向 8 车道就没必要了,成本太高。低速设备用低速时钟,高速设备用高速时钟,才是省电又省钱的做法。

推测三:给每个外设都配一个时钟源?不行!

既然可以有多个时钟源,那我可以给每个外设都单独配一个吗?

不行。因为那样的话:

  • 每一个时钟都要针对不同设备进行使能、分频、倍频;
  • 这会让 MCU 内部出现大量重复的电路设计;
  • 重复就意味着成本增加,而单片机是成本敏感的设备。

结论:必须把时钟功能单独设计成一套硬件电路,黑盒式地对外提供特定频率的时钟。这套硬件电路,就叫时钟树(Clock Tree)。

2.2 时钟树的结构认识

真实的 STM32F103 时钟树电路图非常复杂,看起来像一片密密麻麻的电路网。但不要被它吓到,因为无论多复杂,它的核心特性只有三个:

  1. 时钟源不止一个
  2. 要有分频和倍频功能
  3. 要有使能开关

下面我们逐条拆解。

2.2.1 时钟源不止一个:HSI / HSE / LSI / LSE

STM32F103 系列提供了四个"真正的原始时钟来源"(原始时钟来源 = 直接由硬件产生的第一手脉冲信号):

简称 全称 来源 典型频率
HSI High-Speed Internal(高速内部时钟) 内部 RC 振荡器 8 MHz
HSE High-Speed External(高速外部时钟) 外部晶振 4~16 MHz,我们板子选 8 MHz
LSI Low-Speed Internal(低速内部时钟) 内部 RC 振荡器 40 kHz
LSE Low-Speed External(低速外部时钟) 外部 32.768 kHz 晶振 32.768 kHz

用"内外 × 高低"两张维度把它们放进一个表格里,就很好记了:

内部(Internal) 外部(External)
高速(High-Speed) HSI(8 MHz,内部 RC) HSE(外部晶振,板子用 8 MHz)
低速(Low-Speed) LSI(40 kHz,内部 RC) LSE(外部 32.768 kHz 晶振)

内部 = 芯片里自带(不需要外接硬件,上电就有,但精度一般);外部 = 需要外接晶振(精度高)。

2.2.2 有分频和倍频功能

时钟树图纸上那些标着 /1、/2、/4、/8、/16 的模块,就是分频器;标着 ×N 或者带 PLL 的就是倍频器。它们分两种:

  • 固定分频 :比如 /2,系数写死,不能改;
  • 可配置分频/倍频 :内部有多个系数(如 /1、/2、/4、/8、/16),支持软件动态配置。

常见的倍频器就是前面提到的 PLL(Phase-Locked Loop,锁相环)

此外,时钟树图纸里还有很多矩形的选择器(MUX,复用器/多路选择器):它的输入有多个信号源,可以选择其中一个信号源作为输出。选择器选谁、分频器分几倍,这些未来在软件上都是可以配置的。

2.2.3 有使能开关

时钟树图纸上那些"圈起来的开关",就是一个一个的使能开关(Enable Gate)。这个开关能不能通,由 RCC 寄存器里的某个位(bit)决定------这就是后面我们要讲的重点。

2.3 时钟树的理解:把它分成三部分

整颗时钟树,其实可以分成三部分:

  • 第一部分(核心) :负责 MCU 内部大部分设备的驱动工作,是时钟树最核心、最复杂的部分;
  • 第二部分(低频) :主要用低频时钟来驱动 RTC看门狗
  • 第三部分(输出) :主要用来做时钟输出(MCO)

很长一段时间内,我们只需要重点关注第一部分就够了,其余部分到后面的专题再展开。

2.3.1 第一部分详解:核心时钟家族

我们把第一部分高度简化之后,就能看清它是一条"流水线":

复制代码
 HSE(8MHz)
    │  ×9(PLL 锁相环倍频)
    ▼
 PLLCLK(72MHz)
    │  (选择器选它做系统时钟)
    ▼
 SYSCLK(72MHz,系统时钟,芯片根时钟)
    │
    ├─► FCLK(72MHz,CPU 内核自由运行时钟)
    │
    └─► HCLK(72MHz,AHB 总线时钟,AHB 总线/内存/DMA 时钟)
             │
             ├─ 分频 2 倍 ─► PCLK1(36MHz,APB1 外设:USART2/3、I²C、SPI2、TIM2~4)
             │
             └─ 分频 1 倍 ─► PCLK2(72MHz,APB2 外设:GPIO、USART1、SPI1、TIM1、ADC)

这些常见时钟的详细信息如下:

时钟 全称 英文翻译 核心作用 最高频率 常规工作频率
PLLCLK Phase-Locked Loop Clock 锁相环时钟 倍频输出,使系统可达最高速 72 MHz 72 MHz
SYSCLK System Clock 系统时钟 芯片根时钟,所有总线的源头 72 MHz 72 MHz
FCLK Free-running Clock 自由运行时钟 CPU 内核自由运行时钟 72 MHz 72 MHz
HCLK AHB Clock AHB 总线时钟 AHB 总线、内存、DMA 时钟 72 MHz 72 MHz
PCLK1 APB1 Clock APB1 外设时钟 APB1 外设(USART2/3、I²C、SPI2、TIM2~4)时钟 36 MHz 36 MHz
PCLK2 APB2 Clock APB2 外设时钟 APB2 外设(GPIO、USART1、SPI1、TIM1、ADC)时钟 72 MHz 72 MHz

这里有几个小练习,可以检验一下你是否真正理解了时钟树:

练习 1:如果我要形成 SYSCLK = 72MHz,都有多少种方式?

至少有两种常见思路:

  • HSE 8MHz × PLL 9倍频 = 72MHz(精度高);
  • HSI 8MHz × PLL 9倍频 = 72MHz(HSI 也是 8MHz,同样可以倍频到 72MHz,只是 HSI 精度差一些)。
    练习 2:如何用 SYSCLK 作为时钟源,形成 PLLCLK、FCLK、HCLK、PCLK1、PCLK2 需要的频率?

注意顺序:PLLCLK 是"源",SYSCLK 是从 PLLCLK 选出来的。然后:

  • SYSCLK → 直接就是 FCLK(1:1 跟随,自由运行);
  • SYSCLK → HCLK(默认 1:1);
  • HCLK ÷2 → PCLK1(36MHz);
  • HCLK ÷1 → PCLK2(72MHz)。

2.3.2 为什么要有两个高速时钟源:HSI 与 HSE?

我们之前推测过:"如果只有一个时钟源,万一坏了,整个系统都瘫痪怎么办?"

所以在时钟树第一部分,最核心的"原始时钟来源"有两个:HSI 和 HSE,都是一高一精、一内一外,用来驱动 CPU、SRAM、各种片上外设。

  • 默认情况 :STM32F103 系列 MCU 上电后默认使用 HSI 作为起始"原始时钟来源",先让芯片跑起来;
  • 最佳实践 :我们一般选择 HSE 作为"原始时钟来源",因为它精度够高(外部晶振比内部 RC 振荡器准得多);
  • 兜底机制 :万一运行过程中 HSE 出现异常,时钟树会自动切换使用 HSI 作为替补"原始时钟来源"。而完成这个切换工作的,是一个叫 CSS 的硬件单元。

2.3.3 CSS:时钟安全系统(Clock Security System)

CSS 的全称是 Clock Security System(时钟安全系统),它的核心特性就一句话:

HSE 故障 → 自动切到 HSI。

STM32F103 的 CSS 一旦检测到 HSE 失效(无论 HSE 是直接做 SYSCLK,还是经 PLL 之后再做 SYSCLK),硬件会立即

  1. 关闭 HSE 和 PLL
  2. 把 SYSCLK 强制切换到 HSI(8 MHz)
  3. 这个过程完全由硬件完成,软件来不及干预,但会产生 NMI(不可屏蔽中断)供后续补救。

反过来要注意:HSI 故障 → 不会自动切到 HSE。因为 HSI 是内部 RC 振荡器,规格书里不存在"HSI 失效检测"与"自动切到 HSE"的机制。如果 HSI 意外停振,系统只能死机或复位。

因此,只有 HSE 故障时 MCU 才会自动降级到 HSI;若想反向保护(HSI 挂了切 HSE),需要软件自己写监控逻辑。

CSS 有什么用? 举个例子:在工业、汽车等对可靠性要求高的场景,HSE 一旦停振,PLL 和 SYSCLK 会立刻丢失,CPU、外设、通信全部停摆。CSS 能在毫秒级把时钟切到内部 HSI(8 MHz),至少让 MCU 继续运行,并有机会上报故障或安全停机,避免设备失控。


📌 本知识点总结:什么是时钟树?

时钟树 是 MCU 内部一套专门的硬件电路,它把若干"原始时钟来源"作为树根,通过分频器、倍频器(PLL)、多路选择器和使能开关这些节点,像树一样逐级派生并分发时钟,从而用一套电路满足所有外设不同的频率需求。之所以要单独设计时钟树,是因为给每个外设单独配时钟源会造成大量重复电路、推高成本,所以必须集中管理、按需分配。

STM32F103 有四个原始时钟来源:HSI(8MHz 内部)、HSE(外部晶振,板上 8MHz)、LSI(40kHz 内部)、LSE(32.768kHz 外部)

时钟树可分为三部分:第一部分 驱动 CPU、内存和绝大部分外设,是最核心的部分,从中派生出一组关键时钟------PLLCLK(锁相环倍频输出)、SYSCLK(系统根时钟)、FCLK(CPU 自由运行时钟)、HCLK(AHB 总线时钟,72MHz)、PCLK1(APB1 低速外设时钟,最高 36MHz)、PCLK2(APB2 高速外设时钟,最高 72MHz)第二部分 用低频时钟驱动 RTC 与看门狗;第三部分用于时钟输出(MCO)。

另外,时钟树内置了 CSS(时钟安全系统):当 HSE 故障时,硬件会自动关闭 HSE 与 PLL,并把 SYSCLK 强制切换到 HSI(8MHz),以保证系统不至于完全停摆,同时产生 NMI 供软件补救;但 HSI 故障不会自动切到 HSE。

我们开发板上的标准配置是:HSE 8MHz → PLL 9倍频 → SYSCLK 72MHz → HCLK 72MHz(÷1)→ PCLK2 72MHz(÷1)、PCLK1 36MHz(÷2)


第三章 时钟树、总线与外设是怎么配合的?

看完时钟树,你可能已经隐隐感觉到:时钟树和"外设"、"总线"之间一定有关系。没错,这一章我们就把它们串起来,打通理解。

3.1 总线的理解

先统一概念:这里的 ICode、DCode、System、AHB、APB 等总线,硬件本质是一样的(都是线簇),只是接的设备不同、叫法不同。

总线不是一根线,而是一堆线的集合(线簇)。就像一条高速公路不是单车道,而是多车道的组合。

总线内部一般分为三类:

  1. 地址总线:表明"去哪里"。读的时候表示从哪读,写的时候表示写到哪;
  2. 数据总线:表明"具体数据"是什么;
  3. 控制总线:控制什么时候读、什么时候写、是否读写完毕、是读还是写等与控制逻辑有关的内容。

就 STM32F103 系列来说,官方没有披露具体总线数量,我们按照如下规模来设计(方便理解):

总线类型 根数(示意) 作用
地址总线 32 根 表示"去哪里"
数据总线 32 根 表示"具体数据"
控制总线 若干根 控制读写时序

其中,时钟线(CLK)和读写方向线就设计在控制总线内部

无论 AHB 总线、APB1/APB2 总线,还是 IBUS、DBUS 等,结构都是上面这样:地址 + 数据 + 控制。

3.2 外设寄存器

"寄存器"不是 CPU 的专利,外设里也有寄存器(比如之前讲过的 GPIO)。

STM32F103 上不同片上外设的寄存器种类可能不同,但大体上可以分为这么几类:

  • 数据寄存器:存放要发送/接收的数据;
  • 控制/配置寄存器:设置外设的工作模式;
  • 状态寄存器:反映外设当前的状态(比如忙不忙、有没有出错);
  • ......

所以,"读写数据"的本质,就是把数据写入(或读出来自)目标片上外设的寄存器中。

比如,CPU 要执行这样一行 C 语言代码(示意):

c 复制代码
// 往地址 0x4001100C 处写入数值 10(示意,比如往某个外设寄存器写 10)
*(uint32_t *)0x4001100C = 10;

这一行代码背后,发生了"CPU → 总线 → 外设寄存器"的数据流动。但这里还有一个关键问题:

总线上有数据,你得保证路径上的设备也同时都在协同工作! 数据流过去了,可如果目标外设根本没"醒着",数据就是白写。

这就必须引入时钟树了。

3.3 CPU 操作片上外设:从"保存 1 个比特位"说起

3.3.1 结论先行

  • 设备之间(比如 CPU 和 GPIO)通信的本质:是从 CPU 内寄存器和外设寄存器之间的数据互相拷贝。
  • 至于 CPU 对 SRAM 等内部存储器的访问(地址译码 + 存储单元读写,不涉及外设寄存器),属于另一类特殊场景,这里先不展开。

那么,寄存器到底是怎么工作的?寄存器由很多位(bit)构成,所以先搞清楚:一个存储单元是怎么保存 1 个比特位的?

补充:寄存器其实是由若干个 D 触发器构成的。这里我们先做理论说明,不去深挖硬件电路细节(想了解硬件电路视角可以看硬件加餐课)。

3.3.2 一个能保存 1 个比特位的存储单元

为了方便画图,我们先用 8 位寄存器举例(STM32F103ZET6 实际是 32 位,但道理完全一样,学完这个自然能平滑迁移)。

一个存储单元,要能保存数据,必须具备三种功能:

  1. 写入:能把数据写进去;
  2. 读取(拷贝):能读出来,而且读完之后数据还在里面(拷贝);
  3. 保持(锁存):在下一次写入到来之前,存储单元里的数据保持不变。

要让存储单元支持这三个功能,就要引出对应的导线,并且在内部加一个"开关":

  • 写完成后断开开关,数据就一直保持住;
  • 直到下次开关闭合,才允许新数据再次写入;
  • 也就是说,"开关闭合"就意味着"要有新数据到来了"。

于是,要成功把数据写入存储单元 A,必须同时满足两个条件:

  1. 写端有数据(比如写 1);
  2. 开关闭合

如果开关不闭合,存储单元内部就保持老的值不变,这就是"保持"功能。

那"开关"怎么闭合呢?总不能用手去摁吧?所以存储单元被设计成用电平来控制开关

3.3.3 什么时候"闭合开关"?------触发方式

闭合存储单元的开关,有四种触发方式(暂时不重要,了解即可):

  • 上升沿触发(电平从低变高的瞬间)
  • 下降沿触发(电平从高变低的瞬间)
  • 高电平触发(电平保持高期间)
  • 低电平触发(电平保持低期间)

为简单起见,我们这里就认为:存储单元除了收到数据,还要收到一个上升沿来闭合开关,数据才能写入。

3.3.4 从 1 个比特位到 1 个寄存器

保存 1 个比特位搞定了,那 8 个比特位呢?

做法:让 8 个存储单元共用一个使能位(同一个时钟沿),统一输入、统一步调。

恭喜你!你自己做出了一个 8 位寄存器! 32 位同理,只是并排 32 个存储单元而已。

比如要往寄存器里写入十进制数 10,10 的二进制是 0000 1010,那就让对应的 8 个存储单元分别存入这 8 个 bit。

3.3.5 CPU 和外设通信的完整过程

有了寄存器,CPU 就能和某个外设通信了。整个过程是这样的:

  1. 一个时钟到来,CPU 把数据放到数据总线上(同时也把目标地址、读写方向等信息放到总线对应位置);
  2. 与此同时,不考虑分频的情况下,目标片上外设的寄存器"使能开关"也被打开;
  3. 数据就从 CPU → 总线 → 外设寄存器,写入成功;
  4. 如果考虑分频,也无非是"隔几个上升沿再使能",照样能把数据读进外设寄存器。

所以,终于可以回答最开始那个问题了:时钟线和数据读写的关系是什么?

时钟线上的时钟,让"放数据到总线"和"开启目标设备寄存器"同步进行。 这里的同步不一定是"同时",因为分频的存在,这里的同步是"步调有规律"------该合拍的时候就合拍。


📌 本知识点总结:CPU 是如何操作片上外设的?

总线 是 CPU 与外设通信的"交通干线",它不是一个根线,而是一簇线,分为地址总线 (表示去哪里)、数据总线 (表示具体数据)和控制总线(控制读写时序,时钟线 CLK 也在其中)。

外设内部也有寄存器,分为数据寄存器、控制/配置寄存器、状态寄存器等。CPU 与片上外设通信的本质,就是 CPU 寄存器与外设寄存器之间的数据拷贝

寄存器由若干存储单元构成,每个存储单元具备"写入、读取(拷贝)、保持(锁存)"三种能力,并用一个由时钟沿(如上升沿)控制的开关来决定何时允许新数据写入;多个存储单元共用一个使能位就组成了寄存器。

时钟与数据读写的关系是:时钟线上的时钟,让"CPU 把数据放到总线"和"目标外设寄存器的使能开关打开"同步进行------虽然因分频不一定是同一瞬间,但步调是规律一致的。这也解释了为什么用任何外设之前必须先使能它的时钟:只有时钟打开,外设寄存器才能和总线"合拍"工作。


第四章 RCC:复位与时钟控制器

前面我们讲了时钟、时钟树、时钟树与总线设备的关系。现在,我们要过渡到"配置层面"了。

结论先行:所谓的配置,就是配置寄存器。

我们经常听到一句话:"使用外设之前,要开启外设时钟"。比如开启 GPIO 时钟,我们理解本质是把 GPIO 设备的时钟线开关闭合,让它用上 PCLK2 时钟线上的时钟。

但是,这里有两个疑问:

  1. 那个"开关"到底是什么?
  2. "配置"具体是怎么配置的?

下面我们一层层揭开答案。

4.1 理解"开关"电路:与门(AND Gate)

时钟树里的"使能开关",在硬件上常常就是一个 与门(AND Gate)

与门的逻辑是:输出 = A 与 B。我们枚举一下所有可能:

输入 A 输入 B 输出
1 1 1
1 0 0
0 1 0
0 0 0

观察这张表,你会发现一个绝妙的结论:

  • 当输入 A = 1 的时候,输出与输入 B 相同(B 是 1,输出就是 1;B 是 0,输出就是 0);
  • 当输入 A = 0 的时候,输入 B 无效(无论 B 是几,输出都是 0)。

这不就是一个开关吗!

  • A = 1:开关闭合,B 的信号放行;
  • A = 0:开关断开,B 的信号被挡住。

注意:开关的实现方式有很多种,与门只是其中一种,但理解它的思路是通用的。

4.2 理解"配置寄存器 = 使能外设"

光有与门还不够------谁来控制输入 A 呢?当然不可能靠人手,要靠存储单元(寄存器)

于是有了下面这个组合:

  • 配置存储单元,就可以达到使能开关的目的
  • 存储单元保存了 1,它就持续输出 1,与门开关一直"开启";
  • 存储单元保存了 0,它就持续输出 0,与门开关一直"断开"。

最终,整条链路是这样的:

复制代码
寄存器某一位(存储单元,输出 0 或 1)
        │  走线连接
        ▼
与门(AND Gate)的 enable 输入
        │
   时钟信号进入与门另一个输入端
        ▼
   只有寄存器位 = 1 时,时钟才放行到外设

所以,"通过配置寄存器使能外设"的本质就是:

寄存器的输出,通过走线连接到片上外设的与门开关 enable 输入。你配置寄存器(置 1 或清 0),就是在使能或失能片上外设。
上图只是说明"使能外设"的原理,其他用寄存器进行配置的思想类似,原理上可能略有不同。不过我们走到这里,就已经不需要再追问"为什么配置时钟树改一下寄存器就可以了"------通过一个典型代表,已经足够支撑理解了。更细节的东西,意法官方没有披露,我们也不可能拿到。

4.3 引出 RCC 概念:复位与时钟控制器

整个 MCU 内部,有非常多的功能需要配置,最终都表现为:

用一堆寄存器,来表示 MCU 内部常见的配置------比如时钟源选择、倍频分频操作、使能外设、复位设备等等。

我们把"专门负责 MCU 内部整体时钟与复位配置"的功能模块,叫做 RCC

RCC 的全称是 Reset and Clock Controller,即复位与时钟控制器,字面意思就是"复位和时钟控制"。目前我们主要关注它的时钟部分,复位问题暂时先不管。

4.3.1 RCC 内部常用的寄存器一览

RCC 内部用来配置的寄存器有很多,下面列出一批最常用的:

序号 寄存器名 全名 复位值 主要作用(一句话记忆)
1 RCC_CR Clock Control Register(时钟控制寄存器) 0x0000 0083 开/关全部时钟源(HSI/HSE/PLL/LSI/LSE)及就绪标志
2 RCC_CFGR Clock Configuration Register(时钟配置寄存器) 0x0000 0000 选时钟源、设分频、倍频、MCO 输出
3 RCC_CIR Clock Interrupt Register(时钟中断寄存器) 0x0000 0000 时钟就绪/失钟中断使能与标志管理
4 RCC_APB2RSTR APB2 Peripheral Reset Register(APB2 外设复位寄存器) 0x0000 0000 一键复位挂在 APB2 上的高速外设
5 RCC_APB1RSTR APB1 Peripheral Reset Register(APB1 外设复位寄存器) 0x0000 0000 一键复位挂在 APB1 上的低速外设
6 RCC_AHBENR AHB Peripheral Clock Enable Register(AHB 外设时钟使能寄存器) 0x0000 0014 开/关 DMA、SRAM、CRC、FSMC、SDIO 等 AHB 外设时钟
7 RCC_APB2ENR APB2 Peripheral Clock Enable Register(APB2 外设时钟使能寄存器) 0x0000 0000 开/关 GPIO、TIM1、ADC1/2、USART1、SPI1 等 APB2 外设时钟
8 RCC_APB1ENR APB1 Peripheral Clock Enable Register(APB1 外设时钟使能寄存器) 0x0000 0000 开/关 TIM2~7、USART2/3、I²C1/2、USB、CAN 等 APB1 外设时钟
9 RCC_BDCR Backup Domain Control Register(备份域控制寄存器) 0x0000 0000 控制 RTC、LSE(32 kHz)及备份域复位
10 RCC_CSR Control/Status Register(控制/状态寄存器) 0x0C00 0000 读"谁复位了我",清标志;控制 LSI 开关

我们之前在"理解原理"章节里画的那个寄存器,类比的就是 RCC_APB1ENR 寄存器------把它的某个位写 1,就能使能挂在 APB1 总线上的某个外设的时钟。

现在,当你再翻开《STM32F10XX 参考手册》的 RCC 章节,看这些寄存器的位定义,就都能听懂了。


📌 本知识点总结:RCC 是什么?

RCC(Reset and Clock Controller,复位与时钟控制器) 是 MCU 内部负责统一管理和配置时钟与复位的功能模块。

"配置"的本质就是配置寄存器 。在硬件上,"使能外设时钟"的实现原理是:用一个与门充当时钟的"使能开关",当寄存器某一位输出 1 时,与门放行时钟信号给外设;当该位输出 0 时,时钟被挡住,外设失能。寄存器由若干具备写入、读取、保持能力的存储单元构成,所以"写寄存器"就是在拨动这些开关。

RCC 通过一系列寄存器来完成各类配置,常用的包括:RCC_CR (开关各时钟源与就绪标志)、RCC_CFGR (选择时钟源、配置分频倍频、MCO 输出)、RCC_APB2ENR / RCC_APB1ENR / RCC_AHBENR (分别使能挂在 APB2、APB1、AHB 总线上的外设时钟)、RCC_BDCR(控制 RTC 与 LSE)等。

因此,使用任何片上外设之前必须先使能它的时钟,而"使能时钟"在代码层面就是向 RCC 对应的使能寄存器某一位写入 1。


第五章 动手实操:让 LED 的闪烁速度因时钟而改变

理论部分告一段落,现在我们动手做一个有趣的实验:通过调整时钟配置,改变 LED 灯的闪烁速度。这能让你直观感受到"时钟频率影响系统运行速度"这件事。

5.1 测试方案原理:用 CPU 死循环做延时

之前点灯我们用的是 HAL 库自带的 HAL_Delay() 函数做延时。这次我们不用它,而是写一个简单的 CPU 死循环延时函数:

c 复制代码
/**
 * @brief  CPU 忙等延时函数:让 CPU 空转 delay 次
 * @param  delay  循环次数(无符号 32 位整数)
 * @retval 无
 */
void CPU_Delay(uint32_t delay)
{
    while (delay--);  // 空循环,只消耗 CPU 时间,不做任何事
}

原理非常巧妙:

CPU 执行指令的速度,受时钟树中 FCLK(CPU 自由运行时钟) 的控制。

  • 把时钟频率调高 → 执行同样次数的 CPU_Delay(XXX) 代码,耗时变短 → 灯闪得快;
  • 把时钟频率调低 → 执行同样次数的 CPU_Delay(XXX) 代码,耗时变长 → 灯闪得慢。

就好比军训喊口号:喊得快,走完 100 米正步用的时间就短;喊得慢,用的时间就长。

这个实验的精髓在于:代码里循环次数完全一样,变化的只有时钟频率。 于是 LED 闪烁快慢的变化,就直接体现了时钟配置对系统的影响。

5.2 LED 灯选择与接线

在我的开发板上,STM32 芯片与 LED 灯的连接关系是:

  • 本次实验使用的 LED 连接在 PF8 引脚(GPIOF 端口,第 8 位引脚);
  • 配置模式为 推挽输出(Output Push-Pull)

推挽输出:引脚既能输出高电平,也能输出低电平,驱动 LED 点亮/熄灭非常合适。

5.3 使用 STM32CubeMX 开始配置

下面我们一步步在 STM32CubeMX 里完成工程配置。

第 1 步:开启调试接口(SWD)------非常重要!

我的开发板预留的仿真器调试接口是 SWD(Serial Wire Debug,串行线调试) 。在 CubeMX 的 SYS(System,系统)配置页面中,Debug(调试)选项选择 "Serial Wire"

🚧 注意!这一步非常重要!

如果没有修改这里,使用了默认的 No Debug第一次烧录程序可以成功,但后续烧录程序就会烧录失败!原因在于:第一次烧录后,代码运行时把调试端口占用了,下次就没法再通过调试器连接了。

补充一个小知识------DAP 与 SWD 的区别

  • DAP(Debug Access Port,调试访问端口):是一个物理仿真器(硬件),比如 DAP-Link 就是一块调试器小板子;
  • SWD(Serial Wire Debug,串行线调试):是一种调试通信协议;
  • 关系:DAP-Link 通过 SWD 协议与目标芯片通信。

打个比方:

  • DAP = 考试时坐在座位上的同学本人(物理存在);
  • SWD = 用来传递纸条的通道 + 约定好的手势或暗号(通信方式)。

第 2 步:开启外部时钟源(HSE)

在 CubeMX 的 RCC(复位与时钟控制)配置页面中,HSE(高速外部时钟)选项选择 "Crystal / Ceramic Resonator"

含义:选择外部晶振(陶瓷晶振也属于此类)作为 HSE 时钟源。这样,时钟树里就多了一个"外部高速时钟"这个原始来源。

第 3 步:配置时钟树(配置成 72MHz)

打开 Clock Configuration(时钟配置)页面:

  1. 确认 HSE 输入为 8 MHz(我们开发板上的外部晶振就是 8MHz);
  2. 将 PLL 倍频系数设置为 ×9 ,于是 8 MHz × 9 = 72 MHz
  3. 系统时钟(SYSCLK)选择 PLLCLK,最终系统运行最大频率配置为 72 MHz

在 CubeMX 里,时钟树配置页面会自动帮你计算好 HCLK、PCLK1、PCLK2 等值,配置完成后点一下鼠标就能生效。

第 4 步:开启引脚 GPIO 输出

  1. 在芯片引脚图里,用鼠标左键点击 PF8 引脚;
  2. 在弹出的菜单中选择 GPIO_Output(GPIO 输出模式);
  3. 在 GPIO 配置页面里,设置:
    • Mode(模式):Output Push Pull(推挽输出);
    • 其余保持默认即可。

第 5 步:设置项目路径与工具链

  1. 填写项目名称(Project Name);
  2. 填写项目保存路径(Project Location);
  3. Tool Chain(工具链)选择 "MDK-ARM"

🚧 MDK-ARM 就是 Keil 这一套开发工具。 STM32CubeMX 只是帮我们简化"创建工程"的过程,而真正的"开发代码 / 编译 / 烧录 / 调试"这些过程,仍然是通过 Keil(MDK-ARM)完成的。

第 6 步:生成项目

点击右上角的 "Generate Code"(生成代码)按钮。

此时,STM32CubeMX 就会为我们生成一个完整的工程。

第 7 步:打开项目

生成完成后,CubeMX 会提示我们打开工程。如果你本机装有 Keil,点击打开,Keil 会自动启动并加载这个工程。

5.4 编写代码(带详细注释)

在生成的工程中,找到 main.c,在 CubeMX 预留的 USER CODE 区块中添加我们的代码。

c 复制代码
/* USER CODE BEGIN PFP */
/* 函数声明:CPU 忙等延时函数(放在用户代码区,不会被 CubeMX 覆盖) */
void CPU_Delay(volatile uint32_t delay);
/* USER CODE END PFP */

/* 主循环:进入 while(1) 死循环之前,CubeMX 已经帮我们完成了时钟和外设初始化 */

  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* 翻转 PF8 引脚的输出电平:亮→灭→亮→灭...... */
    HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);

    /* CPU 忙等延时:循环次数相同,但时钟越快耗时越短,灯闪得越快 */
    CPU_Delay(XXXXX);        // XXXXX 是循环次数,需要根据实验效果调整
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */

/* USER CODE BEGIN 4 */

/**
 * @brief  CPU 忙等延时函数
 * @param  delay  循环次数,参数用 volatile 修饰,防止编译器优化掉空循环
 * @retval 无
 */
void CPU_Delay(volatile uint32_t delay)
{
  while (delay--);   // 空循环死等,占用 CPU 时间
}

/* USER CODE END 4 */

💡 小知识volatile 关键字在这里很重要。如果不用 volatile,编译器发现这个循环不改变任何外部变量,很可能直接把它优化没了------那样延时效果就消失了。

5.4.1 粗粒度估算:循环多少次能延时约 200ms?

我们来做一个简单的估算(不追求精确,只求数量级正确):

  • STM32F103 在 72MHz 下,1 个时钟周期 ≈ 13.9ns

  • 我们要延时约 200ms,需要的总时钟周期数:

    复制代码
    200ms = 0.2 秒
    需要的时钟周期数 ≈ 0.2 × 72,000,000 ≈ 1440 万(1.44×10⁷)个周期
  • 考虑到 while(delay--) 这一行在 Cortex-M3 上编译后,大约需要 4~6 个时钟周期(从 Flash 取指令、比较、自减、跳转等,粗算按 4 个计);

  • 所以循环次数大约为:

    复制代码
    delay ≈ 14,400,000 ÷ 4 ≈ 3,600,000 次(约 360 万次)

所以,一个大概的取值可以是 CPU_Delay(3600000)。不过实际烧录后建议边观察边微调这个数值,因为编译器优化、Flash 等待周期等因素都会影响真实耗时。

5.5 烧写测试

代码写好后:

  1. 在 Keil 里点击 编译(Build)
  2. 连接 DAP 仿真器,点击 下载(Download) 烧录;
  3. 上电运行,观察 LED 闪烁效果。

此时你应该能看到 LED 以某种速度闪烁。

5.6 调整时钟:感受时钟对系统速度的影响

实验的"高光时刻"来了!

回到 STM32CubeMX 的时钟配置页面,降低系统时钟频率(比如把 PLL 倍频从 ×9 改成更小的倍数,或者直接降低 HCLK 分频比),重新生成代码、编译烧录。

你会发现:同样一段 CPU_Delay(XXXXX) 代码,LED 闪烁变慢了!

原因正如前面所说:

CPU 执行指令的速度由 FCLK 决定。时钟频率降低 → 同样的循环次数消耗的真实时间变长 → LED 闪烁变慢。这就是时钟配置影响系统运行速度的直接证据。

如果你把频率调高(回到 72MHz),灯又会重新闪得快起来。

5.7 烧写失败?------多半是调试接口没开

如果你遇到了"第一次烧录成功,第二次就烧录失败"的情况,十有八九就是第 5.3 节第 1 步"开启 SWD 调试接口"没做

解决办法:

  1. 使用调试器的擦除功能或 ISP 方式把芯片恢复;
  2. 回到 CubeMX,把 Debug 选项改成 "Serial Wire"
  3. 重新生成代码并烧录一次,之后就能持续正常烧录了。

📌 本知识点总结:如何用 CubeMX 配置时钟并让 LED 验证?

配置 STM32 时钟的完整流程 大致是:先在 STM32CubeMX 中把 SYS 的 Debug 配置为 Serial Wire(SWD) ,否则会出现"第一次烧录成功、之后烧录失败"的问题;然后在 RCC 页面把 HSE 选为外部晶振 ;再在 Clock Configuration 页面把 HSE(8MHz)经 PLL 倍频到 72MHz,并让 SYSCLK 选择 PLLCLK 作为时钟源;接着给用到的引脚(如 PF8)配置为 GPIO 推挽输出;最后设置工程名、路径、工具链(MDK-ARM)并生成代码。

验证时钟影响系统速度的原理是:CPU 执行指令的速度由 FCLK 决定 。用一个空转循环 while(delay--) 作为延时,时钟频率越高,同样循环次数耗时越短,LED 闪烁越快;时钟频率越低,闪烁越慢。所以调整时钟配置后,LED 闪烁快慢会随之改变,从而直观验证时钟树的作用。粗略估算:72MHz 下空循环约 4~6 个时钟周期一次,延时约 200ms 大约需要 300 万到 400 万次循环。


第六章 源码解析:HAL 库背后到底做了什么

CubeMX 帮我们生成了代码,但作为初学者,我们更要知道这些代码为什么能生效。这一章我们就深入到源码层面,看看"使能外设时钟、配置时钟树"这些操作,在 HAL 库里到底是怎么一步步变成寄存器的读写。

6.1 使能 GPIO 与 RCC 的物理地址

还记得前面说的"统一编址"吗?在 STM32F103ZET6 中,RCC(复位与时钟控制)外设也是一个片上外设,它也有自己的地址空间:

RCC 外设的地址空间范围是 0x4002 10000x4002 13FF,大小为 1KB。

这些地址是怎么来的?看下面的宏定义链路(这段代码来自 HAL 库头文件):

c 复制代码
/* 外设基地址:所有片上外设统一编址的起始地址 */
#define PERIPH_BASE            0x40000000UL   /*!< 外设区基地址 */

/* AHB 总线外设基地址 = 外设基地址 + 0x20000 */
#define AHBPERIPH_BASE         (PERIPH_BASE + 0x00020000UL)  /*!< AHB 外设基地址 */

/* RCC 外设基地址 = AHB 外设基地址 + 0x1000 */
#define RCC_BASE               (AHBPERIPH_BASE + 0x00001000UL)  /*!< RCC 外设基地址 */

/* RCC 被定义为一个指向 RCC_TypeDef 结构体的指针,基地址为 RCC_BASE */
#define RCC                    ((RCC_TypeDef *)RCC_BASE)   /*!< RCC 寄存器结构体指针 */

计算一下:

复制代码
0x40000000 + 0x00020000 = 0x40020000  (AHB 外设基地址)
0x40020000 + 0x00001000 = 0x40021000  (RCC 外设基地址)

结果正是 0x4002 1000,与参考手册一致!

而 RCC 的寄存器,就按照固定顺序排列在这个地址上。HAL 库用一个结构体来映射这些寄存器:

c 复制代码
/**
 * @brief  RCC 寄存器结构体定义
 *         结构体成员的顺序,必须和芯片参考手册中寄存器的偏移地址一一对应
 * @note   __IO 表示 volatile(易失性),防止编译器对寄存器读写做优化
 */
typedef struct
{
  __IO uint32_t CR;        /*!< 时钟控制寄存器,偏移 0x00 */
  __IO uint32_t CFGR;      /*!< 时钟配置寄存器,偏移 0x04 */
  __IO uint32_t CIR;       /*!< 时钟中断寄存器,偏移 0x08 */
  __IO uint32_t APB2RSTR;  /*!< APB2 外设复位寄存器,偏移 0x0C */
  __IO uint32_t APB1RSTR;  /*!< APB1 外设复位寄存器,偏移 0x10 */
  __IO uint32_t AHBENR;    /*!< AHB 外设时钟使能寄存器,偏移 0x14 */
  __IO uint32_t APB2ENR;   /*!< APB2 外设时钟使能寄存器,偏移 0x18 */
  __IO uint32_t APB1ENR;   /*!< APB1 外设时钟使能寄存器,偏移 0x1C */
  __IO uint32_t BDCR;      /*!< 备份域控制寄存器,偏移 0x20 */
  __IO uint32_t CSR;       /*!< 控制/状态寄存器,偏移 0x24 */
} RCC_TypeDef;

这样一来,RCC->APB2ENR 在 C 语言里就代表"地址为 RCC 基地址 + APB2ENR 偏移量的那个寄存器",我们对它读写,就是在读写真实的硬件寄存器。

6.2 使能 GPIOF 时钟:__HAL_RCC_GPIOF_CLK_ENABLE()

CubeMX 生成的 MX_GPIO_Init() 函数里,第一步就是使能 GPIOF 端口的时钟:

c 复制代码
/**
 * @brief  GPIO 初始化函数(CubeMX 自动生成)
 * @retval 无
 */
static void MX_GPIO_Init(void)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};   /* 定义 GPIO 初始化参数结构体并清零 */

  /* USER CODE BEGIN MX_GPIO_Init_1 */

  /* USER CODE END MX_GPIO_Init_1 */

  /* GPIO Ports Clock Enable:使能 GPIOF 端口时钟(没有时钟,GPIO 不工作) */
  __HAL_RCC_GPIOF_CLK_ENABLE();

  /* 配置 GPIO 引脚输出电平:先把 PF8 初始化为高电平 */
  HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET);

  /* 配置 GPIO 引脚:PF8 */
  GPIO_InitStruct.Pin   = GPIO_PIN_8;              /* 选择第 8 位引脚 */
  GPIO_InitStruct.Mode  = GPIO_MODE_OUTPUT_PP;     /* 推挽输出模式 */
  GPIO_InitStruct.Pull  = GPIO_NOPULL;             /* 无上下拉 */
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;     /* 输出速度:低速 */
  HAL_GPIO_Init(GPIOF, &GPIO_InitStruct);          /* 把以上配置写入 GPIOF 寄存器 */

  /* USER CODE BEGIN MX_GPIO_Init_2 */

  /* USER CODE END MX_GPIO_Init_2 */
}

那个神秘的 __HAL_RCC_GPIOF_CLK_ENABLE(),其实是一个宏,展开后长这样:

c 复制代码
/**
 * @brief  使能 GPIOF 端口时钟(宏定义)
 * @note   原理:向 RCC->APB2ENR 寄存器写入 IOPFEN 位(置 1)
 *         因为 GPIO 挂在 APB2 总线上,所以用 APB2 的时钟使能寄存器
 */
#define __HAL_RCC_GPIOF_CLK_ENABLE()   do { \
                                          __IO uint32_t tmpreg; \
                                          /* 把 APB2ENR 的 IOPFEN 位置 1,开启 GPIOF 时钟 */ \
                                          SET_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPFEN); \
                                          /* 读回该位并赋值给临时变量,确保写操作真正完成 */ \
                                          tmpreg = READ_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPFEN); \
                                          UNUSED(tmpreg); \
                                        } while(0U)

其中 SET_BIT 的底层实现就是一条"置位"操作:

c 复制代码
/* 宏:把 REG 的 BIT 位置 1,即 REG = REG | BIT */
#define SET_BIT(REG, BIT)     ((REG) |= (BIT))

RCC_APB2ENR_IOPFEN 这个位的定义是:

c 复制代码
#define RCC_APB2ENR_IOPFEN_Pos   (6U)                /*!< 位偏移:第 6 位 */
#define RCC_APB2ENR_IOPFEN_Msk   (0x1UL << RCC_APB2ENR_IOPFEN_Pos)  /*!< 掩码:0x40 */
#define RCC_APB2ENR_IOPFEN       RCC_APB2ENR_IOPFEN_Msk  /*!< GPIOF 时钟使能位 */

一句话总结:__HAL_RCC_GPIOF_CLK_ENABLE() 的本质,就是 RCC->APB2ENR |= 0x40,把 GPIOF 的时钟使能位置 1。 这一位通过芯片内部的走线,连接到 GPIOF 外设时钟的"与门开关",于是时钟信号被放行,GPIOF 开始工作。

6.3 选择时钟源:SystemClock_Config()HAL_RCC_OscConfig()

CubeMX 生成的 SystemClock_Config() 是配置整个系统时钟的"总开关"。它做两件大事:

  1. 配置各振荡器(HSE/HSI 等)和 PLL;
  2. 配置系统时钟源和各级分频。
c 复制代码
/**
 * @brief  系统时钟配置函数(CubeMX 自动生成)
 * @retval 无
 */
void SystemClock_Config(void)
{
  RCC_OscInitTypeDef RCC_OscInitStruct = {0};   /* 振荡器配置结构体:用于配置时钟源 */
  RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};   /* 时钟配置结构体:用于配置分频倍频 */

  /* ========== 第一步:配置振荡器(HSE 使能 + PLL 配置) ========== */
  /* 需要配置的振荡器类型:HSE(外部高速时钟) */
  RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;

  /* HSE 状态:开启 */
  RCC_OscInitStruct.HSEState = RCC_HSE_ON;

  /* HSE 预分频:不分频(8MHz 原样送入 PLL) */
  RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;

  /* HSI 状态:也开启(作为备用,防止 HSE 异常时系统没有时钟) */
  RCC_OscInitStruct.HSIState = RCC_HSI_ON;

  /* PLL 状态:开启 */
  RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;

  /* PLL 输入源:选择 HSE */
  RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;

  /* PLL 倍频系数:9 倍频。HSE 8MHz × 9 = 72MHz */
  RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;

  /* 把结构体配置写入寄存器,并等待相关时钟就绪;失败则进入错误处理 */
  if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
  {
    Error_Handler();   /* 配置失败的错误处理函数 */
  }

  /* ========== 第二步:配置系统时钟源与总线分频 ========== */
  /* 需要配置的时钟类型:SYSCLK、HCLK、PCLK1、PCLK2 */
  RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK
                              | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;

  /* 系统时钟 SYSCLK 的时钟源:选择 PLL 输出(PLLCLK) */
  RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;

  /* AHB 总线分频:HCLK = SYSCLK ÷ 1 = 72MHz */
  RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;

  /* APB1 分频:PCLK1 = HCLK ÷ 2 = 36MHz(APB1 外设最高 36MHz) */
  RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;

  /* APB2 分频:PCLK2 = HCLK ÷ 1 = 72MHz(APB2 外设最高 72MHz) */
  RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;

  /* 写入寄存器;72MHz 时 Flash 需要 2 个等待周期(FLASH_LATENCY_2) */
  if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
  {
    Error_Handler();   /* 配置失败的错误处理函数 */
  }
}

6.3.1 深入:HAL_RCC_OscConfig() 内部做了什么?

HAL_RCC_OscConfig() 的本质其实就是访问寄存器 。比如配置 HSE,最终会调用一个 __HAL_RCC_HSE_CONFIG 宏:

c 复制代码
/**
 * @brief  配置 HSE 振荡器状态(宏定义,摘自 HAL 库)
 * @param  __STATE__  目标状态:RCC_HSE_ON / RCC_HSE_OFF / RCC_HSE_BYPASS
 * @note   本质:读写 RCC->CR(时钟控制寄存器)里的 HSEON、HSEBYP 位
 */
#define __HAL_RCC_HSE_CONFIG(__STATE__)  \
                    do {                 \
                      if ((__STATE__) == RCC_HSE_ON)          /* 要开启 HSE */ \
                      {                                       \
                        /* 将 CR 寄存器 HSEON 位置 1,打开 HSE 振荡器 */ \
                        SET_BIT(RCC->CR, RCC_CR_HSEON);       \
                      }                                       \
                      else if ((__STATE__) == RCC_HSE_OFF)    /* 要关闭 HSE */ \
                      {                                       \
                        /* 清除 HSEON 和 HSEBYP 位,关闭 HSE 并取消旁路 */ \
                        CLEAR_BIT(RCC->CR, RCC_CR_HSEON);     \
                        CLEAR_BIT(RCC->CR, RCC_CR_HSEBYP);    \
                      }                                       \
                      else if ((__STATE__) == RCC_HSE_BYPASS) /* 旁路模式 */ \
                      {                                       \
                        /* 设置 HSEBYP 并打开 HSEON,用外部信号直接注入 */ \
                        SET_BIT(RCC->CR, RCC_CR_HSEBYP);      \
                        SET_BIT(RCC->CR, RCC_CR_HSEON);       \
                      }                                       \
                      else                                    /* 其他情况 */ \
                      {                                       \
                        /* 都清除,关闭 HSE */                \
                        CLEAR_BIT(RCC->CR, RCC_CR_HSEON);     \
                        CLEAR_BIT(RCC->CR, RCC_CR_HSEBYP);    \
                      }                                       \
                    } while(0U)

RCC_CR_HSEON 的定义(位 16):

c 复制代码
/* HSEON 位:位于 CR 寄存器的第 16 位 */
#define RCC_CR_HSEON_Pos      (16U)                          /*!< 位偏移:第 16 位 */
#define RCC_CR_HSEON_Msk      (0x1UL << RCC_CR_HSEON_Pos)    /*!< 掩码:0x00010000 */
#define RCC_CR_HSEON          RCC_CR_HSEON_Msk  /*!< 高速外部时钟使能位 */

LSE 的配置同理,只不过它操作的是 RCC_BDCR(备份域控制寄存器):

c 复制代码
/**
 * @brief  配置 LSE 振荡器状态(宏定义,摘自 HAL 库)
 * @param  __STATE__  目标状态:RCC_LSE_ON / RCC_LSE_OFF / RCC_LSE_BYPASS
 * @note   LSE 位于备份域,所以操作的是 RCC->BDCR 寄存器
 */
#define __HAL_RCC_LSE_CONFIG(__STATE__)  \
                    do {                 \
                      if ((__STATE__) == RCC_LSE_ON)          /* 要开启 LSE */ \
                      {                                       \
                        /* 将 BDCR 寄存器 LSEON 位置 1 */     \
                        SET_BIT(RCC->BDCR, RCC_BDCR_LSEON);   \
                      }                                       \
                      else if ((__STATE__) == RCC_LSE_OFF)    /* 要关闭 LSE */ \
                      {                                       \
                        /* 清除 LSEON 和 LSEBYP */            \
                        CLEAR_BIT(RCC->BDCR, RCC_BDCR_LSEON); \
                        CLEAR_BIT(RCC->BDCR, RCC_BDCR_LSEBYP); \
                      }                                       \
                      else if ((__STATE__) == RCC_LSE_BYPASS) /* 旁路模式 */ \
                      {                                       \
                        SET_BIT(RCC->BDCR, RCC_BDCR_LSEBYP);  \
                        SET_BIT(RCC->BDCR, RCC_BDCR_LSEON);   \
                      }                                       \
                      else                                    /* 其他情况 */ \
                      {                                       \
                        CLEAR_BIT(RCC->BDCR, RCC_BDCR_LSEON); \
                        CLEAR_BIT(RCC->BDCR, RCC_BDCR_LSEBYP); \
                      }                                       \
                    } while(0U)

/* LSEON 位:位于 BDCR 寄存器的第 0 位 */
#define RCC_BDCR_LSEON_Pos      (0U)                          /*!< 位偏移:第 0 位 */
#define RCC_BDCR_LSEON_Msk      (0x1UL << RCC_BDCR_LSEON_Pos) /*!< 掩码:0x00000001 */
#define RCC_BDCR_LSEON          RCC_BDCR_LSEON_Msk  /*!< 低速外部时钟使能位 */

可以总结出一个规律:HSE 的开关位在 RCC_CR 寄存器里,LSE 的开关位在 RCC_BDCR 寄存器里,HSI 的开关位在 RCC_CR 里,LSI 的开关位在 RCC_CSR 里。 不同时钟源对应不同寄存器,这就是为什么参考手册里 RCC 章节有一堆寄存器。

6.4 配置分频倍频:HAL_RCC_ClockConfig()

配置完振荡器,还要配置"分频",让 SYSCLK 分出 HCLK、PCLK1、PCLK2。这个操作由 HAL_RCC_ClockConfig() 完成。

我们看一下它内部对 PCLK1 的处理(以 APB1 分频为例,其他类似):

c 复制代码
HAL_StatusTypeDef HAL_RCC_ClockConfig(RCC_ClkInitTypeDef *RCC_ClkInitStruct,
                                      uint32_t FLatency)
{
  /* ... 前面省略(配置 SYSCLK 源、HCLK 分频、Flash 等待周期等) ... */

  /* -------------------- PCLK1 配置 -------------------- */
  /* 如果要求配置 PCLK1 时钟类型 */
  if (((RCC_ClkInitStruct->ClockType) & RCC_CLOCKTYPE_PCLK1) == RCC_CLOCKTYPE_PCLK1)
  {
    /* 参数合法性检查 */
    assert_param(IS_RCC_PCLK(RCC_ClkInitStruct->APB1CLKDivider));

    /* 修改 RCC->CFGR 寄存器的 PPRE1 字段([10:8] 位),写入 APB1 分频系数 */
    MODIFY_REG(RCC->CFGR, RCC_CFGR_PPRE1, RCC_ClkInitStruct->APB1CLKDivider);
  }

  /* -------------------- PCLK2 配置 -------------------- */
  /* 如果要求配置 PCLK2 时钟类型 */
  if (((RCC_ClkInitStruct->ClockType) & RCC_CLOCKTYPE_PCLK2) == RCC_CLOCKTYPE_PCLK2)
  {
    /* 参数合法性检查 */
    assert_param(IS_RCC_PCLK(RCC_ClkInitStruct->APB2CLKDivider));

    /* 修改 RCC->CFGR 寄存器的 PPRE2 字段([13:11] 位),写入 APB2 分频系数(左移 3 位) */
    MODIFY_REG(RCC->CFGR, RCC_CFGR_PPRE2, ((RCC_ClkInitStruct->APB2CLKDivider) << 3));
  }
  /* ... 后面省略 ... */
}

而分频系数的"值",就定义在宏里,直接对应寄存器里的位模式。比如:

c 复制代码
/* APB1 的 2 分频:对应 CFGR 寄存器 PPRE1 字段([10:8])的值为 100(二进制),即 0x400 */
#define RCC_HCLK_DIV2      RCC_CFGR_PPRE1_DIV2      /*!< HCLK 2 分频 */
#define RCC_CFGR_PPRE1_DIV2      0x00000400U        /*!< 二进制:100 0000 0000 */

/* PPRE1 字段掩码:占 [10:8] 共 3 位 */
#define RCC_CFGR_PPRE1_Pos   (8U)                   /*!< 位偏移:第 8 位 */
#define RCC_CFGR_PPRE1_Msk   (0x7UL << RCC_CFGR_PPRE1_Pos)   /*!< 掩码:0x00000700,即 7(二进制 111)左移 8 位 */
#define RCC_CFGR_PPRE1       RCC_CFGR_PPRE1_Msk     /*!< PRE1[2:0] 位(APB1 预分频) */

总结:配置分频的本质,就是往 RCC->CFGR 寄存器的相应字段(HPRE、PPRE1、PPRE2)写入对应的位模式。

6.5 关键数据结构与宏定义解析

6.5.1 RCC_OscInitTypeDef:振荡器配置结构体

c 复制代码
/**
 * @brief  RCC 内部/外部振荡器(HSE、HSI、LSE 与 LSI)配置结构体定义
 */
typedef struct
{
  uint32_t OscillatorType;       /*!< 需要配置的振荡器类型
                                      该参数可以取 @ref RCC_Oscillator_Type 中的值 */

  uint32_t HSEState;             /*!< HSE 的新状态
                                      该参数可以取 @ref RCC_HSE_Config 中的值 */

  uint32_t HSEPredivValue;       /*!< HSE 预分频系数(参考手册中称 PREDIV1 或 PLLXTPRE)
                                      该参数可以取 @ref RCCEx_Prediv1_Factor 中的值 */

  uint32_t LSEState;             /*!< LSE 的新状态
                                      该参数可以取 @ref RCC_LSE_Config 中的值 */

  uint32_t HSIState;             /*!< HSI 的新状态
                                      该参数可以取 @ref RCC_HSI_Config 中的值 */

  uint32_t HSICalibrationValue;  /*!< HSI 校准微调值(默认为 RCC_HSICALIBRATION_DEFAULT)
                                      取值范围:最小 0x00,最大 0x1F */

  uint32_t LSIState;             /*!< LSI 的新状态
                                      该参数可以取 @ref RCC_LSI_Config 中的值 */

  RCC_PLLInitTypeDef PLL;        /*!< PLL 相关参数结构体 */
} RCC_OscInitTypeDef;

常用的振荡器类型宏:

c 复制代码
/* RCC_Oscillator_Type 定义:用不同的 bit 表示不同类型的振荡器 */
#define RCC_OSCILLATORTYPE_NONE   0x00000000U
#define RCC_OSCILLATORTYPE_HSE    0x00000001U   /*!< 高速外部时钟 */
#define RCC_OSCILLATORTYPE_HSI    0x00000002U   /*!< 高速内部时钟 */
#define RCC_OSCILLATORTYPE_LSE    0x00000004U   /*!< 低速外部时钟 */
#define RCC_OSCILLATORTYPE_LSI    0x00000008U   /*!< 低速内部时钟 */

HSE / LSE / HSI / LSI 状态宏:

c 复制代码
/* RCC_HSE_Config 定义 */
#define RCC_HSE_OFF    0x00000000U
#define RCC_HSE_ON     RCC_CR_HSEON                  /*!< 使能位即状态值 */
#define RCC_HSE_BYPASS ((uint32_t)(RCC_CR_HSEBYP | RCC_CR_HSEON))

/* RCCEx_Prediv1_Factor 定义:HSE 预分频 */
#define RCC_HSE_PREDIV_DIV1  0x00000000U             /*!< HSE 不分频 */
#define RCC_HSE_PREDIV_DIV2  RCC_CFGR_PLLXTPRE       /*!< HSE 2 分频 */

/* RCC_LSE_Config 定义 */
#define RCC_LSE_OFF    0x00000000U
#define RCC_LSE_ON     RCC_BDCR_LSEON
#define RCC_LSE_BYPASS ((uint32_t)(RCC_BDCR_LSEBYP | RCC_BDCR_LSEON))

/* RCC_HSI_Config 定义 */
#define RCC_HSI_OFF  0x00000000U
#define RCC_HSI_ON   RCC_CR_HSION

/* RCC_LSI_Config 定义 */
#define RCC_LSI_OFF  0x00000000U
#define RCC_LSI_ON   RCC_CSR_LSION

观察规律:这些"状态"宏的值,直接就是对应寄存器里那个使能位的值。 所以 HAL 库执行时,其实就是在做"把状态宏的值写到寄存器的相应位"这件事。

6.5.2 RCC_PLLInitTypeDef:PLL 配置结构体

c 复制代码
/**
 * @brief  RCC PLL 配置结构体定义
 */
typedef struct
{
  uint32_t PLLState;   /*!< PLL 的新状态
                            此参数可以是 @ref RCC_PLL_Config 的值 */
  uint32_t PLLSource;  /*!< PLL 入口时钟源
                            此参数必须是 @ref RCC_PLL_Clock_Source 的值 */
  uint32_t PLLMUL;     /*!< PLL 输入时钟的倍频因子
                            此参数必须是 @ref RCCEx_PLL_Multiplication_Factor 的值 */
} RCC_PLLInitTypeDef;

PLL 相关的宏:

c 复制代码
/* RCC_PLL_Config 定义 */
#define RCC_PLL_NONE  0x00000000U
#define RCC_PLL_OFF   0x00000001U
#define RCC_PLL_ON    0x00000002U

/* RCC_PLL_Clock_Source 定义:PLL 输入源 */
#define RCC_PLLSOURCE_HSI_DIV2  0x00000000U   /*!< 选择 HSI ÷ 2 */
#define RCC_PLLSOURCE_HSE       RCC_CFGR_PLLSRC  /*!< 选择 HSE */

/* RCCEx_PLL_Multiplication_Factor 定义:倍频系数 2~16 */
#define RCC_PLL_MUL2    RCC_CFGR_PLLMULL2
#define RCC_PLL_MUL3    RCC_CFGR_PLLMULL3
#define RCC_PLL_MUL4    RCC_CFGR_PLLMULL4
#define RCC_PLL_MUL5    RCC_CFGR_PLLMULL5
#define RCC_PLL_MUL6    RCC_CFGR_PLLMULL6
#define RCC_PLL_MUL7    RCC_CFGR_PLLMULL7
#define RCC_PLL_MUL8    RCC_CFGR_PLLMULL8
#define RCC_PLL_MUL9    RCC_CFGR_PLLMULL9   /*!< 我们开发板用 9 倍频 */
#define RCC_PLL_MUL10   RCC_CFGR_PLLMULL10
#define RCC_PLL_MUL11   RCC_CFGR_PLLMULL11
#define RCC_PLL_MUL12   RCC_CFGR_PLLMULL12
#define RCC_PLL_MUL13   RCC_CFGR_PLLMULL13
#define RCC_PLL_MUL14   RCC_CFGR_PLLMULL14
#define RCC_PLL_MUL15   RCC_CFGR_PLLMULL15
#define RCC_PLL_MUL16   RCC_CFGR_PLLMULL16

6.5.3 RCC_ClkInitTypeDef:总线时钟配置结构体

c 复制代码
/**
 * @brief  RCC 系统时钟、AHB 总线时钟和 APB 总线时钟配置结构体定义
 */
typedef struct
{
  uint32_t ClockType;        /*!< 需要配置的时钟类型
                                  该参数可以取 @ref RCC_System_Clock_Type 中的值 */

  uint32_t SYSCLKSource;     /*!< 用作系统时钟(SYSCLK)的时钟源
                                  该参数可以取 @ref RCC_System_Clock_Source 中的值 */

  uint32_t AHBCLKDivider;    /*!< AHB 时钟(HCLK)分频系数,由系统时钟(SYSCLK)分频得到
                                  该参数可以取 @ref RCC_AHB_Clock_Source 中的值 */

  uint32_t APB1CLKDivider;   /*!< APB1 时钟(PCLK1)分频系数,由 AHB 时钟(HCLK)分频得到
                                  该参数可以取 @ref RCC_APB1_APB2_Clock_Source 中的值 */

  uint32_t APB2CLKDivider;   /*!< APB2 时钟(PCLK2)分频系数,由 AHB 时钟(HCLK)分频得到
                                  该参数可以取 @ref RCC_APB1_APB2_Clock_Source 中的值 */
} RCC_ClkInitTypeDef;

相关宏定义:

c 复制代码
/* RCC_System_Clock_Type 定义:时钟类型 */
#define RCC_CLOCKTYPE_SYSCLK  0x00000001U
#define RCC_CLOCKTYPE_HCLK    0x00000002U
#define RCC_CLOCKTYPE_PCLK1   0x00000004U
#define RCC_CLOCKTYPE_PCLK2   0x00000008U

/* RCC_System_Clock_Source 定义:SYSCLK 的时钟源 */
#define RCC_SYSCLKSOURCE_HSI      RCC_CFGR_SW_HSI     /*!< 系统时钟选 HSI */
#define RCC_SYSCLKSOURCE_HSE      RCC_CFGR_SW_HSE     /*!< 系统时钟选 HSE */
#define RCC_SYSCLKSOURCE_PLLCLK   RCC_CFGR_SW_PLL     /*!< 系统时钟选 PLL 输出 */

/* RCC_AHB_Clock_Source 定义:AHB 分频系数(HCLK = SYSCLK ÷ N) */
#define RCC_SYSCLK_DIV1    RCC_CFGR_HPRE_DIV1
#define RCC_SYSCLK_DIV2    RCC_CFGR_HPRE_DIV2
#define RCC_SYSCLK_DIV4    RCC_CFGR_HPRE_DIV4
#define RCC_SYSCLK_DIV8    RCC_CFGR_HPRE_DIV8
#define RCC_SYSCLK_DIV16   RCC_CFGR_HPRE_DIV16
#define RCC_SYSCLK_DIV64   RCC_CFGR_HPRE_DIV64
#define RCC_SYSCLK_DIV128  RCC_CFGR_HPRE_DIV128
#define RCC_SYSCLK_DIV256  RCC_CFGR_HPRE_DIV256
#define RCC_SYSCLK_DIV512  RCC_CFGR_HPRE_DIV512

/* RCC_APB1_APB2_Clock_Source 定义:APB 分频系数(PCLK = HCLK ÷ N) */
#define RCC_HCLK_DIV1    RCC_CFGR_PPRE1_DIV1
#define RCC_HCLK_DIV2    RCC_CFGR_PPRE1_DIV2
#define RCC_HCLK_DIV4    RCC_CFGR_PPRE1_DIV4
#define RCC_HCLK_DIV8    RCC_CFGR_PPRE1_DIV8
#define RCC_HCLK_DIV16   RCC_CFGR_PPRE1_DIV16

6.5.4 两个重要函数接口

c 复制代码
/**
 * @brief  根据 RCC_OscInitTypeDef 结构体中指定的参数初始化 RCC 振荡器
 * @param  RCC_OscInitStruct:指向 RCC_OscInitTypeDef 结构体的指针
 * @note   当 PLL 被用作系统时钟时,本函数不会关闭 PLL
 * @note   当 USB OTG FS 时钟已启用时,本函数不会关闭 PLL(仅适用于带 USB FS 的器件)
 * @note   本函数不支持 LSE/HSE 在旁路模式与开启状态之间的切换
 * @retval HAL 状态码(HAL_OK、HAL_ERROR、HAL_BUSY 或 HAL_TIMEOUT)
 */
HAL_StatusTypeDef HAL_RCC_OscConfig(RCC_OscInitTypeDef *RCC_OscInitStruct);
c 复制代码
/**
 * @brief  初始化 CPU、AHB 和 APB 总线时钟,并配置系统时钟源
 * @param  RCC_ClkInitStruct:指向 RCC_ClkInitTypeDef 结构体的指针
 * @param  FLatency:Flash 等待周期数(频率越高,等待周期越多)
 * @retval HAL 状态码(HAL_OK、HAL_ERROR、HAL_BUSY 或 HAL_TIMEOUT)
 */
HAL_StatusTypeDef HAL_RCC_ClockConfig(RCC_ClkInitTypeDef *RCC_ClkInitStruct,
                                      uint32_t FLatency);

到这里,整条链路就通了:CubeMX 生成结构体参数 → HAL 函数校验参数 → 读改写 RCC 寄存器 → 寄存器输出控制时钟树的开关与分频倍频 → 各路时钟就绪 → 外设正常工作。


📌 本知识点总结:HAL 库时钟配置源码的本质?

HAL 库配置时钟的本质,就是对 RCC 寄存器的读写,整个过程可以拆成三层:

  1. 地址映射层 :RCC 外设统一编址在 0x40021000,HAL 库用结构体 RCC_TypeDef 按寄存器偏移顺序映射这些寄存器,于是 RCC->APB2ENR 就能直接操作真实的硬件寄存器。
  2. 功能封装层__HAL_RCC_GPIOF_CLK_ENABLE() 的本质是把 RCC->APB2ENRIOPFEN 位置 1,从而使能 GPIOF 的时钟;SystemClock_Config() 通过 RCC_OscInitTypeDefRCC_ClkInitTypeDef 两个结构体分别描述"振荡器与 PLL 配置"和"系统时钟与总线分频配置",然后调用 HAL_RCC_OscConfig()HAL_RCC_ClockConfig() 完成写入。
  3. 寄存器操作层HAL_RCC_OscConfig() 内部通过 SET_BIT/CLEAR_BIT 操作 RCC_CR(控制 HSE/HSI/PLL)和 RCC_BDCR(控制 LSE)等寄存器;HAL_RCC_ClockConfig() 内部通过 MODIFY_REG 修改 RCC_CFGR 寄存器的 SW(系统时钟源选择)、HPRE(AHB 分频)、PPRE1(APB1 分频)、PPRE2(APB2 分频)等字段。

一句话概括:写代码配置时钟 = 按参考手册把 RCC 各寄存器对应的位填对值,HAL 库只是帮我们把"位"包装成了好读的结构体和函数。


附录一 RTC 与看门狗:时钟树的第二部分

前面提到时钟树分三部分,第一部分我们用了整整三章去理解。附录部分,我们把第二、三部分也讲清楚(后续 RTC、看门狗专题会再详细展开)。

第二部分用低频时钟驱动两个"慢节奏"外设:RTC 和独立看门狗(IWDG)。

A1.1 RTC:实时时钟(Real-Time Clock)

是什么? RTC 是 MCU 内部的一个片上外设,核心功能是"实时时钟":它要在系统断电、重启或 MCU 休眠的情况下,仍然持续、准确地记录"现实世界"的年月日时分秒,让 MCU 随时知道"现在几点",从而按时触发事件,无需人工重新校准。

为什么要有 RTC? 举个例子:高速公路 ETC 门架计费系统。门架在过车瞬间要记录车牌、通行时间,并把精确到毫秒的时间戳上传到结算中心。若断电后时间丢失,同一辆车会被漏扣,造成跨省对账失败或者纠纷。所以摄像头里的 MCU 即使断电,也要能正常记录时间。

怎么做到的? 一般完整的产品会通过 MCU 外部的 VBAT 引脚 (Voltage Battery,电池电压引脚)外接一颗纽扣电池

  • 正常 VDD(主电源)供电时,纽扣电池不工作;
  • 一旦 VDD 掉电或关机,纽扣电池自动为 MCU 供电,维持 RTC 时间的更新,保证最基本的时间同步。

我们日常用的笔记本、台式机也是这样,主板上的纽扣电池就是给 RTC(实时时钟)续命的。

A1.2 独立看门狗(IWDG)

是什么? 独立看门狗也是 MCU 内部的一个片上外设,可以把它理解为 MCU 里的"自动重启按钮"。

为什么需要? 我们的程序烧写到芯片后,可能会因为各种原因导致"程序跑飞"(比如指针越界、死循环卡在错误的地方),进而引起死机。如果没人按复位键,看门狗自己到时间就会把板子重启,让设备继续干活,保证系统不会一直"躺尸"。

工作原理: 看门狗其实是一个递减计数器

  • 计数器减到 0 就会自动重启 MCU;
  • 所以编程时,我们要在自己的循环代码里,不断把递减计数器重置到最开始的值,这个动作叫"喂狗";
  • 只要一直喂狗,计数器就永远减不到 0,系统正常运行;
  • 一旦程序跑飞,不再喂狗,计数器慢慢减到 0,然后自动重启 MCU。

A1.3 为什么 RTC 和看门狗需要低频时钟?

RTC 和看门狗本质上都是计数器(RTC 是递增计数器,记录从 MCU 启动到现在的秒数;看门狗是递减计数器)。它们更新计数器的时间都是秒/毫秒级别的,所以:

它们是两个"慢节奏"设备,不需要 MHz 级别的时钟,kHz 级别就够用了。

所以时钟树的第二部分,可以通过配置选择器,从 HSE、LSE、LSI 中选择一个作为时钟源发生器,生成:

  • 给 RTC 使用的 RTCCLK 时钟源;
  • 给看门狗使用的 IWDCLK 时钟源。

最佳实践:

  • RTC :首选 LSE(32.768kHz 晶振) ,掉电也能走;备选 LSI(约 40kHz) 或 HSE/128;
  • IWDG :只能用 LSI(约 40kHz),独立运行,不受外部晶振影响。

A1.4 为什么 LSE 偏偏是 32.768kHz?

因为 32.768 kHz = 32768 Hz = 2¹⁵

这个数很巧妙:用二进制计数器做 15 次二分频,就能精确地得到 1 秒信号:

复制代码
32768 Hz ÷ 2 = 16384 Hz
16384 Hz ÷ 2 = 8192 Hz
...
2^15 = 32768,所以 32768 ÷ 2^15 = 1 Hz(1 秒 1 次)

所以未来学习 RTC 时会知道:如果选择 LSE 作为 RTC 的时钟源,RTC 内部默认分频系数就是 32768。使用 LSE(32.768kHz)可以保证向 RTC 提供一个稳定的 1 秒级时钟,用于时间戳更新。

如果你非要选择 HSE(8MHz)作为时钟源,就需要重新计算 RTC 的预分频器值:

复制代码
8 MHz = 8,000,000 Hz
8,000,000 ÷ 128 ÷ 1 Hz = 62,500   (需要把 RTC 预分频器设置为 62500,才能得到 1Hz)

这些在 RTC 部分都是可以配置的。看门狗原理类似,不再展开。


📌 本知识点总结:时钟树的第二部分(RTC 与看门狗)?

时钟树第二部分的职责是给两个"慢节奏"外设提供低频时钟:

RTC(实时时钟) 是一个持续走时的计数器,即使在系统断电、重启或休眠时,也能借助外部纽扣电池(接 VBAT 引脚)继续记录现实时间,常用于需要精确时间戳且断电不能丢失时间的场景(如 ETC 计费)。

独立看门狗(IWDG) 是一个递减计数器,程序正常运行时需要不断"喂狗"(重置计数器),一旦程序跑飞不再喂狗,计数器减到 0 就会自动复位 MCU,相当于系统自带的"自动重启按钮"。

二者都以秒/毫秒为更新节奏,不需要 MHz 级时钟,kHz 级即可。时钟源选择上:RTC 首选 LSE(32.768kHz,2¹⁵ Hz,便于二分频出精确的 1 秒信号),备选 LSI 或 HSE/128;看门狗只能用 LSI。


附录二 MCO:时钟树的第三部分

第三部分在实际用途中用得非常少,我们只点出几个关键点。

A2.1 MCO 是什么?

MCO(Microcontroller Clock Output,微控制器时钟输出):把 MCU 内部选中的某个时钟信号(HSI、HSE、PLLCLK 等)直接引到某个 GPIO 引脚,对外输出时钟信号。

也就是说,芯片可以把自己的时钟"借"给外部用。

注:第三部分中的 HSI、HSE、PLLCLK 等时钟,是直接走线接入到前两部分的特定位置的(标准文档并没有画出来,但我们要自己理解这一点)。

A2.2 为什么要输出时钟?

两个主要原因:

  1. 给外围器件提供时钟:可以起到同步设备或者节省成本的作用------MCU 的 MCO 可以直接当做一个时钟发生器用。商业板子上,几分钱的成本都要节省;
  2. 当作测试接口:外接示波器,输出内部时钟信号,验证我们的时钟配置是否正确。

📌 本知识点总结:MCO(时钟树第三部分)?

MCO(Microcontroller Clock Output,微控制器时钟输出) 可以把 MCU 内部选中的时钟信号(如 HSI、HSE、PLLCLK)通过 GPIO 引脚对外输出。它的用途主要有两个:一是当作时钟发生器,给外部器件提供时钟,达到同步设备、节省成本的目的;二是作为测试接口,用示波器观察输出的时钟波形,验证内部时钟配置是否正确。


附录三 世界为什么选择了"树"形结构

我们做的所有功能,都是不同硬件互相配合、共同产出的结果:

  • 你打游戏,是 CPU、GPU、内存、网卡、显示屏等硬件配合的结果;
  • 你写文档,是 CPU、内存、磁盘等硬件配合的结果;
  • 你听音乐,是 CPU、内存、声卡等硬件配合的结果。

结论一:用户感知到的各种功能,是硬件协同工作、互相配合的结果。

现实世界中,人之间要互相配合,就必须先把人以某种结构组织起来。看看身边:

  • 军事单位的指挥链;
  • 企业的行政汇报结构;
  • 政府的行政层级体系;
  • 大学/学院的学术与行政管理;
  • 家族的谱系宗族管理。

世界要"集中力量办大事",管理好人必须先组织好人,而组织好人最常见的组织方式,就是树形结构。这是世界的真相与本质,与具体场景无关。树形结构不一定覆盖全部,但一定是主流。

为什么管理的主流结构是树?

人做事,本质上只做两件事:决策、执行。

  • 对个人来讲,决策和执行往往混合着来;
  • 对多人或组织来说,决策和执行分离程度越高,行政效率就越高,本质是协作分工的结果;
  • 决策,往往由特定组织或个人决定,这样效率最高(真理掌握在少数人手中);
  • 执行,往往需要团结大部分人的力量,让大家参与进来,这样效率也最高(实现真理需要大部分人的努力)。

剩下最后一个问题:决策结果如何快速通知给每一个人?

比如:军队的"备战"、学校的"放假"、公司的"年会地点"、医院的"准备急诊"......

通知路径越短,效率越高。而树形结构

  • 只有一个根节点(决策者);
  • 通过灵活调整分支个数 (增多或减少)和树的高度,能大幅缩短从树根到每一个叶子的距离;
  • 树形结构里,通知路径的长度本质上就是"层高"。

所以:

结论二:要让人协作起来,需要让人先组织起来,而主流的组织结构就是树形结构。

这也正是单片机内部时钟树为什么长成"树"的原因------一个决策源头(时钟源),逐级分发,高效有序地驱动成千上万的叶子(外设)。


全文终章:一条主线串起所有知识

把全文压缩成一张"知识地图":

复制代码
4 个原始时钟源(HSI 8M / HSE 8M / LSI 40k / LSE 32.768k)
        │
        ▼
  时钟树(分频、倍频 PLL、选择器、使能开关)
        │
        ▼
  PLLCLK(72M) → SYSCLK(72M) → FCLK(72M, CPU) → HCLK(72M, AHB)
        │                                      ├─→ PCLK1(36M, APB1 低速外设)
        │                                      └─→ PCLK2(72M, APB2 高速外设)
        │
        ▼
   使用外设前,先使能它的时钟(写 RCC 使能寄存器,如 RCC_APB2ENR)
        │
        ▼
  所有配置 = 写 RCC 寄存器(RCC_CR / RCC_CFGR / RCC_APB2ENR / ...)

使用任何外设,永远记住这句话:先开时钟,再用外设。


相关推荐
时空自由民.2 小时前
STM32 IIC HAL库,LL库
stm32·单片机·嵌入式硬件
辰哥单片机设计3 小时前
STM32智能门禁系统(语音版)
stm32·单片机·嵌入式硬件
映翰通朱工4 小时前
Windows 上位机 + EC3320:USB 转 CAN 通信实测(从接线到 SSH 收包)
windows·stm32·ssh
Industio_触觉智能5 小时前
瑞芯微ISP内核驱动深度解析|RKISP39从架构到Probe初始化全流程(上篇)
linux·嵌入式硬件·硬件架构·摄像头·isp·瑞芯微·rk3576
Funing75 小时前
系列一:STM32 外设初始化
stm32·单片机·嵌入式硬件
XiangrongZ6 小时前
debian中配置stm32的交叉编译环境
运维·stm32·debian
✎ ﹏梦醒͜ღ҉繁华落℘6 小时前
单片机基础知识--LVGL学习-lv_obj_add_flag()
c语言·单片机·学习
zcmodeltech7 小时前
钢铁冶金沙盘模型多工序协同控制系统设计:基于STM32与Modbus RTU的高炉-连铸-热轧全流程联动方案
stm32·单片机·嵌入式硬件·数据库架构·嵌入式实时数据库·多分类
西城微科方案开发8 小时前
充气泵PCBA方案:数字传感器VS模拟传感器
单片机