﻿# CPU 如何与 DMA 控制器交互 —— 从发起请求到数据传输完成的完整过程

> 前一篇 [dma.md](/concepts/io/dma.md) 讲了 DMA 是什么、解决了什么问题、有哪几种变体。本篇深入一步：**CPU 怎么告诉 DMA 引擎"去干活"？DMA 引擎怎么处理这个请求？DMA 请求包长什么样？这个过程中 PCIe 总线扮演什么角色？** 从 CPU 视角看整个 DMA 交互的软硬件全链路。

## 零、先破除误区：不是 CPU "发一个请求包"

很多人以为 DMA 是 CPU 发一个"DMA 请求包"给 DMA 控制器，DMA 控制器解析、执行、返回。**实际情况完全不同**：

- 没有"DMA 请求包"这个协议层的东西。
- CPU 和 DMA 引擎的交互方式是：**CPU 写设备寄存器 → DMA 引擎读描述符 → DMA 引擎发起总线传输**。
- 整个过程**高度硬件化、内存化**——通过共享内存（描述符队列）和 MMIO（寄存器）协作。

> 一句话定位：CPU 和 DMA 的交互不是"发包/响应"模型，而是**"共享内存 + 门铃"**模型。CPU 准备好描述符（放在共享内存里），然后写一下设备的"门铃寄存器"——DMA 引擎听到门铃就去读描述符、开始干活。

## 一、完整交互流程：以 NVMe SSD 读磁盘为例

我们以最常见的场景——应用想从 NVMe SSD 读 4KB 数据——来跟踪 CPU 与 NVMe 控制器的 DMA 引擎如何交互。

### 1.1 系统架构前提

```bash
┌─────────────────────────────────────────────────────┐
│                    主机内存 (DRAM)                      │
│  ┌──────────────────────────────────────────────┐    │
│  │          NVMe 提交队列 (Submission Queue)       │    │
│  │  ┌──────┬──────┬──────┬──────┬──────┐         │    │
│  │  │ cmd0 │ cmd1 │ cmd2 │ cmd3 │ ...  │         │    │
│  │  └──────┴──────┴──────┴──────┴──────┘         │    │
│  │  每个 command = 64 字节的 NVMe 命令描述          │    │
│  └──────────────────────────────────────────────┘    │
│  ┌──────────────────────────────────────────────┐    │
│  │          NVMe 完成队列 (Completion Queue)       │    │
│  │  ┌──────┬──────┬──────┬──────┬──────┐         │    │
│  │  │ cmp0 │ cmp1 │ cmp2 │ cmp3 │ ...  │         │    │
│  │  └──────┴──────┴──────┴──────┴──────┘         │    │
│  │  每个 completion = 16 字节                       │    │
│  └──────────────────────────────────────────────┘    │
│  ┌──────────────────────────────────────────────┐    │
│  │          PRP/SGL 列表（物理区域页列表）           │    │
│  │  指向数据所在的物理页                            │    │
│  └──────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
         ▲                              │
         │        PCIe 总线              │
         │                              ▼
┌─────────────────────────────────────────────────────┐
│               NVMe SSD 控制器                         │
│  ┌──────────┐  ┌──────────────┐  ┌───────────┐     │
│  │ SQ Tail  │  │ DMA 引擎      │  │ NAND 控制器│    │
│  │ Doorbell │  │ (Bus Master) │  │           │     │
│  │ 寄存器   │  │              │  │           │     │
│  └──────────┘  └──────────────┘  └───────────┘     │
└─────────────────────────────────────────────────────┘
```

关键数据结构全在**主机内存**中：

- **提交队列（SQ）**：CPU 往这里写命令，NVMe 控制器从这里读命令。
- **完成队列（CQ）**：NVMe 控制器往这里写完成通知，CPU 从这里读结果。
- **PRP/SGL 列表**：描述数据缓冲区在哪（物理地址列表），解决数据不连续的问题。

### 1.2 完整时序图

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "CPU/驱动\n(内核 nvme 驱动)" as CPU
participant "主机内存\n(DRAM)" as MEM
participant "PCIe 总线" as PCIE
participant "NVMe 控制器\n(DMA 引擎)" as NVME
participant "NAND 闪存" as NAND
== 阶段一：CPU 准备 DMA 描述符 ==
CPU -> MEM : ① 构造 NVMe 命令(64B)\n· Opcode=READ\n· NSID=目标命名空间\n· SLBA=起始逻辑块地址\n· NLB=块数-1\n· PRP1=第一个数据页物理地址\n· PRP2=后续页列表物理地址
note right of MEM: 命令写入 SQ 的下一个空闲 slot\nSQ 是环形缓冲，循环使用
CPU -> MEM : ② 更新 SQ Tail 指针\n(Tail+1) mod queue_size
== 阶段二：CPU 通知 DMA 引擎（写门铃） ==
CPU -> PCIE : ③ CPU 执行 MMIO 写\n目标：NVMe 控制器的 BAR 空间\n地址偏移 = SQ0 Tail Doorbell 寄存器
PCIE -> NVME : ④ PCIe TLP(MemWr) 到达 NVMe
note right of NVME: 这是唯一的"通知"动作\nCPU 只写一个寄存器值\n剩下的全由 DMA 引擎完成
== 阶段三：DMA 引擎主动取命令 ==
NVME -> NVME : ⑤ DMA 引擎比较\n新 Tail != 旧 Tail → 有新命令
NVME -> PCIE : ⑥ DMA 引擎发起 PCIe 读(MemRd)\n地址 = SQ 基地址 + Head*64\n长度 = 64 字节(一条命令)
PCIE -> MEM : ⑦ PCIe 总线读取 SQ 中的命令
MEM -> PCIE : ⑧ 返回 64 字节命令数据
PCIE -> NVME : ⑨ 命令到达 NVMe 控制器
== 阶段四：DMA 引擎执行数据传输 ==
NVME -> NVME : ⑩ 解析命令：READ 操作\n从 PRP1 拿到目标物理地址
NVME -> NAND : ⑪ 从 NAND 读取数据\n(这一步不涉及 CPU/PCIe)
NAND -> NVME : ⑫ NAND 数据就绪
NVME -> PCIE : ⑬ DMA 引擎发起 PCIe 写(MemWr)\n地址 = PRP1 中的物理地址\n数据 = 从 NAND 读出的数据
PCIE -> MEM : ⑭ 数据通过 PCIe 写入主机内存
== 阶段五：DMA 引擎通知 CPU 完成 ==
NVME -> PCIE : ⑮ DMA 引擎发起 PCIe 写\n写完成队列条目(16B)到 CQ
PCIE -> MEM : ⑯ Completion 写入 CQ
note right of MEM: Completion 包含\n· SQ Head Pointer(完成哪条命令)\n· Status Field(成功/失败)
NVME -> NVME : ⑰ 更新 SQ Head +1
NVME -> PCIE : ⑱ 可选：发送 MSI-X 中断
PCIE -> CPU : ⑲ 中断到达 CPU\n触发中断 handler
== 阶段六：CPU 处理完成 ==
CPU -> MEM : ⑳ 中断 handler 读 CQ\n检查 Completion Status\n唤醒等待的进程
CPU -> PCIE : ㉑ 写 CQ Head Doorbell\n告诉 NVMe CQ 已处理完
@enduml
```

### 1.3 关键步骤拆解

#### 步骤①-②：准备描述符（纯内存操作，不涉及 PCIe）

```c
// 内核 NVMe 驱动中的典型代码路径（简化）
struct nvme_command cmd = {0};
cmd.read.opcode = nvme_cmd_read;
cmd.read.nsid = cpu_to_le32(ns->head->ns_id);
cmd.read.slba = cpu_to_le64(block_offset);   // 起始逻辑块
cmd.read.length = cpu_to_le16(nr_blocks - 1); // 块数-1
cmd.read.prp1 = cpu_to_le64(dma_addr);        // 第一个物理页地址
// 如果跨多个物理页，还要构造 PRP2 列表
// 把命令写入 SQ 的下一个空闲 slot
sq->cmds[sq->tail] = cmd;
sq->tail = (sq->tail + 1) % sq->qsize;
```

**此时数据还在主机内存中，NVMe 控制器完全不知道有这回事。**

#### 步骤③-④：写门铃（MMIO 写，涉及 PCIe）

这是**整个交互中 CPU 唯一一次主动通知 DMA 引擎**：

```c
// 写 SQ Tail Doorbell 寄存器
writel(sq->tail, nvmeq->q_db);  // q_db 是 PCIe BAR 映射的 MMIO 地址
```

CPU 执行的是一条普通的内存写指令（`mov [addr], value`），但因为目标地址是 MMIO 区域（映射到 PCIe BAR 空间），CPU 的硬件会自动将其转换为 PCIe 总线事务：

```bash
CPU 侧：                     PCIe 总线上：
mov [0xFB000000], 5    →    TLP(MemWr, addr=0xFB000000, data=5)
```

#### 步骤⑥-⑨：DMA 引擎主动取命令（PCIe 读）

NVMe 控制器发现 Tail > Head，知道有新命令。**DMA 引擎自己发起 PCIe 读**：

```bash
NVMe DMA 引擎 → PCIe TLP(MemRd, addr=SQ基址+Head*64, len=64B)
             ← PCIe TLP(CplD, data=64字节命令)
```

**这是 DMA 的核心能力**：外设作为总线主设备，主动读主机内存。CPU 不需要参与——不需要把命令"拷贝"给 NVMe。

#### 步骤⑬-⑭：DMA 引擎执行数据搬运（PCIe 写）

NVMe 从 NAND 拿到数据后，**DMA 引擎直接写主机内存**：

```bash
NVMe DMA 引擎 → PCIe TLP(MemWr, addr=PRP1, data=4096字节)
```

如果数据跨多个物理页（PRP 列表），DMA 引擎会逐个处理每个条目。

#### 步骤⑮-⑱：完成通知

DMA 引擎写完数据后，还要写完成通知：

```bash
NVMe DMA 引擎 → PCIe TLP(MemWr, addr=CQ基址+Head*16, data=Completion条目)
NVMe DMA 引擎 → MSI-X 中断
```

**注意**：Completion 写入和中断是两个独立的动作。先写 CQ，再发中断——这样 CPU 在中断 handler 中一定能读到完整的 Completion。

## 二、DMA 请求"包"到底长什么样

前面说了没有"DMA 请求包"这种东西。但如果我们非要把整个交互抽象成"一个请求"，它由以下部分组成：

### 2.1 逻辑层面的"DMA 请求"

| 组成部分 | 在哪 | 谁写 | 谁读 | 大小 |
|---------|------|------|------|------|
| **命令描述符** | 主机内存的 SQ | CPU | NVMe DMA 引擎 | 64B(NVMe) |
| **数据缓冲区描述** | 主机内存的 PRP/SGL | CPU | NVMe DMA 引擎 | 8B/条目 |
| **门铃通知** | NVMe BAR 寄存器 | CPU | NVMe 控制器 | 4B |
| **完成条目** | 主机内存的 CQ | NVMe DMA 引擎 | CPU | 16B(NVMe) |
| **完成中断** | MSI-X 中断 | NVMe 控制器 | CPU APIC | N/A |

### 2.2 NVMe 命令描述符格式（64 字节）

```bash
Byte 0:   Opcode (1B)     ← 0x02 = READ
Byte 1:   Flags (1B)      ← PRP/SGL 等标志
Byte 2-3: Command ID (2B) ← 命令序号
Byte 4-7: NSID (4B)       ← 命名空间 ID
Byte 8-15: Reserved
Byte 16-23: Metadata Pointer
Byte 24-31: PRP Entry 1   ← 第一个数据物理地址
Byte 32-39: PRP Entry 2   ← PRP 列表物理地址（如果需要）
Byte 40-47: Starting LBA  ← 起始逻辑块地址
Byte 48-49: Number of Logical Blocks ← 块数-1
...
Byte 60-63: 校验/特性
```

### 2.3 不同设备类型的"描述符"差异

不同设备的 DMA 交互本质相同，但描述符格式不同：

| 设备类型 | 队列结构 | 描述符大小 | 通知方式 |
|---------|---------|-----------|---------|
| **NVMe** | SQ/CQ（提交/完成队列） | 命令 64B，完成 16B | SQ Tail Doorbell |
| **网卡** | TX/RX Ring（环形描述符队列） | 描述符 16B | Tail 指针寄存器 |
| **SATA AHCI** | Command List + Command Table | 命令头 32B + 命令表 | PxCI 寄存器 |
| **FPGA DMA IP** | 自定义描述符队列 | 可变（通常 16-64B） | 控制寄存器 |

**但它们遵循同样的"共享内存 + 门铃"模式。**

## 三、与 PCIe 的关系

### 3.1 PCIe 是 DMA 的物理通道

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<cpu>> #E3F2FD
  BorderColor<<cpu>> #1976D2
  BackgroundColor<<pcie>> #FFF9C4
  BorderColor<<pcie>> #F9A825
  BackgroundColor<<dev>> #C8E6C9
  BorderColor<<dev>> #388E3C
}
rectangle "CPU\n(含 PCIe Root Complex)" <<cpu>> as CPU
rectangle "内存控制器\n→ DRAM" <<cpu>> as MC
rectangle "PCIe Root Complex\n(CPU 内部或 PCH)" <<pcie>> as RC
rectangle "PCIe Switch" <<pcie>> as SW
rectangle "NVMe SSD\n(PCIe Endpoint\n+ DMA 引擎)" <<dev>> as NVME
rectangle "网卡\n(PCIe Endpoint\n+ DMA 引擎)" <<dev>> as NIC
rectangle "FPGA\n(PCIe Endpoint\n+ DMA 引擎)" <<dev>> as FPGA
CPU -down-> RC
MC -down-> RC
RC -down-> SW
SW -down-> NVME
SW -down-> NIC
SW -down-> FPGA
note bottom of RC
  Root Complex 是 CPU 和 PCIe 总线的桥梁
  CPU 的 MMIO 写通过 Root Complex 转换成 PCIe TLP
  外设的 DMA 读写也通过 Root Complex 访问内存
end note
@enduml
```

### 3.2 CPU 如何"发起"一个 PCIe 事务

从 CPU 视角看，它做的只是**一条普通的 store 指令**：

```asm
mov dword ptr [rbx], 5   ; rbx = MMIO 地址（映射到 NVMe BAR）
```

但在硬件层面，CPU 的内存控制器会：

1. 检查目标物理地址的范围。
2. 发现它落在 PCIe MMIO 地址空间内（不是 DRAM）。
3. 构造一个 **PCIe TLP（Transaction Layer Packet）**。
4. 通过 Root Complex 发送到 PCIe 总线上。
5. TLP 经过 Switch 路由到目标设备（NVMe）。

```bash
CPU store 指令 → 内存控制器检查地址 → 发现是 MMIO → 
构造 TLP(MemWr, addr=..., data=...) → Root Complex → PCIe → 设备
```

### 3.3 PCIe TLP 的类型与 DMA 的关系

DMA 交互中涉及的 TLP 类型：

| TLP 类型 | 谁发起 | 用途 | 对应 DMA 动作 |
|---------|--------|------|-------------|
| **MemWr** (Memory Write) | CPU → 设备 | CPU 写设备 BAR 寄存器（门铃） | 通知 DMA 引擎 |
| **MemRd** (Memory Read) | 设备 → 内存 | DMA 引擎读 SQ/描述符 | DMA 取命令 |
| **CplD** (Completion with Data) | 内存 → 设备 | 返回读取的数据 | DMA 取命令的响应 |
| **MemWr** (Memory Write) | 设备 → 内存 | DMA 引擎写数据/完成条目 | DMA 搬运数据 |
| **Msg** (Message) | 设备 → CPU | MSI/MSI-X 中断 | DMA 完成通知 |

> **核心**：DMA 的本质就是**外设发起的 PCIe MemRd 和 MemWr TLP**。CPU 不再需要"帮外设读写内存"——外设自己就能发这些 TLP。

## 四、DMA 引擎内部怎么处理"请求"

### 4.1 DMA 引擎的状态机

以 NVMe 控制器中的 DMA 引擎为例：

```plantuml
@startuml
skinparam shadowing false
start
:IDLE 空闲状态;
if (SQ Tail Doorbell 被写?) then (是)
  :读取 SQ Tail 寄存器值;
  :比较 Tail != Head;
  if (有新命令?) then (是)
    :发起 PCIe MemRd\n读 SQ[Head] 位置;
    :等待 CplD 返回命令数据;
    :解析命令 Opcode;
    if (Opcode == READ?) then (是)
      :从 PRP1/PRP2 拿到目标物理地址;
      :等待 NAND 控制器返回数据;
      :发起 PCIe MemWr\n将数据写入目标物理地址;
    elseif (Opcode == WRITE?) then (是)
      :从 PRP1/PRP2 拿到源物理地址;
      :发起 PCIe MemRd 读源数据;
      :将数据传给 NAND 控制器写入;
    endif
    :发起 PCIe MemWr\n写 Completion 到 CQ;
    :Head = (Head + 1) % queue_size;
    :发送 MSI-X 中断;
  else (否)
    :回到 IDLE;
  endif
else (否)
  :回到 IDLE;
endif
stop
@enduml
```

### 4.2 队列管理：Head/Tail 指针的协作

这是 DMA 交互中最重要的并发控制机制：

```bash
SQ (环形缓冲，8 个 slot)：
初始状态：                  CPU 写入 3 条命令后：
Head=0, Tail=0              Head=0, Tail=3
┌──┬──┬──┬──┬──┬──┬──┬──┐  ┌────┬────┬────┬──┬──┬──┬──┬──┐
│  │  │  │  │  │  │  │  │  │cmd0│cmd1│cmd2│  │  │  │  │  │
└──┴──┴──┴──┴──┴──┴──┴──┘  └────┴────┴────┴──┴──┴──┴──┴──┘
DMA 引擎处理 1 条后：        DMA 引擎处理 2 条后：
Head=1, Tail=3              Head=2, Tail=3
┌──┬────┬────┬──┬──┬──┬──┬──┐  ┌──┬──┬────┬──┬──┬──┬──┬──┐
│  │cmd1│cmd2│  │  │  │  │  │  │  │  │cmd2│  │  │  │  │  │
└──┴────┴────┴──┴──┴──┴──┴──┘  └──┴──┴────┴──┴──┴──┴──┴──┘
```

- **CPU 只写 Tail**：每次添加命令后，Tail 前进。
- **DMA 引擎只写 Head**：每次处理完一条命令，Head 前进。
- **Tail == Head 表示队列空**。
- **CPU 永远不会写 Head，DMA 引擎永远不会写 Tail**——这是无锁并发的关键。

## 五、从 PCIe TLP 看一次完整的 DMA 读交互

以 NVMe READ 为例，PCIe 总线上的 TLP 序列：

```bash
时间线 →
CPU侧 TLP:
  [MemWr: SQ[0]=CMD0]          ← 步骤①：写命令到 SQ（普通内存写）
  [MemWr: SQ[1]=CMD1]          ← 再写一条
  [MemWr: NVMe_BAR+0x1000=2]   ← 步骤③：写门铃(Tail=2)
NVMe DMA引擎侧 TLP:
  [MemRd: SQ基址+0, len=64]    ← 步骤⑥：DMA 读 SQ[0]
  [CplD:  64B CMD0数据]        ← 步骤⑨：命令返回
  [MemRd: SQ基址+64, len=64]   ← DMA 读 SQ[1]
  [CplD:  64B CMD1数据]        ← 命令返回
  [MemWr: PRP1, data=4KB]      ← 步骤⑭：DMA 写数据（CMD0）
  [MemWr: CQ基址+0, len=16]    ← 步骤⑯：DMA 写完成条目（CMD0）
  [Msg: MSI-X Vector=X]        ← 步骤⑱：中断
  [MemWr: PRP2, data=4KB]      ← DMA 写数据（CMD1）
  [MemWr: CQ基址+16, len=16]   ← DMA 写完成条目（CMD1）
  [Msg: MSI-X Vector=X]        ← 中断
CPU侧 TLP（中断处理后）:
  [MemWr: CQ_Head_Doorbell=2]  ← 步骤㉑：确认完成
```

可以看到，CPU 只发了 **3 个 TLP**（写命令、写门铃、确认完成），NVMe 的 DMA 引擎发了 **10 个 TLP**（读取命令、写数据、写完成、中断）。**数据搬运的脏活累活全是 DMA 引擎干的。**

## 六、IOMMU 的角色：虚拟化环境中的 DMA 地址翻译

在虚拟化环境中，还有一个重要的角色——IOMMU（Intel VT-d / AMD IOMMU）：

```bash
物理地址视角：                  IOMMU 翻译视角：
DMA 引擎写 0x10000             DMA 引擎写 GPA 0x10000
       ↓                              ↓
   直接到物理内存               IOMMU 翻译 GPA→HPA
                                      ↓
                                 实际写到 HPA 0x500000
```

- **没有 IOMMU**：DMA 引擎用物理地址，如果驱动有 bug，DMA 可能写到任何物理地址（安全风险）。
- **有 IOMMU**：DMA 引擎用 GPA（Guest Physical Address），IOMMU 硬件翻译成 HPA（Host Physical Address），就像 CPU 的 MMU 翻译虚拟地址一样。
- **性能代价**：每次 DMA 需要查 IOMMU 页表，增加延迟。IOMMU 有 TLB（IOTLB），命中则快。

## 七、不同场景的对比总结

| 场景 | CPU 动作 | DMA 引擎动作 | PCIe TLP 数量（CPU侧） |
|------|---------|-------------|---------------------|
| **磁盘读** (NVMe READ) | 写 SQ 命令 + 写门铃 + 处理完成 | 读 SQ → 读 NAND → 写数据到内存 → 写 CQ → 发中断 | 3-4 个 |
| **磁盘写** (NVMe WRITE) | 写 SQ 命令 + 写门铃 + 处理完成 | 读 SQ → 读内存数据 → 写 NAND → 写 CQ → 发中断 | 3-4 个 |
| **网卡收包** | 初始化 RX Ring + 处理完成 | 收到包 → 写数据到内存 → 写描述符 → 发中断 | 1-2 个（处理完成时） |
| **网卡发包** | 写 TX 描述符 + 写门铃 | 读 TX 描述符 → 读内存数据 → 发送 | 2 个 |
| **FPGA 加速** | 准备数据 + 写控制寄存器 | 读内存数据 → 处理 → 写回内存 → 发中断 | 2-3 个 |

## 八、一句话总结

> **CPU 和 DMA 引擎的交互不是"发包/响应"，而是"共享内存 + 门铃"：CPU 在内存中准备好命令描述符（告诉 DMA 引擎从哪搬到哪、搬多少），然后通过一次 MMIO 写（门铃）通知 DMA 引擎；DMA 引擎自己通过 PCIe 总线发起 MemRd 读取描述符、执行数据搬运（MemWr）、写回完成通知、发 MSI-X 中断。整个过程中 CPU 只参与"准备描述符"和"响应中断"两步，数据搬运完全由 DMA 引擎作为 PCIe 总线主设备独立完成。PCIe 是 DMA 的物理通道——DMA 本质就是外设发起的 PCIe MemRd/MemWr TLP。Head/Tail 指针机制保证了 CPU 和 DMA 引擎的无锁并发协作。**

## 九、与仓库其他文档的关系

- **DMA 基础**：[dma.md](/concepts/io/dma.md) — DMA 是什么、为什么需要、Bus Mastering 模型、SG-DMA、DDIO
- **PCIe 总线**：[pcie.md](/concepts/io/pcie/pcie.md) — PCIe 三层协议、TLP 格式、BAR 空间、MMIO 映射
- **FPGA 通信**：[fpga-communication.md](/concepts/io/fpga-communication.md) — FPGA 为什么必须用 DMA
- **中断处理**：[../process/interrupts.md](/concepts/process/interrupts.md) — DMA 完成后的 MSI-X 中断处理
- **IOMMU**：[../container/performance.md](/concepts/container/performance.md) — 容器/虚拟机中 DMA 的 IOMMU 开销

