从 51 到 STM32 · 硬件原理优先

STM32 入门教程

不讲「照抄代码能跑就行」。先用 5 章讲透 STM32 由什么组成、程序是怎么从 Flash 跑到 main 的,再进外设。

17 章 · 6 张原理图 · 对齐 江协科技 教程与 STM32F10xxx 参考手册
单文件离线可用 · 自动记录进度 · 支持深色模式 · 每题配答案

这份教程的重点

部分回答什么问题
第一部分(1-4 章)STM32 是什么?里面有什么?和 51 差在哪?
第二部分(5-9 章)★程序烧进 Flash 后,是怎么一步步跑起来的?
第三部分(10-17 章)各个外设怎么用(GPIO/中断/定时器/PWM/ADC/DMA/串口/I2C)

你的起点

已经会:C 语言、51 单片机(学到步进电机,会 GPIO/数码管/按键/中断/定时器/串口)、电路前三章、数电到 TTL。
还不会:STM32 的时钟体系、寄存器配置、启动过程。本教程从「和 51 有什么不同」讲起。

资料依据

内容来源
芯片参数、片上资源、工程架构、存储器映像江协科技《STM32 入门教程》课件(207 页)
Boot 配置、存储器组织、Flash 页、总线频率、寄存器地址STM32F10xxx 参考手册(中文,755 页)§2 存储器和总线架构
外设寄存器位域、时钟使能同上 §7 RCC / §8 GPIO / §9 EXTI
建议路线:第 1-9 章按顺序看(这是精髓),然后第 10 章开始就可以边看边动手了。
直接从第 10 章点灯开始也行,但你会一直卡在「为什么第一步要开时钟」这个问题上。

第一部分 · 认识 STM32 第 1-4 章

第二部分 · 程序怎么跑起来 第 5-9 章 ★核心

第三部分 · 外设 第 10-17 章

1 第 1 章 · 从 51 到 STM32:变的不只是主频

1.1 从 51 到 STM32:变的不只是主频

你 51 已经学到步进电机了,那 51 的套路是:

sbit LED = P2^0;      // 直接操作一个位
LED = 0;              // 写 1 条指令,引脚立刻变了

STM32 上写同样的功能,代码长这样:

RCC->APB2ENR |= 1 << 2;      // ① 先开 GPIOA 的时钟(51 从不需要这一步!)
GPIOA->CRL &= ~(0xF << 0);   // ② 配 PA0 为推挽输出(51 也不用)
GPIOA->CRL |= (0x2 << 0);
GPIOA->ODR &= ~1;            // ③ 才轮到输出低电平
最反直觉的一点:STM32 复位后所有外设的时钟都是关着的。
参考手册 §2.1 原文:「在每一次复位以后,所有除 SRAM 和 FLITF 以外的外设都被关闭,在使用一个外设之前,必须设置寄存器 RCC_AHBENR 来打开该外设的时钟。」
所以不写第一行 RCC->APB2ENR,后面所有 GPIO 操作全部无效 —— 这是从 51 过来踩的第一个坑。

1.2 一张表看清差别

51(STC89C52)STM32(F103C8T6)
位宽8 位32 位
内核8051ARM Cortex-M3
主频11.0592 MHz(12T,实际 0.92 MIPS)72 MHz(1.25 DMIPS/MHz)
算力对比约 1 MIPS约 90 MIPS(差 90 倍)
Flash8 KB64 KB
RAM512 B20 KB(差 40 倍)
IO 口32 个,功能固定37 个,每个都能复用 8 种模式
时钟晶振直接驱动时钟树:HSI/HSE/PLL 多级分频
外设2 定时器 / 1 串口 / 无 ADC7 定时器 / 3 串口 / 2×12位ADC / DMA / I2C / SPI / CAN / USB…
中断5 个源,2 级优先级60 个源,NVIC 16 级可编程优先级
烧录串口 ISP串口 ISP / ST-LINK 调试(能单步、看寄存器)
心态调整:51 上你「直接操作硬件」,STM32 上你「配置硬件」。
多出来的 RCC/CRL/ODR 这些,都是配置,不是变复杂了 —— 是因为硬件资源多了,必须有开关。

1.3 学 STM32 要过的三道坎

坎具体表现本教程哪章讲
① 时钟不写 RCC 就不工作,但代码看着没错第 9 章
② 寄存器与地址不知道 GPIOA->ODR 到底写到哪个地址第 5、8 章
③ 启动过程不知道程序怎么从 Flash 跑起来,跑飞了不会查第 6、7 章
本教程的安排:先用 5 章讲透「STM32 是什么 + 程序怎么跑起来」,再进入外设。
很多人直接跳到点灯,结果永远停留在「照抄代码能跑,改一点就崩」的阶段。

1.4 本章练习

  1. STM32 上操作 GPIO 前必须做的一件事是什么?51 为什么不需要?
  2. 20KB RAM 对比 512B,大约是多少倍?能装下多少个 int(4 字节)变量?
  3. 为什么说 STM32 是「配置硬件」而 51 是「直接操作硬件」?
📖 参考答案(点击展开)
  1. 必须先开外设时钟(写 RCC->APB2ENR 等)。51 不需要,因为 51 的外设一直通电,没有时钟门控。
  2. 20KB / 512B = 40 倍。20KB = 20480 字节,能装 5120 个 int(4 字节)。
  3. 51 的引脚功能固定,写寄存器立刻生效;STM32 每个引脚有 8 种模式、每条总线有开关,必须先配置再使用。
2 第 2 章 · STM32 是什么:F103C8T6 与片上资源

2.1 STM32 是什么

课件原文:STM32 是 ST 公司基于 ARM Cortex-M 内核开发的 32 位微控制器。

STM32 = ST(意法半导体,造芯片的)
      + ARM Cortex-M(内核设计公司,只卖 IP 不造芯片)
      + 外设(ST 自己加的 GPIO/ADC/USART…)
类比:ARM 是「发动机设计方案」,ST 是「造整车」的。
所以同一颗 Cortex-M3 内核,ST、NXP、TI 都在用,但外设各不相同 —— 这就是为什么换厂家要重新学外设,但内核知识通用。

2.2 我们的芯片:STM32F103C8T6

项目参数对应 51 是多少
系列主流系列 STM32F1—
内核ARM Cortex-M38051
主频72 MHz11.0592 MHz
RAM20 KB(SRAM)512 B
Flash64 KB8 KB
供电2.0~3.6 V(标准 3.3 V)5 V
封装LQFP48(48 个脚)DIP40
3.3V 是个大坑:51 是 5V 系统,STM32 是 3.3V。
把 5V 的传感器信号直接接进 STM32 的引脚会烧掉(引脚只能承受 3.3V+0.3V)。
反过来 STM32 输出 3.3V 去驱动 5V 器件时,可能识别不到高电平。
所以很多模块要加电平转换,或者选 3.3V 兼容的器件。

2.3 型号怎么读

S T M 32 F 1 0 3 C 8 T 6
│ │ │ │  │ │ │ │ │ │ └─ 温度范围:6 = -40~85℃
│ │ │ │  │ │ │ │ │ └─── 封装:T = LQFP
│ │ │ │  │ │ │ │ └───── 容量:8 = 64KB Flash
│ │ │ │  │ │ │ └─────── 引脚:C = 48 脚
│ │ │ │  │ │ └───────── 子系列:103 = 增强型
│ │ │ │  │ └─────────── 系列:F1 = 主流
│ │ │ │  └───────────── F = 通用型
│ │ │ └──────────────── 32 = 32 位
│ │ └────────────────── M = 微控制器(Microcontroller)
│ └──────────────────── T = ST 公司
└────────────────────── S = 意法半导体

2.4 片上资源(这就是「组成」的清单)

课件第 8 页列出的全部外设 —— 先混个眼熟,后面每章会深入一个:

缩写名称本教程缩写名称本教程
NVIC嵌套向量中断控制器第 11 章CANCAN 通信略
SysTick系统滴答定时器第 12 章USBUSB 通信略
RCC复位和时钟控制第 9 章RTC实时时钟第 17 章
GPIO通用 IO 口第 10 章CRCCRC 校验略
AFIO复用 IO 口第 11 章PWR电源控制第 17 章
EXTI外部中断第 11 章BKP备份寄存器第 17 章
TIM定时器第 12-13 章IWDG独立看门狗第 17 章
ADC模数转换器第 14 章WWDG窗口看门狗第 17 章
DMA直接内存访问第 15 章DAC数模转换器略(C8T6 无)
USART同步/异步串口第 16 章SDIOSD 卡接口略(C8T6 无)
I2CI2C 通信第 17 章FSMC可变静态存储控制器略(C8T6 无)
SPISPI 通信第 17 章USB OTGUSB 主机接口略
注意:DAC、SDIO、FSMC、USB OTG 这几项 C8T6 是没有的(它是 48 脚小容量型号)。
看别人教程写了 DAC,你的板子上就是没有 —— 别对着代码怀疑自己。

2.5 本章练习

  1. STM32F103C8T6 里,F/C/8/T 各代表什么?
  2. 为什么 5V 传感器不能直接接 STM32 引脚?
  3. C8T6 有没有 DAC?为什么?
📖 参考答案(点击展开)
  1. F = 通用型,C = 48 脚,8 = 64KB Flash,T = LQFP 封装。
  2. 因为 STM32 是 3.3V 器件,引脚最大只能承受 VDD+0.3V ≈ 3.6V。5V 信号直接接进来会击穿内部保护二极管,烧坏芯片。
  3. 没有。DAC 只在互联型(如 F107)和部分大容量型号上有,C8T6 是中容量 48 脚,没有 DAC(也没有 SDIO/FSMC/USB OTG)。
3 第 3 章 · 组成剖析:内核 + 总线 + 存储器 + 外设

3.1 组成总览:四个部分

STM32 内部可以拆成四块,看图和记名字:

s01_系统结构
部分内容作用
① 内核Cortex-M3 + NVIC + SysTick取指、译码、执行;管中断;系统滴答
② 总线总线矩阵 + AHB + APB1/APB2把内核和所有外设连起来
③ 存储器Flash 64KB + SRAM 20KB存程序代码 / 存运行变量
④ 外设GPIO/TIM/ADC/USART/I2C/SPI…和外部世界打交道

3.2 为什么要有三条总线

参考手册 §2.1 原文:「APB1 操作速度限于 36MHz,APB2 操作于全速(最高 72MHz)。」

总线速度挂什么为什么这样分
AHB72 MHzDMA、RCC、CRC、Flash 接口、SDIO需要高带宽的
APB272 MHzGPIO、AFIO、EXTI、ADC、TIM1/8、SPI1、USART1需要高速的(快速 IO、高速串口)
APB136 MHzTIM2~7、USART2~5、I2C、SPI2/3、CAN、USB、BKP速度要求不高的
分三条的目的:低速外设挂在慢总线上,能省功耗、降低设计难度;高速外设单独挂快总线,互不拖累。
就像城市道路分级:主干道(AHB)→ 快速路(APB2)→ 支路(APB1)。
APB1 的 36MHz 上限是硬限制。如果你把 APB1 分频写成 /1(72MHz),I2C 等外设会直接工作异常。
标准配置里 APB1 一定是 /2 = 36MHz。

3.3 总线矩阵:比「一条总线」更强

51 是单总线,同一时刻只能一个设备用。STM32 是总线矩阵 —— 多条通路并行:

CPU 取指(ICode) ──┐
CPU 取数(DCode) ──┼──→ 总线矩阵 ──→ Flash / SRAM / 外设
DMA 搬运 ──────────┘

所以 CPU 在 Flash 里取指令的同时,DMA 可以往 SRAM 搬数据,不冲突。

3.4 内部还有个大件:启动 ROM

区域地址内容
主 Flash0x0800 0000你的程序(64KB)
系统存储器0x1FFF F000ST 出厂写死的 BootLoader,用于串口下载程序
选项字节0x1FFF F800读保护、看门狗硬件使能等配置

(以上地址来自参考手册 §2.3.3 表 2/表 3,中容量产品)

这就是为什么能用串口下载程序:把 BOOT0 拉高再上电,芯片就从系统存储器启动,运行 ST 的 BootLoader,它通过 USART1 接收你的 hex 文件并写进 Flash。
下载完把 BOOT0 拉低,再从 Flash 正常启动。

3.5 本章练习

  1. APB1 和 APB2 的最高频率各是多少?为什么不同?
  2. 系统存储器里存的是什么?什么情况下会用到?
  3. 总线矩阵相比单总线的好处是什么?
📖 参考答案(点击展开)
  1. APB2 最高 72MHz,APB1 最高 36MHz。因为 APB1 挂的是速度要求不高的外设,设计上就限制在 36MHz 以降低功耗和成本。
  2. 系统存储器存的是 ST 出厂写死的 BootLoader,地址 0x1FFF F000。把 BOOT0 拉高再上电就会运行它,通过 USART1 接收 hex 文件写进 Flash —— 这就是串口下载的原理。
  3. 多条通路可以并行:CPU 取指、CPU 取数、DMA 搬运同时进行不冲突,而 51 的单总线同一时刻只能一个设备用。
4 第 4 章 · 最小系统与引脚

4.1 最小系统:能让芯片跑起来的最少元件

部分元件作用不做会怎样
电源3.3V + 多个 0.1μF 去耦电容供电、滤高频噪声不稳定、随机复位
复位10kΩ 上拉 + 0.1μF 到地上电复位上电时序不对,跑飞
时钟8MHz 晶振 + 2×20pF提供 HSE 精确时钟只能用内部 HSI,精度差、串口波特率不准
启动BOOT0 下拉电阻决定从哪启动可能进不了你的程序
调试SWDIO / SWCLK 引出ST-LINK 下载调试只能串口下载,不能单步调试

4.2 引脚分布(LQFP48)

类别引脚说明
GPIOPA0~PA15、PB0~PB15、PC13~PC15共 37 个可用 IO
电源VDD×2、VSS×2、VDDA、VSSAVDDA 是 ADC 专用电源
时钟OSC_IN/OSC_OUT(PD0/PD1)接 8MHz 晶振
复位NRST低电平复位
启动BOOT0与 BOOT1(PB2)配合
调试SWDIO(PA13)、SWCLK(PA14)ST-LINK 用这两个脚
引脚复用:PA13/PA14 默认是 SWD 调试口,不要拿去当普通 GPIO,否则下次就下载不进程序了(需要用 ST-LINK Utility 特殊解锁)。

4.3 和 51 的引脚对比

51STM32
命名P0.0 / P1.0 …PA0 / PB5 / PC13 …
每组大小8 位16 位(PA0~PA15)
方向读写寄存器自动必须用 CRL/CRH 显式配置 8 种模式
电平5V3.3V
驱动能力约 10mA约 20mA(但建议 ≤8mA 长期)

4.4 本章练习

  1. 最小系统里哪几个元件是必须的?各自作用?
  2. 为什么 PA13/PA14 不能随便当普通 IO 用?
  3. VDDA 和 VDD 有什么区别?
📖 参考答案(点击展开)
  1. 电源(3.3V+去耦电容)、复位(10kΩ+0.1μF)、时钟(8MHz 晶振+2×20pF)、BOOT0 下拉。调试口 SWDIO/SWCLK 建议引出。
  2. PA13/PA14 默认是 SWD 调试口。如果配成普通 GPIO 输出,下次就连不上 ST-LINK 下载程序了(需要用 ST-LINK Utility 的特殊方式解锁)。
  3. VDD 是芯片主电源;VDDA 是 ADC 专用模拟电源,单独供电并加滤波,减少数字电路对模拟采样的干扰。
5 第 5 章 · 存储器映像:4GB 空间怎么划分

5.1 一句话理解存储器映像

STM32 有 4GB 的地址空间(0x0000 0000 ~ 0xFFFF FFFF), 程序、变量、外设寄存器全都摆在这个空间里,用地址就能访问一切。

参考手册 §2.2 原文:「程序存储器、数据存储器、寄存器和输入输出端口被组织在同一个 4GB 的线性地址空间内。数据字节以小端格式存放在存储器中。」

s02_存储器映像

5.2 五个必记地址(课件第 101 页原文)

类型起始地址存储器用途
ROM0x0800 0000程序存储器 Flash存储 C 语言编译后的程序代码
0x1FFF F000系统存储器存储 BootLoader,用于串口下载
0x1FFF F800选项字节存储独立于程序的配置参数
RAM0x2000 0000运行内存 SRAM存储运行过程中的临时变量
0x4000 0000外设寄存器存储各个外设的配置参数
0xE000 0000内核外设寄存器存储内核各个外设的配置参数

5.3 为什么有两个 Flash 地址

这是最容易懵的地方:

Flash 物理上固定在 0x0800 0000
但 CPU 复位后固定从 0x0000 0000 取向量
→ 所以 Flash 被「别名」映射到 0x0000 0000

同一块 Flash,两个地址都能访问:
  0x0800 0000  → 原始地址(写保护操作、擦除用这个)
  0x0000 0000  → 别名地址(CPU 取向量用)

参考手册 §2.4 原文:「从主闪存存储器启动:主闪存存储器被映射到启动空间(0x0000 0000),但仍然能够在它原有的地址(0x0800 0000)访问它。」

为什么 CPU 非要从 0x00000000 取向量?
因为 Cortex-M3 内核是固定设计(ARM 定的规矩),它的复位行为写死在硬件里:从地址 0 取 SP、从地址 4 取 PC。
ST 只能想办法把自己芯片上的存储器「摆」到地址 0 去,这就是 Boot 配置的作用。

5.4 用地址直接访问:寄存器就是内存

// 方法一:直接对着地址写(最原始)
#define GPIOA_ODR   (*(volatile unsigned int *)0x4001080C)
GPIOA_ODR = 1;      // 让 PA0 输出高

// 方法二:用结构体指针(固件库的做法)
typedef struct {
    volatile unsigned int CRL;   // 0x00
    volatile unsigned int CRH;   // 0x04
    volatile unsigned int IDR;   // 0x08
    volatile unsigned int ODR;   // 0x0C
    ...
} GPIO_TypeDef;

#define GPIOA  ((GPIO_TypeDef *)0x40010800)

GPIOA->ODR = 1;     // 等价于上一行
核心认知:C 语言里的 GPIOA->ODR = 1,编译后就是一条汇编:
STR r1, [r0, #0xC] —— 把 1 写到地址 0x4001080C。
硬件收到这个写操作,GPIOA_ODR 的 bit0 就变成 1,PA0 引脚就输出了高电平。
「操作寄存器」在硬件层面就是「往某个地址写数据」。

5.5 volatile 为什么不能省

volatile unsigned int *p = (volatile unsigned int *)0x4001080C;
不写 volatile写了 volatile
编译器看到 *p = 1; *p = 2; 会认为第一次没用,优化掉每次都老老实实生成写内存的指令
寄存器操作被优化 → 硬件没收到寄存器操作正常
所有外设寄存器都必须声明 volatile,因为这个地址的内容会被硬件改变(你读它想知道引脚状态),编译器不知道这件事。

5.6 本章练习

  1. Flash、SRAM、外设寄存器的起始地址各是多少?
  2. 为什么 Flash 要从 0x0800 0000 映射到 0x0000 0000?
  3. 不写 volatile 会有什么后果?
📖 参考答案(点击展开)
  1. Flash 0x0800 0000,SRAM 0x2000 0000,外设寄存器 0x4000 0000,内核外设 0xE000 0000。
  2. 因为 Cortex-M3 内核硬件规定复位后从 0x0000 0000 取 SP、0x0000 0004 取 PC,这个行为改不了。所以 ST 把 Flash 别名映射到 0x0000 0000,让内核能取到向量。
  3. 编译器会认为重复写同一个地址是无用操作而优化掉,导致硬件根本没收到写操作。所有外设寄存器必须声明 volatile。
6 第 6 章 · 程序是怎么跑起来的:从上电到 main

6.1 全流程总览

这是本教程最重要的一章。从你按下电源键,到 main() 第一行代码执行,中间经过 8 步:

s03_启动流程
前三步是硬件自动做的,和你写的代码无关;从第 5 步开始才执行 Flash 里的内容。

6.2 逐步拆解

① 复位

三种情况会触发复位:上电、按复位键、看门狗溢出。复位后所有寄存器恢复默认值,CPU 进入已知状态。

② 锁存 BOOT 引脚

参考手册 §2.4 原文:「在系统复位后,SYSCLK 的第 4 个上升沿,BOOT 引脚的值将被锁存。」

BOOT1BOOT0启动模式说明
X0主闪存存储器正常运行都用这个
01系统存储器运行 ST 的 BootLoader(串口下载)
11内置 SRAM调试用,掉电丢失

③ 取初始 SP

CPU 从地址 0x0000 0000 读 4 个字节,装进主堆栈指针 MSP。

// 假设 Flash 开头是:00 50 00 20
// 小端格式 → 0x20005000
// 这就是栈顶地址(SRAM 里,20KB 的顶部)

④ 取复位向量

CPU 从地址 0x0000 0004 读 4 个字节,装进 PC(程序计数器)。

// 假设是:09 01 00 08
// 小端 → 0x08000109
// 这就是 Reset_Handler 的地址(注意最低位是 1,表示 Thumb 状态)

⑤ 执行 Reset_Handler

这是启动文件 startup_stm32f10x_md.s 里的汇编代码,见第 7 章。

⑥ SystemInit

配置时钟系统:把 HSI 8MHz 切换成 HSE 8MHz → PLL×9 → 72MHz。

⑦ __main

C 库函数,做两件事:

把 .data 段从 Flash 拷贝到 SRAM   ← 已初始化的全局变量
把 .bss  段清零                    ← 未初始化的全局变量
为什么需要这一步:全局变量 int a = 5; 的初值 5 存在 Flash 里(掉电不丢),但运行时变量必须在 SRAM 里(可读可写)。
所以启动时要把初值从 Flash「搬」到 SRAM —— 这就是为什么全局变量的值是「带过来」的。

⑧ main()

终于轮到你的代码。注意:此时时钟已经是 72MHz,但外设时钟还是关着的,用之前要自己开。

6.3 如果启动失败,现象是什么

哪一步出问题现象
BOOT 引脚接错程序完全没有反应(跑的是 BootLoader 或空 SRAM)
SP 值不对(栈溢出/链接脚本错)一进 main 就 HardFault
时钟配置错程序能跑但串口乱码、定时不准
Flash 没烧进去现象是上一次的程序
调试技巧:程序跑飞时,用 ST-LINK 连上去看 0x0800 0000 处的 4 个字节。
正常应该是一个 SRAM 地址(0x2000xxxx);如果是 0xFFFFFFFF 说明 Flash 是空的,程序根本没烧进去。

6.4 本章练习

  1. CPU 复位后从哪两个地址取数据?分别装进什么寄存器?
  2. BOOT0=1、BOOT1=0 时从哪启动?什么场景用?
  3. __main 为什么要从 Flash 拷贝 .data 到 SRAM?
📖 参考答案(点击展开)
  1. 从 0x0000 0000 读 4 字节装进 MSP(主堆栈指针);从 0x0000 0004 读 4 字节装进 PC(程序计数器)。
  2. BOOT0=1、BOOT1=0 → 从系统存储器启动,运行 ST 的 BootLoader。用于串口下载程序(下载完要把 BOOT0 改回 0)。
  3. 全局变量初值存在 Flash(掉电不丢),但运行时变量必须在 SRAM(可读写)。所以启动时要把 .data 段的初值从 Flash 拷贝到 SRAM,.bss 段直接清零。
7 第 7 章 · 启动文件与向量表逐段剖析

7.1 启动文件长什么样

打开 Start/startup_stm32f10x_md.s,最开头是:

Stack_Size      EQU     0x00000400        ; 栈大小 = 1KB

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp                              ; ★ 这个标号就是「初始 SP」

Heap_Size       EQU     0x00000200        ; 堆大小 = 512B
                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit
关键点:__initial_sp 这个标号的值,会被链接器填到向量表的第一个 4 字节(地址 0x0800 0000)。
所以 CPU 从地址 0 读到的就是它 —— 栈顶是链接时算出来的,不是运行时算的。

7.2 向量表:中断入口的清单

                AREA    RESET, DATA, READONLY
__Vectors       DCD     __initial_sp              ; 0x00  ← 初始 SP
                DCD     Reset_Handler             ; 0x04  ← 复位入口
                DCD     NMI_Handler               ; 0x08
                DCD     HardFault_Handler         ; 0x0C
                DCD     MemManage_Handler         ; 0x10
                DCD     BusFault_Handler          ; 0x14
                DCD     UsageFault_Handler        ; 0x18
                DCD     0                         ; 0x1C 保留
                DCD     0
                DCD     0
                DCD     0
                DCD     SVC_Handler               ; 0x2C
                DCD     DebugMon_Handler          ; 0x30
                DCD     0
                DCD     PendSV_Handler            ; 0x38
                DCD     SysTick_Handler           ; 0x3C
                ; …… 之后是 60 个外设中断
                DCD     WWDG_IRQHandler           ; 窗口看门狗
                DCD     PVD_IRQHandler            ; 电源电压检测
                DCD     TAMPER_IRQHandler         ; 侵入检测
                DCD     RTC_IRQHandler            ; RTC
                DCD     FLASH_IRQHandler          ; Flash 全局
                DCD     RCC_IRQHandler            ; RCC
                DCD     EXTI0_IRQHandler          ; 外部中断 0
                ...
为什么第一个是 SP 而不是指令地址:Cortex-M3 的硬件设计如此 —— 复位时先建立栈(因为后续可能进中断要压栈),再从第二个字开始执行。
这和 51 不同:51 复位后直接从 0x0000 执行指令。

7.3 Reset_Handler:真正的第一段代码

Reset_Handler   PROC
                EXPORT  Reset_Handler             [WEAK]
                IMPORT  __main
                IMPORT  SystemInit
                LDR     R0, =SystemInit       ; ① R0 = SystemInit 地址
                BLX     R0                    ; ② 调用它(配置时钟)
                LDR     R0, =__main           ; ③ R0 = __main 地址
                BX      R0                    ; ④ 跳过去(不返回)
                ENDP
指令作用
LDR R0, =SystemInit取函数地址(伪指令,编译成从常量池加载)
BLX R0带返回的跳转(B=分支,L=链接返回地址,X=切换指令集)
BX R0不带返回的跳转 —— 因为 main 永远不返回
这就是课件第 17 页「工程架构」的原文:
startup_xx.s 复位中断 → 调用 SystemInit → 调用 main
你以前写的 main 为什么能被执行?答案就在这 4 条汇编。

7.4 为什么叫「弱定义」

                EXPORT  Reset_Handler             [WEAK]

[WEAK] 是弱符号:如果别的地方(比如 stm32f10x_it.c)定义了一个同名的强符号,就用那个。

所以中断服务函数你可以在任何 .c 文件里写,只要名字对上,就会覆盖启动文件里的空实现:

// stm32f10x_it.c 里写
void TIM2_IRQHandler(void)   // 名字必须和启动文件里的一致
{
    // 你的中断处理
}
改中断函数名拼错 = 中断永远不进。这是新手最常见的问题之一:
TIM2_IRQHandler 写成 TIM2_IRQhandler / TIM2_Handler,编译不报错,但中断不响应。

7.5 本章练习

  1. 初始 SP 的值是链接时定的还是运行时算的?从哪个标号来?
  2. Reset_Handler 里为什么要先调 SystemInit 再调 __main?
  3. [WEAK] 是什么意思?为什么中断函数能随便写在哪个 .c 文件?
📖 参考答案(点击展开)
  1. 链接时定的。来自启动文件 STACK 段的 __initial_sp 标号,链接器把它的值填进向量表第一个 4 字节。
  2. 因为 __main 里要初始化内存(拷贝 .data、清 .bss),而 C 库的某些实现依赖正确的时钟;更重要的是 SystemInit 负责把时钟从 8MHz HSI 切到 72MHz PLL,必须在用户代码前完成。
  3. 弱符号。如果别处有同名强符号就用那个 —— 所以中断服务函数可以写在任意 .c 文件里,只要名字和启动文件里的一致就会覆盖空实现。
8 第 8 章 · 从 C 代码到引脚电平

8.1 一条 C 语句的完整旅程

s06_执行链路

8.2 源码级拆解

GPIOA->ODR = 0x0001;      // C 源码

① 编译器展开宏

// GPIOA 在 stm32f10x.h 里定义成:
#define GPIOA   ((GPIO_TypeDef *)0x40010800)

// 展开后:
((GPIO_TypeDef *)0x40010800)->ODR = 0x0001;

② 算出成员偏移

// GPIO_TypeDef 结构体里,ODR 是第 4 个成员,前 3 个各 4 字节
// 所以 ODR 的偏移 = 0x0C

// 最终等价于:
*(volatile unsigned int *)(0x40010800 + 0x0C) = 0x0001;
*(volatile unsigned int *)(0x4001080C) = 0x0001;

③ 生成汇编

MOVS    r1, #1                 ; r1 = 1
LDR     r0, =0x4001080C        ; r0 = 目标地址(从常量池加载)
STR     r1, [r0]               ; ★ 把 r1 写到 r0 指向的地址

④ CPU 执行 STR

这条 STR 指令会让 CPU 通过 DCode 总线发出一个写操作,地址 0x4001080C,数据 0x00000001。

⑤ 硬件响应

写操作到达 GPIOA 外设,其 ODR(输出数据寄存器)的 bit0 变成 1 → PA0 引脚的输出驱动器动作 → 引脚电平变为高。

8.3 完整对照表

层次内容
C 源码GPIOA->ODR = 0x0001;
预处理后((GPIO_TypeDef *)0x40010800)->ODR = 1;
等效指针*(volatile uint32_t *)0x4001080C = 1;
汇编MOVS r1,#1 / LDR r0,=addr / STR r1,[r0]
机器码0x2101 0x4802 0x6001(示意)
总线操作写 4 字节到 0x4001080C
硬件结果GPIOA_ODR.bit0 = 1 → PA0 输出高电平

8.4 反过来:读引脚

unsigned char key = GPIOA->IDR & 0x01;    // 读 PA0 电平

编译成:

LDR  r0, =0x40010808     ; IDR 的地址
LDR  r0, [r0]            ; ★ 读这个地址
AND  r0, r0, #1          ; 取 bit0
注意区别:
GPIOx_ODR(0x0C)= 你希望引脚输出什么
GPIOx_IDR(0x08)= 实际引脚上的电平(回读)
输出模式下驱动能力不够时,ODR 写 1 但 IDR 读到 0 —— 这就说明被外部拉住了。

8.5 从编译到烧录:文件格式

文件内容谁产生
.c / .h源码你
.o目标文件(机器码 + 符号表)编译器
.axf / .elf带调试信息的可执行文件链接器
.hex纯文本格式的机器码(带地址)链接器
.bin纯二进制机器码(无地址信息)转换工具

烧录时,下载器把 .hex/.bin 按地址写入 Flash 0x0800 0000 开始的位置。

8.6 本章练习

  1. GPIOA->ODR = 1 最终写到了哪个地址?
  2. ODR 和 IDR 有什么区别?什么时候会不一样?
  3. STR 指令在硬件层面做了什么?
📖 参考答案(点击展开)
  1. GPIOA 基地址 0x4001 0800,ODR 偏移 0x0C → 0x4001 080C。
  2. ODR 是输出数据寄存器(你希望引脚输出什么);IDR 是输入数据寄存器(引脚实际电平)。
    输出模式下被外部强拉时会不一样(比如 LED 驱动能力不够,或引脚短路)。
  3. 把寄存器里的数据通过 DCode 总线写到指定地址,外设收到写操作后改变对应的位。
9 第 9 章 · 时钟系统:72MHz 是怎么来的

9.1 时钟树总览

STM32 最反直觉的设计:时钟不是只有一个,而是一棵树。

s04_时钟树

9.2 五个时钟源

时钟源频率来源用途
HSI8 MHz芯片内部 RC 振荡器复位后的默认时钟;便宜但不准(±1%)
HSE8 MHz外部晶振精确,做 PLL 输入
LSI40 kHz内部低速 RC独立看门狗 IWDG
LSE32.768 kHz外部 32.768k 晶振RTC 实时时钟
PLL可配置由 HSI/2 或 HSE 倍频产生 72MHz 系统时钟
为什么 32.768 kHz 这个怪数字:2^15 = 32768。把它 15 次二分频就得到 1Hz(1 秒),硬件分频最省事。

9.3 72MHz 是怎么算出来的

HSE = 8 MHz(外部晶振)
PLL 输入 = HSE / 1 = 8 MHz        (PLLXTPRE 位选择,默认不分频)
PLL 倍频 = ×9                     (PLLMUL 位 = 0111)
PLL 输出 = 8 × 9 = 72 MHz

SYSCLK = PLL 输出 = 72 MHz        (SW 位选择 PLL 作为系统时钟)

AHB 预分频 = /1  → HCLK = 72 MHz
APB2 预分频 = /1 → PCLK2 = 72 MHz
APB1 预分频 = /2 → PCLK1 = 36 MHz  ★ 不能超过 36MHz

9.4 SystemInit 到底做了什么

void SystemInit(void)
{
    /* ① 打开 HSE */
    RCC->CR |= 1 << 16;              // HSEON = 1
    while (!(RCC->CR & (1 << 17)));  // 等 HSERDY 置位

    /* ② 配置 PLL:HSE 不分频,×9 */
    RCC->CFGR &= ~(0xF << 18);        // 清 PLLMUL
    RCC->CFGR |= (0x7 << 18);         // PLLMUL = 0111 = ×9
    RCC->CFGR |= (1 << 16);           // PLLSRC = 1,选 HSE

    /* ③ 打开 PLL */
    RCC->CR |= 1 << 24;               // PLLON = 1
    while (!(RCC->CR & (1 << 25)));  // 等 PLLRDY

    /* ④ 切换系统时钟到 PLL */
    RCC->CFGR &= ~0x3;                // 清 SW
    RCC->CFGR |= 0x2;                 // SW = 10,选 PLL
    while ((RCC->CFGR & (0x3 << 2)) != (0x2 << 2));  // 等 SWS
}
实际工程里:这段代码在 system_stm32f10x.c 的 SystemInit() 里,启动文件自动调用,你不需要自己写。
但你必须知道它做了这件事 —— 否则「为什么我的程序一上来就 72MHz」这个问题永远想不通。

9.5 外设时钟:为什么要单独开

参考手册 §2.1 原文:「在每一次复位以后,所有除 SRAM 和 FLITF 以外的外设都被关闭,在使用一个外设之前,必须设置寄存器 RCC_AHBENR 来打开该外设的时钟。」

RCC->APB2ENR |= 1 << 2;    // 开 GPIOA 时钟(bit2 = IOPAEN)
RCC->APB2ENR |= 1 << 0;    // 开 AFIO 时钟(bit0,用 EXTI 时必须开)
RCC->APB1ENR |= 1 << 0;    // 开 TIM2 时钟
RCC->AHBENR  |= 1 << 0;    // 开 DMA1 时钟
寄存器地址管哪些
RCC_AHBENR0x4002 1014DMA1/2、SRAM、Flash 接口、CRC、FSMC、SDIO
RCC_APB2ENR0x4002 1018AFIO、GPIOA~E、ADC1/2、TIM1/8、SPI1、USART1
RCC_APB1ENR0x4002 101CTIM2~7、USART2~5、I2C1/2、SPI2/3、CAN、USB、PWR、BKP
省电的意义:不用某个外设就不开它的时钟,那部分电路完全断电。
这就是 STM32 低功耗的基础 —— 而不是像 51 那样所有外设一直在耗电。

9.6 本章练习

  1. HSE 8MHz 经 PLL ×9 后是多少?AHB/APB2/APB1 各是多少?
  2. 为什么 APB1 必须 /2?
  3. 复位后默认用哪个时钟?多少 MHz?
  4. RCC_APB2ENR 的地址是多少?开 GPIOA 时钟要写哪一位?
📖 参考答案(点击展开)
  1. 8MHz × 9 = 72MHz。AHB = 72MHz,APB2 = 72MHz,APB1 = 36MHz。
  2. 因为参考手册 §2.1 明确规定 APB1 最高只能工作到 36MHz。超频会导致挂在 APB1 上的外设(I2C、USART2/3 等)工作异常。
  3. 复位后默认用 HSI 8MHz(内部 RC)。所以 SystemInit 必须手动切到 HSE+PLL 才有 72MHz。
  4. RCC_APB2ENR 地址 0x4002 1018,开 GPIOA 时钟要写 bit2(IOPAEN)。
10 第 10 章 · GPIO:8 种模式与寄存器

10.1 GPIO 的 8 种模式

s05_GPIO位结构
模式CRL/CRH 的 4 位用途
浮空输入0100外部已有上拉/下拉时(如串口 RX)
上拉输入1000按键(一端接地)
下拉输入1000(ODR=0)按键(一端接电源)
模拟输入0000ADC 采样必须用这个
推挽输出0010(10MHz)LED、驱动能力强的场合
开漏输出0110I2C 必须用这个
复用推挽1010TIM 输出 PWM、SPI、USART TX
复用开漏1110I2C 硬件外设
最容易踩的坑:
① 用 ADC 时把引脚配成「上拉输入」→ 采样值完全不对,必须配「模拟输入」。
② 用 I2C 时配成「推挽输出」→ 总线冲突,必须开漏 + 外部上拉。

10.2 寄存器:一个端口 7 个寄存器

寄存器偏移作用
GPIOx_CRL0x00配置 Px0~Px7(每个引脚 4 位)
GPIOx_CRH0x04配置 Px8~Px15
GPIOx_IDR0x08输入数据(读引脚电平)
GPIOx_ODR0x0C输出数据(写引脚电平)
GPIOx_BSRR0x10位设置/清除(原子操作,推荐)
GPIOx_BRR0x14位清除
GPIOx_LCKR0x18锁定配置,防误改
GPIOA 基地址 = 0x4001 0800
所以 GPIOA_ODR = 0x4001 0800 + 0x0C = 0x4001 080C ✓

10.3 点灯:三种写法对比

① 直接操作寄存器(最原始,看清本质)

#include "stm32f10x.h"

int main(void)
{
    RCC->APB2ENR |= 1 << 2;        // 开 GPIOA 时钟

    GPIOA->CRL &= ~(0xF << 0);     // 清 PA0 的 4 位配置
    GPIOA->CRL |= (0x2 << 0);      // 00 10 → 推挽输出 2MHz

    while (1)
    {
        GPIOA->ODR &= ~0x0001;     // PA0 低 → 亮
        for (int i = 0; i < 1000000; i++);
        GPIOA->ODR |= 0x0001;       // PA0 高 → 灭
        for (int i = 0; i < 1000000; i++);
    }
}

② 用固件库(江协教程的写法)

#include "stm32f10x.h"

int main(void)
{
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;   // 推挽输出
    GPIO_InitStructure.GPIO_Pin   = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    while (1)
    {
        GPIO_ResetBits(GPIOA, GPIO_Pin_0);   // 低
        Delay_ms(500);
        GPIO_SetBits(GPIOA, GPIO_Pin_0);     // 高
        Delay_ms(500);
    }
}
两种写法等价:固件库内部就是帮你算了 CRL 的位。
区别:寄存器写法能看清「到底写了哪个地址」,固件库写法则更像 51 的 sbit。建议先会 ①,再用 ②。

10.4 BSRR 为什么比 ODR 好

// 想同时把 PA0 置 1、PA1 清 0:

// ODR 写法(读-改-写,3 条指令,可能被中断打断)
GPIOA->ODR |= 0x0001;
GPIOA->ODR &= ~0x0002;

// BSRR 写法(1 条指令,原子操作,绝对不会被打断)
GPIOA->BSRR = 0x0001 | (0x0002 << 16);
//          低 16 位 = 置 1 的位;高 16 位 = 清 0 的位
51 也有这个问题:P2 |= 0x01 是读-改-写,如果中断里也改了 P2,主程序可能覆盖掉中断的修改。
STM32 的 BSRR 就是硬件层面解决这个问题。

10.5 本章练习

  1. GPIOA_ODR 的绝对地址是多少?
  2. ADC 采样时引脚要配成什么模式?配错了会怎样?
  3. BSRR 相比 ODR 的最大优势是什么?
📖 参考答案(点击展开)
  1. GPIOA 基地址 0x4001 0800 + 0x0C = 0x4001 080C。
  2. 必须配成模拟输入(GPIO_Mode_AIN)。配成上拉/下拉/浮空输入都会让内部电路接入,影响采样精度甚至读错。
  3. 原子操作:一条指令完成置位/清零,不会被中断打断,也不需要读-改-写。ODR 需要先读再改再写,有被中断破坏的风险。
11 第 11 章 · 中断体系:NVIC 与 EXTI

11.1 STM32 的中断体系和 51 的差别

51STM32
中断源数量5 个60 个
优先级2 级(高/低)16 级可编程(4 位)
向量表位置固定 0x0003/0x000B…0x0800 0000 起,可重定位
入口写法interrupt 0函数名固定(如 EXTI0_IRQHandler)
控制器无(芯片内部简单逻辑)NVIC(内核里,可配置)

11.2 NVIC:内核里的中断管家

功能说明
使能/失能每个中断源一个使能位(ISER/ICER 寄存器)
优先级每个中断一个 8 位优先级寄存器(实际用高 4 位)
优先级分组把 4 位拆成「抢占优先级 + 响应优先级」
挂起/解挂记录哪些中断正在等待

抢占优先级 vs 响应优先级

抢占优先级响应优先级
作用能不能打断正在执行的中断同时到来时谁先执行
能不能嵌套高的能打断低的不能
// 常用分组:NVIC_PriorityGroup_2 → 2 位抢占 + 2 位响应
// 即抢占优先级 0~3,响应优先级 0~3

NVIC_InitTypeDef n;
n.NVIC_IRQChannel = EXTI0_IRQn;
n.NVIC_IRQChannelPreemptionPriority = 2;  // 抢占
n.NVIC_IRQChannelSubPriority = 1;         // 响应
n.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&n);
整个工程只能设置一次优先级分组。如果在两个地方设了不同的分组,后面的会覆盖前面的,导致优先级行为诡异。

11.3 EXTI:外部中断线

EXTI 有 20 条线,其中 EXTI0~EXTI15 对应 GPIO:

EXTI0  ← PA0 / PB0 / PC0 …(同一时刻只能选一个)
EXTI1  ← PA1 / PB1 / PC1 …
…
EXTI15 ← PA15 / PB15 …

选哪个端口由 AFIO_EXTICR 寄存器决定:

// 把 EXTI0 接到 PA0
AFIO->EXTICR[0] &= ~0xF;      // 清低 4 位
AFIO->EXTICR[0] |= 0x0;        // 0000 = 选 PA
// 选 PB0 就写 0x1,PC0 写 0x2 …

11.4 完整例子:按键触发中断翻转 LED

#include "stm32f10x.h"

void LED_Init(void)
{
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitTypeDef g;
    g.GPIO_Mode  = GPIO_Mode_Out_PP;
    g.GPIO_Pin   = GPIO_Pin_0;
    g.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &g);
    GPIO_SetBits(GPIOA, GPIO_Pin_0);       // 默认灭
}

void Key_EXTI_Init(void)
{
    /* ① 按键引脚:PB1 上拉输入 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    GPIO_InitTypeDef g;
    g.GPIO_Mode  = GPIO_Mode_IPU;          // 上拉输入
    g.GPIO_Pin   = GPIO_Pin_1;
    g.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &g);

    /* ② 开 AFIO 时钟(用 EXTI 必须开!) */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);

    /* ③ 把 EXTI1 接到 PB1 */
    GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource1);

    /* ④ 配置 EXTI1:下降沿触发 */
    EXTI_InitTypeDef e;
    e.EXTI_Line    = EXTI_Line1;
    e.EXTI_Mode    = EXTI_Mode_Interrupt;
    e.EXTI_Trigger = EXTI_Trigger_Falling;
    e.EXTI_LineCmd = ENABLE;
    EXTI_Init(&e);

    /* ⑤ NVIC 使能 */
    NVIC_InitTypeDef n;
    n.NVIC_IRQChannel = EXTI1_IRQn;
    n.NVIC_IRQChannelPreemptionPriority = 2;
    n.NVIC_IRQChannelSubPriority = 1;
    n.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&n);
}

/* ⑥ 中断服务函数:名字必须是 EXTI1_IRQHandler */
void EXTI1_IRQHandler(void)
{
    if (EXTI_GetITStatus(EXTI_Line1) == SET)      // 确认是这条线
    {
        /* 简单消抖 */
        for (int i = 0; i < 20000; i++);

        if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0)   // 确认还在按
        {
            if (GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))
                GPIO_ResetBits(GPIOA, GPIO_Pin_0);
            else
                GPIO_SetBits(GPIOA, GPIO_Pin_0);
        }

        EXTI_ClearITPendingBit(EXTI_Line1);       // ★ 必须手动清标志!
    }
}

int main(void)
{
    LED_Init();
    Key_EXTI_Init();
    while (1) { }
}
三个必错点:
① 忘了开 AFIO 时钟 → EXTI 完全不工作。
② 忘了 EXTI_ClearITPendingBit → 中断只进一次,然后一直卡在中断里。
③ 中断函数名拼错 → 编译通过,就是不进中断。

11.5 本章练习

  1. EXTI0 想接到 PB0,AFIO_EXTICR 应该写什么?
  2. 为什么中断服务函数最后必须清标志位?
  3. 抢占优先级和响应优先级的区别?
📖 参考答案(点击展开)
  1. EXTICR[0] 的低 4 位写 0x1(0=PA, 1=PB, 2=PC…)。
  2. 因为中断标志位是硬件置位的,硬件不会自动清。不清的话中断退出后标志还在,会立刻再次进入中断,程序卡死在中断里。
  3. 抢占优先级决定能否打断正在执行的中断(高的打断低的);响应优先级只在同时到来时决定谁先执行,不能嵌套。
12 第 12 章 · 定时器:时基单元

12.1 STM32 的定时器家族

类型定时器位宽特点
高级TIM1、TIM816 位带死区、刹车、互补输出(电机控制)
通用TIM2~TIM516 位PWM、输入捕获、编码器接口
基本TIM6、TIM716 位只能定时,没有输出
系统SysTick24 位内核里的,给 RTOS/延时用

12.2 时基单元:三个寄存器决定一切

时钟源 → [预分频器 PSC] → [计数器 CNT] → [自动重装 ARR] → 溢出/中断
寄存器作用
PSC(预分频)把输入时钟分频:fcnt = fCLK / (PSC+1)
CNT(计数器)每个时钟周期加 1(或减 1)
ARR(自动重装)CNT 数到这个值就归零,并产生更新事件

定时时间怎么算

定时时间 = (ARR + 1) × (PSC + 1) / f_CLK

例:TIM2 挂在 APB1,时钟 72MHz(见下面说明)
要定时 1ms:
  设 PSC = 71  → f_cnt = 72MHz / 72 = 1MHz(每计数一次 1μs)
  设 ARR = 999 → 1000 × 1μs = 1ms ✓
APB1 的时钟陷阱:APB1 分频系数 ≠1 时,定时器时钟会自动 ×2。
所以 APB1 = 36MHz,但 TIM2~7 的时钟是 72MHz,不是 36MHz。
这个坑参考手册 §7.2 有写,但很多人不知道。

12.3 定时中断完整代码

#include "stm32f10x.h"

void Timer2_Init(void)
{
    /* ① 开 TIM2 时钟(在 APB1 上) */
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

    /* ② 配置时基单元 */
    TIM_TimeBaseInitTypeDef t;
    t.TIM_ClockDivision     = TIM_CKD_DIV1;
    t.TIM_CounterMode       = TIM_CounterMode_Up;   // 向上计数
    t.TIM_Period            = 1000 - 1;             // ARR = 999
    t.TIM_Prescaler         = 72 - 1;               // PSC = 71
    t.TIM_RepetitionCounter = 0;
    TIM_TimeBaseInit(TIM2, &t);

    /* ③ 开更新中断 */
    TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);

    /* ④ NVIC */
    NVIC_InitTypeDef n;
    n.NVIC_IRQChannel = TIM2_IRQn;
    n.NVIC_IRQChannelPreemptionPriority = 2;
    n.NVIC_IRQChannelSubPriority = 1;
    n.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&n);

    /* ⑤ 启动定时器 */
    TIM_Cmd(TIM2, ENABLE);
}

volatile unsigned int cnt = 0;

void TIM2_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) == SET)
    {
        cnt++;
        if (cnt >= 500)      // 500ms × 2 = 1s
        {
            cnt = 0;
            GPIOA->ODR ^= 0x0001;    // 翻转 PA0
        }
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);   // ★ 清标志
    }
}

int main(void)
{
    LED_Init();          // 同第 10 章
    Timer2_Init();
    while (1) { }
}

12.4 和 51 定时器对比

51STM32
位宽13/16 位16 位
初值从初值数到溢出(要重装)从 0 数到 ARR(自动重装)
分频固定 12TPSC 任意分频
中断函数interrupt 1TIM2_IRQHandler
思维转换:51 是「数到溢出」,STM32 是「数到 ARR」。
51 的初值 0x4C00 对应 STM32 的 ARR = 46079(同样数 46080 次),但因为可以 PSC 分频,STM32 更灵活。

12.5 本章练习

  1. 要定时 20ms,PSC=71 时 ARR 应该多少?
  2. 为什么 TIM2 的时钟是 72MHz 而不是 36MHz?
  3. 定时器中断里忘了清标志会怎样?
📖 参考答案(点击展开)
  1. 20ms 需要 20000 个计数。PSC=71 时计数频率 1MHz,所以 ARR = 20000 − 1 = 19999。
  2. 因为 APB1 的分频系数不是 1 时,定时器时钟会自动 ×2。APB1 = 36MHz,所以 TIM2~7 的时钟是 72MHz。这个是参考手册 §7.2 的内容。
  3. 中断标志不清,退出中断后标志仍为 1,会立即再次进入中断,主程序永远得不到执行。
13 第 13 章 · PWM:硬件产生方波

13.1 PWM 是怎么产生的

计数器 CNT 一直从 0 数到 ARR(锯齿波)
同时有个比较值 CCR
  CNT < CCR  → 输出有效电平(高)
  CNT ≥ CCR  → 输出无效电平(低)

于是引脚上就出现了方波,占空比 = CCR / (ARR+1)
参数公式
PWM 频率f = fCLK / ((PSC+1) × (ARR+1))
占空比duty = CCR / (ARR+1) × 100%

13.2 呼吸灯完整代码

#include "stm32f10x.h"

void PWM_Init(void)
{
    /* ①  GPIO:PA0 配成复用推挽(因为要交给 TIM2 控制) */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitTypeDef g;
    g.GPIO_Mode  = GPIO_Mode_AF_PP;        // ★ 复用推挽,不是普通推挽
    g.GPIO_Pin   = GPIO_Pin_0;
    g.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &g);

    /* ②  TIM2 时基:1kHz */
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    TIM_TimeBaseInitTypeDef t;
    t.TIM_ClockDivision     = TIM_CKD_DIV1;
    t.TIM_CounterMode       = TIM_CounterMode_Up;
    t.TIM_Period            = 100 - 1;     // ARR = 99 → 100 级
    t.TIM_Prescaler         = 7200 - 1;    // 72MHz/7200 = 10kHz,再 /100 = 100Hz
    t.TIM_RepetitionCounter = 0;
    TIM_TimeBaseInit(TIM2, &t);

    /* ③  输出比较通道 1 */
    TIM_OCInitTypeDef oc;
    TIM_OCStructInit(&oc);
    oc.TIM_OCMode      = TIM_OCMode_PWM1;
    oc.TIM_OutputState = TIM_OutputState_Enable;
    oc.TIM_OCPolarity  = TIM_OCPolarity_High;
    oc.TIM_Pulse       = 0;                // 初始占空比 0
    TIM_OC1Init(TIM2, &oc);
    TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable);

    TIM_Cmd(TIM2, ENABLE);
}

int main(void)
{
    PWM_Init();
    while (1)
    {
        /* 占空比 0 → 99 渐亮 */
        for (int i = 0; i < 100; i++)
        {
            TIM_SetCompare1(TIM2, i);
            Delay_ms(10);
        }
        /* 99 → 0 渐暗 */
        for (int i = 100; i > 0; i--)
        {
            TIM_SetCompare1(TIM2, i);
            Delay_ms(10);
        }
    }
}

13.3 关键点解析

为什么解释
GPIO 要配「复用推挽」普通推挽是 CPU 写 ODR 控制;复用是让 TIM 外设直接接管引脚
PSC=7199、ARR=9972MHz/7200/100 = 100Hz,肉眼不闪
OCPreload 要开否则改 CCR 会立刻生效,产生毛刺
PWM1 模式CNT<CCR 输出高(另有一种 PWM2 相反)
51 怎么做 PWM:只能软件模拟(定时器中断里手动翻转引脚),占 CPU 且频率低。
STM32 的 PWM 是硬件产生的,设置好之后完全不用 CPU 参与。

13.4 驱动舵机:PWM 的实际应用

// SG90 舵机:周期 20ms,高电平 0.5~2.5ms 对应 0~180°
// 设 ARR=19999, PSC=71 → 20ms
// 0°   → CCR =  500   (0.5ms)
// 90°  → CCR = 1500   (1.5ms)
// 180° → CCR = 2500   (2.5ms)

void Servo_SetAngle(float angle)
{
    TIM_SetCompare1(TIM2, 500 + angle / 180.0 * 2000);
}

13.5 本章练习

  1. PSC=7199、ARR=99 时 PWM 频率是多少?
  2. 为什么 PWM 输出引脚要配成「复用推挽」?
  3. 舵机要 20ms 周期,ARR 和 PSC 怎么设?
📖 参考答案(点击展开)
  1. 72MHz / 7200 / 100 = 100Hz(周期 10ms)。
  2. 因为 PWM 是由 TIM 外设硬件产生的,引脚必须交给外设控制。配成普通推挽的话引脚由 ODR 控制,TIM 的输出根本到不了引脚。
  3. 20ms 周期:PSC = 71(1MHz 计数),ARR = 20000 − 1 = 19999。CCR = 500~2500 对应 0~180°。
14 第 14 章 · ADC:逐次逼近采样

14.1 ADC 是干什么的

把连续变化的电压变成离散的数字。STM32F103 的是 12 位逐次逼近型 ADC。

12 位分辨率 → 2^12 = 4096 个等级
参考电压 3.3V → 每个等级 3.3 / 4096 ≈ 0.806 mV
输入 1.65V → 数字量约 2048
逐次逼近怎么工作:像猜数字游戏 —— 先试中间值(2048),比输入大就往下试(1024),比输入小就往上试(3072)……
12 次比较后得到结果,所以叫「逐次逼近」。速度比 51 外面挂的 ADC0809 快得多。

14.2 单通道 ADC 完整代码

#include "stm32f10x.h"

void ADC1_Init(void)
{
    /* ① GPIO:PA0 必须配「模拟输入」! */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    GPIO_InitTypeDef g;
    g.GPIO_Mode = GPIO_Mode_AIN;       // ★ 模拟输入,不是上拉!
    g.GPIO_Pin  = GPIO_Pin_0;
    GPIO_Init(GPIOA, &g);

    /* ② ADC 时钟:APB2/6 = 12MHz(ADC 最高不能超 14MHz) */
    RCC_ADCCLKConfig(RCC_PCLK2_Div6);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

    /* ③ 配置 ADC */
    ADC_InitTypeDef a;
    a.ADC_Mode               = ADC_Mode_Independent;
    a.ADC_ScanConvMode       = DISABLE;          // 单通道
    a.ADC_ContinuousConvMode = DISABLE;          // 单次
    a.ADC_ExternalTrigConv   = ADC_ExternalTrigConv_None;
    a.ADC_DataAlign          = ADC_DataAlign_Right;
    a.ADC_NbrOfChannel       = 1;
    ADC_Init(ADC1, &a);

    /* ④ 规则组:通道 0,采样时间 55.5 个周期 */
    ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);

    /* ⑤ 使能 + 校准 */
    ADC_Cmd(ADC1, ENABLE);
    ADC_ResetCalibration(ADC1);
    while (ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while (ADC_GetCalibrationStatus(ADC1));
}

unsigned int ADC_Read(void)
{
    ADC_SoftwareStartConvCmd(ADC1, ENABLE);          // 启动转换
    while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC));  // 等转换完成
    return ADC_GetConversionValue(ADC1);             // 读结果(0~4095)
}

int main(void)
{
    ADC1_Init();
    while (1)
    {
        unsigned int val = ADC_Read();
        // 转成电压(mV):val * 3300 / 4095
        volatile unsigned int mv = val * 3300 / 4095;
        Delay_ms(100);
    }
}

14.3 为什么必须校准

ADC 内部有电容和比较器,存在制造偏差。校准让硬件自动测出这个偏差并补偿。不校准会导致读数系统性偏移。

三个必错点:
① GPIO 配成上拉输入而不是模拟输入 → 读数完全是错的。
② ADC 时钟超过 14MHz → 精度下降。
③ 忘了校准 → 读数偏移几十到上百。

14.4 本章练习

  1. 3.3V 参考、12 位 ADC,数字量 4095 对应多少伏?
  2. GPIO 必须配成什么模式?为什么?
  3. ADC 时钟为什么不能超过 14MHz?
📖 参考答案(点击展开)
  1. 4095 × 3.3V / 4096 ≈ 3.2992V(约 3.3V)。实际按 val × 3300 / 4095 算得 3300mV。
  2. 模拟输入(GPIO_Mode_AIN)。因为其他模式会把内部上拉/下拉或施密特触发器接进来,改变引脚上的实际电压。
  3. ADC 内部是逐次逼近电容阵列,采样需要时间建立。时钟太快会导致比较器来不及稳定,转换结果错误。
15 第 15 章 · DMA:不占 CPU 的搬运工

15.1 DMA 解决什么问题

没有 DMA:CPU 要亲自把数据从 A 搬到 B(比如 ADC 结果搬进数组),搬一次占一次 CPU。 有 DMA:告诉 DMA「从这搬到那,搬多少个」,然后 CPU 去干别的事,搬完 DMA 再通知你。
没有 DMA:
  ADC 转换完 → CPU 读 ADC_DR → CPU 写数组 → 循环 100 次

有 DMA:
  配置一次 → DMA 自动搬 100 次 → 全部搬完发个中断
  CPU 全程可以去算别的东西

15.2 关键概念

概念说明
通道(Channel)每个外设的请求对应固定通道,如 ADC1 是 DMA1 通道 1
外设地址源头地址(如 &ADC1->DR)
存储器地址目标地址(如数组名)
数据宽度8/16/32 位,两边必须匹配
传输方向外设→内存 / 内存→外设 / 内存→内存
循环模式搬完自动重来(连续采样必须有)

15.3 代码框架

#define BUF_SIZE 100
uint16_t adc_buf[BUF_SIZE];

void DMA_Init(void)
{
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);   // ★ DMA 在 AHB 上

    DMA_InitTypeDef d;
    d.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;      // 源:ADC 数据寄存器
    d.DMA_MemoryBaseAddr     = (uint32_t)adc_buf;         // 目标:数组
    d.DMA_DIR                = DMA_DIR_PeripheralSRC;     // 外设 → 内存
    d.DMA_BufferSize         = BUF_SIZE;
    d.DMA_PeripheralInc      = DMA_PeripheralInc_Disable; // 外设地址不变
    d.DMA_MemoryInc          = DMA_MemoryInc_Enable;      // 内存地址递增
    d.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
    d.DMA_MemoryDataSize     = DMA_MemoryDataSize_HalfWord;
    d.DMA_Mode               = DMA_Mode_Circular;         // 循环模式
    d.DMA_Priority           = DMA_Priority_Medium;
    d.DMA_M2M                = DMA_M2M_Disable;
    DMA_Init(DMA1_Channel1, &d);

    DMA_Cmd(DMA1_Channel1, ENABLE);
}

// ADC 侧要打开 DMA 请求
ADC_DMACmd(ADC1, ENABLE);

15.4 和 51 的对比

51STM32
DMA没有DMA1(7 通道)+ DMA2(C8T6 无)
搬数据方式CPU 逐字节搬硬件自动搬
什么时候必须用 DMA:
① 高频 ADC 连续采样(每秒几千次)
② 串口大量收发
③ 内存到内存的大批量拷贝
否则 CPU 全耗在搬数据上,没空做别的。

15.5 本章练习

  1. DMA 挂在哪条总线上?开时钟用哪个寄存器?
  2. 为什么外设地址不递增,内存地址要递增?
  3. 连续采样为什么必须开循环模式?
📖 参考答案(点击展开)
  1. DMA 挂在 AHB 总线上,开时钟用 RCC->AHBENR(库函数 RCC_AHBPeriphClockCmd)。
  2. 外设的数据寄存器只有一个地址(如 ADC->DR),每次读它就能拿到新数据,所以地址不变;内存要存成数组的多个元素,所以地址要递增。
  3. 循环模式下缓冲区满了会自动回到开头继续搬,不会停。不开循环的话搬完一次就停了,后续数据丢失。
16 第 16 章 · USART 串口

16.1 和 51 串口的差别

51STM32
数量1 个3 个(USART1/2/3;C8T6 还有 UART4/5 可选)
挂哪—USART1 在 APB2,USART2/3 在 APB1
波特率T1 方式 2 产生专用波特率发生器(BRR 寄存器)
引脚固定 P3.0/P3.1可重映射(PA9/PA10 或 PB6/PB7)

16.2 波特率怎么算

USART1 在 APB2(72MHz):
波特率 = f_CLK / (16 × USARTDIV)

要 115200:
USARTDIV = 72000000 / (16 × 115200) = 39.0625
整数部分 = 39 = 0x27 → 写进 BRR 的 [15:4]
小数部分 = 0.0625 × 16 = 1 → 写进 BRR 的 [3:0]
BRR = 0x271 = 625
和 51 的区别:51 必须用 11.0592MHz 晶振才能整除波特率;STM32 用 BRR 支持小数分频,任何晶振都能配出准确波特率。

16.3 串口发送完整代码

#include "stm32f10x.h"

void USART1_Init(uint32_t baud)
{
    /* ① GPIO:PA9 = TX(复用推挽),PA10 = RX(浮空输入) */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);

    GPIO_InitTypeDef g;
    g.GPIO_Mode  = GPIO_Mode_AF_PP;
    g.GPIO_Pin   = GPIO_Pin_9;
    g.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &g);

    g.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    g.GPIO_Pin  = GPIO_Pin_10;
    GPIO_Init(GPIOA, &g);

    /* ② USART 配置 */
    USART_InitTypeDef u;
    u.USART_BaudRate            = baud;
    u.USART_WordLength          = USART_WordLength_8b;
    u.USART_StopBits            = USART_StopBits_1;
    u.USART_Parity              = USART_Parity_No;
    u.USART_Mode                = USART_Mode_Tx | USART_Mode_Rx;
    u.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_Init(USART1, &u);

    USART_Cmd(USART1, ENABLE);
}

void USART1_SendByte(uint8_t b)
{
    while (!USART_GetFlagStatus(USART1, USART_FLAG_TXE));  // 等发送寄存器空
    USART_SendData(USART1, b);
}

void USART1_SendString(char *s)
{
    while (*s) USART1_SendByte(*s++);
}

int main(void)
{
    USART1_Init(115200);
    while (1)
    {
        USART1_SendString("Hello STM32\r\n");
        Delay_ms(1000);
    }
}

16.4 接收:中断方式

// 初始化里加上
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

NVIC_InitTypeDef n;
n.NVIC_IRQChannel = USART1_IRQn;
n.NVIC_IRQChannelPreemptionPriority = 2;
n.NVIC_IRQChannelSubPriority = 1;
n.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&n);

// 中断函数
void USART1_IRQHandler(void)
{
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        uint8_t dat = USART_ReceiveData(USART1);
        USART_SendData(USART1, dat);        // 回显
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}
和 51 一样的坑:RI/TI 要手动清。STM32 用 USART_ClearITPendingBit 清。
另外 RXNE 在读数据时会自动清,很多人因此少写一句也能跑,但规范写法还是显式清一下。

16.5 本章练习

  1. 115200 波特率、APB2=72MHz,BRR 应该写多少?
  2. PA9/PA10 各是什么功能?要配成什么模式?
  3. USART1 挂在哪条总线上?USART2 呢?
📖 参考答案(点击展开)
  1. USARTDIV = 72MHz / (16 × 115200) = 39.0625 → 整数 39、小数 0.0625×16 = 1 → BRR = 0x271 = 625。
  2. PA9 = TX(复用推挽输出),PA10 = RX(浮空输入或上拉输入)。
  3. USART1 挂在 APB2(72MHz);USART2/3 挂在 APB1(36MHz)。所以两者的 BRR 计算基数不同。
17 第 17 章 · I2C / SPI / RTC / 看门狗 / FLASH

17.1 I2C:两根线串起一堆设备

特点说明
引脚SCL(时钟)+ SDA(数据)
模式必须开漏输出 + 外部上拉(通常 4.7kΩ)
寻址每台设备 7 位地址
速率标准 100kHz / 快速 400kHz
为什么必须开漏:多个设备共享两根线,如果都用推挽,一个输出高一个输出低就会短路烧毁。
开漏只能主动拉低,高电平靠上拉电阻 —— 所以不会冲突(「线与」)。
// 软件 I2C 的两条基本操作(GPIO 模拟)
void I2C_Start(void)   { SDA_H; SCL_H; delay; SDA_L; delay; SCL_L; }
void I2C_Stop(void)    { SDA_L; SCL_H; delay; SDA_H; delay; }
// 起始 = SCL 高电平期间 SDA 由高变低
// 停止 = SCL 高电平期间 SDA 由低变高

17.2 SPI:四根线,更快

信号作用
SCK时钟
MOSI主机发、从机收
MISO从机发、主机收
CS/NSS片选,低电平有效
I2C:2 根线,有地址,速度 100~400kHz
SPI:4 根线,靠 CS 选设备,速度可达 18MHz(快 45 倍)

17.3 RTC 实时时钟

RTC 用 LSE 32.768kHz 晶振,只要 VBAT 有电就一直走,主电源断了也不停。

32.768kHz / 32768 = 1Hz  → 每秒加 1
// 这就是为什么选 32.768k 这个怪数字(2 的 15 次方)
功能说明
计时间年月日时分秒(需自己算,或存 Unix 时间戳)
闹钟定时触发中断
唤醒从待机模式定时唤醒

17.4 看门狗:防止程序跑飞

独立看门狗 IWDG窗口看门狗 WWDG
时钟LSI 40kHz(独立)PCLK1(依赖主时钟)
喂狗时机只要在超时前喂就行必须在窗口内喂(太早太晚都复位)
用途防止死循环卡死检测程序时序异常
// 独立看门狗:1 秒超时
IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable);
IWDG_SetPrescaler(IWDG_Prescaler_64);   // 40kHz/64 = 625Hz
IWDG_SetReload(625);                    // 625/625 = 1s
IWDG_ReloadCounter();                   // 喂一次
IWDG_Enable();

// 主循环里定期喂
while (1)
{
    // ... 干正事 ...
    IWDG_ReloadCounter();     // 忘了这句 → 1 秒后芯片复位
}
看门狗的哲学:程序跑飞时没人能救它,只能让硬件强制复位。
「喂狗」本质是在说「我还活着」。如果程序卡在某处,喂狗语句执行不到,硬件就会重启。

17.5 内部 FLASH 读写

// 读:直接指针访问
uint32_t data = *(volatile uint32_t *)0x0800FC00;

// 写:必须先解锁 → 擦除 → 写入
FLASH_Unlock();
FLASH_ErasePage(0x0800FC00);            // 按页擦(1KB 或 2KB)
FLASH_ProgramWord(0x0800FC00, 0x12345678);
FLASH_Lock();
Flash 三个铁律:
① 写之前必须先擦除(擦除后所有位变 1,写只能把 1 变 0)。
② 擦除以页为单位,不能只擦一个字节。
③ 擦写次数有限(约 1 万次),不要放在循环里频繁写。

17.6 本章练习

  1. I2C 为什么必须用开漏输出?
  2. RTC 为什么用 32.768kHz?
  3. Flash 写入前为什么必须先擦除?
  4. 看门狗「喂狗」是在做什么?
📖 参考答案(点击展开)
  1. 因为总线上有多个设备。如果用推挽,一个设备输出高、另一个输出低就会直接短路。开漏只能主动拉低,高电平靠上拉电阻,多个设备不会冲突(线与)。
  2. 因为 32768 = 2 的 15 次方,经过 15 次二分频正好得到 1Hz(1 秒),用硬件分频器实现最简单。
  3. Flash 的物理特性:写操作只能把位从 1 变成 0,不能从 0 变回 1。擦除是唯一能把位变回 1 的操作,而且以页为单位。
  4. 喂狗是在告诉看门狗「程序还活着」。如果程序卡死、喂狗语句执行不到,看门狗计数溢出就会强制复位芯片。
← 返回教程首页