ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Zynq异构计算实时加速:Linux UIO驱动与中断延迟优化实战

Zynq异构计算实时加速:Linux UIO驱动与中断延迟优化实战 前阵子在一款 Zynq-7010 平台上做运动控制卡的迭代需要在 Linux 下对 FPGAPL 侧产生的高速触发脉冲做亚毫秒级响应。第一版图省事直接在应用层用read /dev/mem轮询寄存器结果延迟飘到几毫秒偶尔还丢中断。后来老老实实把 Linux UIO 驱动配上把中断和寄存器映射全部暴露给用户态配合实时线程调度延迟稳定压到十几微秒。这个项目让我把FPGACPU 异构计算 Linux 实时加速这条路完整走了一遍所以写一篇实战复盘把 UIO 的原理、设备树配置、用户态程序写法、以及实测中踩过的坑都讲透。这篇内容适合正在 Zynq、Zynq UltraScale 或其他带硬核处理器的 FPGA 平台上做采集、运动控制、仪器仪表的工程师。默认你已经具备基本的嵌入式 Linux 知识比如设备树、内核编译、应用层编程但对 UIO 只听过名字、不知道它怎么做到硬实时桥接。1. 为什么在 Zynq 这类异构平台上做实时加速我最终选了 UIO1.1 异构平台的分工困局Zynq 这类 SoC 的特别之处在于它把 ARM 处理系统PS和可编程逻辑PL封在同一颗芯片里两者通过 AXI 总线通信。PL 侧可以放 ADC 接口、PWM 发生器、编码器计数器、自定义协议栈这些逻辑在硬件层面天然具有确定性时序比如一个脉冲产生模块时钟一到来就翻转电平不存在软件抖动。但问题在于硬件逻辑只能做固定的、重复的事情真正的决策、状态切换、和外部协议交互还是得让 CPU 介入。于是异构计算里最典型的场景就出现了——FPGA 负责确定性预处理CPU 负责非实时或弱实时的控制逻辑。我那个运动控制项目就是这样PL 侧周期性地产生编码器计数值当计数值到达某个阈值时需要 CPU 在极短时间内修改下一个输出脉冲的宽度。FPGA 干的是测量和输出CPU 干的是计算和设定。二者之间的通信延迟直接决定了整个系统的实时性能上限。1.2 内核态驱动为什么拖后腿很多人的第一反应是写一个传统的字符设备驱动中断到来内核 ISR 跑一下把数据放到环形缓冲区再 wake up 一个等待队列应用层 read 拿数据。这套流程在普通嵌入式设备上没问题但放到实时场景里就很别扭。问题出在中断处理路径太长。硬件中断触发后内核要先跑 ISR然后根据情况调度 softirq、tasklet 或者工作队列甚至还有下半部延迟处理机制。ISR 里不能阻塞所以真正复杂的活多半丢给下半部而上下文切换和调度延迟就会叠加进来。更麻烦的是你无法控制其他中断和内核线程的优先级网卡收包、SD 卡写数据、内核线程周期性调度的干扰随时可能把延迟拉到毫秒级。当然等价的做法是上 PREEMPT_RT 补丁把内核几乎所有地方变成可抢占。但 PREEMPT_RT 对 Zynq 这种平台也有一些代价比如部分驱动需要改写、中断线程化之后某些实时任务反而变慢、内核维护成本高。而且多数情况下你只是想让一个用户态线程快速响应 FPGA 的中断并不需要把整个内核实时化。1.3 UIO 的真正价值把实时问题放回用户态UIOUserspace I/O的思路很简单粗暴设备的内核驱动只有一个极薄的框架真正的中断响应和寄存器操作全部交给用户态进程。你依然通过标准文件接口操作/dev/uio0但驱动本身不做任何业务逻辑只负责三件事把硬件内存映射给用户态、把硬件中断事件通知给用户态、把中断使能开关暴露给用户态。这样带来的直接好处是用户态线程可以用SCHED_FIFO实时调度策略把响应线程钉在指定的 CPU 核心上再配合mlockall锁定内存避免缺页整个中断到用户态处理的路径变得非常短硬件中断 - 内核 UIO 框架唤醒阻塞的 read - 用户态线程直接处理。中间没有复杂的内核业务逻辑也没有不可控的下半部。我选择 UIO 还有一个实际考虑开发效率。内核态驱动出了问题就是 panic 或死锁调试要靠 JTAG 或者串口打印而用户态程序出问题顶多是个段错误gdb 直接就能上。对于中小团队做一个特定板卡的控制程序UIO 几乎是性价比最高的方案。2. UIO 机制逐层拆解中断、内存映射和用户态的设备视图2.1 UIO 的设备模型从内核框架到 /dev/uioXUIO 不是某一个驱动而是一套内核框架。内核里有一个uio核心模块负责注册miscdevice、管理/dev/uioX、以及对接用户态的 read/write 和 mmap 操作。具体到硬件设备需要一个前端驱动往框架里填信息硬件寄存器地址、中断号、中断处理函数等。芯片厂商或板卡厂商通常会提供对应的前端驱动。对于没有专用驱动的情况内核还内置了uio_pdrv_genirq它可以直接绑定设备树里的generic-uio节点把节点里定义的reg当作内存区域、把interrupts当作中断号。这等于说你给一个 PL 外设的寄存器地址写一段设备树系统就能自动把它注册成 UIO 设备完全不用写内核代码。设备注册成功后系统里会出现这些节点/dev/uio0应用层操作设备的主入口/sys/class/uio/uio0/name设备名与设备树节点或驱动注册名一致/sys/class/uio/uio0/version驱动版本/sys/class/uio/uio0/maps/map0/addr、size可映射的内存区域的物理地址和大小/sys/class/uio/uio0/event已发生的中断事件计数2.2 mmap 映射不只一个内存区域UIO 的每个设备可以登记最多多个内存映射区域分别用uio_info-mem[]数组描述。用户态打算映射哪个区域只要在mmap调用时把offset设置为区域索引 * 页大小即可。比如 index 0 的区域用offset 0 * 4096index 1 用offset 1 * 4096index 2 用offset 2 * 4096。这正是很多教程里UIO 可以映射多段地址第三页给中断状态寄存器用的说法的由来。在运动控制项目里我通常这样规划map0 映射控制寄存器组使能位、模式位、脉冲宽度设定值map1 映射状态寄存器组编码器计数值、忙闲状态、故障标志map2 映射中断状态清楚寄存器读取后写 1清除 PL 侧中断标志mmap之后这些物理寄存器就变成了用户态程序里的一段普通内存地址可以放心地按结构体强制转换访问。需要注意UIO 框架默认会把映射区域设置为非缓存或依据驱动设定避免 CPU 读到 stale 的寄存器数据这在内核的pgprot设置阶段已经处理好了。2.3 read/write 的中断交互语义UIO 设备上的read操作不是用来拿数据的而是用来等待中断事件。应用层执行read(fd, cnt, 4)时如果自上次处理后还没有新的中断事件这个调用会一直阻塞直到 PL 侧产生中断、内核前端驱动的中断处理函数把事件计数加一并把等待队列唤醒read才返回同时把事件计数复制给用户态。而write(fd, val, 4)用来控制中断使能。在常见的内核实现里写非零值会重新启用中断等待写零会禁用。不同内核版本和第三方补丁对事件计数的行为可能不一致有的返回累计值、有的返回差值我在移植到新内核时都会先做一个小实验确认语义避免程序在设备上傻等或者忙读。2.4 硬实时到底指哪一层这句话值得强调UIO 本身不提供硬实时保证它提供的是把实时问题移交到用户态实时调度的通道。真正的确定性来自两个层面——PL 侧逻辑保证硬件行为确定Linux 实时线程调度保证中断唤醒后用户态代码尽量快地执行。我把它理解为桥接式硬实时你无法指望一个没有任何调度的 Linux 系统跑出硬实时但如果你的关键循环是read阻塞等待 FPGA 中断循环体里只有寄存器读写和轻量计算那么借助SCHED_FIFO高优先级线程达到十几微秒到几十微秒级别的确定性响应是完全可能的。后面的实测数据也印证了这一点。3. 从零把 UIO 跑起来内核配置、设备树节点验证与扩展驱动3.1 内核配置选项只开三个开关大多数 Linux 发行版内核默认没开 UIO需要重新编译内核或者加载模块。要确保这三个配置CONFIG_UIOy CONFIG_UIO_PDRV_GENIRQy CONFIG_UIO_DMEM_GENIRQy # 如果需要动态内存区域按需开启CONFIG_UIO是 UIO 框架核心CONFIG_UIO_PDRV_GENIRQ提供了通用的 platform 驱动。配置完成后编译内核或者把对应驱动编成模块加载modprobe uio、modprobe uio_pdrv_genirq。调试阶段建议编译成模块这样改动设备树后只需要重新加载模块不用反复烧内核。我在 Zynq 平台上就用这个方式启动后lsmod | grep uio确认模块加载状态。3.2 设备树节点写法Zynq 为例使用uio_pdrv_genirq时设备树节点compatible必须写generic-uio。一个最简节点长这样#include dt-bindings/interrupt-controller/arm-gic.h / { fpga_uio0: uio40000000 { compatible generic-uio; reg 0x40000000 0x10000; /* PL 侧 AXI 寄存器基地址大小 64KB */ interrupt-parent intc; interrupts GIC_SPI 29 IRQ_TYPE_LEVEL_HIGH; /* SPI 中断号需查 PL 的中断连接 */ }; };关键的三个属性reg把 PL 外设在系统地址空间中的基地址和长度写清楚。Zynq 里 PS 通过 AXI_GP 口访问 PL 的寄存器地址通常在0x40000000到0x7FFFFFFF区间具体看你 Vivado 工程里的地址分配。interruptsGIC_SPI 表示这个中断是共享外设中断29 是 PL 中断线连接到 GIC 的 SPI 编号。注意这个编号不是任意定的它取决于 PL 侧的中断输出接到了 GIC 的哪个输入通常由 Vivado 工程里的 zynq7 processing system 配置决定。reg里的地址空间大小一般要和 Vivado 里的地址段一致宁可多预留一些也别少。改完设备树生成 dtb 并烧写启动。系统起来后用下面几条命令确认 UIO 设备注册成功ls -l /dev/uio* cat /sys/class/uio/uio0/name cat /sys/class/uio/uio0/maps/map0/addr cat /sys/class/uio/uio0/maps/map0/size cat /sys/class/uio/uio0/event如果/dev/uioX没出现优先检查设备树节点有没有被内核 probe 到ls /proc/device-tree看节点是否存在dmesg | grep uio看有没有报错。最常见的错误是reg地址越界、interrupts格式不对或者compatible写错。3.3 当内存区域不止一段时自己写一个极简 platform 驱动generic-uio默认只会暴露节点里reg定义的那一段内存。如果你像我的项目一样需要同时把控制寄存器和状态寄存器分成两个区域映射并且希望中断状态寄存器也能被用户态直接访问就得写一个几行的 platform 驱动。在 Zynq 开发中这个驱动本身没什么神秘的无非是向 UIO 内核框架注册一个uio_info#include linux/module.h #include linux/platform_device.h #include linux/uio_driver.h static struct uio_info my_uio_info { .name my_pl_peripheral, .version 1.0, .irq 0, /* 在 probe 中从 pdev 拿中断号填入 */ .irq_flags IRQF_TRIGGER_HIGH, .handler my_uio_handler, /* 中断处理置 event唤醒等待队列 */ }; static int my_probe(struct platform_device *pdev) { struct resource *res; int i; for (i 0; i pdev-num_resources; i) { res platform_get_resource(pdev, IORESOURCE_MEM, i); my_uio_info.mem[i].memtype UIO_MEM_PHYS; my_uio_info.mem[i].addr res-start; my_uio_info.mem[i].size resource_size(res); } if (platform_get_irq(pdev, 0) 0) my_uio_info.irq platform_get_irq(pdev, 0); return uio_register_device(pdev-dev, my_uio_info); } static int my_remove(struct platform_device *pdev) { uio_unregister_device(my_uio_info); return 0; } static struct platform_driver my_driver { .probe my_probe, .remove my_remove, .driver { .name my_pl_peripheral, .of_match_table my_of_match, }, }; module_platform_driver(my_driver);对应的设备树节点compatible改成自定义的my,pl-peripheral中断和 reg 写法不变。这段代码有两处容易出错一是platform_get_resource拿到的IO_RESOURCE_MEM的start是物理地址用户态通过 UIO 的 mmap 看到的就是这个物理页的映射不需要再做ioremap二是中断处理函数里一定要访问或写清楚 PL 侧的中断源寄存器否则中断没有被清除read会在下一次立即返回而不是阻塞等待新事件。3.4 验证一个完整的中断回路设备注册完成后我建议先写一个十几行的测试程序验证中断回路而不是直接上完整业务逻辑。测试程序做一件事打开/dev/uioXmmap 控制寄存器区域给 PL 侧一个软件触发位然后阻塞 read。PL 侧收到触发后产生中断read返回打印事件计数。这一步能快速区分问题层面read 一直阻塞说明中断没到 UIO 框架查 PL 逻辑、设备树中断号或驱动 handlerread 立即返回且计数不增加往往是 PL 的中断没有被 clear导致水平中断持续拉高。4. 用户态实时程序实战从 open 到稳定响应4.1 核心代码一个实时响应线程的骨架下面这段代码是我在项目里使用的核心模式它基本覆盖了 UIO 用户态程序的标配实时调度、内存锁、设备映射、中断等待循环。#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include sys/mman.h #include sched.h #include string.h #include errno.h #define UIO_DEV /dev/uio0 #define PAGE_SIZE 4096 /* 以 PL 侧寄存器的实际结构体替代 */ typedef struct { uint32_t ctrl; /* 控制寄存器 */ uint32_t status; /* 状态寄存器 */ uint32_t intr_clr; /* 中断清除寄存器 */ } pl_regs_t; static int uio_fd; static volatile pl_regs_t *regs; int main(void) { struct sched_param param; cpu_set_t set; int irq_cnt 0; /* 1. 打开 UIO 设备并映射寄存器 */ uio_fd open(UIO_DEV, O_RDWR | O_SYNC); if (uio_fd 0) { perror(open uio); return -1; } regs mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, uio_fd, 0); if (regs MAP_FAILED) { perror(mmap); return -1; } /* 2. 绑定到指定 CPU 核心避免核间迁移 */ CPU_ZERO(set); CPU_SET(1, set); sched_setaffinity(0, sizeof(set), set); /* 3. 提升为实时调度高优先级 */ param.sched_priority 80; if (sched_setscheduler(0, SCHED_FIFO, param)) { perror(sched_setscheduler); } /* 4. 锁定内存避免换页 */ mlockall(MCL_CURRENT | MCL_FUTURE); /* 5. 主循环等待中断 - 处理 - 使能下一次 */ while (1) { unsigned int evt; ssize_t rd; regs-intr_clr 0x01; /* 先清 PL 侧中断视乎硬件设计 */ rd write(uio_fd, evt, 4); /* 使能 UIO 中断部分版本写 1 使能 */ rd read(uio_fd, evt, 4); /* 阻塞等待 FPGA 中断 */ if (rd 0) { perror(read uio); break; } irq_cnt; printf(irq %d, count%u\n, irq_cnt, evt); /* 在这里处理实时任务读状态、设定输出、搬数据 */ regs-ctrl 0x02; /* 举个例子 */ } munmap((void *)regs, PAGE_SIZE); close(uio_fd); return 0; }几个细节值得说明O_SYNC打开设备避免用户态页缓存影响寄存器访问。虽然 UIO 驱动通常已经配置为不可缓存但这一步可以保证访问行为符合预期。sched_setscheduler必须要 root 权限如果你的应用不是 root 启动可以在程序里先setuid(0)或者在 systemd 服务配置里加Userroot。4.2 为什么 SCHED_FIFO 而不是别的策略SCHED_FIFO是优先级固定的先进先出调度只要一个 FIFO 优先级更高的线程处于可运行状态它就会抢占当前普通进程同优先级之间已经在运行的会一直运行到主动让出或阻塞。对于 UIO 的响应线程来说我们希望它一旦被唤醒就尽可能快地跑到代码不要被时间片轮转干扰所以 FIFO 比SCHED_RR更合适也比任何 CFS 策略都稳定。优先级数值范围在 Linux 一般是 1 到 99越大越优先。我习惯把实时响应线程设为 80 左右把系统中其他自定义实时线程比如网络收包、交互界面设为 50 以下。不要把所有线程都设成 99否则某个线程一旦写死循环整个系统其他任务包括 shell 都会卡死调试时就只能看门狗重启了。4.3 read 等待循环里的实时性陷阱即使线程调度策略正确仍有几个陷阱容易让延迟恶化。内存缺页是最隐蔽的一个如果响应线程的代码页、栈页或者堆页在第一次没有被加载进物理内存中断到来时线程唤醒后会触发缺页异常那一次响应延迟可能直接飙到几百微秒甚至毫秒。用mlockall(MCL_CURRENT | MCL_FUTURE)只能保证当前已分配的内存驻留但还不能保证代码段的热度。更稳的做法是在主循环之前做一次暖机手动将中断等待-处理循环跑上几百次把相关代码页全部带进内存。还有 CPU 亲和性。Zynq 是双核 Cortex-A9响应线程如果不绑定核心内核可能会在两次中断之间把它迁移到另一个 coreTLB 和 L1 cache 会被冲刷延迟增加。实测中这个影响可能超过 20 微秒所以sched_setaffinity几乎是必须的。4.4 中断处理循环里的轻量化原则UIO 把中断处理放到了用户态这给了你便利但也容易让人放飞自我在 read 返回后写一大堆业务逻辑。要记住你的响应线程至少承担了三件事读状态、计算、设定输出只要这个循环体执行超过几十微秒下一次中断的响应就会被推迟。如果业务流程里有大量数据要处理比如 DSP 算法、图片压缩、网络传输那这些活不应该放在响应线程里。标准做法是响应线程做最轻量的寄存器操作把数据写入一个无锁环形队列置一个标志位然后立刻回到read等待下一次中断。另一个线程负责从环形队列拿数据跑重量级业务。这样响应循环的耗时被压缩到微秒级系统整体实时性才能保住。5. 实测延迟数据与踩坑实录5.1 中断到用户态处理延迟的实测数据为了验证 UIO 方案的实时性我搭了一个简单的测量环境PL 侧产生周期为 1ms 的中断同时在中断产生的同时把一个 GPIO 拉高用户态响应线程在 read 返回后立刻读取 ARM 侧的全局定时器计算与上一次中断预期时间的偏差。下面是三个典型场景下的延迟数据环境是 Zynq-7010、Linux 4.14 内核、CPU 主频 667MHz仅作参考。不同板卡、内核版本和 FPGA 逻辑设计会有差异但量级可以作为设计预期负载场景最差延迟平均延迟说明空闲系统约 25 us约 9 us响应线程独占一个核网络收发 文件系统写入约 95 us约 30 us中断争抢和调度干扰出现同核跑高负载进程未绑定超过 300 us约 80 us此时已不可控这个结果证实了一个判断想得到稳定的亚毫秒响应UIO 实时线程 核心绑定缺一不可。一旦 CPU 亲和性没绑好或者系统负载过高延迟照样飘。5.2 踩坑一中断共享和 IRQF_SHARED 带来的异常唤醒Zynq 的 PL 中断线通常接到 GIC 的 SPI 中断多个 PL 外设可能共享同一个中断号尤其在多个 IP 核中断合并到一个 PL 中断输入的情况下。uio_pdrv_genirq默认注册中断时不一定设置共享标志如果设备树里两个节点用了同一个中断号后加载的驱动 probe 就会报IRQ_TYPE冲突甚至直接失败。解决方案有两个一是在 Vivado 里给每个需要 UIO 的外设分配独立的中断线别偷懒共用二是如果必须共用中断号在自己的 platform 驱动里把irq_flags加上IRQF_SHARED同时在中断 handler 里通过读取各外设的中断状态寄存器判断是不是自己的中断不是就返回 IRQ_NONE。5.3 踩坑二FPGA DMA 写内存的缓存一致性问题UIO 把 PL 内存区域映射给用户态但如果 PL 侧通过 DMA 往 DDR 里写一块数据CPU 端用户态访问同一块物理内存时可能读到 stale 的缓存数据。我最初在图像采集项目里就踩过这个坑DMA 明明已经把一帧图像搬进 DDR用户态读到的第一行数据却是上一帧的残留。根因在于 UIO 映射默认用了非缓存属性而 DMA 内存区域的内核缓冲区在一开始被 CPU 缓存写入了不可预测的状态。解决方式有两种第一种是在设备树里给 DMA 内存预留一块一致性内存reserved-memory节点加no-map然后由内核把这块物理内存注册为 UIO 的一个 mem 区域用户态 mmap 它时保持不可缓存第二种是在内核的 DMA 驱动里用dma_alloc_coherent分配一致性缓冲区把物理地址通过 UIO 暴露出去。我最终选择了第二种配合 PL 的 DMA 引擎彻底绕开了缓存同步问题。5.4 踩坑三中断事件计数语义不一致导致第二次 read 忙等UIO 的事件计数在部分内核版本里是累计值用户 read 到非零值后如果没有新中断下一次 read 可能会立即返回相同的计数值而不是阻塞。这个行为会让新手程序变成忙轮询CPU 占用直接拉满而且系统负载越高越乱。我的经验是在接入正式逻辑前先在空循环里打印连续两次read返回的计数和间隔。如果发现第二次 read 没有阻塞而驱动又是标准 uio 框架通常需要在 read 返回后主动 write 一次写 1 或写 0视驱动而定来重置等待条件。如果连 write 都控制不了这套语义那就自己在驱动 handler 里加逻辑中断处理后将事件计数清零并只在新中断到达时才 wakeup。总而言之把这个语义摸清楚比盲目堆代码有用得多。5.5 和 PREEMPT_RT、裸机方案的对比做实时决策的时候我习惯把 UIO、PREEMPT_RT 和完全裸机PL 侧逻辑 CPU 裸跑或 RTOS放在一起列个表方案延迟量级开发维护成本适用场景UIO 用户态实时线程10~100 us低纯应用层开发中等实时需求需要利用 Linux 协议栈/文件系统PREEMPT_RT 内核10~50 us中内核配置和驱动适配全系统实时化内核模块很多的任务裸机或 RTOS无 Linux微秒级甚至更低高所有外设驱动重写强实时、单用途设备UIO 不是最极致实时的方案但它是在 Linux 生态下兼顾开发速度和实时性最好的折中项。如果你的系统里本来就有网络、GUI、数据库这些跑在 Linux 上的组件又需要 FPGA 做确定性硬件加速UIO 基本是绕不开的选项。我在实际操作中最深刻的体会是不要一上来就写全套驱动先用最小测试打通中断回路再逐步叠加业务。UIO 架构本身很简单真正花时间的往往是设备树地址对不对、中断有没有清、缓存一致性问题处理没处理、调度配置是不是合理。把这几项一项项验证过去Zynq 上的实时加速项目就能稳稳落地。
返回列表