株洲市网站建设_网站建设公司_JavaScript_seo优化
2026/1/16 6:58:31 网站建设 项目流程

从零开始写一个能“呼吸”的LED:我的第一个中断程序实战笔记

你有没有试过让单片机的LED灯每秒闪一次?
如果用while(1)里加delay(1000),确实能实现。但问题来了——在这整整一秒里,CPU什么都干不了,只能傻等。这就像你烧水时必须盯着壶看,连刷个手机都不行。

那有没有办法让MCU在“等时间”的时候去处理别的任务,甚至干脆睡一觉,到点自动醒来干活?答案就是:中断(Interrupt)

今天我就带你手把手写出人生中第一个真正的嵌入式级代码——不是轮询、不是延时,而是靠硬件“叫醒”你的程序。我们将用 STM32 实现两个经典场景:

  • 每1秒自动翻转一次LED(定时器中断)
  • 按下按键立刻响应并闪烁(外部中断)

全程寄存器操作,不依赖库函数,让你真正看懂每一行代码背后的原理。


中断到底是什么?为什么它比轮询强那么多?

先抛开术语,我们来打个比方。

想象你在办公室写报告(主程序运行)。突然电话响了(外部事件),你暂停写作,接电话处理事务(执行ISR),处理完挂掉电话,继续写报告(恢复原流程)。整个过程高效且不影响主线工作。

这就是中断服务程序(ISR)的本质:一种能让CPU临时放下手头工作、优先处理紧急事件的机制。

对比传统的“轮询”方式,差距非常明显:

维度轮询中断(ISR)
CPU利用率极低 —— 99%时间在空转高 —— 只有事件发生才唤醒
响应延迟不确定 —— 取决于循环多久轮到确定 —— 几个机器周期内进入
实时性
功耗高 —— 必须持续运行低 —— 可休眠等待中断唤醒

特别是在低功耗设备或实时控制系统中,不用中断 = 放弃专业开发的入场券


定时器中断:让MCU自己“计时”,不再靠delay()发呆

我们的目标是:让PC13上的LED灯每1秒自动翻转一次,同时主程序可以去做其他事,甚至进入低功耗模式。

第一步:搞清楚定时器是怎么“数数”的

STM32 的通用定时器(比如 TIM2)本质上是一个可配置的计数器。它的节奏由时钟驱动,我们可以控制它每隔多长时间“溢出”一次,从而触发中断。

关键参数有三个:
-输入时钟频率:通常来自 APB1 总线,例如 72MHz
-预分频器 PSC:把高频时钟降下来,比如分频7200 → 得到10kHz
-自动重载值 ARR:设定计多少个脉冲后产生中断

计算公式如下:

$$
T_{\text{中断}} = \frac{(PSC + 1) \times (ARR + 1)}{f_{\text{clk}}}
$$

举个例子:
f_clk = 72MHz,PSC = 7199,ARR = 9999
→ 每(7200 × 10000) / 72,000,000 = 1秒触发一次更新中断(Update Interrupt)

完美匹配需求!

第二步:一步步初始化定时器和NVIC

void Timer2_Init(void) { // 1. 开启相关外设时钟:TIM2 和 GPIOC RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 使能 TIM2 时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 使能 GPIOC 时钟 // 2. 配置 PC13 为推挽输出(用于连接LED) GPIOC->CRH &= ~GPIO_CRH_MODE13; // 清除原有模式 GPIOC->CRH |= GPIO_CRH_MODE13_0; // 设置为最大2MHz输出速度 GPIOC->CRH &= ~GPIO_CRH_CNF13; // 推挽模式 // 3. 配置定时器参数 TIM2->PSC = 7199; // 分频系数:72MHz / 7200 = 10kHz TIM2->ARR = 9999; // 自动重载值:10000个计数 → 1秒 TIM2->DIER |= TIM_DIER_UIE; // 使能更新中断(UIE: Update Interrupt Enable) TIM2->SR &= ~TIM_SR_UIF; // 清除可能存在的中断标志 TIM2->CR1 |= TIM_CR1_CEN; // 启动定时器 // 4. 配置 NVIC,允许 CPU 响应该中断 NVIC_EnableIRQ(TIM2_IRQn); }

几点关键说明:

  • volatile必须加!所有被 ISR 修改、又被主程序读取的变量都要这样声明。
  • 必须手动清除SR寄存器中的 UIF 标志位,否则会不断重复进入中断。
  • NVIC 是中断嵌套向量控制器,相当于一个“中断调度中心”,必须启用对应通道才能响应。

第三步:编写中断服务函数

volatile uint32_t sys_tick = 0; void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { // 判断是否为更新中断 TIM2->SR &= ~TIM_SR_UIF; // 手动清除中断标志 sys_tick++; // 全局计数器+1 GPIOC->ODR ^= GPIO_ODR_ODR13; // 翻转 LED } }

注意这里用了位操作^=来翻转引脚状态,比反复赋值更高效。

你现在可以把sys_tick当作系统节拍,在主循环中判断时间间隔,而不必再用delay()卡住整个系统。


外部中断:让按键按下瞬间就被捕获

接下来我们做一个更贴近交互的应用:通过 PA0 上的按键控制 LED 亮灭。要求是——一旦按下,立即响应,不能等到下一轮循环才检测。

这就需要用到EXTI(External Interrupt)

EXTI 是怎么工作的?

每个 GPIO 引脚都可以映射到对应的 EXTI 线上(PA0~PG0 映射到 EXTI0,以此类推)。当某个引脚电平变化满足设定条件(上升沿/下降沿),就会触发中断。

以按键为例,常见接法是接地,平时高电平(上拉),按下变低电平。所以我们选择下降沿触发

但要注意:机械按键会有抖动!如果不处理,一次按下可能被识别成多次触发。虽然本例暂不展开软件消抖,但在实际项目中建议在 ISR 中只设标志,延时判断放在主循环中完成。

初始化 EXTI0(对应 PA0)

void EXTI0_Init(void) { // 1. 开启 GPIOA 和 AFIO 时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN; // 2. 配置 PA0 为输入模式(浮空输入) GPIOA->CRL &= ~GPIO_CRL_MODE0; // 输入模式 GPIOA->CRL |= GPIO_CRL_CNF0_1; // 浮空输入(外部上拉) // 3. 将 EXTI0 连接到 PA0 AFIO->EXTICR[0] &= ~AFIO_EXTICR1_EXTI0; // 清除默认设置 // 注意:AFIO_EXTICR[0] 控制 EXTI0~3,此处无需额外设置即为PA0 // 4. 配置触发方式:下降沿触发 EXTI->FTSR |= EXTI_FTSR_TR0; // 使能下降沿触发 // EXTI->RTSR |= EXTI_RTSR_TR0; // 若需上升沿,取消注释 // 5. 使能中断请求 EXTI->IMR |= EXTI_IMR_MR0; // 取消屏蔽,允许中断 // 6. 配置 NVIC NVIC_EnableIRQ(EXTI0_IRQn); }

写中断服务函数

volatile uint8_t button_pressed = 0; void EXTI0_IRQHandler(void) { if (EXTI->PR & EXTI_PR_PR0) { // 检查 Line0 是否有挂起中断 EXTI->PR = EXTI_PR_PR0; // 写1清零挂起位 button_pressed = 1; // 设置标志 GPIOC->ODR ^= GPIO_ODR_ODR13; // 翻转 LED 做反馈 } }

⚠️ 关键细节:
必须通过写EXTI->PR = 1来清除挂起位,否则中断会一直保持激活状态,导致无限进入 ISR!


ISR 设计的“黄金法则”:快进快出,绝不恋战

很多人写 ISR 最大的问题是——在里面做太多事。

❌ 错误做法:

void USART1_IRQHandler(void) { char c = receive_char(); printf("Received: %c\n", c); // 调用复杂库函数 process_data(c); // 执行耗时算法 delay_ms(10); // 加延时?灾难! }

✅ 正确做法:

volatile char rx_buf[32]; volatile uint8_t rx_count = 0; void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { rx_buf[rx_count++] = USART1->DR; if (rx_count >= 32) rx_count = 0; } } // 主循环中处理数据 int main(void) { while (1) { if (rx_count > 0) { process_received_data(); rx_count = 0; } } }

记住这七条经验法则:

  1. ISR 越短越好:最好在几微秒内完成。
  2. 只做最必要的事:读寄存器、清标志、设标志。
  3. 共享变量加volatile:防止编译器优化导致读不到最新值。
  4. 避免调用标准库函数:如printf,malloc等不可重入且耗时。
  5. 不要使用延时函数:会阻塞整个系统响应。
  6. 合理设置中断优先级:防止高频率中断长期霸占CPU。
  7. 及时清除中断源:忘记清除 = 死循环进入 ISR。

实际系统中的典型架构:中断只是起点

在一个成熟的嵌入式系统中,ISR 很少直接完成全部逻辑,而更像是一个“信使”。

典型的协作模式如下:

硬件事件 → 触发中断 → ISR 设置标志/入队数据 → 通知任务处理 → 主循环或RTOS任务消费数据

比如:

  • ADC采样完成 → ISR 把结果放入缓冲区 → 主循环启动滤波算法
  • UART收到字节 → ISR 存入环形队列 → 协议解析任务取出组包
  • 定时器滴答 → ISR 增加 tick → RTOS 调度器据此切换任务

这种“中断驱动 + 任务处理”的模型,既保证了实时性,又避免了长时占用CPU,是高性能系统的标配设计。


结语:掌握中断,才算真正入门嵌入式

当你第一次看到LED按照精确的时间跳动,而MCU其余时间处于空闲或睡眠状态;当你按下按键的瞬间,系统立刻做出反应——你会感受到一种前所未有的掌控感。

这不是简单的“灯亮灯灭”,这是你在与硬件对话。

本文带你实现了两个最基础但也最重要的中断应用:定时器中断和外部中断。虽然代码只有几十行,但它背后承载的是嵌入式系统的核心思想——用最小的代价换取最大的实时性

下一步你可以尝试:
- 把定时器改成 1ms 中断,为未来移植 FreeRTOS 提供 tick;
- 在 EXTI 中加入双边沿检测,分别识别按下和释放;
- 结合 DMA 使用,实现零CPU干预的数据采集;
- 引入 RTOS,将中断作为任务唤醒信号。

掌握了中断,你就不再是“点灯工程师”,而是真正开始构建可响应、可扩展、高效率的嵌入式系统。这条路才刚刚开始。

如果你动手实现了这个例子,欢迎在评论区晒出你的示波器截图或者现象视频,我们一起交流踩过的坑!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询