做单片机开发的朋友,基本都绕不开寄存器这道坎。哪怕平时用库函数写得飞起,一到调底层问题、查勘芯片行为时,最终还是得回到寄存器层面去看。所以第一章我讲完GPIO点灯和BSRR寄存器之后,第二章就把目标直接瞄准两样东西:中断和定时器。这两个外设一旦用熟,你的裸机程序才算真正具备“及时响应”和“精确计时”的能力。这一章不给虚的,直接上寄存器,从为什么要配置某个位,到配置完会有什么现象,全部用大白话拆开讲。适合手头有一块STM32板子、想从库函数往底层再走一步的朋友,也适合那些学过中断但始终没整明白NVIC分组的新手。
1. GPIO寄存器位进阶:模式、上下拉、复用这回一次说明白
1.1 GPIO寄存器区如何被C语言“直接访问”
很多人第一次看到GPIOA->MODER这种写法都会愣一下:这到底是个变量,还是函数?答案很简单——它是个指针。STM32芯片出厂时,外设寄存器都被安排在固定的地址上,比如GPIOA的起始地址是0x40020000。我们在C代码里只需要把这个地址强制转换成一个结构体指针,寄存器就变成了结构体成员,读写寄存器就跟读写内存一样普通。
这种访问能成立的核心前提是:寄存器必须是volatile的。没有volatile,编译器可能觉得某个变量“反正没有被别的地方改过”,顺手就把你的写入优化掉。第一次在中断和主循环之间共享变量时,你就能体会到不写volatile的酸爽。所以结构体里每个成员我都加上volatile uint32_t,这可不是凑字数。
typedef struct { volatile uint32_t MODER; // 0x00 引脚模式 volatile uint32_t OTYPER; // 0x04 输出类型 volatile uint32_t OSPEEDR; // 0x08 输出速度 volatile uint32_t PUPDR; // 0x0C 上拉/下拉 volatile uint32_t IDR; // 0x10 输入数据 volatile uint32_t ODR; // 0x14 输出数据 volatile uint32_t BSRR; // 0x18 置位/复位 } GPIO_TypeDef; #define GPIOA ((GPIO_TypeDef *)0x40020000UL) #define RCC ((RCC_TypeDef *)0x40023800UL)寄存器操作有个绕不开的“读改写”问题。因为你改的往往只是寄存器里的某几位,又不能直接操作“位”,所以只能先把整个寄存器读回来,把目标位清掉,再写入新值。如果只用一个|=去置位,别的位就会保留旧值,这没问题;但要把某一位从1改回0,就必须先&= ~(...)清一下。这也是新手最容易翻车的地方,后面我会专门聊。
1.2 MODER:每个引脚都有4档模式
MODER全称是Mode Register,模式寄存器。每个引脚占用2位,32位寄存器正好管16个引脚。别看只有2位,它决定了引脚到底是个普通IO,还是被外设接管。
| MODER位段 | 模式 | 使用场景 |
|---|---|---|
| 00 | 输入 | 按键、外部信号读取 |
| 01 | 通用输出 | 点灯、普通高低电平输出 |
| 10 | 复用功能 | UART、SPI、定时器等外设占用 |
| 11 | 模拟 | ADC采集、DAC输出 |
在第一章点灯时,我们就做了一次最典型的配置:把某个引脚设成通用输出。代码是这样写的:
GPIOA->MODER &= ~(0b11 << (2 * 5)); // 先把引脚5这两位清成00 GPIOA->MODER |= (0b01 << (2 * 5)); // 再写入01,即通用输出2 * 5这个魔法数字就是引脚编号乘2,因为每位引脚占2个bit。清位必须用0b11把整个2位都盖掉,如果你只清一位,剩下的一位会保留旧值,模式就会很不可控。这是我在实际项目踩过N次之后总结出来的教训。
顺便说一下,默认复位后几乎所有引脚都是输入模式,这是为了安全考虑。如果你上电后忘配MODER,直接往ODR写1,引脚不会有任何输出。很多“灯不亮”的排查,其实就是查这里。
1.3 OTYPER、PUPDR和AFR:推挽、开漏、上拉下拉、复用选择
MODER定了大方向,接下来几个寄存器决定细节。
OTYPER是输出类型寄存器,每个引脚只有1位。写0是推挽输出,写1是开漏输出。推挽输出就是引脚既能主动输出高、也能主动输出低,驱动能力强,日常点灯、控制继电器都是它。开漏输出则比较特殊,引脚只能主动拉低,想输出高电平就得靠外部上拉到某个电压。I2C总线和“线与”逻辑就喜欢开漏模式,因为多个设备可以把同一根线拉低,谁拉低谁发言。
PUPDR是上拉/下拉寄存器,同样是每引脚2位。它解决的是“引脚悬空”的问题。按键这类输入场景,如果引脚悬空,电平会乱跳,读到的结果跟算命差不多。把内部上拉打开,引脚空闲时就稳定在高电平,按键按下拉低,判断起来就清晰了。
GPIOA->PUPDR &= ~(0b11 << (2 * 0)); // 先清掉PA0原来的上下拉 GPIOA->PUPDR |= (0b01 << (2 * 0)); // 开启内部上拉AFR是复用功能寄存器,分AFRL和AFRH两组,每组管8个引脚,每个引脚用4位。当MODER设置成复用模式后,AFR决定这个引脚接到哪个外设,比如USART1的TX、TIM2的CH1。4位最多表示16种复用功能,不同引脚映射关系要看芯片手册里的“AF mapping”表,光靠死记硬背很痛苦,查表才是正路。
GPIOA->AFR[1] &= ~(0xF << (4 * 3)); // 假设要用PA11的某个复用功能 GPIOA->AFR[1] |= (0x7 << (4 * 3)); // 对应AF7这里用到AFR[1]是因为AFRH对应索引1,4 * 3是因为引脚11在AFRH里的第3个引脚位。配置复用引脚时,务必要把模式、复用编号、以及外设自己的时钟全部打通,缺一不可。
2. 中断系统:NVIC和EXTI是怎么配合干活的
2.1 中断不是“回调函数”,是CPU的应急通道
很多人学中断,把中断服务函数理解成“系统自动调用一个函数”,这个说法不算错,但容易忽略本质。中断不是普通函数调用,它是硬件级别的事件响应:某个外设出现了预期事件,通过中断控制器直接打断CPU当前流水线,保存现场,跳转到固定的中断向量,执行完后再恢复现场。整个过程不受你主循环里写的代码控制,真正做到了“随叫随到”。
轮询和中断的区别,其实特别好理解。轮询就像你一晚上守在门口,每隔几秒问一次“有人来了吗”;中断则是装了个门铃,有人按门铃你才起身,平时该干嘛干嘛。裸机程序想同时兼顾响应速度和CPU利用率,没有中断几乎是做不到的。STM32的中断控制器叫NVIC,它管着所有内核和外设中断源的优先级、使能与挂起,相当于整个中断系统的交通警察。
2.2 EXTI完整链路与配置五步法
EXTI是外部中断/事件控制器,负责把GPIO引脚上的电平边沿转换成中断请求。从PA0按键到CPU执行中断服务函数,中间隔着三道关卡:GPIO引脚、EXTI线路、NVIC通道。
GPIO引脚我们已经在第一章会配了,这一节说的是EXTI本身。EXTI一共有23根线路,0~15分别对应PA0~PG15,意思是任何一组GPIO的0号引脚都映射到EXTI0,但同一时刻只能选一组,这就轮到SYSCFG出场了。SYSCFG的EXTICR寄存器负责选择“到底哪组GPIO的引脚连到EXTI线路”。
配置EXTI可以总结成五步:
- 使能SYSCFG时钟,因为GPIO到EXTI的映射桥接功能在SYSCFG里。
- 通过SYSCFG->EXTICR把目标引脚映射到对应EXTI线。
- 配置EXTI->RTSR(上升沿触发)或EXTI->FTSR(下降沿触发)。
- 打开EXTI->IMR,让它能发出中断请求。
- 在NVIC里使能对应的IRQ通道。
RCC->APB2ENR |= (1 << 14); // 1. 使能SYSCFG时钟 SYSCFG->EXTICR[0] &= ~(0xF << 0); // 2. 清掉EXTI0原来的映射 SYSCFG->EXTICR[0] |= (0x0 << 0); // 2. 0x0表示GPIOA,即PA0映射到EXTI0 EXTI->RTSR |= (1 << 0); // 3. 上升沿触发 EXTI->FTSR |= (1 << 0); // 3. 也允许下降沿触发 EXTI->IMR |= (1 << 0); // 4. 使能EXTI0中断 NVIC_EnableIRQ(EXTI0_IRQn); // 5. 使能NVIC通道中断服务函数里最重要的是清挂起标志。EXTI的挂起寄存器叫PR,当中断触发后它自动置1。清除它有个反直觉的操作:向对应位写1才能清零,写0反而没用。很多人习惯往寄存器写0来表示“清除”,结果标志永远不消,中断一进再出,死循环就来了。
void EXTI0_IRQHandler(void) { if (EXTI->PR & (1 << 0)) { EXTI->PR = (1 << 0); // 写1清除挂起标志,关键操作 // 在这后面做你自己想做的事 } }2.3 NVIC优先级分组:别让“插队”变“堵车”
NVIC允许你给中断设优先级,但“优先级”这事在Cortex-M内核里分两层:抢占优先级和子优先级。抢占优先级高的中断,可以打断正在执行的低抢占优先级中断;而子优先级只在抢占优先级相同的时候,用来决定“谁先排队”。
这两者怎么分,取决于NVIC优先级分组。STM32通过修改内核寄存器SCB->AIRCR来设置分组,Cortex-M3/M4通常有4位可配优先级,分组决定这4位里哪几位是抢占、哪几位是子优先级。
SCB->AIRCR = 0x05FA0000 | (2 << 8); // 设置为分组2:2位抢占 + 2位子优先级分组2意味着抢占优先级可以是0~3,子优先级也可以是0~3,数字越小优先级越高。为什么必须用0x05FA0000这个魔法值?这是Cortex-M内核规定的写入秘钥,防止软件意外改掉这个关键寄存器。如果不带秘钥直接写,寄存器根本不会变化,很多人查半天发现AIRCR没生效,就是漏了秘钥。
实际项目中,我建议把实时性要求最高的中断设为抢占优先级0,普通外设中断设为1或2,主循环纯轮询。这样即使某个中断卡了一下,也不会影响紧急通道。
3. 定时器:时基单元是一台能自己计数的节拍器
3.1 时基单元三件套:PSC、CNT、ARR
如果说中断解决的是“事件来了怎么响应”,那定时器解决的就是“时间到了怎么通知”。STM32的定时器本质是一台硬件计数器,它不需要CPU参与数数,而是靠时钟脉冲驱动。
每个基本定时器内部都有三件套:预分频器PSC、计数器CNT、自动重载寄存器ARR。你可以把它们理解成一组齿轮:时钟源先经过PSC被除以某个倍数,得到一路慢一些的计数脉冲;CNT在这个脉冲下从0开始往上加,加到ARR设定值,就触发更新事件,然后CNT重新从0开始。这个更新事件就是“时间到”的标志。
PSC这个预分频器有一个特别容易记错的地方:PSC寄存器写入的值要加1才是实际分频倍数。比如你写PSC=7199,实际是把时钟源分了7200份。为什么设计成这样?因为定时器往往需要“0到N”的完整计数范围,寄存器从0开始,所以“计数次数 = 寄存器值 + 1”这个规律在定时器里到处都在。
3.2 定时计算:从84MHz降到1Hz要多走几步?
以STM32F407为例,如果系统主频168MHz,APB1总线时钟通常是42MHz。但注意,定时器有个坑:当APB1预分频系数不为1时,定时器时钟是APB1时钟的2倍。所以这里TIM3的输入时钟不是42MHz,而是84MHz。如果你直接拿42MHz去算,所有定时时间都会差一倍。
定时周期公式其实很简单:
计数频率 = 定时器时钟 / (PSC + 1) 定时时间 = (ARR + 1) / 计数频率举个例子,我想让TIM3每500ms触发一次更新中断:
- 先定PSC=8399,那么计数频率 = 84MHz / 8400 = 10kHz,也就是计数器每1/10000秒走一步。
- 再定ARR=4999,计数到4999时一共走了5000步,时间 = 5000 / 10000 = 0.5秒。
| 目标时间 | PSC | ARR | 计数频率 | 实际时间 |
|---|---|---|---|---|
| 1ms | 8399 | 9 | 10kHz | 1ms |
| 100ms | 8399 | 999 | 10kHz | 100ms |
| 500ms | 8399 | 4999 | 10kHz | 500ms |
| 1s | 8399 | 9999 | 10kHz | 1s |
配置代码也很直白:
RCC->APB1ENR |= (1 << 1); // 使能TIM3时钟 TIM3->PSC = 8399; // 分频到10kHz TIM3->ARR = 4999; // 500ms更新一次 TIM3->CNT = 0; // 计数器清零 TIM3->CR1 |= (1 << 0); // CR1的第0位CEN,置1启动计数3.3 更新中断:让定时器主动喊你
前面那段配置只是让定时器自己默默计数,CPU并不知道它数到了哪里。想让系统被定时器“主动叫起来”,就得打开更新中断:在DIER寄存器里置位UIE,然后在NVIC里使能对应通道。
TIM3->DIER |= (1 << 0); // UIE置1,使能更新中断 NVIC_EnableIRQ(TIM3_IRQn); // 使能NVIC里的TIM3通道中断服务函数里同样要清标志。TIM3->SR的第0位是UIF,更新事件一来就变成1。清除方式和EXTI不一样,定时器的SR寄存器是写0清零。
void TIM3_IRQHandler(void) { if (TIM3->SR & (1 << 0)) { TIM3->SR = 0; // 清掉更新标志,也可以只清UIF位 // 到这里说明500ms到了 } }我还想啰嗦一句:很多人习惯用软件延时做节拍,比如delay(500)。这在简单Demo里能跑,但延时期间CPU全程瘫痪,按键响应、通信接收全部断片。定时器中断才是正经方案,主循环该干嘛干嘛,到点被中断叫一下就行。
4. 综合实操:把按键、中断、定时器串成一个呼吸灯Demo
4.1 功能定性与状态机构想
讲了这么多寄存器,是时候组合成一锅实战了。我做了一个特别典型的组合:按键通过EXTI中断切换LED状态,TIM3定时器驱动软件PWM,让LED呈现“呼吸灯”效果。第一次按下,LED开始呼吸;第二次按下,LED熄灭;第三次按下又呼吸,循环往复。
为什么要用软件PWM而不是直接亮灭?因为呼吸灯的本质是LED亮度随时间平滑变化,而普通GPIO只有高电平和低电平。通过在一个固定周期内改变高电平占比,也就是占空比,人眼就会觉得亮度变了。我们拿定时器产生一个固定的节拍,主循环在这个节拍里不断调整占空比,就实现了呼吸效果。
整个过程的状态设计很简单:一个led_enable标志决定LED是否工作;一个breath_phase变量记录呼吸相位;定时器每秒产生1000次节拍中断,主循环根据节拍更新相位和占空比。
4.2 核心代码逐行拆解
初始化部分把前面讲的所有内容串了起来:
// 时钟和GPIO配置 RCC->AHB1ENR |= (1 << 0); // GPIOA时钟 // PA5设为通用输出,推挽,高速 GPIOA->MODER &= ~(0b11 << (2 * 5)); GPIOA->MODER |= (0b01 << (2 * 5)); GPIOA->OTYPER &= ~(1 << 5); GPIOA->OSPEEDR |= (0b10 << (2 * 5)); // PA0设为输入,内部上拉 GPIOA->MODER &= ~(0b11 << (2 * 0)); GPIOA->PUPDR |= (0b01 << (2 * 0)); // 配置PA0到EXTI0,下降沿触发 RCC->APB2ENR |= (1 << 14); // SYSCFG时钟 SYSCFG->EXTICR[0] &= ~(0xF << 0); EXTI->FTSR |= (1 << 0); // 按键按下是下降沿 EXTI->IMR |= (1 << 0); NVIC_EnableIRQ(EXTI0_IRQn); // 配置TIM3,1ms节拍 RCC->APB1ENR |= (1 << 1); TIM3->PSC = 8399; // 84MHz -> 10kHz TIM3->ARR = 9; // 10kHz -> 1ms TIM3->DIER |= (1 << 0); // 更新中断 TIM3->CR1 |= (1 << 0); // 启动 NVIC_EnableIRQ(TIM3_IRQn);中断服务函数保持极简,这是裸机开发的老规矩:中断里只做设置标志和轻量操作,绝对不搞延时、不做复杂计算。
volatile uint16_t tick_ms = 0; volatile uint8_t led_enable = 0; void EXTI0_IRQHandler(void) { if (EXTI->PR & (1 << 0)) { EXTI->PR = (1 << 0); led_enable = !led_enable; // 切换呼吸灯开关 } } void TIM3_IRQHandler(void) { if (TIM3->SR & (1 << 0)) { TIM3->SR = 0; tick_ms++; // 累积节拍 } }主循环里的呼吸算法是这样的:把600ms分成一个呼吸周期,前300ms亮度递增,后300ms亮度递减。我拿tick_ms的每5ms作为一个相位步进,一个周期就120步。
int main(void) { // 上面所有初始化代码 uint8_t brightness = 0; uint16_t phase = 0; while (1) { if (led_enable) { phase = (tick_ms / 5) % 120; if (phase < 60) brightness = phase * 4; // 上升段 else brightness = (119 - phase) * 4; // 下降段 // 软件PWM:在每个10ms周期内,前brightness/24的时间亮灯 if ((tick_ms % 10) < (brightness / 24)) GPIOA->ODR |= (1 << 5); else GPIOA->ODR &= ~(1 << 5); } else { GPIOA->ODR &= ~(1 << 5); // 关闭LED } } }这个Demo把中断、定时器、GPIO全部串在一个状态机里,没有一处阻塞延时,CPU利用率几乎为0,但LED照常呼吸。你把串口打开,甚至还能在主循环里顺便打印数据,一点不耽误。
4.3 烧录验证与常见小坑
烧录后第一次按按键,如果灯没反应,先别怀疑代码。用调试器挂上去,看看EXTI->PR有没有被置1,再看NVIC的对应位有没有真正使能。调试器带你直击寄存器现场,比自己在那猜变量快得多。
如果LED常亮不呼吸,大概率是tick_ms没有增长。查TIM3->SR的UIF有没有频繁置1,再看NVIC里TIM3_IRQn是否被使能。还有个常见坑:TIM3->ARR我写的是9,代表“0~9共10个数”,也就是1ms。很多人把ARR=9想成10ms,时间全部错位。
如果按键按一下,LED状态要么没变要么跳了好几下,那是按键抖动加上悬空输入的问题。PA0此时内部上拉已经打开,理论上不该悬空;但机械按钮按下和松开瞬间会有几毫秒的抖动,一个边沿可能被当成好几次触发。最简单的解决办法是把EXTI同时配置成上升沿和下降沿都触发,然后在中断里加上简单的延时消抖;更稳的方案是接RC滤波电路,或者用定时器做按键扫描。
5. 寄存器调试图鉴:常见翻车现场与避坑笔记
5.1 悬空输入导致乱触发或乱翻转
按键输入不配上拉,引脚就浮空,你在中断和主循环里读到的电平完全看心情。轻则按键没反应,重则中断风暴,CPU一直在执行中断服务函数,主循环饿死。解决方式分两种:内部上拉简单直接,打开PUPDR即可;如果电路板上有外部上拉电阻,也可以把PUPDR配置成00,但前提是外部电阻确实存在。我最怕看到有人既开了内部上拉,又焊了外部上拉,两个上拉并联虽然不会炸,但会给低电平判断带来麻烦,因为等效上拉太强,按下按键时电平可能降不下去。
5.2 中断里干活太多,主循环饿死
中断服务函数不是不能干活,是干活必须“快进快出”。比如在EXTI中断里加一个1秒延时,期间所有其他中断都被堵住,系统时钟节拍都会乱。我见过最野的写法,是在中断里调用打印函数,用阻塞式串口发送几百字节,整个系统直接卡住。正确做法是中断里只置标志、清标志,真正的工作放到主循环的轮询里做。
5.3 清标志位的“反直觉”规则
EXTI的PR清0要写1,TIM的SR清0要写0,这个差异能把人搞晕。原理其实都写在参考手册里:许多状态寄存器标注“rc_w1”,意思是读可清,写1生效;标注“rc_w0”,就是写0生效。如果记不清,每次调试前先翻一下对应寄存器位描述里的缩写,比靠猜靠谱得多。清标志操作必须在中断服务函数最开头就做,防止同一事件反复进入。
5.4 寄存器读改写被优化掉
volatile没写,或者位运算的掩码写错,都会让你调半天。比如给GPIO的MODER设置引脚5时,用了GPIOA->MODER |= (1 << 5),这其实是错的,因为MODER每引脚占2位,这样置位只能把低1位置1,高1位还是原来的值,最后得到的模式可能是复用功能(0b10)或者模拟(0b11),而不是通用输出。寄存器位操作的黄金法则是:清位用完整掩码,置位用完整目标值,宁可多写两行,也不要让编译器替你发挥。
5.5 故障速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 按键触发一次但中断执行了两三次 | 机械抖动 | 消抖电路或定时器扫描 |
| 中断一直进,主循环卡死 | EXTI的PR没写1清除 | 在ISR开头清挂起位 |
| LED常亮不呼吸 | TIM3的ARR算错,节拍不对 | 重新核对PSC和ARR |
| 屏没反应但GPIO有输出 | MODER没配成输出模式 | 检查MODER对应位段 |
| NVIC设置了却不生效 | AIRCR缺少0x05FA秘钥 | 写秘钥后重设分组 |
| 主循环变量永远不更新 | 缺少volatile修饰 | 加上volatile重编 |
坦白讲,这些坑我几乎都踩过一遍。特别是清标志位那事,我当年在I2C的EV中断里漏了清标志,中断服务函数反复进,系统直接崩溃,查了整整两天。后来养成了习惯:凡是涉及外设中断,第一步先确认标志位清除方式,第二步再想业务逻辑。
最后再分享一个小技巧,你可能用得上。调试寄存器的时候,多看复位值,少背默认值。每个寄存器都有所谓的“Reset value”,比如MODER复位后全是00,PUPDR复位后要看型号。如果你发现某位状态和你预期不符,先把寄存器复位值查出来,再顺着代码往下一步步推,比在调试器里瞎点乱猜高效得多。这一章讲的中断和定时器,是后面玩PWM、看门狗、编码器接口的地基,把这两块吃透了,再往复杂外设走会轻松很多。