第1章:PCIe DMA概述 —— 到底什么是DMA?PCIe长什么样?

各位好。我是做PCIe DMA的,十几年了。今天咱们聊聊最基础的事。

说实话,刚入行那会儿,我总把DMA跟普通内存拷贝混在一起。后来踩了坑才明白——DMA的本质是“让设备自己搬数据,CPU只管下命令”。

1.1 什么是DMA?

DMA(Direct Memory Access),中文叫直接存储器访问。说白了,就是让外设(比如网卡、存储卡、FPGA加速卡)直接读写系统内存,不需要CPU一块一块地搬运。

关键点: DMA的核心理念是「从CPU手里抢走数据搬运的活儿」。CPU只写几个描述符,剩下的全交给DMA引擎。

我记得第一次调PCIe DMA的时候,发现数据死活不对。查了两天,原来是CPU cache一致性没处理好。那会儿我才意识到——DMA不只是搬数据,还得管同步。

1.2 PCIe的基本概念

PCIe(Peripheral Component Interconnect Express)是目前主流的设备互连总线。它取代了老旧的PCI、PCI-X。你想想看,现在的显卡、NVMe SSD、FPGA加速卡,哪个不是走PCIe?

特性 PCI PCIe
拓扑 共享并行总线 点对点串行链路
带宽 典型133 MB/s 单通道x1约1 GB/s(Gen3)
可扩展性 差,总线仲裁 强,交换器扩展
DMA支持 需手动配置 原生支持(TLP读写)

经验之谈: 我做项目时见过有人把PCIe当成新型PCI用,结果链路训练都完不成。PCIe是串行的、点对点的,跟并行共享的老家伙完全两码事。

1.3 PCIe的分层结构

PCIe协议栈分三层,从上到下:事务层、数据链路层、物理层。每一层都干自己的活,跟OSI模型似的。

  1. 事务层(Transaction Layer) – 封装读写请求、DMA描述符等。核心单元叫TLP(Transaction Layer Packet)。我举个例子:你要从设备发DMA写,事务层就拼一个Memory Write TLP。
  2. 数据链路层(Data Link Layer) – 加CRC校验、重传机制,保证TLP不丢不坏。这是PCIe可靠性高的根本原因。
  3. 物理层(Physical Layer) – 负责电气信号、串行化、链路训练。你插上显卡,那几秒的“喀喀喀”就是物理层在协商速率和宽度。

我早年调试遇到莫名其妙的丢包,折腾了一周。后来发现是链路不稳定——物理层降速了,数据链路层的重传来不及,事务层自然超时。嗯,从那以后我建了个习惯:先看链路状态,再查数据。

PCIe DMA 知识体系框架 DMA 直接存储器访问 CPU下命令,设备自己搬数据 PCIe 分层结构 事务层 · TLP (Memory Read/Write) 数据链路层 · CRC + 重传 物理层 · 串行化/链路训练 DMA 在 PCIe 上的工作流程 ① CPU 写入 DMA 描述符(源/目的/长度) ② 设备读取描述符(Memory Read TLP) ③ 设备搬运数据(Memory Write TLP) ④ 中断通知 CPU 完成 ⚠️ 注意:DMA 描述符必须放在 CPU 和 PCIe 都可见的内存区域 最好用物理连续内存或IOMMU映射,否则会页面错误

我个人建议: 初学PCIe DMA,先抓三样——描述符队列、TLP类型、完成中断。这三样通了,剩下的都是细节。

1.4 为什么DMA在PCIe上特别香?

PCIe天生就是为DMA优化的。它用Memory Read/Write TLP直接访问系统内存,不需要像USB那样分帧、不需要像SATA那样绕AHCI控制器。

  • 带宽大: Gen3 x8的话,单向约8 GB/s。我做过一个FPGA加速卡,连续DMA读能跑满7.5 GB/s。
  • 延迟低: 端到端几百纳秒。你在GPU里搞个CUDA memcpy都要几微秒。
  • 标准化: 不需要自己定制协议,PCI-SIG早就定义好了。你写驱动只需配置BAR空间、申请DMA缓冲区、填描述符。

警告: 千万别以为DMA就是“读写内存那么简单”。我遇到过两次坑:一次是描述符地址写错,设备读到了乱码;另一次是缓冲区没对齐,提交后设备直接hang死。DMA的描述符地址、长度、方向,必须严格对齐32字节或64字节(看硬件规格)。

1.5 一个真实的小例子

假设我们有一个PCIe DMA设备(比如Intel的FPGA开发板),在Linux下做一次DMA传输,驱动的伪代码大概是这样的:

// 1. 分配DMA缓冲区(物理连续)
dma_addr = dma_alloc_coherent(dev, size, &phys, GFP_KERNEL);
// 2. 将CPU数据拷贝到dma缓冲区(准备发送)
memcpy(phys, data, size);
// 3. 构造描述符:告诉设备从phys读取,写入它自己的FIFO
desc.src = phys;
desc.dst = dev_fifo_addr;
desc.len = size;
desc.ctrl = DMA_READ;
// 4. 将描述符写入设备的描述符队列寄存器
writel(&desc, dev_bar + DESC_QUEUE);
// 5. 等待设备中断(或轮询完成标志)
wait_for_completion(&done);
// 6. 释放DMA缓冲区
dma_free_coherent(dev, size, phys, dma_addr);

瞧,就这么几步。但真正调起来,每一步都可能出状况——描述符格式没对齐、中断号配错、DMA目标地址被MMU挡了……嗯,这就是咱们后面几节课要深刨的东西。

我自己的经验是: 刚起步别急着调大包,先用一个描述符传4字节,看能不能写进去、读出来。通了再上大包。