TheUnknownBlog

Back

前言

前面我们已经从内存管理、虚拟内存、页表、调度器等方面对 Linux 内核有了一个初步的了解。现在我们来看看 Linux 内核里另一个非常重要的部分:设备模型(device model)。设备模型是 Linux 内核里管理硬件设备和驱动的核心框架。它定义了设备、驱动和总线之间的关系,提供了 MMIO、interrupt、DMA、IOMMU 等机制,让驱动能够高效、安全地与硬件交互。

如果你还没有阅读上一章节,建议先阅读 Linux Kernel (Part 5) - Virtual Filesystem

Disclaimer

我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色,本文的 first draft 肯定是 AI 告诉我的内容。如果你对此感到无法接受,或者觉得 AI 讲得不够好,你可以随时退出阅读,或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系,而不是替代你自己去接触原始资料。

如果你能接受这个前提,那么我们就继续往下走了。

为什么需要设备模型

一块网卡插进系统,内核需要回答的问题远不止“它能不能用”:它挂在哪条总线上(PCIe?USB?platform bus)?哪个驱动能管理它?它的寄存器在哪里,用哪个中断,能不能 DMA?它应该暴露成什么用户接口——eth0/dev/xxx,还是一个 block device?suspend/resume 时该怎么处理?

这就是为什么驱动不是简单地调几个 write_register() 就能了事的。驱动必须嵌入 Linux 的设备模型,围绕三个核心对象展开:

struct device;         // 一个具体设备实例
struct device_driver;  // 能管理某类设备的驱动
struct bus_type;       // 一类总线/匹配规则
c

骨架层面,这三个结构体的核心字段大致如下:

struct device {
    const char         *name;
    struct bus_type    *bus;
    struct device_driver *driver;
    struct device      *parent;
    void               *driver_data;
};

struct device_driver {
    const char   *name;
    struct bus_type *bus;
    int  (*probe)(struct device *dev);
    void (*remove)(struct device *dev);
};

struct bus_type {
    const char *name;
    int (*match)(struct device *dev, struct device_driver *drv);
};
c

真实内核结构体更复杂,但骨架就是这个。三者的关系用一句话说就是:bus 负责把设备和驱动配对,配对成功后调用驱动的 probe() 函数,让驱动接管该设备。

Bus 是什么

在 Linux 里,“bus”不仅仅是一根物理总线,更是一套匹配和管理规则。常见的有:

  • PCI bus:设备从 PCI config space 枚举出来,内核自动发现;
  • USB bus:支持热插拔,设备带有 vendor/product id;
  • platform bus:主要用于 SoC/ARM/RISC-V 上那些不能自己枚举的板载设备,依赖 ACPI 或 Device Tree 描述;
  • virtual bus:有些“设备”本身只是内核抽象出来的逻辑对象。

Bus 最核心的工作是调用 bus->match(device, driver)。一旦匹配成功,内核便调用 driver->probe(device),这就是驱动开发中无处不在的 probe()

每种设备最终对用户呈现的接口,取决于它注册到了哪个内核子系统:

设备类型kernel subsystem用户看到
网卡netdevip link, eth0, socket
磁盘block layer/dev/sda, filesystem
键盘鼠标input subsystem/dev/input/eventX
串口tty/dev/ttyS0
简单字符设备char device/dev/mydev
GPUDRM/dev/dri/card0

Probe() 到底是什么

很多初学者会误解 probe() 是“探测有没有这个硬件”。更准确的理解是:内核已经有了一个 device 对象,现在发现某个 driver 声称能处理它,于是调用这个 driver 的 probe,让它初始化并接管该设备。

一个典型的 probe() 大致做这些事:

static int my_probe(struct device *dev)
{
    // 1. 分配驱动自己的私有状态
    struct my_dev *priv = kzalloc(sizeof(*priv), GFP_KERNEL);

    // 2. 找到硬件资源:寄存器地址、中断号、DMA 能力
    // 3. ioremap MMIO 寄存器
    // 4. request_irq() 申请中断
    // 5. 初始化 DMA buffer / ring buffer
    // 6. 注册到某个子系统:net/block/input/char/tty...
    // 7. 保存私有数据
    dev_set_drvdata(dev, priv);
    return 0;
}
c

这里有一个重要认知:probe() 不是用户程序调用的,也不是硬件直接触发的,而是 device model 在 driver 和 device 匹配成功后主动调用的。以 PCI 网卡为例,整条链是:

PCI device
  -> PCI driver probe()
     -> 初始化硬件
     -> 注册 struct net_device
     -> 用户通过 socket 使用它
plaintext

设备是怎么“出现”的

设备对象的来源大致分两类。

可枚举的总线(如 PCI/PCIe、USB):内核可以主动扫描总线,读取 vendor id / device id,据此创建 struct device,再去匹配 driver。整个流程是:先有硬件被枚举出来,再有 kernel 中的设备对象,再去匹配驱动。

不能自枚举的设备:很多 ARM/RISC-V SoC 上的 UART、GPIO、I2C controller、SPI controller 不挂在 PCI 上,没有自我报告的能力。内核的解决方案是依靠硬件描述——x86 通常用 ACPI,ARM/RISC-V 通常用 Device Tree。这些描述告诉内核:“这里有个 UART,寄存器基址是 XXX,中断号是 YYY。”内核据此创建 platform device,再 match platform driver,再调 probe()。

如果用一张图来总结设备的“出生”过程:

Device Tree / PCIe 枚举
  → bus match → probe()
  → MMIO(ioremap → readl/writel)     ← CPU 给设备下命令
  → DMA API(分配、映射、barrier)      ← 设备高效搬运数据
  → interrupt / IRQ handler             ← 设备异步通知 CPU
  → bottom half / NAPI / workqueue      ← 延后的重活
  → subsystem 注册(net_device 等)     ← 用户态可见接口
  → scheduler: wakeup → runnable → running
plaintext

MMIO

Memory-Mapped I/O 的本质

MMIO 的核心思想是:设备寄存器不是普通 RAM,但 CPU 用“像访问内存一样的 load/store 指令”去访问它。这就是为什么驱动里会出现这样的代码:

val = readl(dev->mmio + REG_STATUS);
writel(cmd, dev->mmio + REG_CMD);
c

看起来像在读写内存,实际上是在和设备寄存器对话。

CPU 在发出一次地址访问时,并不知道这个地址后面一定是 DRAM。总线/互联会把这个地址路由到对应的目标——可能是普通内存、PCIe BAR、SoC 外设寄存器、ROM、framebuffer 等等。从 CPU 视角,访问地址 0xffff000012340000 也许指向的不是“内存条里的某个字节”,而是 UART controller 的 status 寄存器、网卡的 TX doorbell,或 I2C controller 的 control 寄存器。这就叫 memory-mapped I/O。

MMIO 与普通内存的根本区别

虽然都“长得像地址”,但语义完全不同。

普通内存可以 cache、prefetch、合并写,读写基本没有副作用——读出来的就是之前存进去的数据。MMIO 寄存器则完全不同:

  • 读一次可能是“读设备当前状态”
  • 写一次可能会“启动 DMA、清中断或发命令”
  • 不能随便 cache,不能随便乱序
  • 有些寄存器的读或写本身就带有副作用

例如 writel(1, mmio + DOORBELL) 不是“把数字 1 存起来”,而是“告诉网卡:去处理 TX ring”。因此 MMIO 本质上是地址形式的设备命令接口——你每一次读写,都是在和设备对话,而不是在操作一块普通内存。

为什么用 readl/writel

内核不鼓励用 *(u32 *)(base + REG) 这种写法,而是统一用 readl()/writel(),原因有三:

  1. 可移植性:不同架构对设备内存的访问规则不同,readl/writel 封装了这些差异;
  2. 顺序保证:设备寄存器访问常常需要特定顺序,普通 C 指针访问可能被编译器或 CPU 以你不希望的方式优化,而 readl/writel 通常带有合适的 barrier 语义;
  3. 宽度与总线语义:某些设备要求 32 位对齐访问,不能拆成 4 个 byte,专用 accessor 语义更明确。

void __iomem *base 中的 __iomem 是给静态检查工具(sparse)用的注解,意思是这是 I/O memory 指针,不是普通内存指针——不要拿它去 memcpy,不要随便解引用,应该配套 readl/writel 使用。这属于内核“类型约束靠约定加工具辅助”的典型体现。

寄存器地址从哪来

对 platform device,通常来自 Device Tree 或 ACPI 里的 reg 属性:

uart@10000000 {
    reg = <0x0 0x10000000 0x0 0x1000>;
};
plaintext

上面表示这个设备有一段寄存器窗口,物理地址范围是 [0x10000000, 0x10000fff]

对 PCI 设备,则来自 BAR(Base Address Register)——设备向系统声明自己需要一段 MMIO 空间,内核在枚举时为其分配地址,驱动再拿到这个 BAR。

ioremap 做了什么

拿到设备的物理地址后,内核代码不能直接把它当指针用,需要先将这段物理设备地址映射到内核虚拟地址空间:

base = ioremap(0x10000000, 0x1000);
// 或者更常见的写法:
base = devm_ioremap_resource(&pdev->dev, res);
c

得到的 base 是一个内核虚拟地址,但它经过页表映射指向的不是 DRAM,而是设备寄存器。整条链是:

Device Tree / PCI BAR
  → resource(物理地址范围)
  → ioremap
  → kernel virtual address
  → readl/writel 访问
plaintext

这和我们前面讲页表/虚拟内存的知识是连起来的:CPU 最终仍然在访问一个虚拟地址,只不过这段虚拟地址被页表映射到了“设备寄存器物理地址”,而不是 DRAM。

寄存器偏移是哪来的

你经常会在驱动里看到这样的宏定义:

#define REG_DESC_BASE  0x1000
#define REG_DOORBELL   0x1008
#define REG_STATUS     0x1010
c

这些不是 Linux 统一规定的名字,而是某个具体硬件设备手册里定义的一组寄存器偏移,驱动作者把它们写成宏。比如某块网卡手册可能规定:offset 0x1000 是描述符环基址寄存器,offset 0x1008 是 doorbell 寄存器。于是:

writel(desc_addr, mmio + REG_DESC_BASE);  // 告诉设备描述符 ring 在哪
writel(kick,      mmio + REG_DOORBELL);   // 按门铃,通知设备开始处理
c

REG_DESC_BASEREG_DOORBELL 里的 offset 来自硬件规格书,不是 Linux 随便发明的。驱动开发者的工作,就是把硬件手册里的这些定义翻译成内核可以管理的对象。

MMIO 访问的顺序问题

MMIO 里有一个驱动新手很容易踩坑的地方:写操作的顺序问题。

比如你写了:

writel(desc_addr, mmio + REG_DESC_BASE);
writel(kick,      mmio + REG_DOORBELL);
c

语义上是:先告诉设备描述符在哪,再按门铃让它开始。但真实硬件世界里,可能出现:

  • CPU 的 store buffer 让写操作延迟可见
  • 总线/桥把写操作 posted 了
  • 某些架构上普通内存写和 MMIO 写顺序不一致
  • DMA 描述符还没对设备可见,你就先 doorbell 了

所以驱动常需要显式使用 barrier:

/* 先把 ring/descriptor 写到内存 */
...
dma_wmb();
/* 再通知设备 */
writel(kick, mmio + DOORBELL);
c

dma_wmb() 保证前面对描述符的写入已对设备可见,然后才按门铃。这不是“为了编译器好看”,而是为了 CPU 和设备之间的时序正确。

MMIO 与中断

MMIO 和中断经常成对出现,不是巧合,而是因为它们分别承担了两个方向的通信:

  • MMIO:CPU → device(发命令,像“我给你发任务”)
  • interrupt:device → CPU(报结果,像“我做完了,来处理一下”)

如果没有中断,驱动就得 busy wait:

writel(START, base + REG_CMD);
while (!(readl(base + REG_STATUS) & DONE))
    ;
c

这对慢速设备(磁盘、网卡、触摸屏)极其低效:浪费 CPU,延迟高,无法并发处理别的事。典型的异步模式是:CPU 通过 MMIO 写寄存器下命令,返回去干别的;设备完成后发中断;驱动在中断 handler 里用 MMIO 读状态、取结果、清中断:

writel(START, base + REG_CMD);
/* 返回去干别的 */
...

/* 设备完成后发 interrupt */
irq_handler() {
    stat = readl(base + REG_STATUS);
    ...
}
c

所以 MMIO 很像“命令(control plane)”,interrupt 很像“事件通知(event plane)”。你会发现,中断处理最后又回到了 MMIO——这正是“MMIO 和 interrupt 经常绑在一起”的根本原因。

Interrupt

上面我们提过,设备通过 interrupt 向 CPU 报告事件。Linux 里对 interrupt 的抽象叫 IRQ(Interrupt Request)。

IRQ 是什么

IRQ 原始上是 Interrupt Request,但在 Linux 语境里它有三层含义:

  1. 最朴素的意思:一次中断请求本身,比如网卡做完收包,向 CPU 这边发出“请处理我”的通知;
  2. 硬件视角:某条中断源/中断线,例如某个 GPIO 中断输入、PCI MSI vector 等;
  3. Linux 视角:内核分配并管理的一个虚拟编号(virq)。

驱动平时接触的是第三层——内核 IRQ 号(virq),而不是硬件中断号(hwirq)。两者之间通过 irq domain 做映射解耦:

硬件中断源(如 GIC line 57)
  → irq domain 映射
  → Linux IRQ number(virq)
plaintext

这对 Device Tree/ACPI 和级联中断控制器非常关键。不同平台的中断硬件长得完全不一样——x86 常见 APIC,ARM/RISC-V 常见 GIC/PLIC,PCI 设备可能用 MSI/MSI-X。Linux 不想让每个驱动都直接面对这些细节,所以它做了抽象:驱动只需要知道“我有一个 IRQ 资源,注册 handler 即可”,底层到底是 GIC 第几号、MSI 第几向量,由 IRQ 子系统处理。

一次中断的完整控制流

设备内部事件发生
  → 设备向 interrupt controller 报告
  → controller 决定把中断送给某个 CPU
  → CPU 暂停当前执行流,进入中断入口
  → 内核保存现场,进入 generic IRQ 层
  → generic IRQ 层找到 Linux IRQ 对应的 handler
  → 调用驱动 handler
  → handler 快速处理、确认、清中断
  → 如有重活,推迟到 bottom half / threaded irq
  → 从中断返回,可能触发调度
plaintext

这里最关键的认知:中断不是“新开了一个 task”,而是当前 CPU 上的执行流被硬件异步打断了。这和 syscall 很像,都是 trap 进内核,但来源不同——syscall 是软件主动请求,中断是外部异步事件。

这也带来两个天然约束:现场必须保存好,否则原来被打断的代码回不去;中断现场不能拖太久,否则整个 CPU 都被你卡住。

为什么驱动还要自己读状态寄存器

你可能会想:既然 Linux 已经知道是 IRQ 42 了,为什么驱动还要 readl(REG_INT_STAT)

因为 IRQ 42 往往只说明“这个设备有事了”,但不一定说明是 RX 完成、TX 完成、DMA error 还是 link change——这些通常要靠设备自己的状态寄存器判断。所以中断处理经常是两层判断:第一层是系统层(哪个 IRQ 来了?),第二层是设备层(这个设备内部到底哪件事发生了?)。这就是为什么 handler 里经常第一句就是 stat = readl(base + REG_INT_STAT)

上半部与下半部

上半部(top half) 就是 request_irq() 注册的那个函数,它的职责是:确认是不是本设备的中断、读状态寄存器、清中断/mask 中断、把少量关键数据摘出来、安排后续处理。例如:

irqreturn_t my_irq_handler(int irq, void *data)
{
    u32 stat = readl(base + REG_INT_STAT);
    if (!(stat & MY_INT))
        return IRQ_NONE;
    writel(stat, base + REG_INT_STAT);    /* ack/clear */
    schedule_work(&priv->work);           /* 或 raise softirq / NAPI */
    return IRQ_HANDLED;
}
c

下半部(bottom half) 负责真正的重活,主要机制有:

  • softirq:偏底层、偏性能导向,仍然不能随便睡眠;
  • workqueue:在内核线程上下文中执行,可以睡眠,适合逻辑比较重的场景;
  • threaded irq:把中断处理的主体放在一个内核线程里执行,代码更像普通内核路径,对某些慢速外设(挂在 I2C/GPIO 上的设备)很友好,但延迟比纯 hardirq 大;
  • NAPI:网络收包的高效批处理机制,低流量时靠 interrupt 提醒,高流量时切换到 polling 批量收包。

中断 handler 里不能随便睡眠,因为这段代码是“插队进来”执行的,不能像普通进程那样阻塞——否则被打断的那段代码就永远回不来了。

为什么一定要 ack/clear 中断

仅仅是 handler 被调用,并不代表中断就自动消停。很多设备内部有 pending bit,不手动清掉就会一直认为“我还在中断中”,导致 CPU 一返回就被重新打断,产生中断风暴,CPU 被打满。

清中断的方式由设备手册规定,不是 Linux 统一规定,常见的语义有:write-1-to-clear、read-to-clear、写某个专门 ACK 寄存器、先 mask 处理完再 unmask。所以驱动里常见的 bug 之一就是:状态寄存器读了,但清中断的顺序错了或者根本没清干净。

共享中断

有些系统里一个 IRQ 线上可能挂了多个设备。这时内核会把多个 handler 都挂在这个 IRQ 上,某次中断来时每个 handler 都可能被问一遍:“这次是不是你家的事?”所以共享中断 handler 里很常见:

stat = readl(dev->base + REG_INT_STATUS);
if (!(stat & EXPECTED))
    return IRQ_NONE;
/* 是我的,处理完返回 IRQ_HANDLED */
c

这也解释了为什么“IRQ 到了”不等于“驱动已经知道发生了什么”——IRQ 只是一个粗粒度入口,具体语义还得设备寄存器自己说。

中断与调度器的连接

当 interrupt 到来,driver 处理完成后,如果某个正在等待这个 I/O 事件的 task 可以醒来,它会被设为 runnable,然后交给调度器决定何时真正运行。完整链条是:

用户 task 发起 I/O,然后睡眠
  → 设备完成 I/O,发 interrupt
  → IRQ handler / bottom half 标记 I/O 完成
  → wake_up(waitqueue)
  → task 变成 runnable
  → 调度器决定什么时候让它真正 running
plaintext

也就是说,设备 I/O 完成并不意味着等待进程立刻恢复执行,而是先 wakeup,再走调度流程。这把我们前面学过的调度器也连了进来。

DMA

DMA(Direct Memory Access)是设备直接访问内存的能力。它让设备可以绕过 CPU,直接读写内存数据,极大提升了性能和效率。

为什么需要 DMA

如果没有 DMA,CPU 要亲自搬运每一个字节:磁盘读 1MB,CPU 得反复从设备寄存器读再写内存;网卡收包,CPU 得一字节一字节取。对 GPU、NVMe、高速网卡这样的高性能设备,这种做法根本不现实。

DMA(Direct Memory Access)让设备在合适条件下直接读写内存,CPU 只负责“安排”和“通知”,不亲自搬运数据。驱动的典型分工是:CPU 控制,设备搬运,interrupt 通知完成

以网卡发包为例,有了 DMA 之后:

  1. 驱动在内存里准备 packet buffer 和 descriptor
  2. descriptor 里写好 buffer 地址、长度、flags
  3. writel(desc_base, REG_DESC_BASE) 告诉设备“工作单放哪”
  4. writel(kick, REG_DOORBELL) 告诉设备“去看工作单”
  5. 网卡自己 DMA 读 descriptor 和 packet data,发出去
  6. 发包完成,interrupt 通知 CPU

这就把前面讲的 REG_DESC_BASEDOORBELL 完整串起来了。

虚拟地址、物理地址、DMA 地址

DMA 里最容易混淆的是地址。驱动里至少要分清三类:

  1. CPU 虚拟地址:内核代码里看到的指针,如 bufpriv->ring
  2. 物理地址:真实物理内存地址;
  3. DMA 地址:设备眼里要用的地址。

三者不一定相等。中间可能隔着 IOMMU 的地址重映射、bounce buffer、cache 一致性约束。这就是为什么驱动不能直接把 kmalloc() 返回的指针写进设备寄存器——设备不走 CPU 页表,它需要经过 DMA 映射后的地址:

void *buf = kmalloc(4096, GFP_KERNEL);
// 错误!不能直接写给设备
writel((u64)buf, dev->base + REG_BUF_ADDR);
c

正确做法是使用 DMA API,让它帮你把一块 CPU 可用内存变成设备也能访问的 DMA 地址。

Linux DMA API 的两大类

Coherent DMAdma_alloc_coherent() 同时给你一个 CPU 用的地址和设备用的 DMA handle,两者指向同一块内存:

cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
c

比较适合 descriptor ring、completion queue、device control block 这类 CPU 和设备都会频繁读写的长期共享结构。

Streaming DMAdma_map_single() / dma_unmap_single(),把一块已有内存临时映射给设备做一次 DMA,用完再 unmap:

dma_addr = dma_map_single(dev, buf, len, DMA_TO_DEVICE);
// ... DMA 完成 ...
dma_unmap_single(dev, dma_addr, len, DMA_TO_DEVICE);
c

更适合 packet buffer、一次性 I/O payload 这类临时场景。高性能驱动常见的组合是:ring/queue 用 coherent,数据 payload 用 streaming。

Cache 一致性与 Barrier

CPU 有 cache、store buffer 和乱序执行;设备的 DMA 可能绕过 CPU cache 直接读写内存。如果不做同步,可能出现 CPU 以为 descriptor 已写好但设备还看不到,或设备已写完数据但 CPU 还在 cache 里看到旧内容等问题。

这就是为什么在 doorbell 前需要 dma_wmb()

desc->addr  = dma_addr;
desc->len   = len;
desc->flags = OWNED_BY_DEV;
dma_wmb();                               // 保证 descriptor 对设备可见
writel(1, dev->base + REG_DOORBELL);     // 再按门铃
c

如果省略 barrier,设备可能在 descriptor 还没写完时就去读,导致极其难复现的数据错误。这个 bug 很真实——不是假设性的。

Bounce Buffer

有些设备有 DMA 限制,比如只能访问低 32-bit 地址,或者对齐要求特殊。这时内核可能引入 bounce buffer——一块设备够得着的中间缓冲区:

  • 发包时先把数据拷到 bounce buffer,再让设备 DMA;
  • 收包时设备 DMA 到 bounce buffer,再拷回原 buffer。

这显然有额外的拷贝开销,但能兼容受限设备。有了 IOMMU 之后情况会好很多,因为地址重映射更灵活,但并非绝对不需要 bounce buffer,仍然取决于设备能力和平台实现。

IOMMU:给设备做地址翻译和隔离

如果你还记得的话,我在去年这个时候写过一篇文章 Enabling KVM GPU Passthrough 讲过 IOMMU 的概念和它在 GPU 直通里的作用。现在我们来更系统地看看 IOMMU 是什么、为什么需要它、它解决了哪些问题,以及它的代价。

类比 CPU 的 MMU

CPU 访问内存时,虚拟地址经过 MMU/页表翻译到物理地址,进程因此互相隔离。对设备做 DMA 时,若没有 IOMMU,设备拿到地址后可能直接碰物理内存——坏了或写错的驱动可能 DMA 到不该写的地方,虚拟化环境更难以安全隔离。

有了 IOMMU,流程变成:

device DMA address (IOVA)
  → IOMMU page table
  → physical memory
plaintext

IOVA(I/O Virtual Address)是设备眼里的地址,IOMMU 负责翻译到物理页。你可以把它类比成:CPU 用虚拟地址,设备用 IOVA,IOMMU 负责把 IOVA 翻译到物理页。

IOMMU 解决的三个问题

安全隔离:IOMMU 允许内核规定“这个设备只能 DMA 到这几页”,即使驱动有 bug,破坏面也会大大减小。这在现代系统里很重要,尤其是外设复杂、PCIe 设备强大、有安全要求的场景。

虚拟化与设备直通:这正是你在玩 QEMU 显卡直通时反复看到它的原因。Guest 里的驱动会让 GPU 去 DMA “guest 物理地址”,但这些地址对 host 来说并非真实物理地址,需要 host 的 IOMMU 帮忙做映射,把 GPU 的 DMA 限制在这台 VM 分配到的内存范围内。没有 IOMMU,安全可靠的 PCI passthrough 基本无法实现——否则 guest 里的设备可以乱 DMA 到 host 甚至别的 VM 的内存。

地址空间灵活性:IOMMU 可以把一串不连续的物理页映射成设备眼里连续的 IOVA,减少对物理连续内存的苛刻要求,方便 scatter-gather,更方便内存管理。

BIOS 里会有这个选项

因为 IOMMU 是硬件能力,不只是操作系统软件功能。在 BIOS 里打开它,系统/内核才能真正使用 DMA remapping 这些功能。不同平台名字不同:Intel 叫 VT-d,AMD 叫 AMD-Vi 或 AMD IOMMU。注意不要和 CPU 虚拟化(VT-x / SVM)混淆——VT-x 是 CPU 级别的虚拟化,VT-d 是设备 DMA 隔离与直通。

IOMMU Group

现实中设备的隔离边界不一定是单个 PCI 设备。几个设备可能在 PCIe 拓扑上共享上游资源,IOMMU 没法把它们完全隔开,于是 Linux 把它们放进同一个 IOMMU group。这就是做显卡直通时“这个 GPU 和它的 HDMI audio 在同一个 group”的原因——同一个 group 只能作为整体被安全隔离,group 不干净,就很难安全直通。

IOMMU 的代价

IOMMU 不是白来的,也有成本:DMA 访问要多过一层地址翻译(虽然 IOMMU 也有自己的 TLB 级别缓存,但仍有开销);映射、解除映射、失效、同步的管理更复杂;做 VFIO、SR-IOV、PASID、ATS 这些高级特性时,复杂度会上来很多。所以有时候 BIOS 里有人会关掉 IOMMU 追求某些极端性能,但那会牺牲隔离和直通能力。

Platform Device / Device Tree

上面我们提到,对于 PCI/USB 这类可枚举总线,内核可以自己扫描枚举出设备对象;但对于 ARM/RISC-V 上的板载设备,内核需要外部描述来告诉它“这里有个设备,寄存器地址是 XXX,中断号是 YYY”。Device Tree 就是这个描述的格式和机制。

Device Tree 提供了什么

对于一个板载设备,kernel 至少需要知道:寄存器基址(MMIO reg)、中断号(interrupts)、时钟(clocks)、reset 线(resets)、GPIO、电源(regulators)、pin multiplexing(pinctrl),有时还有 DMA 通道和 endpoint 拓扑。

一个典型的 Device Tree 节点:

i2c2: i2c@ff150000 {
    compatible = “vendor,my-i2c”;
    reg = <0x0 0xff150000 0x0 0x1000>;
    interrupts = <42>;
    clocks = <&cru 17>;
    status = “okay”;
};
plaintext

驱动的 probe() 本质上就是把这些“描述”翻译成内核里真正可用的资源对象:

static int my_i2c_probe(struct platform_device *pdev)
{
    struct resource *res;
    void __iomem *base;
    int irq;

    res  = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(&pdev->dev, res);
    irq  = platform_get_irq(pdev, 0);
    devm_request_irq(&pdev->dev, irq, my_irq_handler, 0, “my-i2c”, data);
    return 0;
}
c

如果 DT 配错了,常见结果不是“驱动代码有 bug”,而是:根本没创建设备对象、驱动没 match 上、probe 缺资源失败、时钟/regulator/pinctrl 没准备好。这条依赖链里一环错,整个设备就可能完全不工作。

Compatible 是 Device Tree 里的匹配键

DTS 里的 compatible = “goodix,gt911” 和驱动里的 of_device_id 表里的同名字符串是配对关系:

static const struct of_device_id goodix_of_match[] = {
    { .compatible = “goodix,gt911” },
    { }
};
MODULE_DEVICE_TABLE(of, goodix_of_match);
c

DT 说“这是个 goodix,gt911 设备”,驱动说“我支持 goodix,gt911”,两边 match,于是 probe。compatible 不是注释,它是 match 的核心键——字符串写错一个字母,设备可能就永远不会 probe。

-EPROBE_DEFER:依赖链的优雅处理

复杂板子上,设备 A 的 probe 可能需要设备 B(比如 regulator/clock provider)已经就绪。如果 B 还没准备好,A 的 probe() 返回 -EPROBE_DEFER,内核会在 B 就绪后重新尝试调用 A 的 probe。所以在 dmesg 里看到 deferred probe 不一定是坏事——这是内核依赖顺序机制的一部分,设备不是严格线性初始化的,很多驱动会先 defer,等依赖 ready 以后再回来 probe。

devm_* 系列 API

驱动里大量出现的 devm_ioremap_resource()devm_request_irq()devm_clk_get()devm_kzalloc() 等,含义是:将资源的生命周期绑定到 device 上,在 remove 或 probe 失败时由内核自动回收。

这在驱动里很有价值,因为 probe 失败路径通常很长,remove 路径也麻烦,手写清理很容易漏。所以现代驱动里 devm_* 很常见——它不是魔法,但能显著减少资源管理错误。

第一个开发者怎么调试

读到这里,你可能会想:我有了这些知识,拿到一块新板子,是不是就可以直接写驱动了?又或者说,第一个开发者是怎么写驱动以及调试的?我去哪里找资料,怎么验证每一步?

对于一块全新的 ARM 板子,驱动开发者手里通常有:SoC TRM(Technical Reference Manual)、board schematic、PMIC 文档、参考 Device Tree / BSP、厂商 downstream kernel。他不是从纯黑盒开始的,首要工作是建立“硬件事实表”(这个 panel reset GPIO 接哪根 pin?背光是 PWM 还是专用芯片?触摸芯片挂在哪个 I2C 地址?),而不是马上写驱动代码。

最早的观察能力是串口,不是屏幕。 UART 初始化最简单,只要 pinmux 和时钟配对了就能打印。屏幕路径太长(display controller → bridge → panel → backlight → regulator → reset → timing → MIPI DSI/eDP/LVDS),很多板子 bring-up 的前几天乃至前几周,全靠串口日志活着。所以真正的板级 bring-up,工具链优先级是:USB-UART → JTAG → logic analyzer → oscilloscope,而不是桌面、Wayland 或 framebuffer。

按最小启动链逐层验证: boot ROM → bootloader → UART 打印正常 → DRAM 正常 → kernel 能解压启动 → earlycon/printk 正常 → timer/interrupt 正常 → storage/rootfs 正常 → 基础总线(pinctrl/GPIO/I2C/SPI)→ 再往上调 panel/touchscreen/wifi/audio。第一个开发者并不是一上来就调 touchscreen 或 LCD,而是先把能提供观察能力的东西拉起来。

每一层都做最小测试: 调 I2C touchscreen,不是一上来就在桌面里等触摸事件,而是分层验证:I2C controller 是否 probe 成功 → i2cdetect 能否看到设备地址 → interrupt GPIO 是否会跳变 → reset GPIO 是否控制正确 → regulator 是否上电 → 驱动是否产生 input event → /dev/input/eventX 是否有数据。显示调试同理:DRM 驱动是否 probe → panel driver 是否 probe → regulator 是否 enable → reset 时序是否正确 → DSI link 是否训练成功 → backlight 是否真被拉高。

软件侧的调试工具:dmesg/proc/interrupts/sys/firmware/devicetree/base/sys/kernel/debug/*i2cdetect/i2cget/i2csetevtestmodetestethtooltrace-cmd/ftrace。硬件侧的调试工具:示波器(看 reset/clock 信号时序),逻辑分析仪(抓 I2C/SPI 波形),JTAG(单步调试),USB-UART(串口日志)。对于新板子,前两周的 bring-up 可能完全离不开示波器和逻辑分析仪。

PCIe + 网卡

PCIe 设备是怎么被发现的

与 platform device 最大的区别在于:PCIe 设备会自己“报到”。内核扫描 PCI bus,读到每个设备的 vendor id / device id / class code,创建 struct pci_dev;某个 pci_driver 声明支持这些 id,match 成功,调用 probe()。识别机制不是 DT 的 compatible,而是 PCI ID table:

static const struct pci_device_id my_ids[] = {
    { PCI_DEVICE(0x8086, 0x100e) },
    { 0, }
};
MODULE_DEVICE_TABLE(pci, my_ids);
c

PCI probe() 的关键步骤

static int mynic_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    pci_enable_device(pdev);            // 启用这个设备,让它能真正工作
    pci_request_regions(pdev, “mynic”); // 申请 BAR 对应的资源区间,不让别人乱占
    pci_set_master(pdev);              // 允许设备发起 DMA(bus mastering)
    dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64)); // 声明 DMA 地址宽度
    base = pci_iomap(pdev, 0, 0);      // 把 BAR 映射成内核可访问的 MMIO 地址
    // 分配 rings, net_device, irq 等
    return 0;
}
c

pci_set_master() 很关键,因为 PCI 设备不是天然就允许 bus mastering 的——没有这一步,设备根本无法发起 DMA。dma_set_mask_and_coherent() 则是告诉内核这个设备支持多宽的 DMA 地址空间,比如 64-bit 或 32-bit,内核据此决定分配策略。

BAR(Base Address Register)是 PCIe 设备暴露自身寄存器/内存的窗口。设备向系统声明“我需要一段地址空间”,系统分配后驱动通过 pci_iomap() 拿到 base,之后就是熟悉的 readl/writel(base + REG_XXX)。你可以把 BAR 理解成“这个 PCI 设备的 MMIO 窗口入口”。

真正的网卡驱动在 probe 成功后,通常还会继续:分配 net_device(Linux 网络子系统对“一个网络接口”的核心抽象对象)、初始化 TX/RX ring(DMA descriptor queue)、申请 interrupt(现代网卡常用 MSI-X,多队列时每队列一个 IRQ)、最后注册到网络子系统:

register_netdev(ndev);
c

这样用户态才会看到它变成 eth0enp3s0

网卡驱动的两条数据路径

发包(TX)

socket/send → 协议栈 → 网卡驱动 ndo_start_xmit()
  → 把包放进 TX ring
  → DMA 地址写进 descriptor
  → doorbell 通知设备
  → 设备 DMA 取包并发出去
  → interrupt/completion
  → 驱动回收 descriptor
plaintext

收包(RX)

驱动预先准备 RX buffer ring(把空桶摆好)
  → 网卡收到包
  → DMA 将包写进 RX buffer(设备自己倒进空桶)
  → interrupt 或 NAPI poll
  → 驱动取到包
  → 封装成 skb
  → 交给内核协议栈
  → IP/TCP/UDP → socket → 用户进程 recv/read
plaintext

网卡驱动的本质不是“处理 socket API”,而是在硬件 ring / DMA buffer 和 Linux 网络栈之间做翻译

为什么一定要讲 NAPI

纯 interrupt 模式下,每来一个包就中断一次,在高流量时会让 CPU 被 interrupt 打爆,开销太大,吞吐很差。NAPI 的策略是:低流量时靠 interrupt 提醒,一旦流量大,先关掉 RX interrupt,进入 polling 批量收包,收得差不多了再重新开 interrupt。这是延迟与吞吐之间的经典折中,是 Linux 网络驱动里的核心机制。后面讲网络主线时,NAPI 会是重点。

整条主线

我们可以把上面讲的内容串成一条主线,看看一个设备从被发现、配置、搬运数据到通知 CPU 的完整流程:

Device Tree / PCIe 枚举
  → bus match → probe()
  → MMIO(ioremap → readl/writel)    ← CPU 给设备下命令
  → DMA API(分配、映射、barrier)     ← 设备高效搬运数据
  → interrupt / IRQ handler            ← 设备异步通知 CPU
  → bottom half / NAPI / workqueue     ← 延后的重活
  → subsystem 注册(net_device 等)    ← 用户态可见接口
  → scheduler: wakeup → runnable → running
plaintext

驱动通过 MMIO 配置和命令设备,设备通过 interrupt 异步通知完成/错误,内核在 IRQ 上半部快速响应,在下半部做较重处理,最终可能唤醒 task 并触发调度。这就是大量现代驱动(网卡、NVMe、GPU 队列、高速存储控制器)的基本骨架。

你现在已经掌握了驱动/设备模型里最重要的主干——device/driver/bus/probe、MMIO、interrupt/IRQ、DMA、Device Tree/platform device/依赖链。这足以支撑后面的很多内核主题,这条主线已经够清晰了。

Learning Linux Kernel (Part 6) - Device Model
https://20051110.xyz/blog/linux-kernel-6
Author TheUnknownThing
Published at April 24, 2026
Comment seems to stuck. Try to refresh?✨
浙ICP备2025146421号-1 浙公网安备33010502012185号