Learning Linux Kernel (Part 6) - Device Model
In this chapter, we will explore the device model of the Linux kernel, including MMIO, interrupts, DMA, and IOMMU.
前言
前面我们已经从内存管理、虚拟内存、页表、调度器等方面对 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 | 用户看到 |
|---|---|---|
| 网卡 | netdev | ip link, eth0, socket |
| 磁盘 | block layer | /dev/sda, filesystem |
| 键盘鼠标 | input subsystem | /dev/input/eventX |
| 串口 | tty | /dev/ttyS0 |
| 简单字符设备 | char device | /dev/mydev |
| GPU | DRM | /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 → runningplaintextMMIO
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(),原因有三:
- 可移植性:不同架构对设备内存的访问规则不同,
readl/writel封装了这些差异; - 顺序保证:设备寄存器访问常常需要特定顺序,普通 C 指针访问可能被编译器或 CPU 以你不希望的方式优化,而
readl/writel通常带有合适的 barrier 语义; - 宽度与总线语义:某些设备要求 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 0x1010c这些不是 Linux 统一规定的名字,而是某个具体硬件设备手册里定义的一组寄存器偏移,驱动作者把它们写成宏。比如某块网卡手册可能规定:offset 0x1000 是描述符环基址寄存器,offset 0x1008 是 doorbell 寄存器。于是:
writel(desc_addr, mmio + REG_DESC_BASE); // 告诉设备描述符 ring 在哪
writel(kick, mmio + REG_DOORBELL); // 按门铃,通知设备开始处理cREG_DESC_BASE 和 REG_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);cdma_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 语境里它有三层含义:
- 最朴素的意思:一次中断请求本身,比如网卡做完收包,向 CPU 这边发出“请处理我”的通知;
- 硬件视角:某条中断源/中断线,例如某个 GPIO 中断输入、PCI MSI vector 等;
- 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
→ 调度器决定什么时候让它真正 runningplaintext也就是说,设备 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 之后:
- 驱动在内存里准备 packet buffer 和 descriptor
- descriptor 里写好 buffer 地址、长度、flags
writel(desc_base, REG_DESC_BASE)告诉设备“工作单放哪”writel(kick, REG_DOORBELL)告诉设备“去看工作单”- 网卡自己 DMA 读 descriptor 和 packet data,发出去
- 发包完成,interrupt 通知 CPU
这就把前面讲的 REG_DESC_BASE 和 DOORBELL 完整串起来了。
虚拟地址、物理地址、DMA 地址
DMA 里最容易混淆的是地址。驱动里至少要分清三类:
- CPU 虚拟地址:内核代码里看到的指针,如
buf、priv->ring; - 物理地址:真实物理内存地址;
- 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 DMA:dma_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 DMA:dma_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 memoryplaintextIOVA(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);cDT 说“这是个 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/i2cset、evtest、modetest、ethtool、trace-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);cPCI 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;
}cpci_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这样用户态才会看到它变成 eth0 或 enp3s0。
网卡驱动的两条数据路径
发包(TX):
socket/send → 协议栈 → 网卡驱动 ndo_start_xmit()
→ 把包放进 TX ring
→ DMA 地址写进 descriptor
→ doorbell 通知设备
→ 设备 DMA 取包并发出去
→ interrupt/completion
→ 驱动回收 descriptorplaintext收包(RX):
驱动预先准备 RX buffer ring(把空桶摆好)
→ 网卡收到包
→ DMA 将包写进 RX buffer(设备自己倒进空桶)
→ interrupt 或 NAPI poll
→ 驱动取到包
→ 封装成 skb
→ 交给内核协议栈
→ IP/TCP/UDP → socket → 用户进程 recv/readplaintext网卡驱动的本质不是“处理 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 → runningplaintext驱动通过 MMIO 配置和命令设备,设备通过 interrupt 异步通知完成/错误,内核在 IRQ 上半部快速响应,在下半部做较重处理,最终可能唤醒 task 并触发调度。这就是大量现代驱动(网卡、NVMe、GPU 队列、高速存储控制器)的基本骨架。
你现在已经掌握了驱动/设备模型里最重要的主干——device/driver/bus/probe、MMIO、interrupt/IRQ、DMA、Device Tree/platform device/依赖链。这足以支撑后面的很多内核主题,这条主线已经够清晰了。