4、GPU硬件抽象层(HAL)设计:寄存器映射、MMU与IOMMU、中断与时钟管理

各位好,咱们今天聊点硬核的——GPU驱动里的硬件抽象层,也就是HAL。说白了,HAL就是给上层驱动和底层硬件之间搭一座桥。桥搭得稳不稳,直接决定了你的驱动能不能在各种奇葩硬件上跑起来。

我个人习惯把HAL设计分成四个核心模块:寄存器怎么访问、内存怎么管理、中断怎么处理、时钟和电源怎么控制。咱们一个一个来拆解。

4.1 寄存器映射:ioremap 的正确打开方式

GPU的寄存器都在物理地址上。用户态程序不能直接碰,内核态也得先映射一下。这个映射操作就是 ioremap

我见过不少新手直接拿 ioremap 返回的地址当普通内存指针用,结果踩坑踩得鼻青脸肿。为什么?因为GPU寄存器可能有缓存一致性要求,或者有读写顺序约束。

注意: 寄存器映射后,一定要用 readl / writel 这类访问函数,别用 memcpy 或直接解引用。我曾经在一个项目里,因为用了 *(volatile uint32_t *)addr 的方式写寄存器,结果在ARM平台上死活刷不进去数据——编译器优化把写操作给合并了。

代码示例:

void __iomem *gpu_regs;

// 映射GPU寄存器基地址,长度通常为4KB或更多
gpu_regs = ioremap(GPU_BASE_ADDR, GPU_REG_SIZE);
if (!gpu_regs) {
    pr_err("ioremap failed\n");
    return -ENOMEM;
}

// 写寄存器:使能GPU核心
writel(0x1, gpu_regs + GPU_ENABLE_OFFSET);

// 读寄存器:检查状态
uint32_t status = readl(gpu_regs + GPU_STATUS_OFFSET);

这里有个小技巧:映射长度最好按页对齐(4KB)。很多SoC的MMU只支持页粒度映射,不对齐的话 ioremap 会返回错误。

4.2 MMU与IOMMU:内存访问的守门员

GPU有自己的MMU,叫IOMMU。它和CPU的MMU功能类似,但管的是设备访问内存。

你想想看,GPU要访问系统内存里的纹理、顶点缓冲、命令队列。如果没有IOMMU,GPU就得直接操作物理地址。那问题来了:如果用户态程序给了一个错误的地址,GPU直接写挂了整个系统怎么办?

IOMMU就是干这个的。它把GPU看到的“设备虚拟地址”映射到真正的物理地址。这样即使GPU跑飞了,也最多破坏它自己的地址空间。

特性 CPU MMU GPU IOMMU
管理对象 CPU访问内存 设备(GPU)访问内存
页表格式 通常4级页表 可定制,常见2~3级
TLB刷新 由OS管理 由驱动管理,开销大
典型用途 进程隔离 DMA安全、大页支持

我在项目中遇到过一个问题:GPU IOMMU的TLB刷新太频繁,导致性能下降。后来我改成批量刷新——把多个地址的映射更新攒到一起,一次性刷TLB。嗯,效果立竿见影。

经验之谈: IOMMU的页表项里通常有“缓存一致性”位。如果你的GPU和CPU共享数据(比如命令缓冲区),记得把这个位置1。否则你会看到数据不一致的诡异bug,查都查不到。

4.3 中断处理注册:request_irq 的坑与解

GPU干活的时候,CPU不能一直轮询吧?太浪费了。所以GPU完成一个任务后,会发一个中断通知CPU。驱动里注册中断处理函数,用的就是 request_irq

代码示例:

static irqreturn_t gpu_irq_handler(int irq, void *dev_id)
{
    struct gpu_device *gpu = (struct gpu_device *)dev_id;
    uint32_t status;

    // 读中断状态寄存器
    status = readl(gpu->regs + GPU_INT_STATUS);

    // 处理具体中断源
    if (status & GPU_INT_FENCE) {
        // 处理fence信号
        gpu_fence_signal(gpu);
    }
    if (status & GPU_INT_ERROR) {
        // 处理错误,比如页面错误
        gpu_handle_error(gpu);
    }

    // 写中断清除寄存器
    writel(status, gpu->regs + GPU_INT_CLEAR);

    return IRQ_HANDLED;
}

// 注册中断
ret = request_irq(gpu->irq_num, gpu_irq_handler,
                  IRQF_TRIGGER_HIGH | IRQF_SHARED,
                  "gpu", gpu);
if (ret) {
    pr_err("request_irq failed: %d\n", ret);
    return ret;
}

这里有个容易忽略的点:中断处理函数里不能调用 ioremapkmalloc 这些可能睡眠的函数。为什么?因为中断上下文不允许睡眠。我曾经在中断里调了 kmalloc(GFP_KERNEL),结果系统直接死锁——内核调度器都卡住了。

正确的做法是:中断里只做最轻量的工作,比如读状态、清中断、唤醒一个工作队列。真正的处理放到 workqueuetasklet 里。

避坑指南: 我曾经在注册中断时忘了设置 IRQF_SHARED 标志,结果GPU的中断线和别的设备共享,导致中断冲突。系统日志里全是“irq handler mismatch”的报错。排查了两天才发现是共享中断的问题。

4.4 时钟与电源管理接口:别让GPU饿死

GPU是个电老虎。不干活的时候得关掉时钟,甚至断电。干活的时候得把频率提上去。这些操作都通过时钟和电源管理接口完成。

Linux内核里,时钟管理用 clk 框架,电源管理用 regulatorruntime PM

代码示例:

// 获取时钟
struct clk *gpu_clk = clk_get(dev, "gpu_core");
if (IS_ERR(gpu_clk)) {
    dev_err(dev, "failed to get gpu clock\n");
    return PTR_ERR(gpu_clk);
}

// 设置频率
clk_set_rate(gpu_clk, 500000000); // 500MHz

// 使能时钟
clk_prepare_enable(gpu_clk);

// 获取电源域
struct regulator *gpu_reg = regulator_get(dev, "gpu_vdd");
if (IS_ERR(gpu_reg)) {
    dev_err(dev, "failed to get regulator\n");
    return PTR_ERR(gpu_reg);
}

// 设置电压
regulator_set_voltage(gpu_reg, 900000, 900000); // 0.9V

// 使能电源
regulator_enable(gpu_reg);

我个人习惯把时钟和电源的初始化放到 probe 函数里,把关闭放到 remove 里。但光这样不够——你得考虑动态调频。GPU负载低的时候降频省电,负载高的时候升频提性能。

Linux内核提供了 devfreq 框架来做这件事。你只需要实现一个 devfreq_simple_ondemand 的governor,或者自己写一个策略。我建议用 devfreq 而不是自己写定时器,因为内核已经帮你处理了上下限、延迟、采样频率这些细节。

小提示: 时钟使能和电源使能的顺序很重要。通常是先开时钟,再开电源。反过来,关的时候先关电源,再关时钟。顺序错了,GPU可能会进入一个未定义状态,电流飙升。我吃过这个亏,板子差点烧了。

好了,HAL设计的四个核心模块就聊到这儿。寄存器映射要小心缓存问题,IOMMU要关注TLB刷新和一致性,中断处理要轻量快速,时钟电源要按顺序操作。这些细节看起来琐碎,但每一个都可能导致驱动不稳定甚至系统崩溃。

下一章咱们聊聊GPU命令提交和调度——那又是另一片战场了。