everything i should kown about control

预备知识

单片机构成(mcu)

  • 运算器:负责执行算术和逻辑运算。

  • 控制器:计算机的大脑中枢,它协调和控制计算机各部件的工作。控制器从存储器中取出指令,分析指令的内容,并根据指令要求向其他部件发出控制信号,以确保整个计算机系统有条不紊地运行。

  • 存储设备:用于存储数据和程序。它可以分为主存储器(如随机存取存储器RAM)和辅助存储器(如硬盘、固态硬盘等)。

    • 主存储器:暂时存储正在运行的程序和数据。

    • 辅助存储器:长期存储大量的数据和程序。


调试接口标准

  • 为什么需要调试接口标准
    电脑和单片机之间的一条“调试通道”,用来给芯片下载程序、调试程序、读取芯片内部状态。GDB / GDB Server / 这类硬件调试器很多就是通过 JTAG 或 SWD 这类调试接口和单片机通信的。
  1. JTAG:线更多,功能更完整,传统一些。
  2. SWD:线更少,STM32里非常常用,只能用于ARM内核的单片机。


boot 电路

为什么需要boot 电路

单片机一上电,CPU 必须先找一个起点去取第一条指令

这个起点可能有 3 个地方:

  • 主 Flash
  • 系统存储器
  • 内部 SRAM

启动模式的选择

STM32 通过 BOOT0、BOOT1 的电平组合 (见表格)来决定选哪个区域启动

X表示无所谓,0 或 1 都行。

  1. BOOT0 = 0,BOOT1 = X

    • 芯片复位后,直接从 Flash 里执行你的程序(也就是你写的 main() 前面的启动代码,最终都会从这里开始跑。

    • 启动地址:0x08000000 是STM32内置的Flash,一般我们使用JTAG或者SWD模式下载程序时,就是下载到这里,重启后也直接从这启动程序。基本上都是采用这种模式。

  2. BOOT0 = 1,BOOT1 = 0

    • 系统存储器一般放的是 STM32 官方出厂烧好的 BootLoader。

    • 作用是:

      • 你可以不靠 JTAG / SWD。
      • 而是通过串口、USB、CAN 等某些方式下载程序。
  3. BOOT0 = 1,BOOT1 = 1

    • 启动到内部 SRAM也就是从 RAM 启动。

    • 这个模式平时很少用,更多用于:

      • 调试
      • 特殊测试

      因为 RAM 掉电就没了,通常不会拿来做正常程序启动。

电路原理

该电路图本质上是在给 BOOT0 / BOOT1 选择高低电平

你可以看到:

  • 上面接了 VCC3V3,表示高电平
  • 下面接了 GND,表示低电平
  • 中间通过排针跳帽来选择连到哪边

也就是说:

  • 跳帽接到 3.3V → 这个 Boot 引脚为 1
  • 跳帽接到 GND → 这个 Boot 引脚为 0

电阻 R3、R4 是 100k,一般起到:

  • 限流
  • 上拉 / 下拉辅助
  • 保证引脚状态稳定

时钟

时钟树

STM32 里通常不是“一个时钟管全部”,而是有一套时钟树

也就是说:

  • 先有几个“原始时钟源”
  • 再经过选择、分频、倍频
  • 最后分给 CPU 和各个外设

这就叫 时钟树

时钟控制器(RCC)

它主要管两大类事情:

时钟管理

它负责:

  • 选择时钟源
  • 打开某个外设的时钟
  • 设置分频、倍频
  • 让系统时钟分配给各总线和外设

比如:

  • 你要用 GPIOA
  • 先得开 GPIOA 的时钟
  • 这个动作通常就是 RCC 在管

如果时钟没开,你去操作 GPIO 寄存器,往往就不生效。

这就是为什么很多 STM32 程序都会先写类似:

1
__HAL_RCC_GPIOA_CLK_ENABLE();

这句话本质就是:

通过 RCC 把 GPIOA 的时钟打开。

复位管理

RCC 还可以对某些外设进行复位。

比如一个外设状态乱了,可以:

  • 给它复位一下
  • 再重新配置

所以 RCC 不只是时钟模块,它还带“复位控制”。

常见时钟源

  1. HSI(High Speed Internal):高速内部时钟,芯片内部自带的 RC 振荡器

    • 不需要外部晶振
    • 上电就能用
    • 成本低,方便
    • 但精度一般不如外部晶振
  2. HSE(High Speed External):高速外部时钟,通常来自板子上的外部晶振。

    • 精度更高
    • 更稳定
    • 常用于需要较准频率的场景
    • 但要外接晶振电路
  3. LSI(Low Speed Internal):低速内部时钟。

    • 频率低
    • 精度一般
    • 常给独立看门狗、RTC 等低速模块使用
  4. LSE(Low Speed External):低速外部时钟,一般是 32.768kHz 晶振。

    这个频率很经典,因为适合做:

    • RTC 实时时钟
    • 日历计时

Crystal/ceramic resonator(晶体/陶瓷振荡器)

一种外部谐振元件

开发板上常见有一个小小的晶振器件,它不是 MCU 内部自己生时钟,而是:

  • 接在 STM32 的时钟引脚上
  • 配合 MCU 内部振荡电路
  • 产生稳定时钟

晶体振荡器

一般说“晶振”,通常指石英晶体。

特点:

  • 精度高
  • 稳定性好
  • 很常用

比如板子上常见:

  • 8MHz 晶振
  • 12MHz 晶振
  • 25MHz 晶振

陶瓷振荡器

也能用来提供振荡源,但一般精度会比石英晶体差一些。

Bypass clock source(旁路时钟源)

由外部其他设备主动给出该时钟信号,不经过 MCU 内部时钟电路驱动,可直接作为单片机时钟

系统时钟(SYSCLK)

芯片当前最核心的主工作时钟(是 CPU 和很多总线时钟的基础来源

系统时钟可以由这些来源之一提供:

  • HSI
  • HSE
  • PLL 输出

通常 STM32 不会直接一直拿原始时钟跑,而是常常通过 PLL 倍频 之后得到更高的系统时钟。

例如:

  • 外部晶振 HSE = 8MHz
  • 经过 PLL 倍频
  • 得到 SYSCLK = 72MHz

这样芯片主频就提高了。

PLL

PLL 就是 锁相环,一个“倍频器”或者“调频器”

例如:

  • 输入 8MHz
  • PLL 倍频后输出 72MHz

这样就能让芯片从一个较低频率的时钟源,得到一个更高的主频。

所以很多 STM32 的典型配置都是:

  • HSE → PLL → SYSCLK

分频

意思就是把时钟变慢

例如:

  • 系统时钟 72MHz
  • 分频 2
  • 得到 36MHz

为什么要分频?

因为不是所有模块都能跑一样快,也不是所有模块都需要那么快。

所以 STM32 会把系统时钟再分给不同总线和外设。

AHB/APB

  • AHB:高速总线时钟

    通常给:

    • 内核
    • DMA
    • 内存接口
    • 高速外设
  • APB1 / APB2:低速 / 高速外设总线时钟

    常给:

    • USART
    • SPI
    • I2C
    • TIM
    • ADC
    • GPIO 等外设

    不同系列分法略有差异,但核心思想一样:

    系统时钟不会直接一股脑送到所有外设,而是先分到不同总线。

    比如常见一种理解方式:

    • SYSCLK → AHB
    • AHB → APB1
    • AHB → APB2

    然后外设再挂在 APB1 或 APB2 上。

与时钟树相关的参数

  • 串口波特率

  • 定时器定时:定时器溢出时间 = 预分频 × 自动重装值 × 时钟周期

  • ADC采样速度

  • USB、CAN 等外设


GPIO

MCU与外部元件沟通的接触点,一般沟通的是电压信息俗称引脚

工作模式

输入模式

  • 下拉输入

    意思是:

    • 引脚默认通过一个电阻接地
    • 所以在没人管它时,默认是 低电平

    如果外部给它高电平,它才会变成 1。

    用途:

    • 防止引脚悬空
    • 让默认状态明确
  • 上拉输入

    意思是:

    • 引脚默认通过一个电阻接到 VDD
    • 所以默认是 高电平

    如果外部把它拉低,它才会变成 0。

    这个在按键电路里特别常见。

    比如:

    • 默认高电平
    • 按键按下接地
    • 读到低电平表示按下
  • 浮空输入

    意思是:

    • 不上拉,也不下拉
    • 引脚完全“飘着”

    这样如果外部没明确驱动,就可能:

    • 一会高
    • 一会低
    • 很容易受干扰

    所以浮空输入一般只在:

    • 外部已经有明确驱动
    • 或特殊场景

    才会使用。

  • 模拟输入

    这个和前面几种数字输入不一样。

    它不是判断 0 / 1,而是读取一个连续变化的电压值

    比如:

    • 0.3V
    • 1.2V
    • 2.7V

    这种通常给:

    • ADC
    • 模拟传感器输入

    用了模拟输入模式后,GPIO 的数字输入那一套比较器之类通常会被关掉,减少干扰和功耗。

输出模式

  • 推挽输出

    这是最常用的输出模式。

    特点是:

    • 想输出高电平,就能主动拉高
    • 想输出低电平,就能主动拉低

    也就是:

    高低电平都由单片机主动驱动。

    适合:

    • 点亮 LED
    • 输出普通数字信号
    • 控制一般器件
  • 开漏输出

    这个模式下:

    • 只能主动拉低
    • 不能主动拉高

    当它“输出高”时,本质上不是主动输出高电平,而是:

    • 把上面的管子关掉
    • 引脚处于“放开”的状态
    • 需要靠外部上拉电阻把它拉高

    所以开漏输出通常要配合:

    • 外部上拉
    • 或内部上拉
  • 复用推挽输出、复用开漏输出

    这里的“复用”意思是:

    这个引脚不再只是普通 GPIO,而是交给某个外设使用。

    比如这个脚可能不再单纯输出 0 / 1,而是变成:

    • USART_TX
    • SPI_SCK
    • I2C_SCL
    • TIM_PWM

    所以:

    • 复用推挽输出:给外设输出信号,底层输出结构是推挽
    • 复用开漏输出:给外设输出信号,底层输出结构是开漏

    比如:

    • UART TX 常常用复用推挽
    • I2C 常常用复用开漏

总示例图讲解

顺序:从右到左

I/O 引脚和保护二极管

这是 GPIO 引脚真正连到芯片外部的地方

保护二极管的作用主要是:

  • 防止静电冲击
  • 防止电压过高或过低对内部电路造成损伤

输入电路 (中上部分)

上拉电阻、下拉电阻

图里标了大约 50K

这表示 STM32 内部通常集成了弱上拉 / 弱下拉电阻。

所以配置成:

  • 上拉输入
  • 下拉输入

其实就是在控制内部这个电阻是否接上。

注意这个电阻值通常比较大,是“弱上拉/弱下拉”,不是特别强的驱动。

TTL 施密特触发器

把外部不是特别干净的电压信号,转换成芯片内部更稳定的数字 0 / 1 判断

因为外部输入电压可能不是一下子从 0V 跳到 3.3V,而是慢慢变化,还可能有噪声。

施密特触发器可以提高抗干扰能力,让芯片更稳定判断:

  • 这是高电平
  • 这是低电平

IDR:输入数据寄存器

这个寄存器里保存的是当前引脚读到的输入状态。

比如:

  • 引脚现在是高电平 → 对应位是 1
  • 引脚现在是低电平 → 对应位是 0

所以你程序里读取 GPIO 输入,本质上就是在读这个寄存器对应位

模拟输入 / 复用功能输入

模拟输入

这个脚可以接到 ADC 等模拟模块,不走数字输入判断。

复用功能输入

这个脚也可以送给某个片上外设当输入。

比如:

  • 串口 RX
  • 定时器输入捕获
  • 外部中断输入
  • SPI MISO

也就是说,GPIO 虽然叫 GPIO,但它还经常是很多外设功能的“入口”。

输出电路(中下部分)

ODR:输出数据寄存器

你给某个 GPIO 输出 1 或 0,本质上就是改这个寄存器里的某一位。

比如:

  • 写 1 → 希望该引脚输出高
  • 写 0 → 希望该引脚输出低

然后这个值再去控制后面的输出驱动电路。

BRR / BSRR

  • BSRR:位设置 / 复位寄存器
  • BRR:位复位寄存器

它们的作用是:

可以更方便、更原子地对某一位 GPIO 置 1 或清 0。

为什么不用直接改 ODR?

因为直接改 ODR 可能涉及:

  • 先读
  • 再改
  • 再写回

在某些场景下不够安全,尤其在中断或并发访问时。

而 BSRR 可以做到:

  • 写某位到 set 区 → 置 1
  • 写某位到 reset 区 → 清 0

所以很多底层库都喜欢用它来快速控制引脚。

输出控制

这块逻辑会根据你配置的模式决定:

  • 是普通 GPIO 输出
  • 还是复用输出
  • 是推挽
  • 还是开漏

也就是控制下面那两个 MOS 管怎么工作。

P-MOS 和 N-MOS

这两个是输出驱动核心。

你可以简单理解成:

  • P-MOS 负责把引脚往 高电平
  • N-MOS 负责把引脚往 低电平
推挽输出时
  • 输出高:P-MOS 开,N-MOS 关
  • 输出低:P-MOS 关,N-MOS 开

所以推挽可以主动输出高和低。

开漏输出时

通常上面的拉高能力被禁掉,只保留下拉能力:

  • 输出低:N-MOS 开,把引脚拉到低电平
  • 输出高:N-MOS 关,引脚悬空,由外部上拉拉高

这就是为什么开漏不能主动输出高。

复用功能输出

意思是:

这个引脚输出的数据不一定来自 ODR,也可能来自某个外设模块。

比如:

  • 定时器输出 PWM
  • USART 输出发送数据
  • SPI 输出时钟/数据

这时候你虽然看到的是这个 GPIO 脚在变化,但其实背后是外设在控制它,不是你手动写 ODR 了。

分示例图讲解

输入模式

下拉输入(右上角)

  • PC13 这个引脚通过一个 10k 电阻接地
  • 按键另一端接 VCC
  • 按键没按下时,引脚通过电阻被拉到地,所以是 低电平
  • 按键按下时,引脚直接接到 VCC,所以变成 高电平

所以它叫:

下拉输入

因为“默认状态”被电阻拉向 GND。

结论:

  • 松开按键:0
  • 按下按键:1

上拉输入(右下角,标号3)

  • PC13 通过一个 10k 电阻接 VCC
  • 按键另一端接地
  • 按键没按下时,引脚被电阻拉到 VCC,所以是 高电平
  • 按键按下时,引脚直接接地,所以是 低电平

所以它叫:

上拉输入

因为“默认状态”被电阻拉向 VCC。

结论:

  • 松开按键:1
  • 按下按键:0

浮空输入(标号2)

  • PC13 接一个按键
  • 按键一端接地
  • 但是 没有上拉电阻,也没有下拉电阻

这时如果按键没按下,引脚其实谁都没接,相当于“悬空着”。

按下时:

  • 引脚接地,明确是 低电平

没按下时:

  • 引脚没有明确高低,电平可能乱跳

所以浮空输入最大的问题就是:

不稳定,容易受干扰。

为什么要加电阻,不能直接接 VCC 或 GND?

因为按键按下时,电路状态会改变。

比如上拉输入:

  • 平时通过电阻接 VCC
  • 按下时引脚直接接 GND

如果没有那个电阻,而是直接把 VCC 和引脚硬连在一起,那么一按键就等于:

VCC 直接短接到 GND

这就短路了。

所以电阻的作用有两个:

  1. 提供默认电平,让引脚在松开按键时不悬空。

  2. 限流保护,避免按键按下时电源和地直接短路。

输出模式

推挽输出

  • 可以输出高电平和低电平

  • 电平由芯片内部直接提供

  • 只能提供3.3V电平(GPIO 高电平 ≈ 3.3V,GPIO 低电平 ≈ 0V)

  • 无法提供大电流

开漏输出

  • 只能提供低电平

    • Q2 导通时,输出点被拉到地 → 低电平

    • Q2 截止时,输出点不会被主动拉高,而是变成 高阻态

  • 需要外部提供上拉电路

    • 就像图里那个 R1 接到 3.3V

      • Q2 关断 → GPIO 不管这根线了
      • 这时 R1 把线拉到 3.3V → 线变高电平

    所以开漏输出通常都要配:

    外部上拉电阻

    没有上拉的话,Q2 关断时这根线就会悬空。

  • 可以提供除3.3V之外的电平

  • 可以提供大电流

    • 开漏结构更容易配合外部电源和器件实现更强的驱动能力。

    • 真正的大电流通常还是由外部电源和外部器件承担,GPIO 本身只是控制“是否导通到地”。

  • 可以实现“线与”的功能

    所谓 线与,意思是多个器件的输出端可以直接连到同一根线上,再接一个上拉电阻

    这时逻辑规则是:

    • 只要有任意一个器件把线拉低,整根线就是低电平
    • 只有当所有器件都“松手”(都不拉低)时,这根线才会被上拉成高电平

    所以表现出来就像逻辑“与”:

    • 都为 1,线才是高
    • 有一个为 0,线就是低

    这就是“线与”。

操作GPIO常用的函数

HAL_GPIO_ReadPin()

读取某个 GPIO 引脚当前的电平状态

1
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

返回类型:GPIO_PinState

通常就是两种:

  • GPIO_PIN_RESET:低电平
  • GPIO_PIN_SET:高电平

例子:

1
2
3
4
if(HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_RESET)
{
// 按键按下
}

这里的意思就是读取 PC13 的状态

HAL_GPIO_WritePin()

给某个 GPIO 引脚写入高电平或低电平

1
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState);
  • GPIOx:哪个 GPIO 组,比如 GPIOAGPIOBGPIOC
  • GPIO_Pin:哪个引脚,比如 GPIO_PIN_5
  • PinState:写成高还是低

例子:

1
2
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);   // 输出高电平
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // 输出低电平

HAL_GPIO_TogglePin()

把当前引脚电平翻转一次

1
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

例子:

1
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);

NVIC(Nested Vectored Interrupt Controller)

嵌套向量中断控制器

它是 Cortex-M 内核 里专门管理中断的硬件模块,用来完成“哪个中断先响应、能不能打断别的中断、CPU 响应后跳到哪里执行”这些事情。

你可以把它理解成一个“中断调度中心”:

  • 外设产生中断请求,比如按键 EXTI、定时器 TIM、串口 USART
  • 这些请求送到 NVIC
  • NVIC 根据优先级判断:
    谁先执行,谁后执行,当前中断能不能被更高优先级中断打断
  • 然后 CPU 根据中断向量表,跳到对应的中断服务函数里执行

省流版:外设提请求,NVIC 排队和裁决,CPU 去执行。

some definitions

程序计数器(PC)

处理器系统在执行代码的时候,会从存储器依次取出指令和数据,这种能力需要在处理器里保存一个存储器的地址,就是所谓的程序计数器(Program Counter,PC),也叫程序指针。

EXTI(外部中断)

当连接到某个GPIO引脚的中断线检测到预设的电平变化或边沿变化时,就会产生一个中断请求,使CPU暂停当前任务,转而去执行相应的中断服务程序。这样,单片机就能够及时地处理外部事件,提高了系统的实时性和灵活性。

按键没按下时

  • GPIO 通过 10K 电阻被拉到 VCC
  • 所以此时引脚读到的是 高电平 1

按键按下时

  • 开关闭合
  • GPIO 被直接接到地
  • 此时引脚读到的是 低电平 0

三种触发模式

  • External Interrupt Mode with Rising edge trigger detection

    只在 0 变 1 时触发中断。

    比如这个按键电路里:

    • 按键松开那一瞬间:0 变 1
    • 会触发中断
  • External Interrupt Mode with Falling edge trigger detection

    只在 1 变 0 时触发中断。

    比如:

    • 按键按下那一瞬间:1 变 0
    • 会触发中断

    这个在按键检测里很常用,因为一般更关心“按下”的那一刻。

  • External Interrupt Mode with Rising/Falling edge trigger detection

    上升沿和下降沿都触发

    也就是:

    • 按下触发一次
    • 松开再触发一次

    适合既想知道“按下”,又想知道“松开”的场景。

EXTI 线路复用关系

STM32 的 EXTI 线是怎么和 GPIO 引脚对应起来的。

从图中可见:相同编号的引脚,共用同一条 EXTI 线

中间的“选择器”是什么意思

图里像多路开关一样的那个结构,表示:

同一条 EXTI 线只能从若干候选 GPIO 里选一个。

例如 EXTI0:

  • 候选有 PA0、PB0、PC0、PD0…
  • 但最终只能选其中一个接到 EXTI0

不能同时让:

  • PA0 触发 EXTI0
  • PB0 也触发 EXTI0

因为它们抢的是同一条线。

所以一个特别重要的结论

PA0PB0 不能同时作为独立外部中断源使用

因为它们都占 EXTI0

同理:

  • PA1PC1 不能同时独立用 EXTI1
  • PA13PB13 不能同时独立用 EXTI13
AFIO_EXTICR 是什么

图上写了:

  • AFIO_EXTICR1 寄存器的 EXTI0[3:0] 位
  • AFIO_EXTICR1 寄存器的 EXTI1[3:0] 位
  • AFIO_EXTICR4 寄存器的 EXTI15[3:0] 位

这说明:

STM32 通过这些寄存器来决定:

EXTI0 到底接 PA0 还是 PB0 还是 PC0
EXTI1 到底接 PA1 还是 PB1 还是 PC1

也就是说,这些寄存器就是“选择器的控制开关”。

如果你在 CubeMX 里配置了某个引脚为外部中断,CubeMX 最终就是帮你改这些寄存器。

中断事件的优先级

  • 抢占优先级,高的可以直接中断掉低优先级的中断服务函数直接处理

  • 子优先级,在多中断事件发生时,高的可以插队到低的前面先处理,决定同级中断的先后顺序

中断优先级分组

示例解释:

NVIC_PriorityGroup_0

  • 0 bit 给抢占优先级
  • 4 bit 给子优先级

所以结果是:

  • 没有抢占优先级
  • 子优先级范围 0~15

这意味着:

所有中断都不能互相打断。

因为压根没有“抢占”这一说。
只能在“大家同时等着处理”时,按子优先级排先后。

NVIC_PriorityGroup_2

  • 2 bit 给抢占优先级
  • 2 bit 给子优先级

所以:

  • 抢占优先级 0~3
  • 子优先级 0~3

这是一种比较均衡的分法。

意思是:

  • 有 4 档“打断能力”
  • 每档里再分 4 档先后顺序

这个分组很常见,因为比较平衡。

假设现在有三个中断:

  • 串口中断:抢占 1,子 2
  • 定时器中断:抢占 0,子 3
  • 按键中断:抢占 1,子 0
情况1:按键中断正在执行,定时器中断来了
  • 按键抢占优先级 = 1
  • 定时器抢占优先级 = 0

因为 0 比 1 高,所以定时器中断可以打断按键中断。

情况2:串口中断和按键中断同时到来
  • 串口:抢占 1,子 2
  • 按键:抢占 1,子 0

它们抢占优先级一样,所以谁也不能打断谁
但如果这两个中断都在等待响应,那么子优先级更高的先执行。

通常 STM32 里是 数字越小优先级越高,所以:

  • 子优先级 0 比 2 高
  • 先执行按键中断

需要用到的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
外部引脚变化

EXTI 检测到边沿

NVIC 允许这个中断进入 CPU

进入 EXTIx_IRQHandler()

HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_x)

HAL_GPIO_EXTI_Callback(GPIO_PIN_x)

你自己写的业务代码

HAL_NVIC_SetPriorityGrouping(uint32_t PriorityGroup) 

设置 中断优先级分组

例:

1
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

表示通常把优先级位分成:

  • 2 位抢占优先级
  • 2 位子优先级

一般在系统初始化时设置一次就行。

HAL_NVIC_SetPriority(IRQn_Type IRQn, uint32_t PreemptPriority, uint32_t SubPriority)

设置 某个具体中断 的优先级。

  • IRQn:中断号
  • PreemptPriority:抢占优先级
  • SubPriority:子优先级

例:

1
HAL_NVIC_SetPriority(EXTI9_5_IRQn, 1, 0);
  • 设置 EXTI9_5_IRQn 这个中断
  • 抢占优先级 = 1
  • 子优先级 = 0

HAL_NVIC_EnableIRQ(IRQn_Type IRQn)

使能某个中断

例:

1
HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);

意思是把 EXTI9_5_IRQn 这个中断在 NVIC 里打开

为什么需要它:

即使:

  • GPIO 配好了
  • EXTI 配好了
  • 边沿也来了

如果 NVIC 里没使能,CPU 还是不会进去执行中断函数。

所以你可以把它理解成:

给这个中断发“通行证”

 EXTI9_5_IRQHandler(void)

这是 真正的中断服务函数入口

EXTI5 ~ EXTI9 这些外部中断线触发时,CPU 会进入这个函数。

例如如果你用的是 PA6 做外部中断,那一般就会进这个函数,因为 6 属于 5~9 这一组。

为什么叫 EXTI9_5_IRQHandler

因为 STM32 的 EXTI 中断不是每条线都单独一个 IRQ。

常见分组是:

  • EXTI0_IRQn → EXTI0
  • EXTI1_IRQn → EXTI1
  • EXTI2_IRQn → EXTI2
  • EXTI3_IRQn → EXTI3
  • EXTI4_IRQn → EXTI4
  • EXTI9_5_IRQn → EXTI5 ~ EXTI9
  • EXTI15_10_IRQn → EXTI10 ~ EXTI15

所以:

  • GPIO5~9 的外部中断,共用 EXTI9_5_IRQHandler
  • GPIO10~15 的外部中断,共用 EXTI15_10_IRQHandler

例:

你通常不会在这个函数里直接写业务代码, 而是调用 HAL 提供的处理函数:

1
2
3
4
void EXTI9_5_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_6);
}

HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) 

这是 HAL 给你预留的回调函数,当 HAL 确认某个 EXTI 线触发后,会调用它。

例:

1
2
3
4
5
6
7
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_6)
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
}
  • 如果是 6 号引脚触发了中断
  • 就翻转一次 LED

为什么要判断 GPIO_Pin:

因为一个回调函数可能服务多个引脚, 所以通常要用 ifswitch 区分。

这几个函数的调用关系

一个“按下按键翻转 LED”的简单例子

假设:

  • 按键接在 PA6
  • LED 接在 PC13
  • 按键为上拉输入,按下产生下降沿
  • 使用 EXTI6,所以会进 EXTI9_5_IRQHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 初始化
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);
HAL_NVIC_SetPriority(EXTI9_5_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);

// 2. 中断入口
void EXTI9_5_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_6);
}

// 3. 回调处理
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_6)
{
// 这里写你的逻辑
}
}

虚函数(_weak)

弱函数

__weak 的作用,就是给库函数留一个“默认版本”,你如果自己写了同名函数,就优先用你写的那个。

比如 HAL 库里有这样一个函数(弱定义:空实现)

1
2
3
4
5
//弱函数
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
UNUSED(GPIO_Pin);
}

库先给你放了一个空壳版本。你需要时,可以自己在用户文件里写一个同名函数把它顶掉。

实函数

1
2
3
4
5
6
7
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_6)
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
}
  • 优先级更高
  • 一旦存在,就会替代弱定义版本
  • 一般只能有一个同名定义,否则链接报错

编译器最终怎么选

  • 只有弱函数,没有你自己写的普通函数

        那就用库里那个弱函数。

        也就是执行空壳版本,通常什么都不做。

  • 你自己写了一个同名普通函数

        那就用你写的普通函数。也就是说:

        你的函数把 HAL 库那个 __weak 版本覆盖了。

实验

按键控制LED亮灭

  • 把PB9设置为GPIO_EXTI模式

  • 设置上拉电阻

  • 设置为下降沿触发

  • 设置中断优先级分组

  • 设置EXTI优先级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/* main.c */

#include "main.h"

uint32_t last_key_time = 0;

void SystemClock_Config(void);
static void MX_GPIO_Init(void);

int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();

// PC13 初始输出高电平,常见小蓝板上对应 LED 熄灭
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);

while (1)
{
}
}

// EXTI 回调函数:PB9 按下后翻转 LED
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_9)
{
uint32_t now = HAL_GetTick();

// 20ms 软件消抖
if(now - last_key_time > 20)
{
last_key_time = now;
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
}
}

static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};

// 开启 GPIOB、GPIOC、AFIO 时钟
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_AFIO_CLK_ENABLE();

// PC13 配置为推挽输出,用来控制 LED
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);

// PB9 配置为下降沿触发外部中断,上拉输入
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

// 配置并使能 EXTI9_5 中断
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);
HAL_NVIC_SetPriority(EXTI9_5_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(EXTI9_5_IRQn);
}
1
2
3
4
5
6
7
8
9
10
/* stm32f1xx_it.c */

#include "main.h"
#include "stm32f1xx_it.h"

// EXTI5~9 共用这个中断入口,PB9 属于其中
void EXTI9_5_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_9);
}

按键消抖

按键抖动指的是在机械按键在按下或释放时由于机械元件的特性,导致开关多次闭合或断开,从而在单片机IO口上检测到电压信号出现抖动的现象

硬件消抖原理

硬件消抖通常通过‌RC电路实现。在RC消抖电路中,电阻起到限流的作用,电容则用来储存电荷。当输入信号发生变化时,电容会通过电阻进行充放电,从而实现对信号的平滑处理。通过合理选择电阻和电容的数值,可以达到最佳的消抖效果。

RC 电路:由电阻 R(Resistor)和电容 C(Capacitor)组成的电路

软件消抖方法

延时消抖法

在检测到按键按下时延时一段时间(比如10ms)再次读取按键IO的电平,检测两次读取的结果是否相同,相同则认为按键被按下。

  • 按下按键进入中断回调函数

  • 在函数中把某个变量置1

  • 在主函数里开始延时

  • 延时后再读取引脚电平,如果发现是低电平即认为按键被按下

计数消抖法

周期执行按键扫描程序在检测到按键变化时备份输入状态,清零计数器并开始计数,直到检测到按键弹起,如果计数值大于某个设定值则认为按键动作有效,否则按键动作无效

TIM(Timer定时器)

分类

3个重要的参数

PSC (定时器分频系数)

它的作用就是:把输入给定时器的时钟先“降速”

比如定时器输入时钟是 72 MHz,说明 1 秒钟来 7200 万个计数脉冲。 这个速度太快了,直接计数不方便,所以先用 PSC 分频。

STM32 里通常可以理解成:

计数频率 = 定时器时钟 / (PSC + 1)

例:

如果定时器时钟是 72 MHz:

  • PSC = 71
  • 那么计数频率 = 72 MHz / (71 + 1) = 1 MHz

这就表示:计数器每 1 微秒加 1 次

所以 PSC 决定的是:定时器“走得多快”

ARR (自动重装载值)

它的作用是:规定计数器最多数到多少

计数器不是无限加下去的。 它从 0 开始数,数到 ARR 之后,就会产生一次更新事件,然后重新开始。

通常可以理解成:

  • 从 0 数到 ARR
  • 一共会经历 ARR + 1 个计数

所以定时器溢出周期一般是:定时时间 = (PSC + 1) × (ARR + 1) / 定时器时钟

一个完整例子

假设:

  • 定时器时钟 = 72 MHz
  • PSC = 7199
  • ARR = 9999

先算分频后频率:

  • 72 MHz / (7199 + 1) = 10 kHz

也就是计数器每 0.1 ms 加 1。

再数到 ARR=9999:

  • 一共 10000 次

所以总时间:

  • 10000 × 0.1 ms = 1000 ms = 1 s

也就是说:

这个定时器每 1 秒触发一次更新事件/中断

Counter Mode(计数模式 )

计数模式决定的是:计数器 CNT 按什么方式变化

边沿对齐模式

一般就是:

  • 从 0 一直加到 ARR
  • 然后回到 0
  • 再继续加

这叫 向上计数

也可能反过来:

  • 从 ARR 往下减到 0

这叫 向下计数

这种模式因为计数过程只朝一个方向走,所以叫“边沿对齐”。

中心对齐模式

  • 先从 0 数到 ARR
  • 再从 ARR 数回 0
  • 然后再上去

周期计算公式

定时器输出的频率取决于:

  1. 主频:即 MCU 的主时钟频率,通常是 72 MHz
  2. PSC:定时器的预分频器,它控制定时器的输入频率
  3. ARR:定时器的自动重载寄存器,它决定计数器达到多少就会触发事件

假设我们有以下信息:

  • MCU 主频 = 72 MHz
  • PSC = 3600 - 1,即 PSC = 3599
  • ARR = 10000 - 1,即 ARR = 9999

我们想让 LED 闪烁 1 次每 0.5 秒。

计算定时器的主频

首先,定时器的输入时钟频率:

主频=72 MHz=72,000,000 Hz

计算定时器分频后的频率

根据公式:

代入数值:

结果分析

定时器的频率是 2 Hz,这意味着每 0.5 秒触发一次更新事件。

相关函数

流程

开启定时器 → 定时器溢出 → 进入中断入口函数 → HAL 处理中断 → 调用用户回调函数

HAL_TIM_Base_Start() 

启动定时器基础计数功能,但不开中断

HAL_TIM_Base_Start_IT()

启动定时器,同时使能更新中断

TIM3_IRQHandler(void)

中断服务函数

它是定时器 TIM3 对应的中断入口函数。

当 TIM3 真的发生中断时,CPU 会先跳到这里来。

比如 TIM3 更新中断来了,执行流程首先到:

1
2
3
4
void TIM3_IRQHandler(void)
{
HAL_TIM_IRQHandler(&htim3);
}

这个函数通常在 stm32xx_it.c 里。

特点:

  • 这是 硬件中断入口
  • 名字基本是固定的,和中断向量表对应
  • 一般不要在里面写太多自己的业务逻辑
  • 通常只调用 HAL 提供的中断处理函数

HAL_TIM_PeriodElapsedCallback()

定时器更新中断回调函数

当 HAL 帮你处理完底层中断标志以后,如果确认是“周期到达/溢出更新中断”,就会调用这个回调函数。

也就是说:真正写你自己定时任务代码的地方,通常就在这里

常见写法例如:

1
2
3
4
5
6
7
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM3)
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
}
  • 如果当前触发回调的是 TIM3
  • 那就翻转一次 LED