ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

nRF定时器+中断全解析:配置原理、常见坑与代码实战

nRF定时器+中断全解析:配置原理、常见坑与代码实战

做嵌入式开发,定时器和中断几乎是绕不开的组合。nRF系列芯片的定时器功能灵活、通道多,但“灵活”的另一面就是配置复杂。我在项目里见过太多人在定时器中断上翻车:配置了一次性计时没问题,改成周期触发就再也不进中断;或者中断明明进来了,频率怎么都对不上;还有在中断回调里多做了一行打印,整个系统就变得卡顿。这篇文章就围绕nRF定时器+中断这条主线,把硬件行为、事件链路、代码配置、常见坑一次性讲透,适合正在用nRF5 SDK或直接操作寄存器做裸机开发的同学参考,读完你可以直接照着抄一套能稳定跑的中断定时方案。

1. 定时器+中断为什么会成为“问题高发区”

1.1 一个典型的翻车现场

先看一个我实际帮人排查过的例子。某开发者想用nRF52840的TIMER0做一个100ms的周期中断,用来驱动LED闪烁。代码写得并不复杂,初始化定时器、设置比较值、使能中断,看起来每一步都对。结果上电之后,LED只闪了一下就再也不动了。

查了半天,问题出在一个容易被忽略的细节:计数器到达比较值触发事件之后,计数器继续往上走,并没有回零。比较值设置的100ms对应的计数值是100000,计数器跑到100000时触发了一次中断,然后继续跑到几百万、几千万,直到32位计数器溢出回绕,才会再次遇到100000这个值。而这个回绕周期在1MHz计数频率下大约是4295秒,也就是一个多小时。所以表面上看起来就是“中断只触发了一次”。

这个问题的本质,是没有理解定时器中断背后的完整链路:比较事件只是硬件置了一个标志位,要让这个标志位持续地、周期性地产生,你还需要处理计数器的回零或者说“重新装载”机制。这就是为什么很多人单独看每个寄存器的说明都懂,连起来用就出问题。

1.2 先看清定时器的硬件底子

nRF系列芯片的定时器外设,核心是一个向上计数的计数器,时钟来自HFCLK派生出来的16MHz PCLK。通过预分频器(PRESCALER)可以降低计数频率,配置寄存器值0到8,实际分频倍数是2的PRESCALER次方。

分频配置(PRESCALER)实际计数频率每个计数周期典型应用场景
016MHz62.5ns高频捕获、精确脉冲边沿
24MHz250nsDSP采样、高频事件计时
41MHz1µs通用微秒/毫秒级延时、周期中断
6250kHz4µs低频周期任务
862.5kHz16µs低功耗轮询、慢速超时检测

每个定时器有4个比较/捕获通道(CC[0]到CC[3]),可以独立设置比较值。计数器从0开始向上计数,当计数值等于某个CC通道的设定值时,硬件会把这个通道对应的事件寄存器置1。这个事件寄存器本身只是一个“信号”,它要变成真正的中断,还需要经过中断使能开关和NVIC这两道门。

另外要注意位宽设置。定时器可以配置为16位、24位、32位计数,也就是计数器的溢出回绕上限不同。用16位模式时,计数最大值只有65535,在1MHz频率下最多定时65.535ms。如果你想做秒级定时,位宽一定要选32位,否则计数器会在你设定的比较值之前就溢出回绕,中断触发时机完全错乱。

提示:平时调试这种外设问题,我习惯先画一条“计数器值——时间”的数轴,把比较值、清零点、溢出点都标上去。大多数看似诡异的现象,在数轴上都能一眼看穿。

2. 把中断链路彻底捋顺:事件、使能、清标志

2.1 事件寄存器与INTEN的关系

定时器外设里的EVENTS_COMPARE[0]到EVENTS_COMPARE[3]是一组硬件标志位。计数器匹配时,对应的事件位自动置1。但事件位置1之后,CPU是不是马上跳去执行中断函数,还要看INTEN寄存器里对应的中断使能位。

这个过程可以用一个生活场景来类比。事件寄存器就像门口的信箱,信件到达(比较匹配)时,信箱的指示旗会弹起来。INTEN就像是“是否订阅邮件通知”的开关,只有你订阅了,信箱指示旗弹起时才会有人打电话通知你去取信(NVIC产生中断)。如果没订阅,信照样到,指示旗照样弹,但不会通知你。

所以排查“事件发生了但没中断”的时候,要按这个顺序查:先看事件寄存器是否置1(信到了吗),再看INTEN里对应位是否使能(订阅了吗),最后看NVIC里定时器中断是否打开(通知电话线通了吗)。这三层都通了,中断才会真正执行。

还有一个关键的细节:事件寄存器是“写0清除”的。也就是说,你在中断服务函数里处理完业务之后,必须把对应的事件位清零,否则这个标志会一直保持为1。更准确地说,如果不清零,后续即使再次发生比较匹配,硬件也无法把新的事件“锁存”到这个已经置1的寄存器里,结果就是后面的中断全部丢失。

2.2 一次中断从产生到处理的完整时序

把完整时序拆开看,一次定时器中断要经过这些环节:

  1. 计数器值达到CC[x]设定值,硬件将EVENTS_COMPARE[x]置1。
  2. 如果INTEN中COMPARE[x]位使能,外设向NVIC发出中断请求。
  3. NVIC根据优先级和当前中断屏蔽状态,决定是否立即进入TIMERx_IRQHandler。
  4. CPU进入中断服务函数,硬件自动把同级或低优先级中断屏蔽。
  5. 中断服务函数中读取事件状态、执行用户业务逻辑。
  6. 软件将EVENTS_COMPARE[x]写0,清除事件标志。
  7. 中断服务函数返回,CPU恢复现场,继续执行被打断的任务。

这里我特别想说一下第6步。很多人写直接操作寄存器的代码时,会忘记清事件标志,或者清的位置不对。如果你用的是nrfx驱动,情况会好一些,因为nrfx_timer_irq_handler在调用用户回调之前会先帮你把事件清除。但如果你为了调试方便自己直接读写寄存器,那就必须记得在中断处理完之前执行类似NRF_TIMER0->EVENTS_COMPARE[0] = 0;的操作。

2.3 中断优先级与协议栈约束

nRF52系列的中断优先级可以配置为0到3,0最高,3最低。在裸机系统里,你当然可以把定时器中断设为最高优先级,保证响应及时。但如果你的工程里集成了BLE协议栈(SoftDevice),情况就完全不同了。

协议栈自己也在用中断,而且对时序极其敏感。它通常占用优先级0或1。官方要求是:应用程序的外设中断优先级必须设置为比协议栈更低,也就是数值更大,一般用3。如果你把定时器中断设为优先级0,轻则协议栈时序被破坏,出现连接不稳定、蓝牙掉线;重则直接触发协议栈断言,系统死机。

所以在有协议栈的项目里,定时器中断的优先级老老实实设为3,不要为了“看起来快”去抢优先级。真要追求响应速度,选择更合适的硬件方案而不是抢中断优先级。

3. 一套可以直接跑的中断定时方案

3.1 选型:用哪个定时器、驱动API怎么选

nRF52系列一般有TIMER0到TIMER4,数量因型号而异。使用裸机或nrfx驱动时,优先选择没有被其他功能占用的定时器实例。如果你的工程只是跑裸机代码,选TIMER0没有太大问题;如果集成了BLE协议栈,TIMER0通常被协议栈保留,用户代码不能用,这时候选TIMER1、TIMER2比较稳妥。

驱动层面有两种常见选择:一是直接操作寄存器,适合想彻底搞懂硬件行为的场景,也适合对代码体积有极致要求的项目;二是使用SDK提供的nrfx_timer驱动,代码简洁、自动处理事件清除等脏活,适合快速开发和维护。我下面的示例用nrfx_timer展开,因为它在可读性和功能完整性上最平衡。

3.2 预分频与比较值的数学关系

设定时周期为T秒,计数频率为f(单位Hz),则比较值CC = T × f。举个实际例子:如果你要100ms触发一次中断,预分频设置为4,计数频率为1MHz,那么CC = 0.1 × 1000000 = 100000。

反过来,如果你希望中断频率为500Hz,也就是2ms周期,那么CC = 0.002 × 1000000 = 2000。

这里要提醒一个新手的常见误区:不要一上来就把预分频设为0,也就是16MHz计数频率。虽然16MHz的分辨率更高,但对于毫秒级周期中断来说,CC值会变得很大,而且计数器溢出更快,对位宽的要求也更高。通用场景下,预分频为4(1MHz)是最舒服的档位:微秒级分辨率足够,CC值也容易心算。

还有一个需要留意的边界情况:CC值的最大范围受位宽限制。如果使用16位模式,CC最大是65535;如果定时周期算出来的CC超过这个数,系统不会报错,但计数器会在达到CC之前先溢出回绕,导致中断行为完全失控。所以做长定时,优先选32位模式。

3.3 完整代码与配置说明

下面是一个基于nrfx驱动的完整示例,实现1秒周期的定时器中断,中断里翻转一颗LED。代码里包含了初始化、事件回调、IRQHandler桥接三个部分。

#include "nrfx_timer.h" #include "nrf_gpio.h" #define LED_PIN 13 // 定时器实例:NRFX_TIMER_INSTANCE(0) 会生成 nrfx_timer_0_irq_handler static const nrfx_timer_t timer = NRFX_TIMER_INSTANCE(0); // 定时器事件回调 void timer_event_handler(nrf_timer_event_t event_type, void * p_context) { switch (event_type) { case NRF_TIMER_EVENT_COMPARE0: nrf_gpio_pin_toggle(LED_PIN); break; default: break; } } // 定时器初始化 void timer_init(void) { nrfx_timer_config_t config = NRFX_TIMER_DEFAULT_CONFIG; config.frequency = NRF_TIMER_FREQ_1MHz; // 预分频=4,计数频率1MHz config.bit_width = NRF_TIMER_BIT_WIDTH_32; // 32位计数 config.interrupt_priority = 3; // 最低优先级,兼容协议栈 nrfx_timer_init(&timer, &config, timer_event_handler); // 关键:比较事件触发计数器清零,实现自动周期装载 nrf_timer_shorts_enable(timer.p_reg, NRF_TIMER_SHORT_COMPARE0_CLEAR_MASK); // 设置比较值并使能中断:1MHz频率下1000000计数=1秒 nrfx_timer_compare(&timer, NRF_TIMER_CC_CHANNEL0, 1000000, true); nrfx_timer_enable(&timer); } // IRQHandler桥接:把芯片中断连接到nrfx处理函数 void TIMER0_IRQHandler(void) { nrfx_timer_0_irq_handler(); }

这段代码里最容易漏掉的有两个点。

第一个是IRQHandler桥接。nrfx_timer生成的是nrfx_timer_0_irq_handler(),但芯片的中断向量表需要的是TIMER0_IRQHandler。如果你不写这个桥接函数,编译能过,链接也能过,但中断永远不会进入回调。这在调试时非常隐蔽,因为没有编译错误提示。

第二个是SHORT配置。nrf_timer_shorts_enable(timer.p_reg, NRF_TIMER_SHORT_COMPARE0_CLEAR_MASK)这一行把“比较事件0”和“清除计数器任务”在硬件层面直接连接起来。计数器达到CC值时,一边产生事件触发中断,一边自动清零重新开始计时。这样就不用在中断回调里手动清除计数器,定时周期也更准确。

3.4 周期性触发:SHORT与重新装载的取舍

实现周期性触发有两种主流做法,我用一个表格直观看区别。

做法原理优点缺点
SHORT比较事件到CLEAR任务计数器匹配后硬件自动清零周期精确,无软件延迟误差,代码简单周期只能在中断回调外设定,动态调整稍麻烦
中断回调里重新设置比较值读取当前计数值加上周期,写入CC可以灵活调整动态周期存在累计误差,中断延迟会影响精度

我一开始用惯了单片机上的传统做法,习惯在中断里“重新装载”比较值,结果在nRF上栽过跟头。

中断回调里重新装载比较值,代码大概是先读当前计数器值,再加上期望的周期,然后写回CC寄存器。但问题在于,从中断事件发生到代码真正执行“写CC寄存器”这一步,中间有时间差。如果你读计数器时它已经跑到1020了,你加1000写回的是2020,那么下一次触发点就变成了1020+1000=2020,比理想周期慢了20个计数。这个误差看起来不大,但在高频定时或需要稳定周期的场景下,累计起来会让时间基准明显漂移。

所以我现在的习惯是:固定周期优先用SHORT到CLEAR,动态周期才考虑中断里改CC。SHORT方案下,计数器匹配即清零,匹配点永远是你设定的那个CC值,不受中断响应时间影响,周期精度只取决于晶振本身的精度。

4. 避坑指南:我在调试中遇到的几个典型问题

4.1 中断只触发一次就不再进来

这是nRF定时器中断“第一大类”问题,原因通常有两种。第一种就是我前面讲过的不配置SHORT,计数器匹配后继续跑,要等很久才能再次遇到同一个比较值,表现就是“只触发一次”。第二种是直接寄存器操作时没有清除事件标志,事件位一直为1,后续匹配事件无法被锁存。

排查建议:在中断回调的第一行设置一个调试断点,或者翻转一个空闲GPIO。然后暂停程序,查看EVENTS_COMPARE[0]和计数器当前值。如果比较值远小于计数器当前值,而且事件位是1,基本就是没清零或没配置自动清零。

4.2 频率偏低或偏高的排查思路

中断频率和预期不一致,先别急着怀疑硬件,把数学算一遍。用示波器或者逻辑分析仪测GPIO翻转频率,然后反推实际周期,再对照预分频和CC值。最常见的问题是预分频计算错误:比如把PRESCALER=4理解成4分频,实际它是2的4次方等于16分频,频率直接掉了一个量级。

另外要确认你读到的实际频率是翻转频率的两倍关系。GPIO翻转一次输出半个周期,如果你翻转LED,示波器看到的是1秒翻转一次,实际方波周期是2秒。很多人在这里犯迷糊,以为是定时器配置错了。

4.3 在中断回调里做耗时操作的后果

在中断回调里调用延时函数、日志打印、甚至做浮点运算,都会带来一个直接后果:中断占用时间过长。如果定时周期比中断处理时间还短,就会出现中断反复进入、主循环饿死的现象。

更隐蔽的是,如果你在回调里调用了nrfx驱动中某些带阻塞等待的函数,可能因为优先级问题导致死锁。我的经验是:中断回调只做“标记业务”,比如置一个标志位、翻转一个引脚、把数据塞进一个简单的缓冲区。真正的数据处理放到主循环里去做。

4.4 与BLE协议栈共存时的优先级问题

集成SoftDevice之后,定时器中断不触发或者系统频繁复位,首先要怀疑优先级冲突。前面说过,协议栈中断占用了高优先级,用户外设中断必须设置为3。有同学为了“提高响应速度”把定时器中断改成优先级1,结果蓝牙连接瞬间断开,调试了一整天才定位到是优先级问题。

还有一个容易被忽略的坑:你在初始化定时器时用了正确的优先级3,但之后又在某个地方调用了NVIC_SetPriority重新配置,程序里存在两个地方写优先级,后面覆盖前面。建议全局搜索一下,确保只有一个地方统一管理外设中断优先级。

4.5 定时器清零的两种方式和误差

除了用SHORT硬件清零,还有一种做法是在事件触发后软件清零计数器。SHORT清零因为是硬件动作,匹配事件的产生和计数器的清零发生在同一个时钟周期内,周期不积累误差。软件清零则发生在中断回调里,从匹配到执行清除指令之间存在中断响应延迟,这个延迟虽然只有几微秒,但会让每个周期的实际长度变成“设定周期+响应延迟”,累计起来就是频率偏移。

如果对周期一致性要求高,比如生成精确的采样时钟,一定要用SHORT方式。我在一个采集项目里对比过:软件清零方案下100ms的定时周期,实际测量有±20µs的抖动;改成SHORT方案后,抖动直接降到±1µs以内。

4.6 接仿真器正常、断开后异常

这个现象很经典:J-Link连着的时候,一切正常,定时器中断周期很准;拔掉调试器重新上电,系统行为就不对了。原因通常是调试器连接时,芯片的调试时钟域和复位行为会掩盖某些初始化时序问题。比如HFCLK还没有稳定,你就启动了定时器;调试器连接时因为断点或单步操作,变相给了时钟稳定时间,所以看起来正常。

排查思路:把代码在main函数最开始的位置加一个HFCLK稳定的等待逻辑,或者干脆把定时器初始化放在时钟稳定之后。裸机工程里,很多人是从其他平台移植过来的,习惯了上电立刻初始化外设,在nRF上就容易踩这个坑。

4.7 低功耗场景下的定时器选择

如果你的产品对功耗敏感,想用定时器做周期唤醒,要特别注意:TIMER外设由HFCLK驱动,也就是高频时钟。只要TIMER在工作,HFCLK就必须持续运行,芯片无法进入最低功耗状态,电流会明显偏高。我实测过,nRF52840跑1MHz计数的TIMER,比用RTC做同等周期定时,平均电流高了几百微安。

所以低功耗项目里,秒级以上的周期定时优先用RTC外设,或者直接用SDK的app_timer,它底层就是RTC。TIMER更适合做毫秒级、微秒级的高精度短定时,用完之后立刻disable并释放HFCLK请求,让系统能尽快睡下去。

5. 进阶:用PPI把“中断”扔掉

5.1 为什么需要无中断方案

中断虽然方便,但它本质上是“CPU被强行拉过去处理事件”。CPU响应中断需要保存现场、跳转、执行回调、恢复现场,这个过程有不可预测的延迟。在需要精确输出脉冲、生成确定性波形的场景里,中断的延迟抖动是无法接受的。

这时候可以用nRF独有的PPI(可编程外设互连)。PPI的作用,是把一个外设的事件直接连接到另一个外设的任务,完全绕过CPU。事件产生,任务立刻执行,中间只有纳秒级硬件延迟。

5.2 硬件连接方法:事件到任务

拿“定时器比较事件触发GPIO翻转”这个场景举例。你需要配置三样东西:定时器的比较事件作为PPI的输入端点,GPIOTE的TASK_OUT作为PPI的输出端点,以及一个PPI通道把两者连起来。

GPIOTE的TASK_OUT任务可以配置为拉高、拉低或翻转引脚。配合定时器的COMPARE事件,每到一个比较点,引脚就自动翻转一次,不需要CPU参与。再加上SHORT配置把COMPARE事件连接到CLEAR任务,就能输出一路连续的方波。

5.3 一个无中断方波的实现

下面是一个示例片段,目标是在指定引脚上输出500ms周期翻转的方波,也就是1Hz方波,高低电平各500ms。定时器使用1MHz计数频率,CC值为500000。

#include "nrfx_timer.h" #include "nrf_gpiote.h" #include "nrf_ppi.h" static const nrfx_timer_t timer = NRFX_TIMER_INSTANCE(0); void ppi_square_wave_init(void) { // 1. 初始化定时器,不挂中断回调 nrfx_timer_config_t config = NRFX_TIMER_DEFAULT_CONFIG; config.frequency = NRF_TIMER_FREQ_1MHz; config.bit_width = NRF_TIMER_BIT_WIDTH_32; nrfx_timer_init(&timer, &config, NULL); // 2. 比较事件触发清零,实现自动周期 nrf_timer_shorts_enable(timer.p_reg, NRF_TIMER_SHORT_COMPARE0_CLEAR_MASK); nrfx_timer_compare(&timer, NRF_TIMER_CC_CHANNEL0, 500000, false); // 3. 配置GPIOTE任务:引脚翻转 nrf_gpiote_task_configure(0, LED_PIN, NRF_GPIOTE_POLARITY_TOGGLE, NRF_GPIOTE_INITIAL_VALUE_LOW); nrf_gpiote_task_enable(0); // 4. 配置PPI通道:定时器比较事件 -> GPIOTE翻转任务 nrf_ppi_channel_endpoint_setup(NRF_PPI_CHANNEL0, (uint32_t)&timer.p_reg->EVENTS_COMPARE[0], (uint32_t)&NRF_GPIOTE->TASKS_OUT[0]); nrf_ppi_channel_enable(NRF_PPI_CHANNEL0); // 5. 启动定时器 nrfx_timer_enable(&timer); }

这段代码里没有启用任何定时器中断,但引脚上的方波一样在输出。我在测试中发现,这种方案的频率稳定性和相位一致性远超中断方案,因为输出完全由硬件链路决定,不受CPU负载、中断延迟、缓存命中等因素影响。如果你在做一个对时序要求苛刻的驱动信号源,可以考虑把这种“事件-任务”直连的思路应用进去。

踩过几次坑之后,我现在写nRF定时器相关代码的基本流程已经固定了:先确认时钟源和分频需求,再算好CC值和位宽,然后决定用中断还是PPI,中断方案一定顺手把SHORT配置写上,最后再检查一遍中断优先级是否和协议栈冲突。这套流程虽然不能保证一次成功,但至少能让你在遇到“定时器+中断”问题的时候,不再靠瞎试碰运气。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进