3. Linux UIO框架详解:UIO内核模块结构、mmap机制、中断处理流程

好,咱们进入正题。UIO这个框架,说白了就是Linux内核给用户态驱动开的一扇后门。你想想看,传统驱动得在内核里写一大堆代码,一不小心就搞崩整个系统。UIO的思路很直接——把硬件寄存器映射到用户空间,让应用程序直接操作,内核只做最基础的中断通知工作。

我个人习惯把UIO比作一个「看门大爷」。内核就是那个大爷,它不帮你处理具体业务,只负责两件事:开门(mmap映射)和喊人(中断通知)。具体活儿,你自己干。

3.1 UIO内核模块结构

一个标准的UIO驱动,内核部分其实很薄。我刚开始接触时也觉得不可思议——就这么点代码?没错,UIO的精髓就是「内核最小化」。

来看一个典型的内核模块结构:

// uio_pci_generic.c 的核心结构
static struct uio_info my_uio_info = {
    .name = "my_uio_device",
    .version = "1.0",
    .irq = UIO_IRQ_CUSTOM,  // 中断类型
    .irq_flags = IRQF_SHARED,
    .handler = my_handler,   // 中断处理函数
    .mmap = {
        {
            .name = "regs",
            .addr = 0xfe000000,  // 物理地址
            .size = 0x1000,      // 4KB
            .memtype = UIO_MEM_PHYS,
        },
        {
            .name = "dma_buf",
            .addr = 0xfe010000,
            .size = 0x100000,    // 1MB
            .memtype = UIO_MEM_PHYS,
        },
    },
};

这里有个关键点——uio_info结构体。它定义了设备的所有属性。我在项目中遇到过有人把memtype写错,结果mmap死活映射不成功。嗯,这个坑我踩过。

UIO内核模块的生命周期很简单:

  1. probe阶段:注册uio_info,创建/dev/uioX设备节点
  2. 运行阶段:等待用户态程序打开设备,处理mmap和中断
  3. remove阶段:清理资源,删除设备节点

你可能会问:「就这么简单?」对,就这么简单。内核部分只做三件事:注册、映射、通知。剩下的,全交给用户态。

3.2 mmap机制详解

mmap是UIO的核心机制。它把内核空间的内存直接映射到用户空间。说白了,就是让用户态程序能直接读写硬件寄存器。

我画了个示意图,帮你理解这个过程:

用户空间 应用程序 用户态驱动 内核空间 UIO内核模块 硬件寄存器 mmap() 直接访问 硬件设备(PCIe / DMA / 自定义外设)

映射过程其实不复杂。用户态调用mmap()时,内核的uio_mmap函数会被触发。它会检查请求的偏移量,找到对应的uio_mem区域,然后建立页表映射。

这里有个细节——偏移量对应的是uio_info.mmap[]数组的索引。比如你mmap时offset=0,映射的就是mmap[0];offset=4096,映射的就是mmap[1]。每个区域大小必须是页对齐的。

⚠️ 注意: mmap的offset必须是N * page_size。我曾经见过有人传了个奇数偏移,结果mmap返回EINVAL,查了半天才发现是页对齐的问题。

3.3 中断处理流程

中断处理是UIO里最讲究的部分。内核模块收到硬件中断后,不能直接处理——它得通知用户态程序来干活。

流程是这样的:

硬件产生中断 UIO内核handler 禁用设备中断 写中断计数到/dev/uioX 用户态select()返回

具体步骤是这样的:

  1. 硬件触发中断,CPU跳转到UIO内核模块的handler函数
  2. 内核handler立即禁用设备中断——这是关键。为什么?防止中断风暴。如果不关,设备可能连续触发中断,把CPU占满
  3. 写中断计数:内核把中断次数写入/dev/uioX对应的内存区域
  4. 唤醒用户态进程:通过wake_up_interruptible(),让用户态的select()或poll()返回
  5. 用户态处理完业务后,重新使能中断:通过写/dev/uioX的某个控制位

💡 核心要点: 中断处理是「内核关中断 → 用户态处理 → 用户态开中断」的闭环。内核只做最轻量的通知工作,绝不碰业务逻辑。

我记得有个项目,设备中断频率特别高,每秒几千次。如果每次中断都让内核做复杂处理,系统早就挂了。但用UIO后,内核只做「关中断+写计数+唤醒」三个动作,耗时不到1微秒。剩下的业务逻辑在用户态慢慢处理,完全不影响系统稳定性。

3.4 用户态中断处理示例

来看一段实际代码。这是用户态程序怎么等中断:

#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/select.h>
#include <unistd.h>

int main() {
    int fd = open("/dev/uio0", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    // 映射硬件寄存器
    void *regs = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE,
                      MAP_SHARED, fd, 0);
    if (regs == MAP_FAILED) {
        perror("mmap");
        return -1;
    }

    // 中断计数缓冲区
    unsigned int irq_count;

    while (1) {
        fd_set fds;
        FD_ZERO(&fds);
        FD_SET(fd, &fds);

        // 等待中断
        int ret = select(fd + 1, &fds, NULL, NULL, NULL);
        if (ret < 0) {
            perror("select");
            break;
        }

        // 读取中断计数(这一步会重新使能中断)
        read(fd, &irq_count, sizeof(irq_count));

        // 处理硬件事件
        // 直接读写 regs 指针即可
        unsigned int status = *(volatile unsigned int *)(regs + 0x00);
        printf("中断 #%d, 状态=0x%x\n", irq_count, status);

        // 写寄存器清除中断状态
        *(volatile unsigned int *)(regs + 0x04) = 0x1;
    }

    munmap(regs, 0x1000);
    close(fd);
    return 0;
}

🔧 避坑指南: 我曾经犯过一个错误——在用户态处理中断时忘了调用read()。结果中断只触发了一次,之后就再也没反应了。为什么?因为read()会触发内核重新使能设备中断。你不读,中断就一直关着。

3.5 多内存区域管理

实际项目中,一个设备往往有多个内存区域。比如:

索引 名称 物理地址 大小 用途
0 regs 0xfe000000 4KB 控制寄存器
1 dma_tx 0xfe010000 1MB DMA发送缓冲区
2 dma_rx 0xfe020000 1MB DMA接收缓冲区
3 fifo 0xfe030000 64KB 数据FIFO

用户态程序需要分别mmap每个区域:

void *regs   = mmap(NULL, 0x1000,   PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
void *dma_tx = mmap(NULL, 0x100000, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0x1000);
void *dma_rx = mmap(NULL, 0x100000, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0x101000);
void *fifo   = mmap(NULL, 0x10000,  PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0x201000);

注意偏移量的计算方式——每个区域的偏移量是前一个区域的页对齐大小之和。不是物理地址偏移,而是mmap[]数组索引对应的页偏移。

嗯,这块容易搞混。我建议你在内核模块里把每个区域的size都设为页对齐的,这样用户态算偏移量时不容易出错。

3.6 性能考量

UIO的性能怎么样?说实话,对于大多数场景,足够了。但有几个瓶颈你得心里有数:

  • 中断延迟:从硬件中断到用户态收到通知,大概几微秒到几十微秒。主要开销在上下文切换
  • mmap开销:建立映射时有一次TLB刷新的代价,但之后访问就和访问普通内存一样快
  • read()系统调用:每次中断处理都要调用一次read(),这个开销大概几百纳秒

如果你需要更低延迟,可以考虑用eventfd代替read(),或者用DPDK那种轮询模式。但那是另一个话题了。

好了,UIO的核心内容就这些。内核模块结构、mmap映射、中断处理,这三块搞明白了,你就能写出稳定可靠的用户态驱动。下一章咱们聊聊vFIO——UIO的进阶版,支持DMA和设备直通,那才是真正的大杀器。