GPIO输入与中断:从配置到精确测量

GPIO输入和中断,是嵌入式系统里最基础也最容易被忽视的环节。很多人觉得不就是读个引脚电平嘛,有什么好讲的?但我在几个量产项目中吃过亏——中断响应慢了导致数据丢包,电平抖动没处理好引发误触发,优先级配错了造成死锁。嗯,这些坑踩过一遍,你就知道这里面的门道有多深了。

上拉与下拉配置:别让引脚“悬空”

先说说上拉和下拉。说白了,就是给引脚一个确定的电平状态。如果引脚既不接高也不接低,那就是“悬空”状态——外界一点电磁干扰就能让电平乱跳。

我曾经踩过的坑: 某次做按键检测,忘了配置内部上拉,结果手指还没碰到按键,电平就自己跳了好几次。查了半天才发现是引脚悬空导致的。

在嵌入式HAL里,配置上拉/下拉通常就一行代码。以STM32的GPIO为例:

// 配置PA0为上拉输入
let gpioa = dp.GPIOA.split();
let button = gpioa.pa0.into_pull_up_input();
// 或者下拉输入
let button = gpioa.pa0.into_pull_down_input();

我个人习惯:按键类输入用上拉,低电平触发;传感器信号用下拉,高电平触发。这样逻辑更直观。

配置方式默认电平适用场景
上拉输入高电平按键、开关、机械触点
下拉输入低电平传感器输出、逻辑信号
浮空输入不确定外部已有上下拉电路
小技巧: 如果外部电路已经接了上拉电阻,就别再用内部上拉了。两个上拉并联会降低阻抗,增加功耗。

中断触发方式:边沿还是电平?

中断触发方式的选择,直接影响系统的可靠性。边沿触发和电平触发,各有各的脾气。

边沿触发:检测电平的跳变(上升沿或下降沿)。优点是响应快,只触发一次。缺点是容易受噪声干扰——一个毛刺就可能触发一次中断。

电平触发:只要电平保持有效状态就一直触发。优点是抗干扰强,缺点是如果中断服务程序没清掉电平状态,会反复触发。

我在项目中遇到过这样一个问题:用边沿触发检测编码器脉冲,结果电机一启动,中断频率直接飙到几十KHz,CPU啥也干不了了。后来改成电平触发+定时查询,才把问题解决。

// 配置上升沿触发中断
let exti = Exti::new(dp.EXTI);
let button_interrupt = exti.listen_gpio(&button, TriggerEdge::Rising);

// 配置下降沿触发
let button_interrupt = exti.listen_gpio(&button, TriggerEdge::Falling);

// 配置双边沿触发
let button_interrupt = exti.listen_gpio(&button, TriggerEdge::Both);
核心原则: 信号干净用边沿,信号嘈杂用电平。如果实在拿不准,先用电平触发+软件去抖。

中断优先级与嵌套:别让高优先级饿死

中断优先级配置,是实时系统的命门。配置不好,轻则响应延迟,重则系统崩溃。

ARM Cortex-M系列通常支持8~16级抢占优先级。数值越小,优先级越高。我一般这样分配:

  • 最高优先级(0-1):系统时钟、看门狗、紧急故障
  • 高优先级(2-3):实时通信(SPI、I2C)、DMA完成
  • 中优先级(4-7):GPIO中断、定时器
  • 低优先级(8-15):UART接收、ADC转换完成
// 设置中断优先级(数值越小优先级越高)
NVIC::set_priority(Interrupt::EXTI0, 4);
NVIC::set_priority(Interrupt::TIM2, 2);
NVIC::enable(Interrupt::EXTI0);
NVIC::enable(Interrupt::TIM2);

中断嵌套要注意一个关键点:高优先级中断可以打断低优先级中断,但同优先级不会互相打断。我曾经见过一个案例,两个同优先级的GPIO中断互相抢CPU,结果谁都没法完整执行——这就是典型的优先级配置失误。

注意: 中断服务程序要短!短!短!我见过有人在中断里做浮点运算、打印日志,结果整个系统卡死。中断里只做标志位设置和数据搬运,具体处理放到主循环。

测量中断延迟:你到底快不快?

中断延迟,就是从硬件触发到CPU开始执行中断服务程序的时间。这个指标直接决定了系统的实时性。

测量方法其实不复杂:在中断触发引脚上接一个逻辑分析仪,同时在中断服务程序入口翻转另一个GPIO。两个信号的时间差,就是中断延迟。

// 测量中断延迟的示例代码
static mut START_TIME: u32 = 0;

#[interrupt]
fn EXTI0() {
    // 记录当前时间
    let now = DWT::get_cycle_count();
    // 计算延迟
    let latency = now - unsafe { START_TIME };
    // 翻转测试引脚
    test_pin.set_high().ok();
    // 记录下一次触发时间
    unsafe { START_TIME = now; }
}

影响中断延迟的因素主要有:

  • CPU当前是否在执行更高优先级的中断
  • 中断向量表的访问速度(RAM还是Flash?)
  • CPU的流水线状态
  • 总线仲裁延迟(特别是DMA在传输时)
实测数据: 在STM32F4上,无竞争时中断延迟约12个CPU周期(72MHz下约167ns)。但如果同时有DMA传输,延迟可能增加到40-60个周期。

使用DWT周期计数器:精确到纳秒级

DWT(Data Watchpoint and Trace)是Cortex-M内核自带的调试组件。它的周期计数器可以精确记录CPU时钟周期数,精度远高于普通的定时器。

启用DWT很简单:

// 启用DWT周期计数器
let dwt = DWT::new(dp.DWT, &mut dp.DCB);
dwt.enable_cycle_counter();

// 测量代码段执行时间
let start = DWT::get_cycle_count();
// 要测量的代码
critical_section::with(|cs| {
    // 中断处理
});
let end = DWT::get_cycle_count();
let elapsed_cycles = end - start;
let elapsed_ns = elapsed_cycles * 1000 / 72; // 72MHz下

我个人习惯用DWT做三件事:

  1. 测量中断延迟:精确到单个时钟周期
  2. 分析代码热点:找出最耗时的函数
  3. 生成微秒级延时:比定时器中断更精准
注意: DWT计数器是32位的,在72MHz下约60秒溢出一次。长时间测量需要处理溢出。

知识结构总览

下面这张图把GPIO输入与中断的核心知识点串起来了:

GPIO输入与中断知识体系 GPIO输入配置 上拉/下拉配置 中断触发方式 优先级与嵌套 上拉:默认高电平 下拉:默认低电平 浮空:外部电路决定 边沿触发:响应快 电平触发:抗干扰 双边沿:编码器场景 抢占优先级 子优先级 嵌套规则 DWT周期计数器:精确测量中断延迟

实战建议:从配置到验证的完整流程

最后,我总结一套我自己在项目中用的流程:

  1. 先确认硬件:看原理图,引脚有没有外部上下拉?电平逻辑对不对?
  2. 配置GPIO:设置模式、上下拉、输出类型
  3. 配置中断:选择触发方式、设置优先级、使能中断
  4. 写中断服务程序:尽量短,只做必要操作
  5. 测量验证:用DWT或逻辑分析仪测中断延迟,确认满足实时性要求
  6. 压力测试:模拟最坏情况(多个中断同时触发),看系统是否稳定
最后一个小建议: 别迷信理论值。同样的配置,在不同PCB布局、不同电源质量下,表现可能完全不同。一定要实测,用数据说话。