
🔥铅笔小新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):
- 32.768 kHz 的低速晶振:频率低,专给 RTC(实时时钟)用。为什么偏偏是 32.768kHz?因为 32.768 kHz = 32768 Hz = 2¹⁵,可以非常方便地用计数器二分频出精确的 1 秒信号。
- 8 MHz 的高速晶振:作为 HSE(高速外部时钟)的来源,喂给 PLL 倍频出 72MHz。
⚠️ 细节:区分"时钟源"和"时钟信号"
- 时钟源:是"产生或提供时钟"的硬件实体(比如那颗晶振);
- 时钟信号:是这个实体最终输出到电路中的电信号波形。
也就是说,晶振是"水厂",时钟信号是水厂里流出来的"水"。
📌 本知识点总结:什么是时钟?
时钟 是单片机内部一个以固定频率产生高低电平方波的硬件机制,它像"节拍器"一样,持续不断地"催促"CPU 和各种片上外设工作。CPU 再聪明也不会主动执行指令,它必须在时钟的驱使下,才能完成"取指令→分析指令→执行指令"的循环;片上外设同样必须依靠时钟驱动才能工作。因此,在硬件层面上,MCU 内所有设备都依赖时钟统一步调、协调运行。
衡量时钟快慢的指标是 时钟频率 ,单位是赫兹(Hz),它表示每秒输出的完整周期数;时钟周期 则是完成一次完整波动(一个上升沿加一个下降沿)所需的时间,二者互为倒数。
由于不同外设工作频率不同,MCU 不会给每个外设单独配时钟源,而是采用 倍频(如 PLL 锁相环)和分频 的方式,从同一时钟源派生出各种频率的时钟。同时,为了兼顾功耗与功能,每个外设的时钟都带有"使能开关",使用外设之前必须先使能它的时钟,用不到的外设时钟保持关闭以省电。
第二章 时钟树:MCU 内部的"电力调度中心"
2.1 为什么需要"时钟树"?------像硬件工程师一样思考
有了关于时钟的宏观认识,我们现在站在单片机设计者的角度,做几个"大胆推测":
推测一:一个时钟源不够用。
如果只有一个时钟源,万一它因为极端情况(高温、线路老化等)而无法正常工作,那么整个系统都会瘫痪。
是的,所以 STM32F103 系列的单片机不可能只有一个时钟源,具体几个取决于设计。
推测二:多个时钟源之间,频率最好有差别。
- 片上外设工作频率不同,虽然可以靠倍频/分频来满足要求;
- 但是,不同时钟源自身频率有差别,就能覆盖更大的频率范围,还能在不需要高频的时候,选择低频时钟源来工作,从而降低功耗。
就像修路:高速路修双向 8 车道能满足交通需求,可小区内部也修双向 8 车道就没必要了,成本太高。低速设备用低速时钟,高速设备用高速时钟,才是省电又省钱的做法。
推测三:给每个外设都配一个时钟源?不行!
既然可以有多个时钟源,那我可以给每个外设都单独配一个吗?
不行。因为那样的话:
- 每一个时钟都要针对不同设备进行使能、分频、倍频;
- 这会让 MCU 内部出现大量重复的电路设计;
- 重复就意味着成本增加,而单片机是成本敏感的设备。
结论:必须把时钟功能单独设计成一套硬件电路,黑盒式地对外提供特定频率的时钟。这套硬件电路,就叫时钟树(Clock Tree)。
2.2 时钟树的结构认识

真实的 STM32F103 时钟树电路图非常复杂,看起来像一片密密麻麻的电路网。但不要被它吓到,因为无论多复杂,它的核心特性只有三个:
- 时钟源不止一个;
- 要有分频和倍频功能;
- 要有使能开关。
下面我们逐条拆解。
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),硬件会立即:
- 关闭 HSE 和 PLL;
- 把 SYSCLK 强制切换到 HSI(8 MHz);
- 这个过程完全由硬件完成,软件来不及干预,但会产生 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 等总线,硬件本质是一样的(都是线簇),只是接的设备不同、叫法不同。
总线不是一根线,而是一堆线的集合(线簇)。就像一条高速公路不是单车道,而是多车道的组合。
总线内部一般分为三类:
- 地址总线:表明"去哪里"。读的时候表示从哪读,写的时候表示写到哪;
- 数据总线:表明"具体数据"是什么;
- 控制总线:控制什么时候读、什么时候写、是否读写完毕、是读还是写等与控制逻辑有关的内容。
就 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 位,但道理完全一样,学完这个自然能平滑迁移)。
一个存储单元,要能保存数据,必须具备三种功能:
- 写入:能把数据写进去;
- 读取(拷贝):能读出来,而且读完之后数据还在里面(拷贝);
- 保持(锁存):在下一次写入到来之前,存储单元里的数据保持不变。
要让存储单元支持这三个功能,就要引出对应的导线,并且在内部加一个"开关":
- 写完成后断开开关,数据就一直保持住;
- 直到下次开关闭合,才允许新数据再次写入;
- 也就是说,"开关闭合"就意味着"要有新数据到来了"。
于是,要成功把数据写入存储单元 A,必须同时满足两个条件:
- 写端有数据(比如写 1);
- 开关闭合。
如果开关不闭合,存储单元内部就保持老的值不变,这就是"保持"功能。
那"开关"怎么闭合呢?总不能用手去摁吧?所以存储单元被设计成用电平来控制开关。
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 就能和某个外设通信了。整个过程是这样的:
- 一个时钟到来,CPU 把数据放到数据总线上(同时也把目标地址、读写方向等信息放到总线对应位置);
- 与此同时,不考虑分频的情况下,目标片上外设的寄存器"使能开关"也被打开;
- 数据就从 CPU → 总线 → 外设寄存器,写入成功;
- 如果考虑分频,也无非是"隔几个上升沿再使能",照样能把数据读进外设寄存器。
所以,终于可以回答最开始那个问题了:时钟线和数据读写的关系是什么?
时钟线上的时钟,让"放数据到总线"和"开启目标设备寄存器"同步进行。 这里的同步不一定是"同时",因为分频的存在,这里的同步是"步调有规律"------该合拍的时候就合拍。
📌 本知识点总结:CPU 是如何操作片上外设的?
总线 是 CPU 与外设通信的"交通干线",它不是一个根线,而是一簇线,分为地址总线 (表示去哪里)、数据总线 (表示具体数据)和控制总线(控制读写时序,时钟线 CLK 也在其中)。
外设内部也有寄存器,分为数据寄存器、控制/配置寄存器、状态寄存器等。CPU 与片上外设通信的本质,就是 CPU 寄存器与外设寄存器之间的数据拷贝。
寄存器由若干存储单元构成,每个存储单元具备"写入、读取(拷贝)、保持(锁存)"三种能力,并用一个由时钟沿(如上升沿)控制的开关来决定何时允许新数据写入;多个存储单元共用一个使能位就组成了寄存器。
时钟与数据读写的关系是:时钟线上的时钟,让"CPU 把数据放到总线"和"目标外设寄存器的使能开关打开"同步进行------虽然因分频不一定是同一瞬间,但步调是规律一致的。这也解释了为什么用任何外设之前必须先使能它的时钟:只有时钟打开,外设寄存器才能和总线"合拍"工作。
第四章 RCC:复位与时钟控制器
前面我们讲了时钟、时钟树、时钟树与总线设备的关系。现在,我们要过渡到"配置层面"了。
结论先行:所谓的配置,就是配置寄存器。
我们经常听到一句话:"使用外设之前,要开启外设时钟"。比如开启 GPIO 时钟,我们理解本质是把 GPIO 设备的时钟线开关闭合,让它用上 PCLK2 时钟线上的时钟。
但是,这里有两个疑问:
- 那个"开关"到底是什么?
- "配置"具体是怎么配置的?
下面我们一层层揭开答案。
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(时钟配置)页面:
- 确认 HSE 输入为 8 MHz(我们开发板上的外部晶振就是 8MHz);
- 将 PLL 倍频系数设置为 ×9 ,于是 8 MHz × 9 = 72 MHz;
- 系统时钟(SYSCLK)选择 PLLCLK,最终系统运行最大频率配置为 72 MHz。
在 CubeMX 里,时钟树配置页面会自动帮你计算好 HCLK、PCLK1、PCLK2 等值,配置完成后点一下鼠标就能生效。
第 4 步:开启引脚 GPIO 输出
- 在芯片引脚图里,用鼠标左键点击 PF8 引脚;
- 在弹出的菜单中选择 GPIO_Output(GPIO 输出模式);
- 在 GPIO 配置页面里,设置:
- Mode(模式):
Output Push Pull(推挽输出); - 其余保持默认即可。
- Mode(模式):
第 5 步:设置项目路径与工具链
- 填写项目名称(Project Name);
- 填写项目保存路径(Project Location);
- 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 烧写测试
代码写好后:
- 在 Keil 里点击 编译(Build);
- 连接 DAP 仿真器,点击 下载(Download) 烧录;
- 上电运行,观察 LED 闪烁效果。
此时你应该能看到 LED 以某种速度闪烁。
5.6 调整时钟:感受时钟对系统速度的影响
实验的"高光时刻"来了!
回到 STM32CubeMX 的时钟配置页面,降低系统时钟频率(比如把 PLL 倍频从 ×9 改成更小的倍数,或者直接降低 HCLK 分频比),重新生成代码、编译烧录。
你会发现:同样一段 CPU_Delay(XXXXX) 代码,LED 闪烁变慢了!
原因正如前面所说:
CPU 执行指令的速度由 FCLK 决定。时钟频率降低 → 同样的循环次数消耗的真实时间变长 → LED 闪烁变慢。这就是时钟配置影响系统运行速度的直接证据。
如果你把频率调高(回到 72MHz),灯又会重新闪得快起来。
5.7 烧写失败?------多半是调试接口没开
如果你遇到了"第一次烧录成功,第二次就烧录失败"的情况,十有八九就是第 5.3 节第 1 步"开启 SWD 调试接口"没做。
解决办法:
- 使用调试器的擦除功能或 ISP 方式把芯片恢复;
- 回到 CubeMX,把 Debug 选项改成 "Serial Wire";
- 重新生成代码并烧录一次,之后就能持续正常烧录了。
📌 本知识点总结:如何用 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 1000到0x4002 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() 是配置整个系统时钟的"总开关"。它做两件大事:
- 配置各振荡器(HSE/HSI 等)和 PLL;
- 配置系统时钟源和各级分频。
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 寄存器的读写,整个过程可以拆成三层:
- 地址映射层 :RCC 外设统一编址在
0x40021000,HAL 库用结构体RCC_TypeDef按寄存器偏移顺序映射这些寄存器,于是RCC->APB2ENR就能直接操作真实的硬件寄存器。- 功能封装层 :
__HAL_RCC_GPIOF_CLK_ENABLE()的本质是把RCC->APB2ENR的IOPFEN位置 1,从而使能 GPIOF 的时钟;SystemClock_Config()通过RCC_OscInitTypeDef和RCC_ClkInitTypeDef两个结构体分别描述"振荡器与 PLL 配置"和"系统时钟与总线分频配置",然后调用HAL_RCC_OscConfig()和HAL_RCC_ClockConfig()完成写入。- 寄存器操作层 :
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 为什么要输出时钟?
两个主要原因:
- 给外围器件提供时钟:可以起到同步设备或者节省成本的作用------MCU 的 MCO 可以直接当做一个时钟发生器用。商业板子上,几分钱的成本都要节省;
- 当作测试接口:外接示波器,输出内部时钟信号,验证我们的时钟配置是否正确。
📌 本知识点总结: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 / ...)
使用任何外设,永远记住这句话:先开时钟,再用外设。
