← Back to Blog
FPGAZynqLinux驱动开发UIO设备树PetaLinuxAXI

Zynq 实战 11|PL 自定义 IP 的 Linux 驱动实战:UIO、/dev/mem 与内核驱动三条路

This article was written in Chinese and auto-translated via Google Translate.
View Chinese Original →

Zynq 实战 11|PL 自定义 IP 的 Linux 驱动实战

这是《Zynq FPGA 嵌入式系统设计实战》系列的第 11 篇。 板子:Pynq-Z2(XC7Z020)。工具链:Vivado / Vitis / PetaLinux 2023.2。 上一篇:《Zynq 实战 10|PetaLinux 根文件系统定制》


0. 这一篇要解决什么问题

第 06 篇我们在 PL 里做了一个 AXI-Lite 接口的 PWM IP,Vivado 仿真过了,也用 Vitis 裸机控制过了。现在 PS 这边跑了 Linux,问题变成:怎么从 Linux 用户态去读写 PL IP 的寄存器?

这一篇给三条路:

  1. UIO(Userspace I/O):把寄存器 mmap 到用户态,read() 等中断,最简单,生产环境可用
  2. /dev/mem + mmap:两行代码就能访问任意物理地址,调试快刀,生产禁器
  3. 完整内核字符设备驱动file_operations + ioremap,最复杂,功能最完整

本文不讲”字符设备驱动是什么”——默认你知道 Linux 驱动的基本概念。本文讲的是:在真实 Zynq 工程里,这三条路怎么走、各自踩什么坑

本文不覆盖 DMA 引擎(AXIDMA/VDMA)驱动——那是下一篇的内容。


1. 先把物理地址从 Vivado Address Editor 里找出来

在写任何驱动之前,你要知道 PL IP 被分配到了哪个物理地址。这个地址由 Vivado 的地址分配器决定,在 Address Editor 里查。

在 Vivado Block Design 界面:菜单 Window → Address Editor,或者直接在 Diagram 画布里点任意 AXI-Lite 从机 IP,Tcl Console 会打印它的地址。

以第 06 篇的 PWM IP 为例,连在 M_AXI_GP0 上的情况下,典型的分配结果是:

IP 名称接口基地址范围高地址
pwm_ctrl_0S_AXI0x43C0_000064K0x43C0_FFFF

🚧 避坑:地址范围(Range)是 Vivado 分配时让你选的,一般选 64K(0x10000) 就够,哪怕你的 IP 只有 4 个 32-bit 寄存器(16 字节)。因为 Linux mmap 是 PAGE_SIZE(4096 字节)对齐的,选太小会造成设备树和 mmap 不一致。另外,Vivado 分配的地址和 Linux 设备树里的 reg 属性必须完全一致——如果你修改了 Block Design 重新分配,设备树也要同步改,否则驱动 probe 时拿到的地址会映射到错误的物理空间。

Vivado 里确认地址后,记下两个数字:

  • 基地址0x43C00000
  • 范围0x10000(64KB)

这两个数字后面会反复出现在设备树、mmap() 调用和驱动代码里。


2. 三条路对比

PL IP 访问路径:UIO vs /dev/mem vs 内核驱动 ① UIO ② /dev/mem ③ 完整内核驱动 用户态 (User Space) 用户程序 open + mmap + read 用户程序 open("/dev/mem") + mmap 用户程序 open + ioctl + read/write syscall syscall syscall 内核态 (Kernel Space) uio_pdrv_genirq.ko 仅处理中断 enable/ack 数据路径不过内核 ✅ mem.c (内置驱动) 直接访问任意物理地址 无访问控制 ⚠️ 自定义 .ko 驱动 ioremap + file_operations 完整访问控制、DMA 支持 寄存器页直接 mmap 到用户态 PL Hardware — AXI-Lite Slave (PWM IP @ 0x43C00000) CTRL_REG [0x00] · PERIOD_REG [0x04] · HIGH_REG [0x08] · STATUS_REG [0x0C] 通过 M_AXI_GP0 挂在 PS 地址空间 · IRQ_F2P[0] → GIC SPI #61 推荐:日常开发 / 生产 仅调试用,不上生产 需要 DMA / 复杂控制时
图 1. UIO vs /dev/mem vs 完整内核驱动——数据路径对比

用一句话概括三者的本质区别:

方法内核参与度数据路径中断支持生产可用难度
UIO最低(只处理中断)用户态直接 mmap 寄存器read() 阻塞等 IRQ⭐⭐
/dev/mem无(内核内置驱动)用户态直接映射物理地址❌ 无法使用
完整内核驱动最高(全部在内核)系统调用 → 内核 → 硬件request_irq⭐⭐⭐⭐

UIO 的核心优势是:寄存器读写这条数据路径根本不过内核——你 mmap 之后,reg[0] = 0x01 这种操作直接翻译成一条 AXI 总线写事务,中间没有系统调用,没有上下文切换,延迟比完整驱动低得多。中断这条路才需要read() 进内核等一下。


3. UIO 完整流程

3.1 内核配置:打开 CONFIG_UIO_PDRV_GENIRQ

PetaLinux 2023.2 的 BSP 默认不一定开这个选项,需要手动确认:

# 在 PetaLinux 工程目录下
petalinux-config -c kernel

进入 menuconfig 后,搜索 UIO(按 / 键):

Device Drivers
  └── Userspace I/O drivers
        ├── [*] Userspace I/O platform driver with generic IRQ handling   <-- CONFIG_UIO_PDRV_GENIRQ=y
        └── [*] Userspace I/O platform driver with generic irq and dynamic memory

UIO_PDRV_GENIRQ 选成 [*](编译进内核),或者 [M](模块,需要手动 modprobe uio_pdrv_genirq)。

推荐直接编译进内核([*]),省一步 modprobe

🚧 避坑:很多教程会说”UIO 直接可以用”。在 Xilinx 官方 BSP 里不一定——我实际测试 PetaLinux 2023.2 的 BSP 里 CONFIG_UIO_PDRV_GENIRQ 默认是 M(模块),你不 modprobe 就不会出现 /dev/uio0。建议在 system-user.dtsi 里指定 compatible = "generic-uio" 后,先 dmesg | grep uio 确认驱动有没有 probe 成功。

3.2 设备树节点

PetaLinux 2023.2 的设备树定制文件是:

<petalinux-project>/project-spec/meta-user/recipes-bsp/device-tree/files/system-user.dtsi

在这个文件里为 PWM IP 加节点:

/* system-user.dtsi — PetaLinux 2023.2,对应 Vivado Address Editor 分配 */
/ {
    amba_pl: amba_pl@0 {
        #address-cells = <1>;
        #size-cells    = <1>;
        ranges;

        pwm_ctrl: pwm@43c00000 {
            compatible    = "generic-uio";       /* 触发 uio_pdrv_genirq 驱动 */
            reg           = <0x43c00000 0x10000>; /* 基地址 0x43C00000,大小 64KB */
            interrupt-parent = <&intc>;           /* Zynq GIC,在 pl.dtsi 里定义 */
            interrupts    = <0 29 4>;
            /*  ↑ 三元组含义:
             *  0 = SPI(Shared Peripheral Interrupt,PL 到 GIC 都是 SPI)
             *  29 = SPI 编号 = IRQ_F2P[0] 对应的 GIC SPI ID(61) - 32 = 29
             *  4 = active-high level-sensitive(见 UG585 表 7-4)
             */
            uio,name      = "pwm_ctrl";           /* /sys/class/uio/uio0/name 里显示 */
        };
    };
};

interrupts 的 SPI 编号怎么算

Zynq 的 PL 中断线 IRQ_F2P[N] 对应的 GIC SPI ID 见 UG585 Table 7-4。最常用的几个:

PL 中断线GIC SPI ID设备树 SPI 编号(SPI ID - 32)
IRQ_F2P[0]6129
IRQ_F2P[1]6230
IRQ_F2P[7]6836
IRQ_F2P[8]8452

在 Vivado Block Design 里,PWM IP 的中断输出接到 xlconcat_0In0,再接到 processing_system7_0IRQ_F2P[0:0],所以用 SPI 编号 29

🚧 避坑:如果你在 Vivado 里把 PWM 中断接到了 IRQ_F2P[1],设备树里就得改成 interrupts = <0 30 4>。不要照抄我这里的数字——以你实际 Block Design 的连接为准,在 Vivado 的 Diagram 里找到 IRQ_F2P 的连线。

3.3 确认设备节点出现

重新 build 并启动后:

# 在板子 Linux 上
ls /dev/uio*
# 期望输出:/dev/uio0

cat /sys/class/uio/uio0/name
# 期望输出:pwm_ctrl

cat /sys/class/uio/uio0/maps/map0/addr
# 期望输出:0x43c00000

cat /sys/class/uio/uio0/maps/map0/size
# 期望输出:0x10000

如果 /dev/uio0 不出现:

  1. dmesg | grep uio — 看 uio_pdrv_genirq 有没有 probe
  2. dmesg | grep 43c0 — 看地址有没有冲突
  3. lsmod | grep uio — 如果是模块方式,确认已 modprobe uio_pdrv_genirq

4. 完整 C 代码:用户态控制 PWM IP

下面是完整、可编译的用户态程序,控制第 06 篇的 PWM IP 调节占空比,并等待中断。

PWM IP 寄存器定义(来自第 06 篇 AXI Slave 寄存器映射)

偏移名称描述
0x00CTRL[0] enable,[1] reset
0x04PERIODPWM 周期(PL 时钟 clock cycles)
0x08HIGH高电平时长(clock cycles,即占空比)
0x0CSTATUS[0] busy,[1] irq_pending
/*
 * pwm_uio_ctrl.c — 通过 UIO 接口控制 Zynq PL 自定义 PWM IP
 *
 * 编译(在板子上或交叉编译):
 *   aarch64-linux-gnu-gcc -O2 -Wall -o pwm_uio_ctrl pwm_uio_ctrl.c
 *
 * 运行:
 *   ./pwm_uio_ctrl 75      # 设置占空比 75%
 *
 * 依赖:
 *   - /dev/uio0 已出现(设备树节点 compatible = "generic-uio")
 *   - PL 时钟 FCLK_CLK0 = 100 MHz(在 PS7 配置中设置)
 */

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <errno.h>
#include <string.h>

/* ── PWM IP 寄存器偏移 ── */
#define PWM_CTRL_REG    0x00  /* [0]=enable [1]=reset            */
#define PWM_PERIOD_REG  0x04  /* 周期,单位:PL 时钟 cycle        */
#define PWM_HIGH_REG    0x08  /* 高电平时长,单位:PL 时钟 cycle  */
#define PWM_STATUS_REG  0x0C  /* [0]=busy [1]=irq_pending        */

#define UIO_DEV         "/dev/uio0"
#define MAP_SIZE        0x10000   /* 64KB,和设备树 reg 大小一致    */

/* ── 寄存器访问宏(volatile 防止编译器优化掉 IO 读写) ── */
#define REG_WR(base, off, val)  (*(volatile uint32_t *)((uint8_t *)(base) + (off)) = (val))
#define REG_RD(base, off)       (*(volatile uint32_t *)((uint8_t *)(base) + (off)))

static void print_status(void *base)
{
    uint32_t ctrl   = REG_RD(base, PWM_CTRL_REG);
    uint32_t period = REG_RD(base, PWM_PERIOD_REG);
    uint32_t high   = REG_RD(base, PWM_HIGH_REG);
    uint32_t status = REG_RD(base, PWM_STATUS_REG);

    printf("  CTRL=0x%08x  PERIOD=%u  HIGH=%u  STATUS=0x%08x\n",
           ctrl, period, high, status);
    if (period > 0)
        printf("  → 实际占空比: %.1f%%\n", 100.0f * high / period);
}

int main(int argc, char *argv[])
{
    int      fd;
    void    *base;
    uint32_t period_cycles, high_cycles;
    uint32_t irq_count;
    int      duty_pct;

    if (argc < 2) {
        fprintf(stderr, "Usage: %s <duty_percent 0-100>\n", argv[0]);
        return 1;
    }
    duty_pct = atoi(argv[1]);
    if (duty_pct < 0 || duty_pct > 100) {
        fprintf(stderr, "Error: duty_percent 必须在 0~100 之间\n");
        return 1;
    }

    /* ── Step 1: 打开 UIO 设备 ── */
    fd = open(UIO_DEV, O_RDWR);
    if (fd < 0) {
        fprintf(stderr, "open %s 失败: %s\n", UIO_DEV, strerror(errno));
        fprintf(stderr, "提示: 检查 dmesg | grep uio, 确认驱动已 probe\n");
        return 1;
    }

    /* ── Step 2: mmap 寄存器到用户态 ──
     *
     * UIO 驱动内部调用 remap_pfn_range 时会自动加上 pgprot_noncached(),
     * 保证映射是非缓存(Device Memory)的。
     * 这意味着你对 base 的每次读写都直接上 AXI 总线,不会被 CPU cache 拦截。
     * offset=0 对应设备树 maps/map0(即 0x43C00000)。
     */
    base = mmap(NULL, MAP_SIZE,
                PROT_READ | PROT_WRITE,
                MAP_SHARED,
                fd,
                0 * getpagesize());  /* offset = map_index × PAGE_SIZE */
    if (base == MAP_FAILED) {
        fprintf(stderr, "mmap 失败: %s\n", strerror(errno));
        close(fd);
        return 1;
    }

    /* ── Step 3: 配置 PWM ──
     *
     * PL 时钟 FCLK_CLK0 = 100 MHz(在 Vivado PS7 Clocking 配置中设置)
     * 目标频率:1 kHz PWM 输出
     * period_cycles = 100_000_000 Hz / 1000 Hz = 100_000 cycles
     */
    period_cycles = 100000;                            /* 1 kHz @ 100 MHz PL 时钟 */
    high_cycles   = (uint32_t)(period_cycles * duty_pct / 100);

    printf("[pwm_uio_ctrl] 配置前寄存器状态:\n");
    print_status(base);

    /* 先关 PWM,防止写 PERIOD 时出现毛刺 */
    REG_WR(base, PWM_CTRL_REG, 0x00);

    /* 写周期和高电平时长 */
    REG_WR(base, PWM_PERIOD_REG, period_cycles);
    REG_WR(base, PWM_HIGH_REG,   high_cycles);

    /* 启动 PWM */
    REG_WR(base, PWM_CTRL_REG, 0x01);

    printf("[pwm_uio_ctrl] 配置后寄存器状态:\n");
    print_status(base);
    printf("[pwm_uio_ctrl] PWM 已启动,占空比 %d%%,频率 1kHz\n", duty_pct);

    /* ── Step 4: 等待一次 PWM 周期完成中断 ──
     *
     * UIO 的中断模型:
     *   - read("/dev/uio0", &irq_count, 4) 会阻塞,直到 IRQ 发生
     *   - 返回值是累计中断次数(uint32_t,自驱动 probe 以来)
     *   - 每次 read 返回后,驱动会自动 enable 下一次中断(uio_pdrv_genirq 行为)
     *
     * 注意:如果你的 PWM IP 中断线没有实际连接,这里会永久阻塞。
     * 加 O_NONBLOCK 可以改为非阻塞,但一般调试时阻塞更方便。
     */
    printf("[pwm_uio_ctrl] 等待 PWM 中断(阻塞)...\n");
    if (read(fd, &irq_count, sizeof(irq_count)) != sizeof(irq_count)) {
        perror("read (等待中断)");
        /* 非致命,继续 */
    } else {
        printf("[pwm_uio_ctrl] 收到中断,累计次数: %u\n", irq_count);
    }

    /* ── Step 5: 清理 ── */
    REG_WR(base, PWM_CTRL_REG, 0x00);   /* 停止 PWM */
    munmap(base, MAP_SIZE);
    close(fd);

    return 0;
}

交叉编译(在 x86 Ubuntu 开发机上)

# PetaLinux 2023.2 自带 SDK,先 source 环境
source <petalinux-proj>/components/yocto/buildtools/environment-setup-cortexa9t2hf-neon-xilinx-linux-gnueabi

arm-xilinx-linux-gnueabi-gcc -O2 -Wall -o pwm_uio_ctrl pwm_uio_ctrl.c

# scp 到板子
scp pwm_uio_ctrl root@<board-ip>:/home/root/

运行示例

root@pynq-z2:~# ./pwm_uio_ctrl 75
[pwm_uio_ctrl] 配置前寄存器状态:
  CTRL=0x00000000  PERIOD=0  HIGH=0  STATUS=0x00000000
  → 实际占空比: (period=0, 跳过)
[pwm_uio_ctrl] 配置后寄存器状态:
  CTRL=0x00000001  PERIOD=100000  HIGH=75000  STATUS=0x00000000
  → 实际占空比: 75.0%
[pwm_uio_ctrl] PWM 已启动,占空比 75%,频率 1kHz
[pwm_uio_ctrl] 等待 PWM 中断(阻塞)...
[pwm_uio_ctrl] 收到中断,累计次数: 1

5. UIO 里的 Cache 一致性问题

这一节是整篇最需要认真读的部分,原资料完全没讲清楚。

为什么 UIO mmap 不需要手动 flush cache?

Zynq 的 AXI-Lite 寄存器空间是 Device Memory,不是普通的 System RAM。Linux 内核里,UIO 驱动(uio_pdrv_genirq)在执行 mmap 时,会对物理页的内存属性做特殊处理:

/* 来自 kernel/drivers/uio/uio.c */
static int uio_mmap_physical(struct vm_area_struct *vma)
{
    ...
    vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
    /* ↑ 这一行是关键:强制非缓存映射 */
    return remap_pfn_range(vma, vma->vm_start,
                           mi->addr >> PAGE_SHIFT,
                           vma->vm_end - vma->vm_start,
                           vma->vm_page_prot);
}

pgprot_noncached() 在 ARM 架构下会把页表项的 TEX/C/B 位设置为 Strongly OrderedDevice,这意味着:

  • CPU 不会把对这块地址的读写放入 L1/L2 Cache
  • 每次读写都直接走 AXI 总线到 PL 硬件
  • 你不需要也无法__flush_dcache_area() 或类似函数来同步——因为 cache 根本没参与

对比 DMA 缓冲区(System RAM)

如果你用的是 DMA 缓冲区(比如 PS DDR 里一块给 PL 做 DMA 源的 buffer),情况就完全不一样了——CPU 写 DDR 的数据会先在 L1/L2 cache 里,如果你不 flush,PL 通过 AXI HP 端口看到的还是旧数据。这种情况需要:

  • dma_alloc_coherent()(走 HP 端口不经过 cache)
  • 或手动 dma_sync_single_for_device() 在 DMA 传输前 flush

但 AXI-Lite 寄存器空间(0x43C00000 这类地址)不是 System RAM,一开始就被标记为 Device Memory,UIO 里的 pgprot_noncached 只是再确保一遍。

🚧 避坑:如果你自己手写一个 mmap 实现(完整内核驱动路线),一定要加 pgprot_noncached(),不加的话某些 ARM 处理器可能把寄存器读写 speculatively cache 起来,导致你读到的值是 CPU cache 里的旧值,排查起来极度痛苦。


6. /dev/mem 方法:调试快刀,生产禁器

/dev/mem 是 Linux 内核内置的物理内存访问设备,允许 root 用户 mmap 任意物理地址。调试阶段用来验证寄存器地址正不正确非常方便。

/*
 * devmem_peek.c — 用 /dev/mem 读写 PWM IP 寄存器(仅调试用)
 *
 * 编译:gcc -O0 -o devmem_peek devmem_peek.c
 * 运行:sudo ./devmem_peek        (需要 root)
 */
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#define PWM_BASE    0x43C00000UL
#define MAP_SIZE    0x10000      /* 必须是 PAGE_SIZE(4096) 的整数倍 */

int main(void)
{
    int   fd;
    void *base;

    fd = open("/dev/mem", O_RDWR | O_SYNC);
    if (fd < 0) { perror("open /dev/mem"); return 1; }

    /*
     * mmap 的 offset 参数必须是 PAGE_SIZE(4096)的整数倍。
     * 0x43C00000 & ~(PAGE_SIZE-1) = 0x43C00000(已对齐,没问题)。
     * 如果你的 IP 地址不是 4K 对齐的(罕见但存在),
     * 需要先对齐到页边界,再用指针偏移访问目标地址。
     */
    base = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE,
                MAP_SHARED, fd, PWM_BASE);
    if (base == MAP_FAILED) { perror("mmap"); return 1; }

    /* 读 CTRL 寄存器 */
    uint32_t ctrl = *(volatile uint32_t *)((uint8_t *)base + 0x00);
    printf("CTRL  = 0x%08x\n", ctrl);

    /* 写 PERIOD = 100000(1kHz @ 100MHz) */
    *(volatile uint32_t *)((uint8_t *)base + 0x04) = 100000;
    printf("PERIOD 已写入 100000\n");

    munmap(base, MAP_SIZE);
    close(fd);
    return 0;
}

/dev/mem 的几个实际陷阱

陷阱说明
PAGE_SIZE 对齐mmap 的 offset 参数(最后一个参数)必须是 getpagesize()(通常 4096)的倍数,不是的话会 EINVAL
O_SYNC 标志打开 /dev/mem 时加 O_SYNC,防止读写被合并或重排;缺了可能导致写操作延迟生效
内核 CONFIG_STRICT_DEVMEMPetaLinux 2023.2 默认开 CONFIG_STRICT_DEVMEM,限制只有非 RAM 的物理地址(即 MMIO 区域)才能访问——对 PL 寄存器(0x43C00000)是放行的,但你不能用它访问 DDR 物理地址
没有中断支持/dev/mem 只能读写,无法等中断;用轮询 STATUS 寄存器勉强替代,效率极差
多进程不安全两个进程同时 mmap 同一块地址,没有任何互斥保证,在调试多线程应用时容易踩

🚧 避坑/dev/mem 调试完记得删掉,或者加 #ifdef DEBUG 保护。我见过有人把 /dev/mem 方法直接用在产品固件里,结果一旦某个进程崩溃,PWM IP 进入未知状态,硬件失控。


7. 完整内核驱动路线(何时需要)

UIO 满足大部分寄存器读写场景,但以下情况需要写完整内核驱动:

  • DMA 引擎axidma 之类需要在内核里管理 scatter-gather descriptor
  • 多用户互斥:多个进程抢同一个 IP,需要内核级 mutex 保护
  • sysfs / debugfs 接口:暴露 IP 状态给系统监控工具
  • 与其他内核子系统集成:比如把 PWM IP 注册成 pwm_chip,让 /sys/class/pwm 管理它

完整驱动的骨架(以 platform driver 为例):

/* pwm_ip_drv.c — 最小可工作骨架,Vivado 2023.2 / PetaLinux 2023.2 */
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/io.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/uaccess.h>

#define DRV_NAME "pwm-ip"

/* 寄存器偏移(和 UIO 版本一致) */
#define PWM_CTRL   0x00
#define PWM_PERIOD 0x04
#define PWM_HIGH   0x08
#define PWM_STATUS 0x0C

struct pwm_ip_dev {
    void __iomem    *base;  /* ioremap 后的内核虚拟地址 */
    struct miscdevice mdev;
};

static long pwm_ip_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct pwm_ip_dev *priv = container_of(filp->private_data,
                                            struct pwm_ip_dev, mdev);
    /* 实际项目里在这里实现 _IOR/_IOW 命令 */
    (void)priv; (void)cmd; (void)arg;
    return -ENOTTY;
}

static const struct file_operations pwm_ip_fops = {
    .owner          = THIS_MODULE,
    .unlocked_ioctl = pwm_ip_ioctl,
};

static int pwm_ip_probe(struct platform_device *pdev)
{
    struct pwm_ip_dev *priv;
    struct resource   *res;
    int                ret;

    priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL);
    if (!priv) return -ENOMEM;

    /* 从设备树 reg 属性获取物理地址和大小 */
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    priv->base = devm_ioremap_resource(&pdev->dev, res);
    if (IS_ERR(priv->base)) return PTR_ERR(priv->base);

    /* 注册 misc 设备 → /dev/pwm_ip */
    priv->mdev.minor  = MISC_DYNAMIC_MINOR;
    priv->mdev.name   = DRV_NAME;
    priv->mdev.fops   = &pwm_ip_fops;
    ret = misc_register(&priv->mdev);
    if (ret) return ret;

    platform_set_drvdata(pdev, priv);
    dev_info(&pdev->dev, "PWM IP @ %pa probed OK\n", &res->start);
    return 0;
}

static int pwm_ip_remove(struct platform_device *pdev)
{
    struct pwm_ip_dev *priv = platform_get_drvdata(pdev);
    misc_deregister(&priv->mdev);
    return 0;
}

static const struct of_device_id pwm_ip_of_ids[] = {
    { .compatible = "kaiyo,pwm-ip-1.0" },  /* 和设备树 compatible 一致 */
    { }
};
MODULE_DEVICE_TABLE(of, pwm_ip_of_ids);

static struct platform_driver pwm_ip_driver = {
    .probe  = pwm_ip_probe,
    .remove = pwm_ip_remove,
    .driver = {
        .name           = DRV_NAME,
        .of_match_table = pwm_ip_of_ids,
    },
};
module_platform_driver(pwm_ip_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kaiyo Nan");
MODULE_DESCRIPTION("Zynq PL PWM IP 内核驱动骨架");

注意:完整驱动的设备树里 compatible 要改成你自己的字符串(如 "kaiyo,pwm-ip-1.0"),不能继续用 "generic-uio",否则 uio_pdrv_genirq 和你的驱动会抢同一个设备,出现 probe 竞争。

🚧 避坑devm_ioremap_resource 这一行做的事情是:从 struct resource 读出物理地址和大小,调用 request_mem_region 登记(防止其他驱动重复映射),再 ioremap 成内核虚拟地址。不要自己写 ioremap(0x43C00000, 0x10000)——硬编码地址会在设备树地址变更时悄悄失效,并且绕过了内核的资源管理。


8. 本篇你应该带走的几个判断

  • 能独立从 Vivado Address Editor 读出 PL IP 的物理基地址和范围,并写到设备树 reg
  • 知道 interrupts = <0 29 4> 里的 29 是怎么从 IRQ_F2P[0] 算出来的
  • 知道 UIO 的 mmap 用 pgprot_noncached,所以不需要手动 flush cache
  • 能区分 AXI-Lite 寄存器(Device Memory,天然非缓存)和 DMA buffer(System RAM,需要 cache 管理)
  • 知道 /dev/mem 的 PAGE_SIZE 对齐要求,以及它不能用于中断处理
  • 能判断自己的场景适合 UIO 还是需要写完整内核驱动

9. 下一篇预告

下一篇 《Zynq 实战 12|AXI DMA 引擎驱动:从 PL 向 PS DDR 高速搬数据》,我们会:

  • 在 Vivado 里加 AXI DMA IP,配置 MM2S / S2MM 通道
  • 用 Linux dmaengine 框架(不是手写驱动)操控 AXIDMA
  • 测试实际吞吐:HP 端口、burst size、对 DDR 带宽的影响
  • 捋清楚 dma_alloc_coherentdma_map_single 的区别——以及在 Zynq HP 端口下各自的正确用法

参考资料

文档号名称用途
UG585Zynq-7000 SoC TRM第 7 章 Table 7-4:IRQ_F2P 到 GIC SPI ID 的映射
UG585Zynq-7000 SoC TRM第 22 章:OCM / DDR 地址映射,以及 AXI GP/HP 端口地址范围
UG1144PetaLinux Tools Reference Guide 2023.2第 4 章:device tree 定制,system-user.dtsi 位置
Linux kerneldrivers/uio/uio.cUIO 框架源码,uio_mmap_physical 函数里可以看到 pgprot_noncached
Linux kerneldrivers/uio/uio_pdrv_genirq.cgeneric-uio 驱动源码,了解中断处理逻辑
PG021AXI GPIO Product GuideGPIO IP 参考,AXI-Lite 从机寄存器布局示例

所有文档均可在 AMD 官方文档页 免费下载。Linux 内核源码在 elixir.bootlin.com 可以在线浏览。


这是《Zynq FPGA 嵌入式系统设计实战》系列第 11 篇。 如果你在 UIO probe、设备树调试、或 cache 一致性问题上踩了别的坑,欢迎留言。