最近有朋友做IMX6ULL裸机开发明明GPIO外部中断已经能用了结果换成EPIT定时器中断就卡住中断不触发、延时不对、进了中断还死循环整整折腾了两天。其实问题不在EPIT本身而在于从GPIO中断跳到定时器中断之后中断处理的链路从“外设—CPU”变成了“外设—GIC—CPU”中间多了一个叫中断控制器的东西。这篇就顺着这个思路把GIC和EPIT定时器一次性讲透。这个系列写到第五十一篇前面已经聊过GPIO操作、UART、按键中断等内容。这篇适合正在做IMX6ULL裸机驱动、或者从STM32转到IMX6ULL后对中断系统还不熟的开发者也适合想搞清楚EPIT到底怎么用的人。我会按“为什么选EPIT—GIC中断链路—EPIT寄存器机制—配置代码—中断服务函数—实测踩坑”的顺序来写代码可以直接抄到自己的工程里跑通。1. IMX6ULL的定时器那么多为什么偏偏用EPIT1.1 从GPIO中断到EPIT中断难的不是定时器而是中断模型很多人学完GPIO中断后会有一个错觉中断不就是“外设触发—CPU跳转—执行ISR”吗换到定时器应该更简单毕竟定时器连外部引脚都不用接。实际上确实更简单前提是你理解了GIC的参与方式。GPIO中断在IMX6ULL里走的是GICEPIT中断走的也是GIC如果之前用的是某些封装好的库函数可能根本没意识到GIC的存在。所以这篇先不急着贴EPIT代码先把中断控制器这个东西讲清楚。等搞明白“EPIT计数到0—GIC分发—CPU响应—执行ISR—清标志”这整条链路后GPIO中断和定时器中断就再也没有区别了你甚至可以举一反三去写任何外设的中断。1.2 EPIT和GPT、系统节拍定时器的区别IMX6ULL里面定时器资源不少有GPT、EPIT还有看门狗自带的定时器。很多人会问GPT明显功能更多为什么要用EPIT我当时的判断标准很简单任务越窄配置越少越好。GPT支持输入捕获、输出比较、PWM等一堆功能适合做复杂的波形测量和电机控制EPIT就是一个纯粹的32位递减计数器配一个比较中断结构简单到几乎没有多余的东西。如果目的只是“每毫秒进一次中断做节拍”或者“延时到时间后触发一个事件”EPIT比GPT更省心因为它不需要考虑捕获模式、引脚复用这些额外配置。另外EPIT有两个实例EPIT1和EPIT2可以同时跑两路不同的周期任务。比如一个管系统节拍一个管某个超时检测互不干扰。这篇的代码以EPIT1为例EPIT2完全同理只差一个基地址和中断号。2. 真正的主角是GICIMX6ULL中断响应的完整链路2.1 GIC v2的构成分发器和CPU接口ARM Cortex-A7内核里的中断控制器是GICGeneric Interrupt ControllerIMX6ULL用的是GIC v2架构。它内部有两个主要模块职责划分非常清晰Distributor分发器负责中断的使能、优先级设置、目标CPU路由。你可以把它理解成一个“前台调度”所有外设中断先汇集到这里它决定这个中断该不该发、发给谁、以什么优先级发。CPU InterfaceCPU接口负责和CPU核直接打交道。Distributor决定要发中断后CPU Interface会向CPU核产生IRQ信号同时提供中断号查询、中断完成确认这类寄存器接口。这两个模块在IMX6ULL里是有独立寄存器地址的Distributor在0x00A00000CPU Interface在0x00A01000。裸机开发时得直接操作这两个地址因为不像Linux有现成的驱动我们要自己把GIC跑起来。2.2 中断类型SGI、PPI和SPIGIC v2把中断分成三类理解这个很重要因为EPIT属于其中特定的一类类型中断号范围特点典型例子SGI0-15软件触发用于核间通信多核唤醒PPI16-31私有外设中断每个核独有全局定时器、看门狗SPI32-1019共享外设中断外设专用GPIO、UART、EPITIMX6ULL是单核Cortex-A7SGI基本用不上PPI里常用的也就全局定时器剩下我们日常打交道的全都是SPI。EPIT1的中断号是88EPIT2是89注意这个编号是GIC视角的完整SPI编号不是某个偏移量写代码时直接用就行。2.3 一条完整的中断路径从EPIT计数到0到ISR执行把整条链路画出来会更清楚EPIT产生中断后大概是这么走的EPIT计数器递减到比较值硬件自动置位EPIT_SR里的OCIF标志。中断触发信号送到GIC Distributor。Distributor检查该中断是否使能、优先级是否足够、路由目标是不是当前CPU然后通过CPU Interface向CPU核拉高IRQ。CPU核响应IRQ硬件自动跳到异常向量表0x18处执行。向量表里的IRQ入口跳转到统一的irq_handler。irq_handler从GICC_IAR寄存器读出中断号判断是谁触发的中断调用对应的EPIT1_IRQHandler。ISR处理完后往GICC_EOIR写中断号表示“这个中断我处理完了”。CPU恢复现场继续跑主程序。我一开始在实际开发中犯过一个错误写ISR只清了EPIT的OCIF标志忘了写GICC_EOIR结果整个中断处理流程没有走完主程序直接卡死。所以这条链路必须形成肌肉记忆少一步都不行。3. EPIT的核心机制递减计数器、比较事件和加载方式3.1 四个寄存器之间的配合关系EPIT的寄存器不多核心的也就五个但很多初学者把EPIT_LR和EPIT_CMPR搞混。先记住一句话LR是倒数开始的地方CMPR是倒数到哪儿触发事件的地方CNT是实时读数。EPIT_CR控制寄存器管时钟源、分频、工作模式、中断使能等。EPIT_SR状态寄存器只有第0位OCIF有用是输出比较中断标志写1清除不是写0。EPIT_LR加载寄存器存放计数初值。EPIT_CMPR比较寄存器存放比较值。EPIT_CNT计数器实时向下递减只读。启动后计数器从LR一步步往下数每次数到CMPR的值就产生一个比较事件。如果CMPR设成0那事件就发生在计数器减到0的时候。比较事件会触发中断前提是OCIEN位打开了。3.2 set-and-forget模式和free-running模式EPIT_CR的第3位RLD决定计数器数到比较值后干什么RLD1set-and-forget模式触发比较事件后计数器立刻重新装载LR值继续开始下一轮递减。这就是周期定时器的工作方式我推荐做周期中断时用这个模式。RLD0free-running模式触发事件后计数器不会重新装LR而是继续往下减到0再绕回0xFFFFFFFF继续减呈自由运行状态。这个模式适合做“一次性延迟”或者持续运行的计时器但你要自己管理什么时候重装初值。实际使用中最简单也最常用的组合是RLD1CMPR0LR周期计数值。这样计数器从LR减到0触发中断再自动装回LR周而复始软件上只需要在ISR里做自己的事。有一个容易被忽略的点CMPR不一定要设成0。假设LR是10000CMPR设成2000那么计数器从10000数到2000时就触发事件周期其实是8000个计数时钟。这种“非满周期”的触发在做某些提前报警场景时很有用但如果你刚开始接触EPIT老老实实让CMPR0就行。3.3 时钟源和分频计算把频率对清楚EPIT的时钟源选择在EPIT_CR的CLKSRC[25:24]位常见选择有ipg_clk、ipg_clk_highfreq、ipg_clk_32k。默认时钟树里ipg_clk通常是66MHz这是最常用的一路。分频器在EPIT_CR的PRESCALAR[15:4]位12位宽度支持0-4095实际分频系数是PRESCALAR1。这个“1”最容易算错。比如想得到1MHz的计数时钟66MHz ÷ 频率 66所以分频系数为66。PRESCALAR 66 - 1 65。写成二进制就是65 4。1MHz计数时钟意味着一个计数周期是1微秒那么LR值直接就是微秒数。比如要定100万微秒1秒LR1000000要定10毫秒LR10000。这种1:1的对应关系非常方便调试我习惯固定用1MHz需要改周期的时候只改LR。4. 寄存器级配置把EPIT1跑起来的完整代码4.1 外设寄存器结构体定义裸机开发喜欢用结构体指针访问外设寄存器所有外设基地址在芯片手册的Memory Map表里都能查到。EPIT1基地址是0x020D0000EPIT2是0x020E0000。寄存器顺序如下#define EPIT1_BASE (0x020D0000u) typedef struct { volatile uint32_t CR; /* 0x00 控制寄存器 */ volatile uint32_t SR; /* 0x04 状态寄存器 */ volatile uint32_t LR; /* 0x08 加载寄存器 */ volatile uint32_t CMPR; /* 0x0C 比较寄存器 */ volatile uint32_t CNT; /* 0x10 计数器寄存器 */ } EPIT_Type; #define EPIT1 ((EPIT_Type *)EPIT1_BASE)需要说明的是IMX6ULL的外设时钟默认是开启的不像有些单片机还需要单独打开外设时钟门控所以这里少了一步“开时钟”的操作。如果你用了某种低功耗配置把EPIT时钟关了那得先检查时钟控制寄存器但我这在项目里通常没遇到这种情况。4.2EPIT_CR控制寄存器的关键位配置EPIT绝大多数时间都是在掰扯EPIT_CR。下面这几个位是每次都要用的位名称作用[25:24]CLKSRC时钟源选择00关闭01ipg_clk10ipg_clk_highfreq11ipg_clk_32k[15:4]PRESCALAR分频值实际分频系数PRESCALAR1[3]RLD1set-and-forget0free-running[2]OCIEN比较中断使能1使能[1]ENMOD1先装载LR再开始计数0立即开始[0]EN模块使能1启动ENMOD位特别说一下。它决定启动时计数器怎么跑如果ENMOD1置EN1后计数器先装载LR再开始递减如果ENMOD0置EN1后计数器立即从一个不确定的值开始递减。做周期定时器时必须ENMOD1否则第一次周期根本不准。4.3 初始化函数和GIC使能的衔接下面直接给出初始化的完整代码。这里period_us的单位是微秒基于66MHz时钟源、1MHz计数频率的前提void epit1_init(uint32_t period_us) { /* 先把控制寄存器清零让EPIT处于完全停止状态 */ EPIT1-CR 0; /* * 时钟源选ipg_clkCLKSRC[25:24] 01 * 分频系数66PRESCALAR[15:4] 65 * 66MHz / 66 1MHz一个计数周期1微秒 */ EPIT1-CR (1u 24) | (65u 4); /* 加载值1MHz下数值就是微秒数 */ EPIT1-LR period_us; /* 比较值设0计数器减到0时触发比较事件 */ EPIT1-CMPR 0; /* * RLD1 —— set-and-forget周期循环 * OCIEN1 —— 使能比较中断 * ENMOD1 —— 先装LR再启动 * EN1 —— 模块启动 */ EPIT1-CR | (1u 3) | (1u 2) | (1u 1) | (1u 0); /* 先清一次中断标志避免上电残留标志导致一使能就误进中断 */ EPIT1-SR | (1u 0); /* 在GIC里使能EPIT1对应的SPI中断号 */ gic_enable_irq(88); }注意最后两步的顺序先清EPIT的OCIF标志再使能GIC中断。图上翻遍了很多中文教程有人把这俩顺序写反结果一开中断就立刻进一次ISR还以为是EPIT坏了。其实那是因为SR里还残留着之前的比较标志。养成“外设侧标志清零—再开GIC”的习惯能少踩不少坑。5. GIC使能与中断服务函数裸机下的完整响应代码5.1 GIC使能函数的字节访问细节GIC使能这部分有几个地址细节值得注意。Distributor的偏移寄存器里ISENABLER是按32位一组、每个中断占1位的这个可以按字访问但IPRIORITYR和ITARGETSR是按字节分组的每个中断占1个字节必须用字节指针操作。很多人在这一步踩坑直接定义成uint32_t数组按中断号索引结果目标路由和优先级全写到了错误的中断上中断当然不触发。正确的做法是逐字节赋值#define GIC_DIST_BASE (0x00A00000u) #define GIC_CPU_BASE (0x00A01000u) void gic_enable_irq(int irq) { int reg irq / 32; int bit irq % 32; volatile uint32_t *dist (volatile uint32_t *)GIC_DIST_BASE; /* 使能中断ISENABLER[n]一个bit一个中断 */ dist[0x100 / 4 reg] (1u bit); /* 优先级IPRIORITYR一字节一个中断0xA0表示中等偏好优先级 */ *(volatile uint8_t *)(GIC_DIST_BASE 0x400 irq) 0xA0; /* 目标CPU路由ITARGETSR一字节一个中断0x01表示路由到CPU0 */ *(volatile uint8_t *)(GIC_DIST_BASE 0x800 irq) 0x01; /* 打开Distributor */ *(volatile uint32_t *)(GIC_DIST_BASE 0x000) 1; /* 打开CPU Interface并设置优先级掩码为0xF0 */ *(volatile uint32_t *)(GIC_CPU_BASE 0x000) 1; *(volatile uint32_t *)(GIC_CPU_BASE 0x004) 0xF0; }关于优先级掩码PRIMASK0xF0这里的意思是任何优先级数值小于0xF0的中断都会被CPU接口放行。我们给EPIT设的优先级是0xA00xA0小于0xF0所以能正常触发。如果你把中断优先级设得比掩码还大那中断会被CPU Interface屏蔽怎么查都查不出来。5.2 向量表重定位和统一的IRQ入口裸机程序的向量表默认在0x00000000但IMX6ULL上电后是从内部ROM启动的我们自己的代码通常跑在DDR里所以要把向量表放到DDR中并告诉CPU新地址。这个操作靠CP15协处理器完成启动汇编里一般长这样ldr r0, 0x80100000 /* 向量表链接地址 */ mcr p15, 0, r0, c12, c0, 0 /* 设置VBAR寄存器 */向量表本身是8个32位跳转指令IRQ位于偏移0x18处。项目里startup文件里大概是这样.section .vectors, ax b reset_handler /* 0x00 复位 */ b undef_handler /* 0x04 未定义指令 */ b svc_handler /* 0x08 SVC */ b pabt_handler /* 0x0C 预取指中止 */ b dabt_handler /* 0x10 数据中止 */ nop /* 0x14 保留 */ b irq_handler /* 0x18 IRQ */ b fiq_handler /* 0x1C FIQ */irq_handler是统一入口所有外设中断响应后都会先跑到这里。它的职责就两件事读出中断号调用对应ISR然后写EOIR确认完成void irq_handler(void) { uint32_t iar *(volatile uint32_t *)(GIC_CPU_BASE 0x0C); int irq (int)(iar 0x3FF); switch (irq) { case 88: epit1_isr(); break; default: break; } /* 写EOIR告知GIC本次中断已处理完毕 */ *(volatile uint32_t *)(GIC_CPU_BASE 0x10) iar; }我自己项目里习惯把这里的case编号和芯片手册的中断号全部用宏替代比如#define EPIT1_IRQn 88代码可读性好很多也不会写错数字。5.3 编写EPIT1的中断服务函数EPIT的ISR相比GPIO中断多了一个“清标志”的步骤。EPIT1的SR寄存器第0位OCIF是写1清除的如果写成写0清除标志永远清不掉ISR会反复被触发看起来就像死循环void epit1_isr(void) { static uint32_t tick 0; /* 判断确实是EPIT的比较中断 */ if (EPIT1-SR (1u 0)) { /* 写1清除中断标志 */ EPIT1-SR | (1u 0); tick; /* 每计数1000次翻转一次LED即周期LR*1000 */ if (tick % 1000 0) { /* 假设LED接在GPIO1_IO09这里只做示意 */ gpio_toggle(); } } }从ISR里就能看出来EPIT侧的处理非常轻量清标志、计数、翻转LED。真正的时间精度由硬件递减计数器保证不占用CPU时间这也是中断定时器比软件delay强的地方。6. 实测踩坑记录中断不触发、时间对不上、一进就卡死的排查思路6.1 中断完全不触发先查GIC的三个地方我调试时遇到过EPIT寄存器配置看起来完全没问题计数也在走SR的OCIF都被硬件置1了但ISR就是进不去。这是典型的“外设已触发、GIC没放行”问题按下面的顺序排查查使能寄存器ISENABLER里EPIT1对应bit有没有写进去代码里很容易写错reg索引导致使能写给了别的中断。查目标路由ITARGETSR对应字节是不是0x01有没有路由到CPU0如果写成0x00这个中断就没人接收。查优先级掩码CPU接口的PRIMASK是不是小于等于中断优先级数值GIC里优先级数值越小越优先0xA0的优先级对0xF0掩码是能通过的。这三个地方有一处不对表现出的现象就是“外设在跑、标志在置、中断不进”。6.2 定时时间比预期长一倍或者完全对不上十有八九是分频算错EPIT的PRESCALAR是“N-1”逻辑要分频66倍就得写65写66实际分频是67倍时间会偏长一点。而如果时钟源选错了比如选了ipg_clk_highfreq但频率心里想的是66MHz实际可能是几百MHz时间就会偏得离谱。我的排查方法是先在SR里数中断次数配合逻辑分析仪或者直接让LED翻转频率肉眼可见地去推实际时间。先把1MHz跑准再谈微秒级精度。6.3 ISR一执行就死循环重点怀疑EOIR没写这种“一进中断就卡死”的观感很迷惑CPU好像在执行ISR但主程序再也回不去了。最直接的原因是irq_handler里没有写GICC_EOIR。GIC没有收到EOIR就意味着中断没有被确认完成CPU Interface会认为这个中断还处于活跃状态不会允许低优先级中断进来导致系统整体卡死。另外还有一个坑某些代码把EOIR写成了中断号本身或者写成了别的寄存器这也不对。EOIR必须写上从IAR读出来的原值两者一一对应才能正确关闭该中断。6.4 运行中改周期不生效先停EPIT再改LR如果你需要动态改定时周期直接写EPIT1-LR new_value往往不会立刻生效因为当前这一轮计数还没结束。正确的做法是先停掉EPIT改好LR再重新启动void epit1_reload(uint32_t new_period_us) { /* 先停EPIT避免计数状态下改LR的竞态问题 */ EPIT1-CR ~(1u 0); /* 改装载值并清一次标志 */ EPIT1-LR new_period_us; EPIT1-SR | (1u 0); /* 重新启动ENMOD保持1以确保先装LR再计数 */ EPIT1-CR | (1u 0); }这一点在写参数可调的延时/超时功能时非常实用。比如协议解析中超时重传的场景每次收到新数据都要重置定时器周期不按这个套路做定时器总会晚一轮才生效导致超时判断错乱。写在最后的工程经验折腾完EPIT和GIC之后我最大的体会是IMX6ULL这类带GIC的处理器写中断驱动的关键不是把某个外设寄存器配置得多熟练而是把“外设标志—GIC使能—CPU响应—EOIR确认”这条链路刻在脑子里。外设侧搞清楚自己的状态机GIC侧搞清楚分发和路由CPU侧搞清楚向量表和优先级掩码任何一个环节断了现象都千奇百怪。另外一个建议是刚开始调定时器中断时不要把ISR里放复杂逻辑就在里面翻转一个GPIO把示波器或者逻辑分析仪接上先确认硬件层面的周期对不对再去确认中断链路通不通。等你看到方波的周期和LR完全对上了再去加业务代码会省掉大量调试时间。最后留个小技巧EPIT1和EPIT2的配置代码完全一样你写好EPIT1的初始化后把基地址换成0x020E0000中断号换成89就能多出一路独立的周期中断。很多RTOS或者裸机调度框架里的时间片轮询就是用这种方式搭出来的。