﻿# DMA(直接内存访问)—— 数据搬运别让 CPU 亲自搬

> 你让 CPU 从磁盘读一块数据，最笨的办法是 CPU 一个字节一个字节地从磁盘控制器读到寄存器、再写到内存——这期间 CPU 什么都干不了，全耗在"搬砖"上。**DMA(Direct Memory Access，直接内存访问)** 就是为解决这个问题而生的：专门搞一个 DMA 引擎，CPU 只需告诉它"从哪搬到哪、搬多少"，然后 CPU 就可以去干别的；DMA 引擎自己把活干完，完事后通过中断告诉 CPU。本篇讲清：没有 DMA 时 CPU 怎么搬数据(为什么慢)、DMA 的硬件模型和完整工作流、总线控制权(Bus Mastering)、SG-DMA(分散-聚集)等变体、以及为什么服务器访问 FPGA 要用 DMA——本质是让 FPGA 也能当"总线主设备"自己读写主机内存。


> 相关：DMA 完成后通过中断通知 CPU 见 [../process/interrupts.md](/concepts/process/interrupts.md)，零拷贝中的 SG-DMA 见 [../network/zero-copy.md](/concepts/network/zero-copy.md)，PCIe 总线是 DMA 传输的物理通道见 [pcie.md](/concepts/io/pcie/pcie.md)。

## 零、一句话认知：DMA = 让外设自己读写内存，CPU 只做指挥官不做搬运工

没有 DMA 时，外设和内存之间的数据传输有三种方式，都需要 CPU 亲自参与每一次数据搬运：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<cpu>> #FFCDD2
  BorderColor<<cpu>> #C62828
  BackgroundColor<<dma>> #C8E6C9
  BorderColor<<dma>> #388E3C
  BackgroundColor<<mem>> #E3F2FD
  BorderColor<<mem>> #1976D2
}
rectangle "CPU\n'一个一个字节地搬'\n全程占用，其他活全停" <<cpu>> as CPU
rectangle "内存" <<mem>> as MEM
rectangle "外设\n(磁盘/网卡/GPU...)" <<cpu>> as DEV
CPU -down-> MEM : CPU 从外设读到寄存器\n再写到内存
CPU -down-> DEV : 方向反过来也一样
rectangle "DMA 引擎" <<dma>> as DMA
rectangle "内存" <<mem>> as MEM2
rectangle "外设\n(磁盘/网卡/GPU...)" <<cpu>> as DEV2
DEV2 -right-> DMA : DMA 控制器接管总线\n自己完成 DEV↔MEM 传输
DMA -right-> MEM2
note bottom of DMA : CPU 只发"启动指令"\n然后解放去做别的事\nDMA 完成后中断通知 CPU
@enduml
```

> **核心记忆**：DMA 的本质是**把 CPU 从数据搬运中解放出来**。CPU 不再是搬运工，而是指挥官——"你去把那块数据从磁盘搬到内存 0x1234，搬完叫我"。

## 一、没有 DMA 的世界：三种 CPU 亲自搬数据的方式

在 DMA 出现之前，外设和内存之间传输数据全靠 CPU，有三种方式，一种比一种浪费 CPU：

### 1.1 轮询(Polling / PIO)

CPU 反复读外设的状态寄存器，等数据就绪后从外设的数据寄存器读一个字节/字，再写到内存。**CPU 全程占着，不停轮询，其他什么都干不了。**

```bash
while (设备没准备好) {
    等;  // CPU 空转，白白烧周期
}
data = 读设备数据寄存器;
写内存 = data;
```

- **代价**：CPU 100% 占用，哪怕数据还没到也在空转。
- **仅适合**：极简单嵌入式场景、延迟要求极高的少量数据传输。

### 1.2 中断驱动 I/O

外设数据就绪后发中断，CPU 在中断 handler 里把数据从设备寄存器搬到内存。CPU 在等数据期间可以去干别的——**比轮询好，但每次只搬少量数据（通常一个寄存器宽度），大量数据时中断次数爆炸。**

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
}
participant "外设" as DEV
participant "CPU" as CPU
participant "内存" as MEM
CPU -> CPU : 干别的活
DEV -> CPU : ① 数据就绪 → 中断
CPU -> CPU : ② 中断 handler：\n从设备寄存器读一个数据\n写到内存
CPU -> CPU : ③ 返回，继续干之前的活
note over DEV, CPU : 每来一个数据(或一小块)就要中断一次\n数据量大时 → 中断风暴 → CPU 全耗在中断处理上
@enduml
```

- **问题**：传 1MB 数据，如果每次中断只搬 4 字节，需要 262144 次中断——CPU 光进出中断 handler 的开销就爆炸了。

### 1.3 对比

| 方式 | CPU 参与 | 数据量大时的表现 |
|------|---------|----------------|
| 轮询(PIO) | CPU 亲自读、亲自写，100% 占用 | CPU 全程空转，无法做任何其他事 |
| 中断驱动 I/O | CPU 在中断 handler 里搬数据 | 中断次数太多，CPU 全耗在 handler 进出和少量搬运上 |
| **DMA** | **CPU 只发启动命令和收完成中断** | **CPU 几乎完全解放** |

## 二、DMA 的硬件模型：谁在搬、怎么搬

### 2.1 传统 DMA 控制器模型(老式 ISA 总线时代)

早期 PC 在主板上有一个独立的 **DMA 控制器芯片**(如 Intel 8237)，它连接着 CPU、内存和外设。工作流程：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "CPU" as CPU
participant "DMA 控制器\n(如 8237)" as DMAC
participant "内存" as MEM
participant "外设" as DEV
CPU -> DMAC : ① 编程 DMA 控制器:\n设置源地址、目标地址、传输长度
CPU -> DEV  : ② 告诉外设"准备传输"
CPU -> CPU  : ③ CPU 释放总线，去干别的
DMAC -> DEV : ④ DMA 控制器发出读请求
DEV -> DMAC : ⑤ 外设返回数据
DMAC -> MEM : ⑥ DMA 控制器把数据写到内存
note over DMAC : 重复 ④⑤⑥，直到传完所有数据
DMAC -> CPU : ⑦ 传输完成 → 发中断通知 CPU
CPU -> CPU  : ⑧ CPU 在中断 handler 中\n确认传输完成、做后续处理
@enduml
```

- **关键**：CPU 只在第①步"编程控制器"和第⑦步"响应完成中断"时参与。中间的所有数据搬运由 DMA 控制器独立完成，CPU 全程被解放。
- **局限**：老式 8237 只有 4 个通道、16 位地址线只能访问低 16MB 内存，速度慢，早已过时。

### 2.2 现代总线主设备(Bus Mastering)模型

现代系统(PCIe 时代)不再用独立 DMA 控制器，而是**每个外设自己就是一个"总线主设备(Bus Master)"**——外设自带 DMA 引擎，能主动发起对系统内存的读写：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<cpu>> #E3F2FD
  BorderColor<<cpu>> #1976D2
  BackgroundColor<<dma>> #C8E6C9
  BorderColor<<dma>> #388E3C
  BackgroundColor<<bus>> #FFF9C4
  BorderColor<<bus>> #F9A825
}
rectangle "CPU" <<cpu>> as CPU
rectangle "内存控制器\n→ 内存" <<cpu>> as MC
rectangle "PCIe 总线" <<bus>> as BUS
rectangle "网卡\n(自带 DMA 引擎)" <<dma>> as NIC
rectangle "NVMe SSD\n(自带 DMA 引擎)" <<dma>> as NVME
rectangle "GPU\n(自带 DMA 引擎)" <<dma>> as GPU
rectangle "FPGA\n(自带 DMA 引擎)" <<dma>> as FPGA
CPU -down- BUS
MC -down- BUS
BUS -down- NIC
BUS -down- NVME
BUS -down- GPU
BUS -down- FPGA
note bottom of BUS : 每个外设都是"总线主设备"\n可以主动发起对内存的读写\n不再需要中央 DMA 控制器
@enduml
```

**Bus Mastering DMA 的工作流程**（以网卡收包为例）：

1. **驱动初始化**：网卡驱动在内存中分配一段**DMA 缓冲区(环形描述符队列)**，把缓冲区的物理地址告诉网卡。
2. **数据到达**：网卡收到网络包。
3. **网卡自己发起 DMA 写**：网卡(作为总线主设备)通过 PCIe 总线，直接把包数据写到步骤 1 分配好的内存缓冲区——**CPU 全程不参与**。
4. **通知 CPU**：数据写完，网卡发一个中断；CPU 在中断 handler 中处理这个包(走协议栈)。
5. **驱动补充新缓冲区**：CPU 把新的空缓冲区地址写入描述符队列，供网卡下次使用。

> **关键洞察**：现代 DMA 的本质是"外设直接往内存写数据"，就像外设是内存的另一个"写入者"。CPU 和网卡/磁盘/NVMe/GPU/FPGA 在总线上是对等的总线主设备——谁拿到了总线仲裁，谁就能发起传输。

## 三、DMA 与 CPU Cache 的一致性陷阱

DMA 把数据直接写进内存，但 CPU 可能已经把这部分内存缓存到了自己的 L1/L2/L3 cache 里——这就产生了一个经典问题：

```bash
DMA 写内存 ← → CPU cache 里有旧数据
```

- **DMA 写内存后**：CPU cache 里的对应行是**脏的/过时的**。CPU 读这块内存时如果命中 cache，读到的就是旧数据。
- **CPU 写内存后、DMA 读之前**：CPU 的新数据可能还在 cache 里没写回内存，DMA 从内存读到的也是旧数据。

**解决方案**：

| 方法 | 原理 | 适用场景 |
|------|------|---------|
| **Cache Coherent Interconnect**(如 ARM CCI/CCN、x86 的硬件一致性) | 硬件自动维护 DMA 和 cache 之间的一致性，DMA 写会 invalidate 对应 cache line | 现代 x86 服务器大多硬件一致，大部分场景不用管 |
| **显式 cache flush/invalidate** | 软件在 DMA 前后手动调用 `dma_map_single()`/`dma_unmap_single()` 等 API，内核负责 flush/invalidate | 不保证硬件一致性的平台(某些 ARM/嵌入式) |
| **DMA 一致性内存(DMA-coherent memory)** | 分配内存时标记为"不使用 cache"或"硬件自动维护" | 频繁 DMA 的小块控制结构(如描述符环) |

> Linux 内核的 **DMA API**(`include/linux/dma-mapping.h`)封装了这些细节：驱动调用 `dma_map_*()` 建立映射、DMA 完成后调用 `dma_unmap_*()` 解除映射并确保 cache 一致性。驱动程序不需要关心底层是硬件一致性还是软件 flush。

## 四、DMA 的变体：SG-DMA 与 DDIO

### 4.1 SG-DMA（Scatter-Gather DMA，分散-聚集 DMA）— 与传统 DMA 的配置方式完全不同

普通 DMA（含 8237，见 [dma-8237.md](/concepts/io/dma-8237.md)）的编程模型是**寄存器直写**：CPU 把源地址、目标地址、传输长度分别写入 DMA 控制器的寄存器，然后DMA 引擎开始搬连续的物理内存。但实际数据经常分散在多个不连续的物理页中——SG-DMA 的解法是**把"要搬什么"和"怎么搬"描述成一份数据结构（描述符），DMA 引擎自己去读这份数据、自己执行**。这不只是"多了一个链表"——它彻底改变了 CPU 和 DMA 引擎的协作方式。

#### 4.1.1 两种模型的本质区别

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<old>> #FFCCBC
  BorderColor<<old>> #E64A19
  BackgroundColor<<new>> #C8E6C9
  BorderColor<<new>> #388E3C
}
rectangle "传统 DMA (8237 模型)\n━━━━━━━━━━━━━━━\nCPU 逐寄存器写:\n  OUT 0xC0, 地址低8位\n  OUT 0xC0, 地址高8位\n  OUT 0xC0, 计数低8位\n  OUT 0xC0, 计数高8位\n  OUT 0xCA, 解除屏蔽\n\n→ 只能搬一段连续物理内存\n→ 最大16KB(8237)/4KB(其他)\n→ 每次传输都要重新写寄存器\n→ 传输期间不能改寄存器" <<old>> as OLD
rectangle "SG-DMA (描述符模型)\n━━━━━━━━━━━━━━━━\nCPU 在共享内存中构造描述符:\n  [addr1 | len1 | flags | next]\n  [addr2 | len2 | flags | next]\n  [addr3 | len3 | flags | next]\n→ 写门铃寄存器: '去看地址X的环'\n\n→ 可以搬任意多个不连续的内存块\n→ 单次可传输 GB 级数据\n→ 驱动可边传边补充新描述符\n→ 自动链式处理, 硬件自管理" <<new>> as NEW
OLD -down-> NEW : 编程模型进化
@enduml
```

| 维度 | 传统 DMA（寄存器直写） | SG-DMA（描述符模型） |
|------|----------------------|---------------------|
| **配置方式** | CPU 写硬件寄存器（`OUT`/MMIO 写）| CPU 写共享内存中的描述符，再写门铃寄存器通知硬件 |
| **数据在哪** | 源/目标各一个连续物理地址 | 每个描述符指向一段物理地址，链表/环形串联 |
| **一次能搬多少** | 计数器上限（8237 是 64KB，其他平台类似）| 理论上无上限——描述符可以链任意多个 |
| **传输期间能改配置吗** | 不能——寄存器正在被 DMA 使用 | 可以——驱动在 Tail 指针前插入新描述符（环形模式） |
| **谁管理进度** | DMA 控制器内部计数器，软件读状态寄存器 | 硬件更新 Head 指针（已消费到哪），软件更新 Tail 指针（生产到哪） |
| **完成粒度** | 整次传输结束后一个中断 | 每个描述符完成后可发中断 + 写 completion 位 |
| **代表芯片** | Intel 8237 | NVMe 控制器、网卡 DMA 引擎、GPU DMA 引擎 |

> **核心区别**：传统 DMA 的配置是对一组硬件寄存器的**一次性写入**——配置完 DMA 就开跑，跑完为止。SG-DMA 的配置是**在共享内存中维护一份描述符表**——驱动写描述符（生产者），DMA 引擎读描述符（消费者），通过 Head/Tail 指针实现无锁的异步协作。详见 [dma-cpu-interaction.md](/concepts/io/dma-cpu-interaction.md)。

#### 4.1.2 描述符的硬件结构

一个 SG-DMA 描述符是内存中的一个结构体，由 DMA 引擎硬件直接解析。不同的硬件（NVMe、网卡、GPU）描述符略有不同，但核心字段不变：

```bash
┌─────────────────────────────────────────────┐
│  DMA 描述符 (典型 16~64 bytes)                │
├─────────────────────────────────────────────┤
│  Address[63:0]    源/目标物理地址              │  8 bytes
│  Length[31:0]     本段传输长度                  │  4 bytes
│  Flags[15:0]      标志位                        │  2 bytes
│    bit0:   EOP (End of Packet: 最后一个描述符)   │
│    bit1:   INTR (完成此描述符后发中断)            │
│    bit2:   RSVD (保留/硬件提供方自定义)           │
│    bit3:   WRITE (1=写设备→内存, 0=读内存→设备)  │
│    bit4-6: RSVD                                 │
│    bit7:   DONE (硬件完成标记, 写完置1)           │
│  Next[63:0]       下一个描述符的物理地址          │  8 bytes
│  (或有: 无 Next 指针——环形描述符表中靠地址推算)     │
└─────────────────────────────────────────────┘
```

**Linux 内核中的 scatterlist**（`include/linux/scatterlist.h`）是硬件无关的中间层：

```c
struct scatterlist {
    unsigned long   page_link;   // 指向 struct page + 最后2bits编码链信息
    unsigned int    offset;      // 页内偏移
    unsigned int    length;      // 本段长度
    dma_addr_t      dma_address; // DMA 映射后的物理地址(驱动/IOMMU 翻译后)
    unsigned int    dma_length;  // DMA 映射后的长度
};
```

> `scatterlist` 通过 `sg_next()` 连接成链。驱动把 `scatterlist` 链交给 `dma_map_sg()` 映射后，硬件描述符的构造由驱动的 DMA 映射层完成——上层代码不直接操作硬件描述符。

#### 4.1.3 初始化流程：驱动侧怎么准备一次 SG-DMA 传输

以 NVMe 读磁盘扇区为例——希望把 4KB+3KB+1KB 三个不连续的页拼成一个逻辑 IO：

```bash
Step 0: 块层分配了 3 个不连续的 page cache 页面 → scatterlist[3]
Step 1: 构造 scatterlist
  sg[0]: page=A, offset=0,    length=4096
  sg[1]: page=B, offset=2048, length=3072
  sg[2]: page=C, offset=0,    length=1024
Step 2: DMA 映射
  nents = dma_map_sg(dev, sg, 3, DMA_FROM_DEVICE);
  // 内核为每个 sg 条目分配 DMA 地址(IOMMU 翻译后的物理地址)
  // 返回值 nents = 合并后的物理连续段数(IOMMU 可能把相邻物理页合并)
Step 3: 构造硬件描述符(Tail 插入)
  从 NVMe SQ(Submission Queue) 的 PRP/SGL 描述符区域:
  描述符 0:  addr = sg_dma_address(&sg[0]),  len = 4096, EOP=0
  描述符 1:  addr = sg_dma_address(&sg[1]),  len = 3072, EOP=0
  描述符 2:  addr = sg_dma_address(&sg[2]),  len = 1024, EOP=1  ← 最后一个
Step 4: 更新 Tail 指针(写门铃)
  writel(tail + 3,  sq->doorbell);  // 告诉 NVMe 控制器"有 3 个新描述符"
Step 5: NVMe 硬件自动处理(详见 4.1.4)
Step 6: NVMe 完成后写 CQ(Completion Queue) → MSI-X 中断 → 驱动收 completion
```

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<cpu>> #E3F2FD
  BorderColor<<cpu>> #1976D2
  BackgroundColor<<desc>> #FFF9C4
  BorderColor<<desc>> #F9A825
  BackgroundColor<<dma>> #C8E6C9
  BorderColor<<dma>> #388E3C
}
rectangle "驱动(CPU)侧\n━━━━━━━━━━\n① 分配 scatterlist\n② dma_map_sg() 映射\n③ 构造硬件描述符\n④ 写门铃 → Tail++" <<cpu>> as CPU
rectangle "共享内存中的\n描述符环\n━━━━━━━━━━\n[desc0]addrA+4096→\n[desc1]addrB+3072→\n[desc2]addrC+1024→\n ←Head →Tail\n" <<desc>> as RING
rectangle "NVMe 控制器\n(DMA 引擎)\n━━━━━━━━━━\n读描述符→\n发起 PCIe DMA 写→\n物理页 A/B/C\n→更新 Head\n→写 Completion" <<dma>> as DMA
CPU -right-> RING : 写描述符
DMA -left-> RING : 读描述符
CPU --> DMA : 门铃通知
DMA --> CPU : MSI-X 中断
note bottom of RING : Head/Tail 指针\nHead=硬件消费到哪\nTail=软件生产到哪\nHead==Tail = 空\nTail+1==Head = 满
@enduml
```

#### 4.1.4 DMA 引擎硬件遍历流程

SG-DMA 引擎内部是一个小状态机，逐个处理描述符：

```plantuml
@startuml
skinparam shadowing false
skinparam title SG-DMA 引擎硬件状态机
state "IDLE\n(等门铃/Tail变化)" as IDLE
state "FETCH\n读描述符(PCIe读)" as FETCH
state "DECODE\n解析addr/len/flags" as DECODE
state "XFER\n发起PCIe DMA传输\n(每TLP传max_payload字节)" as XFER
state "NEXT\n地址推进到下一个描述符\n或回绕到环首" as NEXT
state "WAIT\nHead==Tail\n等待驱动补充描述符" as WAIT
IDLE --> FETCH : 门铃/Tail != Head
FETCH --> DECODE : 描述符拿到
DECODE --> XFER : addr/len有效
DECODE --> FETCH : NULL描述符(跳过)
XFER --> XFER : 本描述符还没传完\n(分段→多个TLP)
XFER --> DECODE : 本描述符传输完成
DECODE --> IDLE : EOP=1 且 INTR=1\n→写CQ→发MSI-X中断\n→Head更新
DECODE --> FETCH : EOP=0\n→读下一个描述符
FETCH --> WAIT : 下一个描述符的地址\n还没被驱动写入(owned by CPU)
WAIT --> FETCH : 超时/门铃重试
@enduml
```

**关键硬件细节**：

| 细节 | 说明 |
|------|------|
| **描述符所有权** | 每个描述符的 flags 中有一个"owned by HW"位——HW 在开始处理前置 1，CPU 只有在这个位为 0 时才能回收/覆盖 |
| **PCIe TLP 分段** | 描述符的 length 可能很大（如 512KB），但 PCIe Max Payload Size 通常是 128~256 字节。硬件自动把一个描述符拆成多个 TLP——一个 512KB 的描述符 ≈ 4000+ 个 PCIe 写 TLP |
| **乱序处理** | 高端 DMA 引擎可乱序处理多个描述符（不同 PCIe tag），只要完成顺序按描述符排序即可 |
| **Head 更新时机** | 每个描述符处理完后硬件**不立即**写回 Head——通常攒够一批或传完后一次更新，减少 PCIe 写事务 |

#### 4.1.5 环形描述符 vs 链式描述符

两种 SG-DMA 布局，各有适用场景：

```bash
【环形描述符 Ring】(NVMe、网卡多队列)
  ┌──┬──┬──┬──┬──┬──┬──┬──┐
  │D0│D1│D2│D3│D4│D5│D6│D7│  ← 固定大小(如 1024 条目)
  └──┴──┴──┴──┴──┴──┴──┴──┘
   ↑Head               ↑Tail
  驱动: 写完一轮回绕到 D0(需要确认 D0 已被 HW 消费)
  HW:   处理完 D7 后回绕到 D0
  优点: 无动态分配、cache 友好、预取简单
  缺点: 最大吞吐受环大小限制(描述符必须连续)
【链式描述符 Chain】(GPU DMA、FPGA DMA)
  [D0|next→D1] → [D1|next→D2] → [D2|next→NULL]
  驱动: 每次分配一个新描述符, 链上去
  HW:   沿 next 指针一直走到 NULL
  优点: 无上限、灵活
  缺点: 每个描述符多 8 字节(next指针)、cache 不友好(每次 next 可能 cache miss)
```

| | 环形 | 链式 |
|---|---|---|
| **内存布局** | 一段连续物理内存 | 描述符各自分配,next 指针串联 |
| **硬件实现复杂度** | 低（地址+长度推算）| 略高（需解析 next 指针）|
| **驱动复杂度** | 需管理回绕和"环满"检测 | 分配/释放管理 |
| **描述符上限** | 环大小固定 | 无上限 |
| **典型场景** | NVMe SQ/CQ、网卡 TX/RX ring | GPU 命令流、FPGA BD ring |
| **Cache 友好度** | 更友好（连续访问）| 较差（next 指针可能导致 cache miss）|

#### 4.1.6 与传统 DMA 的全面对比

| 维度 | 传统 DMA（8237 模型）| SG-DMA（现代模型）|
|------|---------------------|-------------------|
| **配置接口** | CPU 写 I/O 端口 → 硬件寄存器 | CPU 写共享内存 → 门铃寄存器 |
| **数据布局** | 单段连续物理内存 | 多段不连续物理页，描述符串联 |
| **单次最大传输** | 计数器上限（16bit→64KB）| 理论上无限（受描述符数量/环大小限制）|
| **传输期间参数可变吗** | 不可变（寄存器被 DMA 占用）| 可以——驱动在 Tail 前加新描述符 |
| **CPU 参与度** | 每次传输都要重新编程（连续多次传输 = 多次写寄存器）| 一次初始化描述符环后, 驱动只需更新 Tail+补描述符 |
| **完成通知** | 一次传输一个中断（EOP#/TC）| 每描述符可触发中断 + completion 队列 |
| **错误处理** | 无——错了就错了 | 每描述符有 completion status，可精确定位哪个描述符出错 |
| **并发** | 单通道独占——多个外设不能同时用同一通道 | 多个队列/环可并发, 通道化到不同 CPU 核心 |
| **IOMMU** | 不支持（实地址）| 支持——描述符中的地址经过 IOMMU 翻译 |
| **典型代表** | Intel 8237、老式声卡 DMA | NVMe SGL、网卡 BD ring、GPU DMA engine |

#### 4.1.7 零拷贝中的 SG-DMA 完整流程（`sendfile`）

这是 SG-DMA 最经典的应用——`sendfile()` 从磁盘发文件到网络, 数据不经过用户态缓冲区（详见 [../network/zero-copy.md](/concepts/network/zero-copy.md)）。SG-DMA 的角色：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<cpu>> #E3F2FD
  BorderColor<<cpu>> #1976D2
  BackgroundColor<<mem>> #FFF9C4
  BorderColor<<mem>> #F9A825
  BackgroundColor<<dma>> #C8E6C9
  BorderColor<<dma>> #388E3C
}
rectangle "应用程序\nsendfile(fd_in, fd_out)" <<cpu>> as APP
rectangle "内核\n━━━━━━━━━━━━\nVFS→page cache\n找到文件页面:\n page A(前2扇区)\n page B(后512B)\n→构造 scatterlist[2]\n→dma_map_sg()\n→填网卡TX描述符" <<cpu>> as KERNEL
rectangle "Page Cache\n━━━━━━━━━\n物理页A: 4KB\n(文件数据前4096)\n物理页B: 4KB\n(文件数据后512)" <<mem>> as PC
rectangle "网卡 DMA 引擎\n━━━━━━━━━━━━\n读描述符0: pageA+0, 4096B\n读描述符1: pageB+0, 512B\n→通过 PCIe 读出数据\n→组装以太网帧\n→发到网线" <<dma>> as NIC
APP -down-> KERNEL : sendfile()
KERNEL -down-> PC : 定位文件页
KERNEL -down-> NIC : ① 写 SG-DMA 描述符环\n② 写门铃(Tail更新)
NIC -left-> PC : ③ 网卡读取 page A\n(PCIe DMA 读)
NIC -left-> PC : ④ 网卡读取 page B\n(PCIe DMA 读)
NIC -right-> NIC : ⑤ 组装+发包
@enduml
```

**这里 SG-DMA 的价值**：两个物理页在内存中不连续，普通 DMA 需要先把它们拷到一个连续缓冲区再传——这就是一次多余的 `memcpy`。SG-DMA 让网卡直接从分散的 page cache 页面中收集数据发送，跳过了中间拷贝。这就是"零拷贝"的硬件基础。

> **一句话**：传统 DMA 的配置是一次性写硬件寄存器，SG-DMA 的配置是在共享内存中维护一份描述符表——驱动是生产者（写描述符 + 推进 Tail），DMA 引擎是消费者（读描述符 + 执行传输 + 推进 Head）。这不只是"能搬分散内存"的增量改进，而是把 CPU 和 DMA 引擎的协作从"同步寄存器写"彻底重构为"共享内存 + Head/Tail 指针"的无锁异步模型。

### 4.2 DDIO(Data Direct I/O，Intel)

Intel 高端至强处理器的特殊能力：**网卡 DMA 写数据时不写内存，直接写进 CPU 的 L3 cache**。这样 CPU 处理网络包时直接从 cache 读，延迟从 ~100ns(内存)降到 ~40ns(L3 cache)，对高频交易、低延迟网络场景意义重大。

> 这是 DMA 方向的一次进化：从"写内存"到"写 cache"——让数据和 CPU 更近。

## 五、为什么服务器访问 FPGA 要用 DMA

> 这是 [task.md](/task.md) 中的一个具体问题：服务器通过 PCIe 插 FPGA 加速卡，FPGA 和主机之间怎么传数据？

**如果不用 DMA**：CPU 通过 **MMIO(Memory-Mapped I/O)** 方式，用 `memcpy` 之类的操作把数据写到 FPGA 映射的 PCIe BAR 空间，或者从 BAR 空间读到主机内存。问题是：

- CPU 全程参与每次读写——就像回到了"轮询/PIO 时代"。
- PCIe MMIO 写入延迟比普通内存高很多（~几百 ns），CPU 被 stall。
- 大量数据传输时，CPU 被完全占满，FPGA 加速省下的时间全被数据传输吃回去了。

**用了 DMA 之后**：FPGA 内部实现一个 **DMA 引擎 IP 核**，让它成为 PCIe 总线主设备：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "CPU/应用程序" as CPU
participant "主机内存" as MEM
participant "FPGA\n(PCIe EP + DMA 引擎)" as FPGA
CPU -> MEM  : ① 准备输入数据在主机内存中
CPU -> FPGA : ② 写 FPGA 控制寄存器：\n"输入数据在物理地址 0x...,长度 N\n处理结果写到物理地址 0x...,长度 M\n启动!"
CPU -> CPU  : ③ CPU 释放，去干别的
FPGA -> MEM : ④ FPGA 的 DMA 引擎发起 PCIe 读\n从主机内存搬输入数据到 FPGA 内部
FPGA -> FPGA: ⑤ FPGA 处理数据(计算/加解密/压缩/网络处理)
FPGA -> MEM : ⑥ FPGA 的 DMA 引擎发起 PCIe 写\n把处理结果写回主机内存
FPGA -> CPU : ⑦ 处理完成 → 通过 MSI-X 中断通知 CPU
CPU -> CPU  : ⑧ CPU 在中断 handler 中\n读取结果、唤醒等待的用户进程
@enduml
```

**为什么必须用 DMA**：

| 对比维度 | 不用 DMA(CPU PIO/MMIO) | 用 DMA(FPGA 自带 DMA 引擎) |
|---------|----------------------|--------------------------|
| CPU 占用 | 100%(CPU 全程搬数据) | ~0%(CPU 只发命令和收中断) |
| 吞吐量 | 受限于 CPU 搬数据的速度 | 可达 PCIe 线速(如 Gen4 x16 ~32GB/s) |
| 延迟 | CPU 被 stall，其他任务受影响 | CPU 同时处理其他请求 |
| 数据量 | 仅适合少量控制数据(KB 级) | 适合大数据流(GB 级) |

> **一句话**：FPGA 加速卡插在 PCIe 上，要发挥它的算力，数据必须高速进出 FPGA——DMA 是唯一能让数据流以 PCIe 线速流动、同时不拖死 CPU 的方式。FPGA 必须实现 DMA 引擎成为总线主设备，才能自己主动读写主机内存。

## 六、DMA 的性能开销：不是完全免费

虽然 DMA 解放了 CPU，但它也有代价：

| 开销类型 | 说明 |
|---------|------|
| **内存带宽争抢** | DMA 引擎直接读写内存，和 CPU 抢内存带宽。大量 DMA(如 NVMe 全速读写)会让 CPU 的可用内存带宽下降。 |
| **Cache 污染** | 传统 DMA 写内存后，CPU 再次访问这块数据时会 cache miss(DMA 把数据写进了内存而不是 cache)。DDIO 试图解决这个问题。 |
| **中断开销** | 每次 DMA 完成触发中断，数据量小时中断频率高(类似中断驱动 I/O 的问题)。解决：**中断合并(interrupt coalescing)**——攒够 N 个包或等 T 微秒再发一次中断。 |
| **IOMMU 开销** | 虚拟化环境中，DMA 地址需要经过 IOMMU 翻译(类似 CPU 的 MMU 页表翻译)，增加延迟。 |
| **DMA 映射/解映射开销** | 内核 DMA API 的 `dma_map_*`/`dma_unmap_*` 调用本身有开销(页表操作、cache flush)。 |

## 七、观测与排查

```bash
# 查看 DMA 相关中断
cat /proc/interrupts | grep -E "nvme|eth|DMA"
# 查看 IOMMU/DMA 映射相关内核日志
dmesg | grep -i -E "dma|iommu"
# 查看 PCIe 设备的 DMA 能力
lspci -vvv | grep -i -E "bus master|dma" 
# 查看 DMA 缓冲区使用情况(以网卡为例)
ethtool -S eth0 | grep -i dma
# perf 观测 DMA 相关的 cache miss
perf stat -e cache-misses,cache-references dd if=/dev/nvme0n1 of=/dev/null bs=1M count=1024
```

- **DMA 映射失败**(`swiotlb buffer is full`)：内核日志出现此信息表示 DMA  bounce buffer 不够，可能需要在启动参数中加大 `swiotlb`。
- **DMA 超时**：设备驱动中常见，可能是硬件故障或 PCIe 链路问题。

## 八、和本仓库其他文档的关系

- **CPU 与 DMA 交互**：[dma-cpu-interaction.md](/concepts/io/dma-cpu-interaction.md)——CPU 如何与 DMA 控制器交互：从发起请求到数据传输完成的完整过程，描述符队列、门铃机制、PCIe TLP 序列、IOMMU 地址翻译。
- **中断处理**：[../process/interrupts.md](/concepts/process/interrupts.md)——DMA 完成后通过中断通知 CPU，是中断的下半部(NET_RX softirq)处理 DMA 收到的数据。
- **零拷贝**：[../network/zero-copy.md](/concepts/network/zero-copy.md)——SG-DMA 是零拷贝的终极形态，网卡用 SG-DMA 直接从 page cache 收集数据。
- **PCIe 总线**：[pcie.md](/concepts/io/pcie/pcie.md)——现代 DMA 的物理传输通道，Bus Mastering 依赖 PCIe 的总线仲裁机制。§四.1 详述 BAR 与 DMA 的控制面/数据面关系。
- **容器性能**：[../container/performance.md](/concepts/container/performance.md)——IOMMU 对容器/虚拟机中 DMA 的性能影响。
- **中断合并**：[../process/interrupts.md](/concepts/process/interrupts.md) NAPI 部分——网络 DMA 收包后的中断合并策略。

## 九、一句话总结

> **DMA 是让外设(网卡/磁盘/NVMe/GPU/FPGA)自己读写内存、CPU 只发命令和收完成中断的机制，把 CPU 从数据搬运中彻底解放。现代系统用 Bus Mastering 模型：每个外设自带 DMA 引擎，通过 PCIe 总线成为"总线主设备"主动读写内存。SG-DMA 支持分散-聚集描述符链表，是零拷贝的硬件基础；DDIO 让 DMA 直接写进 L3 cache 进一步降低延迟。服务器访问 FPGA 必须用 DMA——让 FPGA 成为总线主设备以 PCIe 线速搬数据，否则 CPU 搬数据的时间远超 FPGA 加速省下的时间。DMA 不是完全免费，它会争抢内存带宽、引入 cache 一致性问题、产生中断开销，但相比 CPU 亲自搬数据，这些代价不值一提。**

