1. UIO中断机制的前置认知
1.1 为什么MPSOC上要单独处理中断
MPSOC(多处理器片上系统)在嵌入式领域用得越来越广,尤其是同时集成了PS(处理系统,通常是ARM核)和PL(可编程逻辑,也就是FPGA逻辑)的芯片。这种架构最大的优势在于软硬件协同——PS端跑Linux系统处理复杂任务,PL端做高速并行逻辑处理实时性要求高的信号。但两者一旦要协作,中断就成了绕不开的环节。
PL端产生的中断信号,如果想让PS端Linux里的应用程序及时感知并响应,通常有三个思路:
- 用GPIO模拟中断,轮询或者通过内核GPIO子系统上报;
- 把硬件中断设备树配置好,写一个完整的内核驱动来管理;
- 用UIO(Userspace I/O)机制,把中断和寄存器映射直接暴露给用户态程序,不写完整内核驱动。
前两种方案的共性是:你得维护内核态代码,编译进内核或者作为模块加载,调试要反复交叉编译、烧写、重启,非常折腾。第三种方案UIO,就是专门为“不想写内核驱动”的场景准备的——中断来了以后,内核只负责把硬件中断转发到用户态,用户程序自己决定怎么响应。
我在实际项目中用过一段时间的UIO方案,老实说,它在某些场景下确实省事,但也有不少坑。这篇文章就把PS和PL之间通过UIO传递中断这件事掰开揉碎讲清楚。
1.2 什么场景适合UIO方案
UIO的定位是“轻量级用户态驱动框架”。它适合以下几类情况:
- 你只是想快速验证PL逻辑和PS端的联动,不想一开始就陷入内核驱动开发的泥潭;
- 你的中断处理逻辑很特殊,放在内核态反而麻烦,比如业务处理本身就在用户态,中断触发后需要直接访问大量应用层数据;
- 你希望驱动开发和业务开发解耦,PL侧的寄存器频繁变动,每一次改动都重新编译内核模块太痛苦;
- 你用的是Yocto或者PetaLinux这类工具链,内核模块的交叉编译环境配置繁琐,改用UIO可以让你直接用交叉编译的纯C程序(甚至Python调用mmap)完成任务。
当然,UIO并不适合对实时性和中断延迟有极高要求的场合。毕竟一次中断从硬件触发到用户态程序跑起来,中间经历过内核UIO框架的转发。如果PL端中断频率非常高(比如十几微秒一次),你还要考虑用户态程序的处理能力和调度延迟,这时候老老实实写内核驱动反而更靠谱。
注意:UIO不是万金油,它适合“中等频率、秒级到毫秒级响应都够用”的典型场景。我见过有人把UIO用在光纤通信的MAC中断上,结果用户态处理不过来导致丢包,最后还是回归内核态驱动。选型阶段一定要把中断频率和应用延迟需求先摆出来。
2. UIO中断链路在MPSOC上的整体结构
2.1 PS与PL中断信号的物理通路
在MPSOC芯片内部,PL端的中断信号要到达PS端的GIC(通用中断控制器),得经过一套固定硬件通路。以常见的美满/Xilinx系列MPSOC为例,PL端的中断通常连到PS端的IRQ编号区间上,每条中断线对应一个中断ID。
这部分属于硬件管脚分配和Block Design连线的问题。你在Vivado里做Block Design时,需要给PL的中断输出引脚绑定到PS端的中断输入端口上,通常是通过Concat模块把多个PL中断汇总成一路或几路,再接到ps_pl_interrupt端口。
关键点在于:PL逻辑中,中断信号必须是脉冲或电平有效的方式,而且要在设备树中指定正确的中断号。如果设备树里写的中断号不对,Linux内核根本不知道这个中断对应哪个设备,UIO自然也就无法正常工作。
以典型配置为例,UIO设备在设备树中的中断属性通常写成:
interrupts = <0 29 4>;这行配置的含义是:GIC类型为SPI(共享外设中断)时第一个参数填0,第二个参数是中断ID,第三个参数是中断触发方式。其中触发方式4代表高电平有效。
2.2 设备树里UIO节点的配置要点
设备树是PS端硬件资源的描述语言,Linux内核对UIO框架的支持来自通用UIO驱动,设备树里只要挂上“generic-uio”这个compatible,系统启动后就会自动生成/dev/uioN节点。
我常用的UIO中断节点模板如下:
/ { reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; uio_reserved: uio@0x40000000 { reg = <0x0 0x40000000 0x0 0x10000000>; no-map; }; }; }; &axi { uio_pl_dev: uio-pl@a0000000 { compatible = "generic-uio"; reg = <0x0 0xa0000000 0x0 0x1000>; interrupts = <0 31 4>; interrupt-parent = <&gic>; memory-region = <&uio_reserved>; }; };这里有两个重点。第一,reg中的物理地址要和Vivado里PL逻辑的地址分配一致;第二,如果PL侧需要和PS侧共享大块内存做数据交互,最好预留reserved-memory,这块内存不在Linux管理范围内,PS用户态程序可以直接读写,PL侧的DMA引擎也可以直接访问,两边不会因为Linux的缓存一致性机制出问题。
实战心得:设备树里UIO节点的
interrupts配置经常踩坑。有人用interrupts = <0 31 4>没问题,有人改成<0 31 1>(上升沿触发)就失效了。原因是PL端逻辑输出中断信号的电平/脉冲方式必须与触发类型匹配。如果你的PL逻辑是持续高电平输出,设备树触发方式必须是4(高电平有效),设成1(上升沿)只在边沿瞬间有效,一旦逻辑保持高电平,UIO就可能收不到第二次中断。这点在调试时一定要先确认PL端的信号行为。
3. UIO用户态联动设计
3.1 用户态与UIO共享内存通信分析
UIO不只是中断转发,它还同时把PL侧的寄存器空间映射到了用户态,让写应用和写嵌入SQL一样的便利。一般使用UIO的PL与PS的联动机制包含两条链路,其中一条是PL中断到PS,另一条是其反向链路。
先说PL到PS的中断链路。硬件上,PL逻辑通过Interrupt端口发出中断请求,经过PS的PL中断控制逻辑到GIC,再触发UIO内核驱动,最终通过文件描述符把事件通知用户态。用户在用户态通过select/poll/epoll监听该文件描述符。
再说PS到PL的控制链路。用户态程序打开/dev/uioN之后,调用mmap映射PL寄存器。比如你将PL中某个寄存器映射到用户空间的虚拟地址,向该地址写一个控制字,PL端逻辑就能立刻响应,比如启动数据采集或改变输出状态。
这两条链路一组合,整个“双向实时控制”的闭环就通了:用户态程序向控制寄存器发指令,PL开始干活,干完了发中断,用户态读状态寄存器确认结果,再发下一步指令。
3.2 mmap映射寄存器空间的机制拆解
UIO设备的mmap行为是内核UIO框架驱动的,它的实现原理是把注册时传入的物理地址区域映射到用户空间的虚拟地址段。整个过程不需要用户写任何内核代码,这就是UIO最爽的地方。
在实际写用户态程序时,我通常这样做:
#include <fcntl.h> #include <sys/mman.h> #include <unistd.h> #include <stdio.h> int main(int argc, char *argv[]) { int fd = open("/dev/uio0", O_RDWR | O_SYNC); if (fd < 0) { perror("open /dev/uio0 failed"); return -1; } unsigned int *pl_regs = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (pl_regs == MAP_FAILED) { perror("mmap failed"); return -1; } /* 写控制寄存器 */ pl_regs[0] = 0x01; /* 等待中断 */ unsigned int irq_count = 0; ssize_t nread = read(fd, &irq_count, sizeof(irq_count)); if (nread == sizeof(irq_count)) { printf("interrupt count = %u\n", irq_count); } /* 读状态寄存器 */ unsigned int status = pl_regs[1]; printf("status = 0x%08x\n", status); munmap(pl_regs, 4096); close(fd); return 0; }这里有个很容易犯的致命错误:打开UIO设备时,只用了O_RDWR,没有加O_SYNC。对硬件寄存器来说,一般必须用O_SYNC打开,否则mmap出来的内存是带Cache的,你写入的数据可能停留在Cache中,不会立即到达PL寄存器,PL等半天等不到数据,或者读回的寄存器永远是旧值。
4. 中断信号与设备树中断号映射关系
4.1 MPSOC中断号的计算方法
MPSOC的中断控制器是GIC,支持两种中断类型:
- SPI(共享外设中断)— 对应设备树interrupts属性第一个参数为0;
- PPI(私有外设中断)— 对应设备树interrupts属性第一个参数为1。
在Linux系统中,IRQ number不是简单的硬件中断ID,GIC驱动会把硬件中断号映射成一个Linux内部的IRQ number。通常SPI的起始IRQ number在系统里有一个偏移量,比如是32,也就是说硬件中断ID是31的SPI,系统IRQ number可能是63。
但在设备树里你不需要关心LinuxIRQ number,只需要写硬件中断ID。这个ID怎么确定?MPSOC芯片手册的“PS-PL interrupt”小节里会给出一个表,列出PL端每个中断端口对应的中断ID。以常见芯片为例,PL到PS的中断ID范围是121到135,共16根线。
如果你在Vivado里把PL的中断接到了第0号PL中断端口,对应中断ID就是121,那么设备树里写:
interrupts = <0 121 4>;4.2 interrupt-parent的作用与常见误区
设备树里中断控制器是一个树状结构,SoC上可能存在多个中断控制器。interrupt-parent属性就是告诉内核“我这个设备的中断是挂在哪个中断控制器上的”。
在MPSOC的设备树里,最常见的写法是:
&gic { #interrupt-cells = <3>; }; &uart1 { interrupt-parent = <&gic>; interrupts = <0 22 4>; };如果设备树里漏了interrupt-parent,内核会沿着设备节点的父节点向上查找默认中断控制器,大多数时候系统能自动找到GIC,但为了阅读和维护的清晰度,显式写出来是最佳习惯。
这里有一个需要注意的地方:MPSOC的GIC版本不同,中断触发类型的编码可能不同。GIC v2的触发类型编码是:1=上升沿触发,2=下降沿触发,4=高电平触发,8=低电平触发。如果你把4写成1,PL端发出高电平中断时,UIO可能在中断第一次触发后就不再响应了。
5. UIO驱动在内核态的工作流程
5.1 UIO框架的注册与设备节点生成
UIO框架是Linux内核自带的标准框架,核心代码路径是:
- 内核启动时扫描设备树,发现compatible属性为“generic-uio”的节点;
- 调用uio_register_device注册一个UIO设备;
- 框架自动创建/dev/uioN字符设备节点;
- 为每个UIO设备分配一个中断号,注册中断处理函数uio_interrupt;
- 中断到来时,uio_interrupt调用用户的irqcontrol回调(generic-uio的回调为空),然后唤醒等待队列中阻塞的read/poll用户。
这个流程不需要你写任何内核代码,设备树配好了,中断链路就能自动建立。
5.2 UIO中断处理与用户态唤醒的时序
当PL端中断信号到达GIC时,内核中断子系统先把中断分发到UIO设备的处理函数。UIO驱动做的第一件事是禁掉该中断,防止在用户态还没响应时频繁触发导致中断风暴。
然后,它把中断计数加1,并且唤醒等待在该设备文件描述符上的进程。用户态read函数此时返回,返回值为一个无符号整数,表示累计中断次数。
重点来了:用户态程序在中断处理完成后,必须重新“使能”中断,否则后续的中断不会再上报。这个动作是通过write一个int值到UIO设备文件实现的,无论写什么值,都是使能中断的意思。
int irq_enable = 1; write(fd, &irq_enable, sizeof(irq_enable));这一步非常容易漏。我在最初调试时,第一次中断能收到,第二次就再也没反应,被这个问题折磨过半天。原因是中断处理完成之后没有重新写设备文件使能中断。
6. 核心实操:验证中断链路的过程记录
6.1 环境准备与工具清单
做这个实验,建议准备以下工具和环境:
- 一块MPSOC开发板,PS端能启动Linux
- PetaLinux或Yocto构建出的内核镜像和设备树,内核开启CONFIG_UIO和CONFIG_UIO_PDRV_GENIRQ
- PL端烧录好一个最简单的Verilog中断发生器:通过一个计数器,每计数到某个值,拉高中断引脚一个时钟周期
- USB转串口工具,用来查看Linux控制台日志
- 交叉编译工具链
PL端逻辑不需要太复杂,一个简单的中断发生器就够用:
module intr_gen ( input clk, input rst_n, output reg intr ); reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; intr <= 1'b0; end else begin cnt <= cnt + 1; if (cnt == 32'd1000000) begin intr <= 1'b1; cnt <= 32'd0; end else begin intr <= 1'b0; end end end endmodule这个模块的作用是:每1000000个时钟周期输出一个时钟周期的高电平脉冲中断信号,相当于周期性产生中断。时钟采用PS端输出的PL时钟,比如100MHz,那就是每10ms产生一次中断,非常适合验证。
6.2 设备树配置与内核启动验证
设备树里我使用的是前面提到的generic-uio节点。编译设备树后重新烧写启动,进入Linux后首先查看设备节点是否生成:
ls -l /dev/uio*如果看到/dev/uio0,说明UIO设备注册成功。再查看系统中断分配:
cat /proc/interrupts | grep uio输出里能看到类似这样的内容:
63: 0 0 GIC-0 121 uio-pl其中第一列是Linux里的IRQ number,最后一列是设备名称,这就是你的UIO设备。中断计数值是0,因为还没有程序打开这个设备并等待中断。
6.3 用户态测试程序与预期结果
接下来运行一个简单的测试程序,验证中断链路是否完全打通:
#include <fcntl.h> #include <sys/mmap.h> #include <stdio.h> #include <unistd.h> #include <stdint.h> int main() { int fd = open("/dev/uio0", O_RDWR | O_SYNC); if (fd < 0) { perror("open"); return -1; } unsigned int *regs = mmap(NULL, 16384, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (regs == MAP_FAILED) { perror("mmap"); return -1; } printf("waiting for interrupt...\n"); for (int i = 0; i < 10; i++) { uint32_t irq_count; int res = read(fd, &irq_count, sizeof(irq_count)); if (res != sizeof(irq_count)) perror("read"); printf("[%d] got interrupt, total = %u\n", i, irq_count); /* 重新使能中断,这步必须做 */ int enable = 1; res = write(fd, &enable, sizeof(enable)); if (res != sizeof(enable)) perror("write"); } munmap(regs, 16384); close(fd); return 0; }预期结果:每秒约100次中断(取决于PL逻辑的中断周期),程序打印出不断增长的中断计数。如果中断计数器递增正常,说明从PL逻辑、物理连线、设备树、内核UIO驱动、用户态程序全链路打通。
注意:中断计数返回的是累计值,不是增量值。每次read拿到的数值都是历史累计的。程序里用这个值来判断中断是否真的发生过,同时也能用来检测中断是否有丢失——如果前后两次读到的差值大于预期,说明中断处理不够及时。
7. 常见问题与排查技巧
7.1 /dev/uioN节点不存在
设备节点没有生成,多数是设备树没配对或是内核配置没有打开UIO框架。检查方法:
dmesg | grep -i uio如果没有任何输出,多半是内核没配置CONFIG_UIO_PDRV_GENIRQ。打开该配置并重新编译内核。如果内核报错了节点解析错误,检查设备树语法和compatible字符串是否与驱动匹配。
7.2 中断收到一次后不再触发
这个问题在实操中非常普遍。原因通常是:
- 用户态在中断处理完之后没有重新写UIO设备文件使能中断;
- PL端中断信号是持续电平而非脉冲,且设备树触发类型配置为边沿触发;
- 用户态read中断后,程序忙别的事情太久,没有及时重新使能中断。
排查方法:在中断处理完成后立即将使能写回,如果还不行,检查PL端的信号波形,确认中断是否真的是脉冲信号。
7.3 中断回调触发风暴导致系统假死
如果PL端中断信号异常(一直是高电平),并且设备树里配置成了高电平触发,UIO框架虽然会自动屏蔽中断,但如果用户态处理完成又重新使能,会立刻再次触发中断,导致程序陷入死循环。
这种情况看起来很像是系统崩溃了,实际上只是用户态程序在反复响应中断。解决方法是修改PL端逻辑,把持续电平改成短的脉冲信号,或者在设备树里把中断触发类型改为上升沿。
7.4 UIO中断失效排查速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 设备节点不存在 | 内核配置未开 | 检查CONFIG_UIO_PDRV_GENIRQ |
| 节点存在但无中断上报 | 设备树interrupts写错 | 核对硬件中断ID和类型 |
| 第一次中断能收到,后续无 | 未重新使能中断 | 在read后write任意int |
| mmap后寄存器读写无效 | 打开文件没带O_SYNC | 改为open(“/dev/uio0”, O_RDWR/SYNC) |
| 中断风暴 | PL端中断信号异常 | 修改PL逻辑为脉冲输出 |
| 读寄存器一直是0 | 地址映射错误 | 核对设备树reg地址和Vivado地址一致 |
7.5 个人调试技巧总结
我在用UIO调试MPSOC中断时,有几个从实践中沉淀出来的好习惯:
第一,拿到板子先做“裸跑测试”。也就是PL端逻辑里加一个调试寄存器,通过JTAG或者Vivado的ILA观察中断信号到底有没有产生。先确认信号没问题,再去查软件配置,能帮你把排查范围砍掉一大半。
第二,设备树的修改要养成版本管理习惯。MPSOC的设备树编译后烧写比较费时,我一般会先在开发环境里把.dtsi文件放Git,每次改动都做diff,避免烧一次板子才发现是自己把数字改错了。
第三,UIO设备节点在多个PL中断时,每个中断单独注册一个UIO设备,这样用户态可以用多个文件描述符分别处理不同中断优先级。我见过有人把多个中断合到一个UIO设备上,结果用户态不知道是谁触发了,处理逻辑一团乱麻。多注册几个UIO设备成本很低,收益却很明显。
第四,如果PL和PS需要大量数据交互,优先用reserved-memory做无缓存共享内存。用UIO直接mmap PL的BRAM虽然方便,但没法解决缓存一致性问题,数据容易错乱。
8. 方案延伸与反思
8.1 UIO方案在MPSOC项目中的扩展思路
UIO方案不止能处理简单的中断,还能做大块数据搬运。配合PS端的DMA控制器,用户态程序可以在UIO映射的寄存器里配置DMA源地址、目的地址和数据长度,PL端发一个“DMA完成”中断,用户态就能在共享内存里拿到一整块数据。这种模式在数据采集卡、高速ADC采样、视频帧抓取之类的场景里非常常见。
8.2 与DMA结合时的注意事项
DMA和UIO一起用时,最需要关注的是内存物理地址连续性。用户态malloc出来的内存是虚拟地址,其物理页不一定连续,DMA控制器需要物理地址。通常做法是在设备树里预留一段连续的物理内存(reserved-memory),然后通过UIO mmap把它映射到用户态,DMA的源和目的都指向这段内存。
这种方式下,PL和PS的数据通路可以是:
- PS写共享内存中的命令区
- PL通过AXI接口轮询命令区
- PL执行完成后写结果区并拉高中断
- PS收到中断后在共享内存中取结果
整个数据流不需要任何内核态代码参与,用户态程序全包了。
实际项目中我曾遇到一次共享内存被Linux回收导致PL读到错误数据的问题。后来将那段reserved-memory加上no-map属性,并确保用户的应用程序中不会直接free该区域的虚拟地址,才稳定下来。
8.3 UIO方案的适用边界
UIO方案看上去很完美,但也需要清醒认识其边界。对于中断频率非常高的场景(比如几十纳秒一次的中断),用户态程序频繁切换上下文消耗太大,UIO框架的两次复制、唤醒、等待队列调度带来的延迟不可忽视。这时候正确做法是:写内核驱动,用底部一半逻辑处理高频事件,只把关键状态放在共享内存供用户态按需读取。
如果项目对稳定性要求极高(比如医疗设备、工业控制),我更建议用主线内核的标准框架而不是UIO。UIO的好处是开发快,坏处是内核态和用户态之间隔着一个“不可控”的调度层,中断延迟抖动完全取决于系统负载。
对我个人来说,选型逻辑是:验证阶段、Demo阶段、或者PL逻辑还在频繁变动的时期,用UIO;产品化定型、要求极致稳定性的模块,花时间把驱动写成内核态更值得。
写过完整内核驱动之后再回头看UIO,你会更理解Linux中断机制的两面性——UIO把复杂藏在框架里,内核驱动把控制权交给你。两条路各有各的门道,关键在于你对项目的阶段目标和风险承受能力有清晰判断。