本文是上一篇《STM32 中断入门:中断系统 + 外部中断 EXTI,一篇讲透》的
续篇(实验篇)
。上一篇讲了 EXTI 的原理,这一篇直接上手做两个实验:对射式计数器、编码器计数器,把外部中断的 "五步配置法" 和中断函数写法彻底跑通。
如果你还没看原理篇,建议先读一下,尤其是外部中断的配置流程,这篇的代码就是它的落地。
实验一:对射式计数器
1. 实验现象与原理
先用对射式红外传感器做个计数器。硬件连接如下:

原理:
-
OLED 用来显示程序的现象(计数值);
-
当挡光片或编码盘在对射式红外传感器中间经过时,传感器 DO 口就会输出电平跳变的信号;
-
这个电平跳变信号触发 STM32 PB14 号口的外部中断;
-
在中断函数内执行变量 ++ 的程序;
-
主循环里调用 OLED 显示这个变量。
简单说就是:传感器挡一下 → PB14 产生一个下降沿 → 触发外部中断 → 计数加 1 → OLED 刷新显示。
2. 外部中断的 "五步配置流程"
写代码之前,先把配置流程记下来,这是外部中断的通用套路(库函数方式):
-
配置 RCC:把用到的外设时钟都打开(GPIO、AFIO);
-
配置 GPIO:选择端口为输入模式(浮空 / 上拉 / 下拉);
-
配置 AFIO:选择我们用的这一路 GPIO,连接到后面的 EXTI;
-
配置 EXTI:选择边沿触发方式(上升沿 / 下降沿 / 双边沿),以及触发响应方式(中断响应或事件响应);
-
配置 NVIC:给这个中断选一个合适的优先级。
最后,通过 NVIC,外部中断信号就能进入 CPU,CPU 才能收到中断信号、跳转到中断函数执行中断程序。这五步就是外部中断的配置流程。
3. CountSensor.c 完整代码与解析
#include "stm32f10x.h" // Device header
//第一步都是先写初始化函数
void CountSensor_init(void)
{
//在这里就要进行外部中断的配置
//a.配置RCC,把用到的外设时钟都打开(GPIO、AFIO)
//b.配置GPIO,选择端口为输入模式
//c.配置AFIO,选择这一路GPIO连接到后面的EXTI
//d.配置EXTI,选择边沿触发方式、触发响应方式
//e.配置NVIC,给这个中断选一个合适的优先级
//这五步就是外部中断的配置流程
/*第一步:开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); //AFIO也是APB2总线上的外设
//如不清楚哪个外设接在哪个总线上,可以转到函数定义,看参数列表
//EXTI和NVIC两个外设的时钟是一直都打开着的,不需要再开启,内核的外设不需要开启时钟
//RCC管理的都是外核的外设
/*第二步:配置GPIO*/
GPIO_InitTypeDef GPIO_InitStruture; //定义结构体
GPIO_InitStruture.GPIO_Mode = GPIO_Mode_IPU; //选择上拉输入,默认高电平
GPIO_InitStruture.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStruture.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStruture); //初始化GPIOB外设
//其他外设使用GPIO时,如果不清楚该配置什么模式,可以看参考手册GPIO一章的"外设GPIO配置表"
/*第三步:配置AFIO外设*/
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14); //把PB14连接到EXTI的中断线
/*第四步:配置EXTI外设*/
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line14; //指定要配置的中断线(第14号线)
EXTI_InitStructure.EXTI_LineCmd = ENABLE; //使能
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; //中断模式
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; //指定触发边沿:下降沿
EXTI_Init(&EXTI_InitStructure);
//将EXTI第14线路配置为:中断模式 + 下降沿触发 + 开启中断
//这样PB14的电平信号就能通过EXTI通向下一级NVIC了
/*第五步:配置NVIC外设*/
//NVIC是内核外设,它的库函数在杂项文件(misc.h)里
//配置中断之前,先指定中断分组,再用NVIC_Init初始化
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); //分组2:2位抢占 + 2位响应
//注意:分组方式整个芯片只能用一种,这个分组的代码整个工程只需要执行一次
//如果放在模块里分组,要确保每个模块选的都是同一个分组
//也可以放在主函数最开头,这样模块里就不用再分组了
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn; //指定中断通道(EXTI10~15共用这个通道)
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //使能
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; //抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; //响应优先级
NVIC_Init(&NVIC_InitStructure);
}
//外部中断的信号从GPIO到AFIO,再到EXTI,再到NVIC,最终通向CPU,
//这样CPU才能由主程序跳转到中断程序执行。
//那么中断程序放在哪里呢?这就需要写一个中断函数了。
//STM32中中断函数的名字是固定的,每个中断通道对应一个中断函数,
//名字可以参考启动文件(startup_stm32f10x_md.s),
//里面以IRQHandler结尾的字符串就是中断函数的名字。
uint16_t countsensor_Get(void) //在这个模块写一个Get函数,返回计数变量
{
return countsensor_count;
}
void EXTI15_10_IRQHandler(void) //中断函数都是无参无返回值的,名字写错就进不了中断
{
//中断函数里,一般先进行中断标志位判断,确保是我们想要的中断源触发的
//因为这个函数EXTI10~EXTI15都能进来,所以要判断是不是EXTI14进来的
if (EXTI_GetITStatus(EXTI_Line14) == SET)
{
//如果是,就执行中断程序
countsensor_count++;
EXTI_ClearITPendingBit(EXTI_Line14);
//中断程序结束后,一定要调用清除中断标志位的函数
//只有中断标志位置1了,程序才会跳转到中断函数
//如果不清除标志位,它会一直申请中断,程序就会不断响应中断,卡死在中断函数里
}
}
//注意:中断函数不需要声明,因为它不需要被调用,是自动执行的
几个容易忽略的点:
-
为什么选上拉输入:传感器 DO 空闲时输出高电平,挡光时拉低,配合下降沿触发正好。上拉输入保证默认电平稳定为高。
-
为什么是 EXTI15_10_IRQn :PB14 属于 EXTI14,而 EXTI10~15 共用同一个中断通道(中断函数
EXTI15_10_IRQHandler),所以要在函数里判断到底是哪条线触发的。 -
清除标志位不能忘 :
EXTI_ClearITPendingBit不调用,中断会反复触发,程序卡死在中断里。
4. CountSensor.h
#ifndef ___COUNT_SENSOR_H
#define ___COUNT_SENSOR_H
void CountSensor_init(void);
uint16_t countsensor_Get(void);
#endif
5. Main.c
#include "stm32f10x.h" // Device header
#include "delay.h"
#include "led.h"
#include "key.h"
#include "LightSensor.h"
#include "OLED.h"
#include "CountSensor.h"
uint8_t Key_Num; //全局变量
uint8_t LightSensor;
int main()
{
OLED_Init();
CountSensor_init();
OLED_ShowString(2, 1, "COUNT:");
while (1)
{
OLED_ShowNum(2, 8, countsensor_Get(), 5);
}
}
主循环里只做一件事:不断把 countsensor_Get() 的值刷到 OLED 上。计数本身完全在中断里完成 ------ 这就是 "中断式" 和 "轮询式" 最本质的区别。
实验二:编码器计数器
1. 原理与接线
旋转编码器旋转时会输出 A、B 两路相位相差 90° 的方波。通过判断两路信号的相位关系,就能知道旋转方向和圈数。接线如下:

(上图为 OLED 下方被遮住的接线图)
这里把编码器的 A、B 两相分别接到 PB0 和 PB1,各用一路外部中断(EXTI0、EXTI1),配合电平判断实现 "正转加一、反转减一"。
2. Encoder.c 完整代码与解析
#include "stm32f10x.h" // Device header
void Encoder_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); //开启GPIOB的时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); //开启AFIO的时钟,外部中断必须开启AFIO的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure); //将PB0、PB1引脚初始化为上拉输入
/*AFIO选择中断引脚*/
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource0); //将外部中断0号线映射到GPIOB,即选择PB0为外部中断引脚
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource1);
/*EXTI初始化*/
EXTI_InitTypeDef EXTI_InitStructure; //定义结构体变量
EXTI_InitStructure.EXTI_Line = EXTI_Line0 | EXTI_Line1; //选择配置外部中断的0号线和1号线
EXTI_InitStructure.EXTI_LineCmd = ENABLE; //使能
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; //中断模式
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; //下降沿触发
EXTI_Init(&EXTI_InitStructure); //配置EXTI外设
/*NVIC中断分组*/
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); //配置NVIC为分组2
//即抢占优先级范围:0~3,响应优先级范围:0~3
//此分组配置在整个工程中仅需调用一次
//若有多个中断,可以把此代码放在main函数内、while循环之前
//若调用多次配置分组的代码,后执行的配置会覆盖先执行的配置
/*NVIC配置*/
NVIC_InitTypeDef NVIC_InitStructure; //定义结构体变量
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn; //选择EXTI0线
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //使能
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; //抢占优先级1
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; //响应优先级1
NVIC_Init(&NVIC_InitStructure);
NVIC_InitStructure.NVIC_IRQChannel = EXTI1_IRQn; //选择EXTI1线
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //使能
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; //抢占优先级1
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2; //响应优先级2
NVIC_Init(&NVIC_InitStructure);
}
int16_t Encoder_Num;
int16_t Encoder_Get(void)
{
int16_t temp;
temp = Encoder_Num;
Encoder_Num = 0; //读完后清零,下次从0开始累计
return temp;
}
void EXTI0_IRQHandler(void)
{
if (EXTI_GetFlagStatus(EXTI_Line0) == SET)
{
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0) //PB0跳变时判断PB1电平
{
Encoder_Num--;
}
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
void EXTI1_IRQHandler(void)
{
if (EXTI_GetFlagStatus(EXTI_Line1) == SET)
{
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == 0) //PB1跳变时判断PB0电平
{
Encoder_Num++;
}
EXTI_ClearITPendingBit(EXTI_Line1);
}
}
方向判断的核心思路(正交解码的 "土办法"):
-
当 PB0(A 相) 产生下降沿时,去读 PB1(B 相) :如果 B 相是低电平,说明是正转,
Encoder_Num++;反过来如果在 PB1 下降沿时读 PB0 为低,则Encoder_Num--。 -
因为 A、B 相相位差 90°,旋转方向不同时,一个边沿到来时另一相的电平状态也不同 ------看 "谁先谁后" 就知道转了哪个方向。
Encoder_Get() 读完后把内部变量清零,主函数里 Num += Encoder_Get() 累加,这样计数不会丢,也不会重复。
3. Encoder.h
#ifndef ___ENCODER_H
#define ___ENCORDE_H
void Encoder_Init(void);
uint16_t Encoder_Get(void);
#endif
4. Main.c
#include "stm32f10x.h" // Device header
#include "delay.h"
#include "led.h"
#include "key.h"
#include "LightSensor.h"
#include "OLED.h"
#include "Encoder.h"
uint8_t Key_Num; //全局变量
uint8_t LightSensor;
int16_t Num;
int main()
{
OLED_Init();
Encoder_Init();
while (1)
{
Num += Encoder_Get();
OLED_ShowString(3, 1, "count");
OLED_ShowSignedNum(3, 7, Num, 5);
}
}
注意这里用 OLED_ShowSignedNum(有符号显示),因为 Num 是 int16_t,正转显示正数、反转显示负数。
中断编程的三个注意点
做完两个实验,最后记住这三条,能帮你少踩很多坑:
1. 中断函数里不要执行耗时过长的代码
中断是处理 "突发事情" 的,函数要简短快速 。别一进中断就 Delay 几百毫秒 ------ 你为了一个突发的事情赖在中断里不出来,主程序就会被严重阻塞。
2. 不要在中断函数和主函数里操作同一个硬件
尤其是 OLED 这类显示函数:既在主程序里调用、又在中断里调用,OLED 就会显示错乱。正确做法是:在中断里只操作变量或标志位,中断返回后,主循环再根据变量去显示、去操作硬件。这样既保证中断简短快速,又避免硬件冲突。
3. 多用变量和标志位,减少代码耦合
在其它地方也可以多用变量、标志位来降低代码之间的耦合:让各部分代码相互独立,仅使用变量、标志位或函数作为接口,程序会更清晰,也更健壮。
小结
这两个实验把上一篇的原理全部落到了代码上:
-
对射式计数器:传感器边沿 → PB14 外部中断 → 中断里计数 → 主循环 OLED 显示;
-
编码器计数器:A/B 两相各接一路外部中断,在中断里互读另一相电平判断方向,实现正反转计数;
-
两段代码都遵循同样的五步配置法:RCC → GPIO → AFIO → EXTI → NVIC;
-
中断函数统一套路:判断标志位 → 执行逻辑 → 清除标志位。
把这套配置流程和中断写法记住,STM32 的其它中断(定时器、串口、DMA 事件)配置思路都是一样的,只是把 "GPIO + AFIO" 换成对应的外设而已。