如果你也跟我一样,手里的 Zynq 开发板放了大半年,每次想实现 PS 和 PL 之间的数据交换,一搜资料就是教你写platform_driver、注册file_operations,再看一眼 Linux 设备模型……直接劝退。后来我换了个思路:先把内核驱动那个大包袱放下,用 UIO(Userspace I/O)框架把 AXI 寄存器直接映射到用户空间,读写自定义外设就像操作普通数组一样简单。这篇文章从环境配置、设备树、mmap 原理到 AXI 寄存器读写实战,完整记录我跑通 PS-PL 极简通信的过程。适合想快速验证硬件逻辑又不想深陷内核开发的人。
这个方案的收益很直接:不需要编译内核模块,不需要写file_operations,不需要处理copy_to_user,甚至不需要为某一个 IP 单独定制驱动代码,内核里只有一个通用壳子,剩下的寄存器地址映射和中断等待全部交给应用程序。代价也不是没有:没有内核权限体系保护、没有并发访问控制,所以它更像一把趁手的原型验证工具,而不是完整的工业化驱动。但用 UIO 先把功能跑通、再把业务逻辑想清楚,效率是真的高。如果你们也遇到过"写了半天驱动只是为了点亮一个 LED"的尴尬局面,就能明白我说的是什么。
1. 为什么我放弃写内核驱动,转头用 UIO 做 PS-PL 通信
1.1 传统字符设备驱动的"繁琐"到底繁琐在哪
一个最简单的寄存器外设,比如 AXI-Lite 从机,想从 Linux 用户态读它一个状态寄存器,按常规路线要走完下面这一整串流程:
- 定义
platform_driver和of_device_id,在probe里获取平台资源; - 申请注册字符设备号,初始化
cdev,填file_operations; - 实现
read/write或ioctl,通过ioremap映射物理地址,再借助copy_to_user把寄存器值送到用户态; - 写 Makefile,按当前内核版本编译模块,处理根文件系统签名等问题;
- 如果外设还涉及中断,还要在中断上下文里保存数据、等待队列唤醒、做事后清理。
这一套下来,真正和业务相关的代码可能就十几行,但骨架和流程占据了几百行。更难受的是,调试内核模块时稍不留神就触发内核崩溃,日志还不怎么友好,一条bug: unable to handle kernel NULL pointer就够你查半天。
我并不是反对写内核驱动,而是说:在你还没有确定外设行为、寄存器布局、中断语义之前,直接用内核开发流程来验证硬件设计,投入产出比太低了。特别是有时候只是想看一眼 AXI 上的某个寄存器有没有按预期变化,还要为此重启系统、加载模块,非常别扭。
1.2 UIO 框架是怎么把内核权利下放给用户态的
UIO 的英文全称是 Userspace I/O,最初就是为这类"用户程序直连硬件资源"的场景设计的。它的核心思想很朴素:外设的寄存器本质上就是一段物理地址空间,内核不需要理解这段空间里的业务含义,只需要把它安全地暴露给用户进程,用户进程通过mmap拿到地址后,用指针读写即可。
Linux 内核在驱动层提供了一个通用平台驱动uio_pdrv_genirq,它专门匹配设备树中的compatible = "generic-uio"。当系统扫描到这样的设备树节点时,会自动负责以下工作:
- 解析节点的
reg属性,把这段物理地址登记为 UIO 设备的一个内存资源; - 解析节点的
interrupts属性,如果存在,就把中断号包装成事件通知; - 在
/dev下生成/dev/uio0之类的设备节点,并提供mmap和read两个核心操作。
用户态程序不需要知道设备树细节,只需要打开/dev/uio0,然后mmap文件描述符,把外设地址映射到进程虚拟地址空间。之后你写某个偏移,数据就会从处理器总线走到 PS 的 GP 口,再经过 AXI 互联到达 PL 里的从机寄存器;你读某个偏移,就会从 PL 取回数据。
有人会问,这不是和直接使用/dev/mem差不多吗?确实有相似之处,但 UIO 更规范:它只暴露设备树里声明的那一段资源,权限控制也落在设备节点上,比/dev/mem这种"整个物理地址空间任你操"的方式安全得多。至少不小心写错地址时,不会立刻把整个系统搞挂。
1.3 UIO 不是万能胶:适用边界先摆清楚
在动手之前,我建议先给自己提三个问题:
- 我的外设是否只是寄存器控制类设备?
- 中断频率是不是不高于每秒几万次?
- 是不是只有单个用户进程访问,不需要复杂的多进程同步?
如果三个答案都偏向"是",那 UIO 会非常顺手。典型的适用场景包括:AXI-Lite 寄存器堆、AXI GPIO 的按键和 LED 控制、AXI BRAM 的数据读写、自定义 IP 的状态查询和参数配置。
但如果你的项目是高频数据采集、大数据量流式传输,或者中断频率高到每毫秒一次,UIO 就不合适了。它没有内核态 DMA API 的支撑,也没有专门的并发访问控制,中断处理路径上还有一个用户态唤醒的往返开销。真到了那种场景,老老实实写内核驱动,或者用现有子系统会更靠谱。
我自己的习惯是:UIO 用来做"能看见硬件"的快速通道,正经驱动留到方案定型后再写。一上来就用 UIO 把 AXI 地址、寄存器读写逻辑跑熟,后面写内核驱动的思路也会清晰很多。
2. 部署 UIO 前的三张入场券:内核配置、设备树节点、udev 权限
2.1 确认内核已经把 UIO 框架编进去
大部分发行版内核默认会打开 UIO 相关选项,但嵌入式板卡上的内核往往经过裁剪,很可能没编译。所以第一步不是写代码,而是确认内核里有没有这两个配置:
CONFIG_UIOCONFIG_UIO_PDRV_GENIRQ
我一般在开发板上直接看:
zcat /proc/config.gz | grep CONFIG_UIO如果/proc/config.gz不存在,也可以去内核 build 目录里查.config。如果你用的是 PetaLinux,可以通过petalinux-config -c kernel进入内核菜单,在 Device Drivers -> Userspace I/O drivers 下面勾选Userspace I/O和Userspace platform driver with generic IRQ handling。保存后重新编译内核,生成 BOOT.BIN 或 image.ub,烧进去再确认。
这里有个小坑:CONFIG_UIO_PDRV_GENIRQ的名字容易记成CONFIG_UIO_PDRV,后者是老驱动,在使用中断的设备树节点上不一定匹配。如果你只是做寄存器读写、不需要中断,两个里有一个可用就差不多;但为了统一,我建议直接打开uio_pdrv_genirq,它兼容compatible = "generic-uio",处理中断更完整。
2.2 设备树里声明"这是一个用户态外设"的写法
在 Zynq 的 PetaLinux 或 Yocto 环境下,硬件设备树一般由工程自动生成。但 UIO 节点通常需要手动添加,因为硬件描述文件不会自动指示某段地址应该交给 UIO 框架。
我的做法是在system-user.dtsi里追加一个根节点,名字随意,关键是compatible和reg:
/ { uio_axilite_0 { compatible = "generic-uio"; reg = <0x40000000 0x1000>; }; };这里0x40000000必须和你 Vivado Address Editor 里给外设分配的基地址一致,0x1000是地址范围。对于大多数 AXI-Lite 外设,给 4KB 足够。
如果你需要 UIO 同时管理中断,可以在节点里增加:
interrupt-parent = <&intc>; interrupts = <0 29 4>;其中<0 29 4>的意思是:这是 SPI 中断,编号 29,触发类型为高电平有效。具体编号要翻 zynq 的系统设备树,或者直接看 PetaLinux 自动生成的设备树里 GIC 相关定义。插一句,不是每个 PL 中断都能随便选号,已经被占用的中断最好不要重复使用。
2.3 重启后 /dev/uio0 不见了?权限和自动生成
设备树配置正确、内核也支持 UIO 的情况下,系统启动后应该会自动出现/dev/uio0。但如果没出现,先别急着改代码,按这个顺序排查:
- 执行
ls /sys/class/uio,看看有没有生成uio0; - 有的话,说明设备节点生成有问题,多半是
/dev管理规则没跟上; - 没有的话,说明 UIO 设备和设备树节点没有绑定成功,用
dmesg | grep uio看有没有加载记录。
我遇到过一种情况:明明设备树里加了节点,但/dev/uio0一直不出现,后来发现是内核把某个外设自己的兼容驱动也编进去了,二者抢设备,系统优先加载了普通平台驱动。解决办法是到设备树里把原外设节点的status设为disabled,或者干脆去掉对 Compatible 的匹配。
权限问题也很常见。默认情况下/dev/uio0属于 root,普通用户打不开。一般我会写一条简单的 udev 规则:
echo 'KERNEL=="uio*", MODE="0660", GROUP="gpio"' > /etc/udev/rules.d/99-uio.rules改完后重新加载规则:
udevadm control --reload udevadm trigger如果你只是临时调试,直接chmod 666 /dev/uio0也不是不行,但每次重启都要重新做,不如规则一步到位。
3. AXI 寄存器通信原理:地址、映射、barrier 一个都不能少
3.1 一个 AXI-Lite 从机外设在地址空间里长什么样
Zynq 的 PS 端有多组M_AXI_GP接口,默认挂在 AXI 互联阵列上,用于访问 PL 里的外设。在 Vivado Block Design 里,给某个 AXI-Lite 从机 IP 分配地址后,它就会落在整个地址空间的某个窗口里。比如自定义 IP 的基地址是0x40000000,那它在内存映射上就是:
- 物理地址
0x40000000对应寄存器 0(偏移0x00) - 物理地址
0x40000004对应寄存器 1(偏移0x04) - 物理地址
0x40000008对应寄存器 2(偏移0x08)
之所以偏移是 4 的倍数,是因为 AXI-Lite 从机通常按 32 位宽度对齐寄存器。设备树里的reg = <0x40000000 0x1000>就是告诉内核:这段区域属于这个设备。
上面这个地址窗口在 PS 一侧看到的是 32 位物理地址,但 CPU 默认不直接访问它,必须经过 MMU 映射到虚拟地址。这也是驱动原先需要ioremap的原因。UIO 框架则把这件事抹平了:你mmap后拿到的指针,CPU 访问时先经过 MMU 翻译,最终落到总线地址0x40000000,再进入 PL。
3.2 mmap 之后,用户态指针还是物理地址吗
不是。你拿到的是进程虚拟地址,但底层映射关系已经由 UIO 驱动建立好了。UIO 的mmap实现会遍历设备树解析出来的 region,把reg属性中的物理地址通过remap_pfn_range映射给用户进程。
在调用mmap时,最后一个参数offset不是寄存器偏移,而是 UIO 的 region 序号。绝大多数 UIO 设备只有一个 region,所以 offset 填 0;如果有多个reg段,那么第 0 段对应 offset 0,第 1 段对应 offset 1 页大小倍,依次类推。
我第一次踩坑就在这:以为mmap的 offset 是外设寄存器偏移,于是在mmap时填了 0x40,结果内核直接返回错误。正确写法很简单:
#define UIO_DEV "/dev/uio0" #define REGION_OFFSET 0 int fd = open(UIO_DEV, O_RDWR); if (fd < 0) { /* 处理错误 */ } size_t page_size = sysconf(_SC_PAGESIZE); volatile uint32_t *regs = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, REGION_OFFSET); if (regs == MAP_FAILED) { /* 处理错误 */ }映射的大小必须是页大小的整数倍。范围 0x1000 正好是很多系统的一页,所以上面映射一页没问题;如果外设范围有 64KB,那就映射 64KB。
3.3 volatile 与内存屏障:防止编译器"好心办坏事"
现在regs已经指向外设寄存器区域了,但有一个细节必须强调:一定要用volatile uint32_t *来访问它,或者使用自定义的宏操作。
如果你写普通uint32_t *,编译器可能认为某次读寄存器的结果在循环中没有变化,于是只读一次,后面直接复用旧值。外设寄存器可不是普通内存,它可能在上一次读之后已经被硬件逻辑改写。所以必须禁止编译器优化。
我习惯在用户态程序里定义两个宏:
#define UIO_READ32(addr) (*(volatile uint32_t *)(addr)) #define UIO_WRITE32(addr, val) (*(volatile uint32_t *)(addr) = (uint32_t)(val))调用方式:
uint32_t reg0_value = UIO_READ32(®s[0]); UIO_WRITE32(®s[1], 0x5A5A0001);至于内存屏障,如果只是同一个进程先后写多个寄存器,普通 volatile 访问在单核上够用;但如果你在多核环境下,一个核写配置、另一个核读状态,那最好在写完后加__sync_synchronize()。PL 端的 AXI 互联通常是按顺序处理事务的,这里加屏障主要是为了让编译器和处理器不乱序。
4. 实战:给自定义 AXI-Lite 外设写一个不存在的"驱动"
4.1 Vivado 里搭一个最小 AXI-Lite 寄存器堆
这次我用的模拟项目 X,是一个带两个寄存器的 AXI-Lite 从机 IP:
- 寄存器 0(偏移 0x00):可读写,低 4 位用来控制 4 个 LED;
- 寄存器 1(偏移 0x04):只读高 32 位,内容是一个在 PL 内自由运行的计数器。
在 Vivado 里创建 IP 时,模板选 AXI4-Lite Slave,然后在生成的slv_reg0、slv_reg1基础上做小改动。slv_reg0的输出直接接到 IP 的led_out端口上,slv_reg1的输入接一个计数逻辑。这样 PS 写slv_reg0,LED 会立刻变化;PS 读slv_reg1,能看到计数器在递增。
生成 bitstream 前,记得检查 Block Design 里处理器和 IP 的连接。最关键的是三个信号:
S_AXI_ACLK,要连接到 PS 的FCLK_CLK0或某个 PL 时钟;S_AXI_ARESETN,要连接到proc_sys_reset的输出,否则外设一直处于复位状态;M_AXI_GP0主机口,要连到自定义 IP 的S_AXI从机口。
很多"读不到数据"的问题,并不是 UIO 配错,而是 IP 的时钟和复位根本没配好。
4.2 导硬件时把地址记好,设备树照着填
综合、实现、生成 bitstream 后,在 Vivado 里导出 XSA 文件。导出后可以顺手打开 Address Editor 再确认一次基地址,我这次分配的是0x40000000,范围0x1000。这个地址意义重大,后面设备树的reg和用户态程序能访问到哪一段,全看它。
如果你用的是 PetaLinux,把 XSA 交给petalinux-config --get-hw-description后,硬件设备树会自动生成大部分节点,但 UIO 节点不会智能出现。需要手动在system-user.dtsi里补上我们之前写的节点:
/ { uio_axilite_0 { compatible = "generic-uio"; reg = <0x40000000 0x1000>; }; };如果 PetaLinux 自动生成的文件里已经给这个地址分配了一个普通驱动节点,比如某个axi节点的子节点,而你又希望完全由 UIO 管理,可以直接把那个节点的status = "disabled"标掉,避免两个驱动打架。
4.3 用户态读写程序:两个宏打天下
完整的验证程序我放在下面,文件名就叫uio_test.c:
#include <stdio.h> #include <stdint.h> #include <fcntl.h> #include <sys/mman.h> #include <unistd.h> #include <string.h> #include <errno.h> #define UIO_DEV "/dev/uio0" #define REGION_OFFSET 0 #define MAP_SIZE 0x1000 #define UIO_READ32(addr) (*(volatile uint32_t *)(addr)) #define UIO_WRITE32(addr, val) (*(volatile uint32_t *)(addr) = (uint32_t)(val)) int main(int argc, char *argv[]) { int fd; volatile uint32_t *regs; uint32_t val; int i; fd = open(UIO_DEV, O_RDWR); if (fd < 0) { fprintf(stderr, "open %s failed: %s\n", UIO_DEV, strerror(errno)); return -1; } regs = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, REGION_OFFSET); if (regs == MAP_FAILED) { fprintf(stderr, "mmap failed: %s\n", strerror(errno)); close(fd); return -1; } /* 读寄存器 0,打印初始值 */ val = UIO_READ32(®s[0]); printf("reg0 initial: 0x%08x\n", val); /* 写寄存器 0,让低 4 位全亮 */ UIO_WRITE32(®s[0], 0x0000000f); printf("reg0 written: 0x0000000f\n"); /* 连续读寄存器 1 五次,观察计数器变化 */ for (i = 0; i < 5; i++) { val = UIO_READ32(®s[1]); printf("reg1[%d]: 0x%08x\n", i, val); usleep(100000); } munmap((void *)regs, MAP_SIZE); close(fd); return 0; }编译的时候不需要任何特殊内核头文件,也不需要内核模块机制:
gcc -O2 -Wall -o uio_test uio_test.c然后在开发板上运行:
./uio_test如果你的外设功能正常,你会看到reg0 initial可能是复位初始值,reg1五次打印值依次递增。这套代码放在任何 Zynq 平台上,只要设备树节点和物理地址对得上,基本不用改。
4.4 板上实测:PS 写灯亮,PS 读计数器涨
我实际跑这个模拟项目 X 的时候,输出大致是这样:
reg0 initial: 0x00000000 reg0 written: 0x0000000f reg1[0]: 0x000012ab reg1[1]: 0x000012bb reg1[2]: 0x000012cb reg1[3]: 0x000012db reg1[4]: 0x000012eb每 100ms 读一次,读出的值增长0x10,说明 PL 里计数器时钟频率和我的预期基本一致。同时,开发板上的 LED 在程序运行后亮起,证明写操作确实通过 AXI 总线到达了 PL 内部。
这一步跑通了,UIO 的部署就完成了一大半。接下来遇到问题,基本都集中在排查路径上,而不是再折腾驱动代码。
5. 排除故障的完整思路:从 /proc/iomem 到信号波形
5.1 现象一:mmap 成功但读到全零
mmap成功,说明虚拟地址映射建立起来了,但一读取就全 0,有几种常见原因。
第一个原因是 PL 侧根本没加载 bitstream。Zynq 的 PL 是可以动态配置的,如果内核起来后 FPGA manager 没有加载.bit,那 PS 访问0x40000000时可能读到总线的默认值,表现为全零。排查方法很简单:
cat /sys/class/fpga_manager/fpga0/state如果能显示active或operating,说明 PL 已配置;否则需要先加载 bitstream。
第二个原因是设备树里的reg地址和 Vivado 分配的地址不一致。比如地址编辑器里实际是0x40001000,你设备树写了0x40000000,那访问的就是另一段空区域。检查方法是用 BusyBox 自带的devmem直接读物理地址:
devmem 0x40000000 32如果devmem读出来有非零数据,说明问题出在 UIO 映射参数上;如果devmem读出来也是 0,问题大概率在硬件连接或地址分配。
第三个原因,也是很容易忽略的:IP 在 PL 内部一直处于复位状态。去 Vivado Hardware Manager 里看S_AXI_ARESETN信号是否一直拉低,如果复位没释放,AXI 从机不会响应正常的读写事务。
5.2 现象二:程序一运行就段错误
段错误一般出现在访问了未映射的地址,或者mmap返回了MAP_FAILED但程序没有检查,仍然继续读写。
先确认mmap返回值,打印strerror(errno)。常见的报错是Invalid argument,原因多半是 mmap 的 size 不是页大小整数倍,或者 offset 对应的 region 不存在。比如只有一个 region,却把 offset 填成 1 页,内核找不到对应 region,直接返回错误。
另外要注意open的时候必须带O_RDWR。只用O_RDONLY打开,后续mmap即使成功,某些内核版本下也会拒绝可写映射,导致程序在写寄存器时触发 SIGBUS 或段错误。
还有一个保险做法:mmap的映射范围不要超过设备树reg声明的长度。如果你的外设只有 4KB,你非要映射 64KB,映射本身可能成功,但访问超出实际 AXI 地址范围的部分时,总线不一定有人应答,程序直接崩溃。
5.3 现象三:写回调不了 PL 逻辑状态
如果你写了寄存器,但 PL 侧逻辑没有任何反应,先检查程序里mmap是否用了MAP_SHARED。如果误用了MAP_PRIVATE,写入只会留在进程私有的页副本上,永远不会回到物理外设。这是一个很蠢但很容易掉的坑。
然后检查字节序和对齐。AXI-Lite 是 32 位总线,寄存器偏移必须 4 字节对齐。如果你在程序里用uint8_t *指针偏移去访问奇数地址,AXI 从机不会按你的习惯拼接数据,结果可能谁也说不清。
最后,如果写寄存器后,PL 内部计数器、状态机都没反应,确定时钟和复位都没问题后,我建议直接在 Vivado 里给自定义 IP 加一个 ILA,观察S_AXI_AWVALID、S_AXI_WVALID、S_AXI_BREADY等握手信号。AXI 事务没走通,多半在握手信号上能看到端倪,比如主机一直等从机回应,或者写地址通道卡住。
5.4 需求中断时:UIO 事件通知的正确用法
UIO 支持中断,但它的中断语义和普通驱动不太一样:用户态不是注册一个回调函数,而是用read阻塞在/dev/uio0上。当硬件中断触发时,内核会给 UIO 设备发送一个事件,read会返回一个 4 字节的计数器,应用程序根据事件计数判断是否发生了中断。
先靠设备树把中断配好:
interrupt-parent = <&intc>; interrupts = <0 29 4>;用户态程序这样等待:
uint32_t events = 0; ssize_t ret; ret = read(fd, &events, sizeof(events)); if (ret == 4) { /* 说明发生了一次中断,events 是累计事件数 */ }这里有个很重要的机制:read返回后,UIO 驱动会认为中断已经被应用层处理。如果你的 PL 中断源是电平触发,并且在读完事件后没有在 PL 侧清中断,那中断线会一直有效,read会立刻返回,形成忙等。所以 PL 端的ip2intc_irpt信号在清除中断源之前,不能一直保持有效。我在自定义 IP 里通常做一个"读状态寄存器即自动清中断"的逻辑,这样用户态程序里一次read对应一次事件,非常干净。
6. 再往前走两步:BRAM 吞吐、中断驱动与生产性思考
6.1 连续读写 AXI BRAM,顺手测一下吞吐量
如果需要 PS 和 PL 之间共享一块数据缓冲区,Axi BRAM 是很常见的选择。在 Block Design 里加入 AXI BRAM Controller 和一块 BRAM,分配基地址后,同样用 UIO 节点指向这段范围,用户态程序就能像访问普通数组一样读写 BRAM。
我做过一个简单测速实验:把 1MB 数据从用户态数组memcpy到映射后的 BRAM 地址,统计耗时。由于这段地址被内核标记为设备内存,CPU 不会走 cache,每次写操作都可能触发真正的事务往返,所以不要期待它能达到 DDR 那种带宽。在我的试验环境下,纯单核memcpy连续写大约只能跑到几十 MB/s,还占满了 CPU。这个量级做少量数据交换、命令传递够用,但大规模数据搬运不合适。
如果只是要一个大概测速值,可以在程序里用clock_gettime统计时间:
struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, &start); memcpy(bram_ptr, src, 1 << 20); clock_gettime(CLOCK_MONOTONIC, &end);连续访问时,建议一次读写整页,避免跨页碎片化,反而比随机访问更快。
6.2 用户态中断:把 read() 当指令等待
谈到中断驱动,第一种玩法是纯轮询寄存器,比如循环读状态位。但更省 CPU 的做法是用 UIO 的read阻塞事件。你可以在一个独立线程里等待事件,主线程继续处理界面或网络,事件到来时再去读外设寄存器。
我调试某个模拟项目时,PL 端每完成一次 ADC 采集就产生一个高电平脉冲,PS 端线程阻塞在read(fd, &event, 4)上,每次返回后立刻读取采集结果寄存器。整套逻辑运行得很好,CPU 占用不到 1%,比轮询舒服多了。
不过要记住,read阻塞的是进程,不是线程队列。如果一个进程里开了多个线程同时read同一个 UIO 设备,只有其中一个会被唤醒,这不是无线程模型。真正需要多路同步时,还是回到内核驱动里用poll/waitqueue组织架构更清晰。
6.3 什么时候该放弃 UIO 转去写正经驱动
如果你发现自己的项目正在逼近这几个边界,就该考虑告别 UIO 了:
- 数据吞吐要求很高,需要 CPU 高效搬运,或者需要使用 DMA;
- 中断频率很高,要求纳秒级响应,UIO 的用户态唤醒往返明显跟不上;
- 多个进程需要共享同一个外设,并且有权限、互斥、优先级要求;
- 外设要参与电源管理、休眠唤醒、复杂的系统级生命周期管理。
UIO 的价值在于极简和透明,要让用户态以最低门槛碰到硬件。它像一个自由开放的实验台,但不是一座防御严密的生产车间。我个人的评判标准是:如果写了三次 UIO,每次都还在纠结权限、并发和中断清不清的细节,那说明这个外设的复杂度已经超出 UIO 的舒适区了。
我在自己的小项目里用 UIO 读了一整年的寄存器,后面真正要上量产逻辑时,还是把关键链路换回了内核驱动。但那段 UIO 调试经历帮我省下了大量初期验证时间,也让我在写正式驱动之前,提前把整个 AXI 寄存器模型和行为摸得清清楚楚。如果你们也想在 Zynq 上快速把 PS-PL 通道打通,不妨先 UIO 一把,跑通了再决定下一步怎么走。