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()导致内存泄漏,系统跑几天就挂掉。记住:ioremapiounmap必须成对出现。

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任务是怎么跑的:

  1. 准备数据:CPU用dma_alloc_coherent分配缓冲区,把渲染命令和数据填进去。
  2. 通知GPU:通过MMIO写入DMA地址和大小,然后写启动寄存器。
  3. GPU干活:GPU通过DMA读取数据,开始渲染。CPU可以去做别的事。
  4. 任务完成:GPU发中断。中断处理函数里读取状态、清除标志、唤醒等待队列。
  5. 收尾工作:CPU被唤醒,检查结果,释放缓冲区。

这个流程看起来简单,但每个环节都可能出问题。我建议你在写驱动时,先把这个流程在纸上画一遍,标出每个步骤用到的函数和数据结构。等代码写完了,再对照着检查一遍。

调试技巧:如果驱动跑不起来,先检查MMIO读写是否正确。用devmem工具直接读物理地址,看看寄存器的值是不是你写进去的。如果MMIO没问题,再查中断——用cat /proc/interrupts看看中断有没有触发。最后才是DMA的问题。

好了,这一章的内容就到这里。下一章我们会深入GPU的初始化流程,看看怎么把这些接口整合到一个完整的驱动框架里。记住:硬件接口编程,说白了就是“读、写、等、传”四个字。但每个字背后,都有无数细节等着你去踩。