2、硬件接口编程:MMIO寄存器访问、中断处理注册、DMA缓冲区管理
好,咱们直接进入正题。硬件接口编程,说白了就是让CPU和GPU能“说上话”。你想想看,GPU是个独立的硬件单元,CPU怎么告诉它“开始渲染”?怎么知道它干完了?数据又怎么传过去?
这背后就是三个核心机制:MMIO、中断和DMA。我当年刚接触驱动时,觉得这玩意儿就是读读写写,后来踩了不少坑才明白,这里面的门道深着呢。
2.1 MMIO寄存器访问:CPU和GPU的“对话窗口”
MMIO,全称Memory-Mapped I/O。说白了,就是把GPU的寄存器映射到CPU的地址空间里。CPU读写某个内存地址,实际上就是在读写GPU的寄存器。
嗯,这里要注意:这个“内存地址”可不是你malloc出来的普通内存。它是硬件保留的物理地址区域。在Linux里,我们通常用ioremap()来建立映射。
核心要点:MMIO访问必须使用专门的读写函数,不能直接用指针解引用。原因很简单——编译器可能会优化掉你的访问,或者CPU的Cache会捣乱。
我个人习惯用readl()和writel()这两个函数。它们保证是严格顺序的,不会被乱序执行。
// 映射GPU的寄存器基地址
void __iomem *gpu_regs = ioremap(0xFE000000, 0x1000);
if (!gpu_regs) {
pr_err("ioremap failed\n");
return -ENOMEM;
}
// 写一个控制寄存器
writel(0x1, gpu_regs + GPU_CTRL_REG);
// 读一个状态寄存器
u32 status = readl(gpu_regs + GPU_STATUS_REG);
我在项目中遇到过一个问题:某个GPU在读取状态寄存器时,总是返回0。查了半天,发现是硬件设计上的一个坑——这个寄存器需要连续读两次才能拿到真实值。第一次读是“唤醒”操作,第二次才是有效数据。这种“硬件特性”在文档里往往写得模棱两可,只能靠经验去试。
避坑指南:我曾经因为忘记使用iounmap()导致内存泄漏,系统跑几天就挂掉。记住:ioremap和iounmap必须成对出现。
2.2 中断处理注册:让GPU主动“喊”你
轮询?那是新手干的事。真正高效的驱动,都是用中断。GPU干完活了,发个中断信号给CPU,CPU再来处理结果。这样CPU就不用傻等着了。
注册中断的流程其实不复杂,但细节决定成败。我们来看看标准做法:
// 中断处理函数
static irqreturn_t gpu_irq_handler(int irq, void *dev_id)
{
struct gpu_device *gpu = (struct gpu_device *)dev_id;
u32 status;
// 读取中断状态寄存器
status = readl(gpu->regs + GPU_INT_STATUS);
if (status & GPU_INT_DONE) {
// 渲染完成,唤醒等待队列
complete(&gpu->done);
// 清除中断标志
writel(GPU_INT_DONE, gpu->regs + GPU_INT_CLEAR);
}
return IRQ_HANDLED;
}
// 注册中断
int ret = request_irq(gpu->irq_num, gpu_irq_handler,
IRQF_SHARED, "gpu_driver", gpu);
if (ret) {
pr_err("request_irq failed: %d\n", ret);
return ret;
}
这里有个关键点:中断处理函数里要尽量少做事。为什么?因为中断上下文不能睡眠,不能调用可能导致阻塞的函数。我见过有人在中处理函数里用printk,结果系统直接卡死。
我的经验:中断处理函数里只做最必要的事——读取状态、清除中断标志、唤醒等待队列。真正的数据处理,放到下半部(如tasklet或workqueue)里去做。
还有一点:中断共享。现在的硬件越来越复杂,多个设备可能共享同一个中断号。所以你的中断处理函数必须能判断这个中断是不是自己的。这就是为什么request_irq要传入dev_id参数——用来区分不同设备。
2.3 DMA缓冲区管理:数据搬运的“高速公路”
DMA,Direct Memory Access。说白了,就是让GPU自己直接读写内存,不用CPU插手。CPU只需要告诉GPU:“数据在地址A,大小是B,你去处理吧。”然后GPU就自己干活去了。
DMA缓冲区管理,核心问题有两个:
- 物理连续性:DMA操作要求缓冲区在物理地址上是连续的。但内核分配的内存,虚拟地址连续,物理地址不一定连续。
- Cache一致性:CPU和DMA设备看到的同一块内存,可能因为Cache导致数据不一致。
Linux内核提供了dma_alloc_coherent来解决这两个问题。它分配的内存既是物理连续的,又保证了Cache一致性。
dma_addr_t dma_handle;
void *cpu_addr;
// 分配DMA缓冲区
cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
if (!cpu_addr) {
pr_err("dma_alloc_coherent failed\n");
return -ENOMEM;
}
// cpu_addr是CPU看到的虚拟地址
// dma_handle是设备看到的物理地址(总线地址)
// 把dma_handle告诉GPU,它就能直接访问这块内存了
writel(dma_handle, gpu_regs + GPU_DMA_ADDR);
writel(size, gpu_regs + GPU_DMA_SIZE);
writel(0x1, gpu_regs + GPU_DMA_START); // 启动DMA传输
嗯,这里有个容易忽略的点:dma_alloc_coherent分配的内存,默认是带Cache的。但有些硬件要求DMA缓冲区必须是非Cache的。这时候你需要用dma_alloc_writecombine或者自己设置页表属性。
性能考量:DMA缓冲区的大小不是随便定的。太小了,频繁启动DMA开销大;太大了,浪费内存。我一般根据GPU的处理能力来算:比如GPU每秒能处理100帧,每帧数据量是4MB,那缓冲区至少得能装下2-3帧的数据,才能保证流水线不空转。
我在项目中遇到过最头疼的问题:DMA传输完成后,CPU读到的数据还是旧的。这就是Cache一致性问题。虽然dma_alloc_coherent保证了这一点,但如果你用了dma_map_single这种流式映射,就必须手动处理同步:
// 在启动DMA之前,把数据刷出Cache
dma_sync_single_for_device(dev, dma_handle, size, DMA_TO_DEVICE);
// 在DMA完成后,从Cache里读取最新数据
dma_sync_single_for_cpu(dev, dma_handle, size, DMA_FROM_DEVICE);
忘了做同步?那恭喜你,你会看到各种随机性的数据错误。我曾经因为这个bug调试了整整三天,最后发现就是少了一句dma_sync_single_for_cpu。
2.4 三者协同:一个完整的传输流程
好了,三个核心机制都讲完了。咱们把它们串起来,看看一个完整的GPU任务是怎么跑的:
- 准备数据:CPU用
dma_alloc_coherent分配缓冲区,把渲染命令和数据填进去。 - 通知GPU:通过MMIO写入DMA地址和大小,然后写启动寄存器。
- GPU干活:GPU通过DMA读取数据,开始渲染。CPU可以去做别的事。
- 任务完成:GPU发中断。中断处理函数里读取状态、清除标志、唤醒等待队列。
- 收尾工作:CPU被唤醒,检查结果,释放缓冲区。
这个流程看起来简单,但每个环节都可能出问题。我建议你在写驱动时,先把这个流程在纸上画一遍,标出每个步骤用到的函数和数据结构。等代码写完了,再对照着检查一遍。
调试技巧:如果驱动跑不起来,先检查MMIO读写是否正确。用devmem工具直接读物理地址,看看寄存器的值是不是你写进去的。如果MMIO没问题,再查中断——用cat /proc/interrupts看看中断有没有触发。最后才是DMA的问题。
好了,这一章的内容就到这里。下一章我们会深入GPU的初始化流程,看看怎么把这些接口整合到一个完整的驱动框架里。记住:硬件接口编程,说白了就是“读、写、等、传”四个字。但每个字背后,都有无数细节等着你去踩。