审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
设备和 CPU 用内存地址对话——DMA 是"你别碰,我自己搬",MSI-X 是"别广播,点对点叫我"。
2026-09-26 · PCIe, DMA and MSI-X

第 17 关 · PCIe、DMA 与中断:设备如何与 CPU 协作

定位

CPU 和 PCIe 设备对话只有两条路:CPU 主动找设备——通过 BAR 映射的 MMIO 窗口读写设备寄存器;设备主动找 CPU——用 DMA 直接读写内存,完事发个中断说"搬完了"。本关回答这三件事在内核里的真实形状:BAR 怎么变成 CPU 能访问的地址,DMA 的 ownership 契约是什么,中断怎么从"广播引脚"进化成"点对点消息"。

三个机制支点:pci_resource_start / pcim_iomap(BAR → MMIO)、dma_map_single / dma_alloc_coherent(streaming vs coherent 的所有权契约)、MSI/MSI-X(中断即内存写)。M08 讲了 CPU 收到中断后怎么处理,本关讲设备那一头的发起侧;M15 的缓存一致性会在这里以 dma_sync_* 的形式再出现一次。

核心机制深讲

2.1 BAR 与 MMIO:设备寄存器住在内存地址里

PCI 配置空间里,每个 function 有 6 个标准 BAR(#0–#5)——include/linux/pci.h 里就是这么编号的(PCI_STD_RESOURCES 一组)。BAR 里写的是设备寄存器窗口的物理地址和长度,枚举时内核已经分配好。驱动要访问,先拿地址:pci_resource_start(dev, bar)(宏,本体就是 pci_resource_n(dev, bar)->start),再映射进内核地址空间:pcim_iomap(pdev, bar, maxlen)——managed 版本,driver detach 时自动 unmap,少写一段 cleanup。

映射回来的是 void __iomem *——注意那个 __iomem 注解:sparse 靠它抓"直接解引用 MMIO 指针"的 bug。读写必须用 readl / writel 系列:它们保证访问宽度和顺序语义。直接 *(u32 *)regs 解引用是错的:编译器可能合并/拆分访问(M18 的主题),CPU 也可能重排(M16 的主题)。

铁证:MSI-X 的中断向量表就住在 BAR 里。msix_map_region()(drivers/pci/msi/msi.c)从配置空间读出 PCI_MSIX_TABLE,BIR(BAR Index Register)指出表在第几个 BAR,phys_addr = pci_resource_start(dev, bir) + table_offset 再 ioremap。设备寄存器和中断表住在同一个地址空间里——这就是"用内存地址对话"。

2.2 DMA API:streaming 与 coherent 是两种 ownership 契约

DMA 的本质:设备绕过 CPU 直接读写内存。内核 DMA API(include/linux/dma-mapping.h,kernel/dma/mapping.c)管两件事:给设备一个它能用的地址(dma_addr_t),以及管住缓存一致性。两套 API,对应两种契约:

怎么选:描述符环、队列这种小块高频、CPU 和设备来回碰的控制结构 → coherent;收发包的大 buffer、"CPU 填好 → 设备搬走 → CPU 不再碰"的一次性搬运 → streaming。选错的代价:streaming 忘了 sync = 读到 stale 数据;coherent 乱用 = 白付 uncached 访问的性能税。

2.3 dma_addr_t 不是物理地址:IOMMU 是翻译官兼保安

include/linux/dma-mapping.h 的注释写得清楚:dma_addr_t 是设备能用的 DMA/bus 地址,不一定等于 CPU 的物理地址,两者之间可能有转换。原因有二:设备位宽可能盖不住内存(32 位设备 + 64GB 内存),以及 IOMMU。

kernel/dma/mapping.c 的 dma_map_phys() 是个三岔路口:direct 映射、IOMMU(use_dma_iommu(dev) → iommu_dma_map_sg())、或设备的 dma_map_ops。IOMMU 这层(drivers/iommu/dma-iommu.c,文件头自称"DMA-API 到 IOMMU-API 的通用 glue layer"):给设备一个独立的 IOVA 地址空间,把设备看到的 IOVA 翻译成物理页,带 IOTLB flush 队列。两个后果:第一,隔离——设备只能碰映射给它的 IOVA,直通物理地址等于给设备整台机器的读写权;第二,连续性——IOMMU 能把不连续的物理页拼成连续的 IOVA,省掉 bounce buffer。驱动侧的声明是 dma_set_mask_and_coherent():"我这设备只能看 N 位地址",DMA 层据此选路。

2.4 中断:从 INTx 广播到 MSI/MSI-X 点对点

传统 INTx:4 根边带引脚(INTA–INTD,PCI_NUM_INTX = 4,见 include/linux/pci.h 的 pci_interrupt_pin 枚举),电平触发、多设备共享一根线,来了中断 CPU 还要逐个问"是谁"。MSI 把中断变成一次普通的内存写:设备往约定的 (address, data) 写一次 = 一个中断。点对点、无共享、消息里天然携带向量号——"别广播,点对点叫我"就是这个意思。

内核侧(drivers/pci/msi/msi.c):

使能 MSI/MSI-X 的第一件事都是 pci_intx_for_msi(dev, 0):关掉 INTx。一山不容二虎,MSI 和边带引脚互斥——这是替代,不是补充。

2.5 驱动 probe:从枚举到"能干活"的装配线

PCI 枚举在启动期建好每台设备的 pci_dev(vendor / device / class,见 struct pci_dev)。驱动装配走 drivers/pci/pci-driver.c:__pci_device_probe() → pci_match_device() 拿驱动的 ID 表逐项匹配 → 命中 → pci_call_probe() → local_pci_probe() → 终于调到 pci_drv->probe(pci_dev, id)。

probe 里驱动按固定顺序装配:使能设备的 PCI 命令寄存器(Memory Space / Bus Master)→ pci_resource_start + pcim_iomap 拿寄存器 → dma_set_mask_and_coherent() 声明地址能力 → 分配 MSI/MSI-X 向量(2.4 那套)→ 建 DMA 描述符环(2.2 选 coherent)→ 注册到子系统。任何一步失败都要按逆序回滚——这就是 pcim_(managed)存在的原因。

为什么这样设计

为什么 DMA 要分 streaming 和 coherent,而不是统一一种?

因为代价模型不同,没有免费午餐。coherent 要求内存永远对设备可见——要么 uncached(每次 CPU 访问都慢),要么硬件维护一致性;好处是驱动不用操心 sync,适合描述符环这种"双方高频来回碰"的控制结构。streaming 允许驱动用 cacheable 内存全速访问,代价是驱动必须遵守 ownership 契约:映射期间 CPU 别碰,收回前先 sync/unmap——dma_map_sgtable 注释把这条写成了 correctness 条件。统一成一种,要么所有 DMA 内存都付 coherent 的税,要么所有驱动都背 ownership 的心智负担。分开是按需付费。

为什么 MSI/MSI-X 要取代 INTx?

INTx 是边带引脚:最多 4 根、设备间共享、电平触发、CPU 收到后还要轮询"是你吗"。MSI 把中断变成内存写:点对点(地址里天然带目标)、无共享、消息里自带向量号;MSI-X 更进一步——表放 BAR 里、每个向量独立 mask、独立 affinity,多队列网卡/NVMe 的每个队列可以有自己的中断亲缘性。铁证是互斥性:内核使能 MSI/MSI-X 时第一件事就是 pci_intx_for_msi(dev, 0) 关掉 INTx——新旧两套机制不能共存,说明这是替代不是补充。

为什么 dma_addr_t 不直接用 CPU 物理地址?

因为设备和 CPU 不一定活在同一个地址视角里:32 位设备看不见高位内存;隔离要求设备看不见整台机器。dma_set_mask_and_coherent() 让驱动先声明"我能看多少位",DMA 层在 direct / IOMMU / ops 三条路里选。IOMMU 的 IOVA 既是翻译(拼连续、降位宽)也是保安(设备只能碰自己的 domain)。直通物理地址等于把整台机器的读写权交给一块 PCIe 卡——性能上偶尔行,安全上永远不行。

与已学模块的关联

本关是"设备侧"的发起端,和几关拼成完整链条:

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. BAR 是什么?pci_resource_start(dev, bar) 返回的是什么地址?

BAR(Base Address Register)是 PCI 配置空间里声明的设备寄存器窗口:每个 function 有 6 个标准 BAR(#0–#5),存的是窗口的物理地址和长度。pci_resource_start(dev, bar) 返回该 BAR 窗口的物理起始地址(宏本体就是 pci_resource_n(dev, bar)->start)——注意它还不是 CPU 能直接访问的地址,还要经过 pcim_iomap / ioremap 映射。

Q2. 拿到 pcim_iomap 返回的 void __iomem * 后,为什么必须用 readl/writel,而不能直接解引用?

三层理由。编译器:直接解引用会被当成普通内存访问,编译器可能合并、拆分或重排(M18 的主题);CPU:MMIO 访问需要确定的宽度和顺序,readl/writel 在需要的架构上带 barrier 语义;类型系统:__iomem 注解就是用来让 sparse 抓"直接解引用 MMIO 指针"的 bug 的。设备寄存器有副作用(读清中断、写触发动作),"看起来像内存"不等于"可以当内存用"。

Q3. streaming DMA:dma_map_single 成功后、dma_unmap 之前,驱动又读了 CPU 侧的 buffer,会发生什么?为什么?

可能读到 stale 数据(设备刚写完、还在 cache 或设备侧没回写),或者更糟——破坏 DMA 传输。原因是 ownership 已经移交:dma_map_sgtable() 的注释写得直白,映射成功后 buffer ownership 转到 DMA domain。映射期间 CPU 侧的 buffer 对驱动来说是"别人的内存",要再碰必须先 dma_sync_single_for_cpu() 或 dma_unmap_*() 收回。这不是性能建议,是 correctness 条件。

Q4. 网卡的收包 buffer 和描述符环,分别该用 streaming 还是 coherent?为什么?

收包 buffer → streaming:大块、单向、一次性——CPU 准备好 → dma_map_single → 设备填包 → 中断 → dma_unmap/sync 后 CPU 读。用 cacheable 内存全速访问,只在交接点付 sync 代价。描述符环 → coherent:小块、高频、CPU 和设备来回碰(生产/消费索引、状态位),每次 sync 的开销和心智负担都受不了,dma_alloc_coherent 一次分配、之后不用管缓存。但记住 coherent 的边界:它只解决可见性,"描述符写完再敲 doorbell"的顺序仍要驱动自己用 barrier 保证。

Q5. dma_addr_t 和 phys_addr_t 是什么关系?什么情况下两者数值会相等?

dma_addr_t 是设备视角的 DMA/bus 地址,phys_addr_t 是 CPU 视角的物理地址——include/linux/dma-mapping.h 的注释明确说两者之间可能有转换。数值相等的典型情况:direct 映射 + 无 IOMMU + 设备位宽覆盖全部内存,这时 dma_map_phys() 走 direct 分支,dma_addr_t 就是物理地址。有 IOMMU 时设备看到的是 IOVA,数值一般不相等——这也是"不能把 dma_addr_t 当物理地址传给 CPU 侧函数"的原因。

Q6. msix_capability_init 为什么先置 MASKALL|ENABLE,最后才清 MASKALL?注释里给了哪两个理由?

两个理由都写在源码注释里。先置 MASKALL:"Mask all the vectors to prevent interrupts coming in before they're fully set up"——表还没写完、IRQ domain 还没建好,这时来中断就是野中断。最后清 MASKALL 之前先 msix_mask_all() 逐个 mask:"prevent stale entries from firing in a crash kernel"——kexec 进 crash kernel 时,旧内核写过的 entry 还残留在表里,不清掉会误触发。顺序即正确性:先全部屏蔽 → 慢慢装配 → 确认干净 → 一次放行。

Q7. 一块只支持 32 位 DMA 地址的设备插在 64GB 内存的机器上,驱动声明 32 位掩码后,dma_map_single 可能发生什么?驱动必须做什么?

dma_set_mask_and_coherent() 声明"我只能看低 4GB"后,dma_map_phys() 的三岔路口据此选路:有 IOMMU 时可以走 IOMMU 分支,把任意物理页重映射进 32 位 IOVA 窗口;没有 IOMMU 且 buffer 落在 4GB 之上时,映射会失败(或走 bounce,取决于配置)。所以驱动必须检查每次 dma_map_* 的返回值,失败就要回滚——"声明掩码"不是告诉内核"我能处理",而是让 DMA 层知道"超过这个范围的地址给我就是错的"。

Q8. INTx 和 MSI 在"多设备共享"和"CPU 如何知道是哪个设备"上的本质区别是什么?

共享:INTx 是 4 根边带引脚(INTA–INTD),多设备硬共享一根线;MSI 是内存写,每条消息独立,不存在"共享一根线"的概念。识别:INTx 是电平信号,CPU 收到后只知道"这根线上有设备在叫",必须逐个轮询设备"是你吗";MSI 的消息里自带向量号(data 字段),写到哪个 address 就发给哪个 CPU/APIC——身份信息在消息里,不在轮询里。这就是"别广播,点对点叫我":从"一根线大家喊"变成"每人一封写好收件人的信"。

Q9. pci_write_msg_msix 更新 entry 时为什么先 mask 该 entry?如果不 mask,设备可能发出什么样的中断?

因为 MSI-X 表在 BAR 里,设备一直在看这张表。更新一个 entry 要写三次(address_lo、address_hi、data),不是原子的——如果设备在 address 写完、data 还没写时触发中断,就会按新地址 + 旧 data(或反过来)发出去:中断被送到错误的 CPU、带错误的向量号。pci_write_msg_msix() 按规范先 mask 该 entry(vector_ctrl 置 MASKBIT),写完三件套、readl 确保 flush,再 unmask。改共享表的第一件事永远是让读者先别看。

Q10. 设计题:16 队列的 NVMe 盘,每队列要独立中断 + 独立 CPU affinity,选 MSI 还是 MSI-X?向量数怎么定?INTx 为什么不行?

选 MSI-X。MSI 最多 32 个向量且按 2 的幂分配,16 队列勉强够数,但 MSI 的向量共享一个 data 模式、mask 粒度粗,更重要的是 affinity 控制不如 MSI-X 精细。MSI-X 每个向量是 BAR 表里独立的 entry:独立 address/data(可指向不同 CPU)、独立 mask 位、独立 affinity——16 个队列配 16 个向量(+1 个管理队列),每个队列的中断亲缘性绑到处理该队列的 CPU,NUMA 本地化。INTx 不行:只有 4 根共享引脚,16 个队列挤 4 根线,中断一来先要轮询"是哪个队列",延迟和扩展性全崩——这正是 MSI-X 被发明出来要解决的场景。

延伸阅读

一次 DMA 往返(streaming):
CPU 填 buffer → dma_map_single() → dma_addr_t 交给设备
    → 设备直接读写内存(CPU 别碰!ownership 已移交)
    → 设备写 MSI-X entry → 中断到达 CPU
    → 驱动 dma_sync_single_for_cpu() / dma_unmap_*() 收回
    → CPU 读结果
记住:sync/unmap 是"收回所有权"的仪式,不是性能优化。
MSI-X entry(三件套,住在 BAR 里):
  +0x00  address_lo   中断消息写往哪个地址(目标 CPU/APIC)
  +0x04  address_hi
  +0x08  data         向量号等消息内容
  +0x0C  vector_ctrl  MASKBIT:改表时先 mask,改完再开
表的位置:PCI_MSIX_TABLE 配置字 → BIR 指出第几个 BAR
          → pci_resource_start(dev, bir) + offset → ioremap
中断演进史:
INTx   4 根边带引脚,共享,电平触发,CPU 靠轮询认人
MSI    中断 = 一次内存写 (address, data),≤32 向量(2^5),
       点对点,使能即关 INTx
MSI-X  表放进 BAR,每个向量独立 entry / 独立 mask /
       独立 affinity —— 多队列设备的答案

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。