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内核模块的生命周期很简单:
- probe阶段:注册
uio_info,创建/dev/uioX设备节点 - 运行阶段:等待用户态程序打开设备,处理mmap和中断
- remove阶段:清理资源,删除设备节点
你可能会问:「就这么简单?」对,就这么简单。内核部分只做三件事:注册、映射、通知。剩下的,全交给用户态。
3.2 mmap机制详解
mmap是UIO的核心机制。它把内核空间的内存直接映射到用户空间。说白了,就是让用户态程序能直接读写硬件寄存器。
我画了个示意图,帮你理解这个过程:
映射过程其实不复杂。用户态调用mmap()时,内核的uio_mmap函数会被触发。它会检查请求的偏移量,找到对应的uio_mem区域,然后建立页表映射。
这里有个细节——偏移量对应的是uio_info.mmap[]数组的索引。比如你mmap时offset=0,映射的就是mmap[0];offset=4096,映射的就是mmap[1]。每个区域大小必须是页对齐的。
N * page_size。我曾经见过有人传了个奇数偏移,结果mmap返回EINVAL,查了半天才发现是页对齐的问题。
3.3 中断处理流程
中断处理是UIO里最讲究的部分。内核模块收到硬件中断后,不能直接处理——它得通知用户态程序来干活。
流程是这样的:
具体步骤是这样的:
- 硬件触发中断,CPU跳转到UIO内核模块的
handler函数 - 内核handler立即禁用设备中断——这是关键。为什么?防止中断风暴。如果不关,设备可能连续触发中断,把CPU占满
- 写中断计数:内核把中断次数写入
/dev/uioX对应的内存区域 - 唤醒用户态进程:通过
wake_up_interruptible(),让用户态的select()或poll()返回 - 用户态处理完业务后,重新使能中断:通过写
/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和设备直通,那才是真正的大杀器。