编辑
2025-11-23
系统知识
0

目录

PCIE体系结构中的角色
PCIE系统的初始化
Linux下用户态与内核态的交互
Linux PCIE驱动框架
PCIE三层协议栈概览
如何通知硬件设备开始DMA操作
DMA的开始与结束
DMA读写细节
流程概要总结
实例剖析
用户程序
内核驱动

本篇文章中,我会根据我目前的工作经验,并结合相关资料,从基本流程上简要地探究一下PCIE DMA操作的交互流程。

适用PCIE的设备有很多类型,典型的就是网卡、NVMe SSD、GPU,不同系统上细节也不同,因此我将以Linux为例,进行相关研究。

我们不需要过分追求某个细分主题下的详细内容,因为每个主题都很复杂,不是一篇文章就可以讲完的,本文旨在让你想通完成一个PCIE DMA的操作大体上都会经历什么。

PCIE体系结构中的角色

一个典型的PCIE结构拓扑如下图所示:

image.png

在Linux系统下,可以用命令lspci -vvv来查看详细的PCIE设备信息,若要查看树形拓扑结构,可以考虑使用命令lspci -tv

image.png

  1. Root Complex (RC) —— 根复合体

Root Complex (RC) 是 PCIe 拓扑结构的树根,它是 PCIe 总线与 CPU/内存子系统之间的接口。

它负责将 CPU 和内存连接到 PCIe 网络,它将 CPU 的指令(如读写请求)转换为 PCIe 数据包,反之亦然。

在现代架构中,RC 通常直接集成在 CPU 内部(以往在北桥芯片中),在配置空间中,它通常表现为总线 0。

  1. Endpoint (EP) —— 端点设备

Endpoint (EP) 是 PCIe 树形结构的“叶子”节点,是实际执行具体功能的设备,例如网卡、显卡等具体设备,它可以是请求的发起者,也可以是请求的响应者。

  1. Switch —— 交换机

当 CPU (RC) 提供的 PCIe 通道数量有限,但需要连接更多设备时,就需要 Switch 来进行扩展。

Switch 将一个上行端口(Upstream Port)扩展为多个下行端口(Downstream Ports),并负责在 RC 和 EP 之间,或者两个 EP 之间转发数据包。

它有一个上行桥(连接 RC 方向)和多个下行桥(连接设备方向),中间通过内部总线连接。

  1. Bridge —— 桥接器

虽然 Switch 内部也有桥的概念,但这里特指PCIe to PCI/PCI-X Bridge。

其用于连接不同类型的总线协议。例如,将现代的 PCIe 总线转换为古老的 PCI 总线,以便在现代电脑上插旧的声卡或工控卡。

本文主要关注CPU、内存、EP设备之间的交互流程,其中会涉及RC的地址转换和路由,我们不打算讨论Switch和Bridge。

PCIE系统的初始化

  1. 硬件上电复位

这是物理层面的准备,主要是硬件内部寄存器、时钟等部件的复位。

  1. 链路训练

由LTSSM(链路训练状态机)控制,按照以下顺序进行:

Detect → Polling → Configuration → L0

最终的 L0 状态表示链路已正常,即链路训练完成,进入正常工作模式。此时物理链路已经打通,可以传输数据包。

这时候有关硬件的初始化便结束了,硬件设备已经可以访问了,接下来就到软件层面的初始化了。

  1. 总线枚举与资源分配

一般由BIOS负责,操作系统可能会再次执行这个过程,这和具体架构有关系,例如x86下BIOS 在 POST(Power-On Self-Test)完成枚举,操作系统可以在启动时重新枚举。

该过程中,BIOS/UEFI扫描PCIe总线,发现所有连接的PCIe设备,确定设备的拓扑结构(使用DFS遍历PCIE树来构造拓扑),然后进行资源分配,包括分配总线号(Bus Number)、内存/IO 空间、IRQ,同时写回 BAR 值、Command Register(用于 Memory/IO Space、Bus Master)。

此过程结束后,操作系统就获取到了各个PCIE设备的信息了(要么是操作系统本身重新枚举以此来记录设备信息,要么是去读取BIOS保存在内存中某个位置的、已经通过枚举所获取的设备信息),操作系统会负责维护这些信息,在需要的时候使用。

同时,也在RC中建立起了CPU物理地址空间和IO地址空间的映射(IO地址空间包括PCIE域、DRAM域等),这样当CPU访问某一物理内存地址时,RC会判断该地址落于何处,若是落于PCIE域所对应的映射位置上,会发起PCIE事务进行实际的PCIE操作。

CPU的物理地址空间并不是全部都给内存条(DRAM)使用的,内存条的空间只是物理地址空间的一个子集,物理地址空间中还有留给外设的MMIO空间。

现在处理器架构中会提供MMU进行虚实地址转换,理论上来说,操作系统运行过程中(本质上是在跑一系列CPU指令)发出的内存访问请求,其中的地址都是虚拟地址,该地址会先送往MMU做转换,得到物理地址,再送往RC进一步处理,虚实地址的转换涉及到复杂的页表机制,为了不引入复杂性,描述与设备交互的流程时省略这个地址转换的过程,这不影响我们理解主要的关键步骤。

  1. PCIE驱动加载

在Linux下,设备驱动可能包含在编译的内核中(系统启动后自动加载),也有可能在系统启动后再动态按需手动加载,无论如何,PCIE驱动都会给出该驱动所适配的设备。

操作系统通过查询先前维护的PCIE设备信息,通过主从设备号去匹配对应的驱动,然后调用驱动中定义的初始化函数。

Linux下用户态与内核态的交互

这一主题和PCIE本身没有太大关系,主要是为Linux设备驱动编程做铺垫。

想要在用户态和内核驱动做交互,比较常见的做法是使用ioctl系统调用。

这一般要求内核驱动实现一个字符设备并给它注册上ioctl的文件操作回调函数。

以下给出驱动编程模板:

C
#include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/uaccess.h> #define DEVICE_NAME "myioctl" #define MY_MAGIC 'x' #define MY_IOCTL_GET _IOR(MY_MAGIC, 0, int) #define MY_IOCTL_SET _IOW(MY_MAGIC, 1, int) static int device_open = 0; static int kernel_value = 123; static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { int ret = 0; switch (cmd) { case MY_IOCTL_GET: if (copy_to_user((int __user *)arg, &kernel_value, sizeof(int))) { ret = -EFAULT; } break; case MY_IOCTL_SET: if (copy_from_user(&kernel_value, (int __user *)arg, sizeof(int))) { ret = -EFAULT; } break; default: ret = -ENOTTY; } return ret; } static struct file_operations fops = { .unlocked_ioctl = my_ioctl, // ioctl的回调函数 }; static int __init my_init(void) { register_chrdev(240, DEVICE_NAME, &fops); printk(KERN_INFO "My device loaded\n"); return 0; } static void __exit my_exit(void) { unregister_chrdev(240, DEVICE_NAME); printk(KERN_INFO "My device unloaded\n"); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE("GPL");

在用户态编程中,可以这样调用ioctl实现与内核驱动的交互:

C
#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #define MY_MAGIC 'x' #define MY_IOCTL_GET _IOR(MY_MAGIC, 0, int) #define MY_IOCTL_SET _IOW(MY_MAGIC, 1, int) int main() { int fd; int value; fd = open("/dev/myioctl", O_RDWR); if (fd < 0) { perror("open"); return 1; } // 读取内核值 if (ioctl(fd, MY_IOCTL_GET, &value) == 0) { printf("Current kernel value: %d\n", value); } // 设置新值 value = 456; if (ioctl(fd, MY_IOCTL_SET, &value) == 0) { printf("Value set to: %d\n", value); } // 验证新值 if (ioctl(fd, MY_IOCTL_GET, &value) == 0) { printf("New kernel value: %d\n", value); } close(fd); return 0; }

这样,我们就打通了用户态和内核态的交互,此后,可以在用户程序中发送控制命令给内核驱动,随后内核驱动根据控制命令去对硬件做出合适的操作。

若是要在用户态和内核态之间进行大量的数据交换,可以采用mmap的方式,具体细节不在此赘述了,网络上对该技术有很多讨论。

Linux PCIE驱动框架

https://jklincn.com/posts/qemu-edu-driver/

https://www.kernel.org/doc/html/latest/PCI/index.html

https://www.qemu.org/docs/master/specs/edu.html

PCIE三层协议栈概览

就像网络协议栈一样,PCIE设备之间的交互,也根据功能职责划分了三个层次,至顶向下分别是:事务层、数据链路层、物理层。

软件通过读写设备的寄存器或内存来与设备通信,这些操作在事务层被“翻译”成标准化的数据包TLP,即PCIE操作都是通过发起PCIE事务来完成的,PCIE事务承载在名为TLP的报文当中,TLP报文在PCIE事务层进行构造。

比如 CPU(软件的运行本质就是跑CPU指令) 想读取显卡的数据,首先CPU会发出一个物理地址,表示要读取的数据的起始位置,该地址交到RC手上后,RC判断这个地址映射到PCIE域,那么这就是一个PCIE操作,随即RC会发起PCIE事务,事务层就会生成一个“存储器读请求”的TLP。

数据链路层位于中间,确保可靠传输。通过添加序列号、循环冗余校验(CRC)和流控制信用,它处理 ACK/NAK 确认和重传。 它还管理数据链路层数据包(DLLPs),用于链路维护。

物理层负责PCIE底层实际的物理通信,即把数据信号通过物理介质从一个设备传到另一个设备。

image.png

Intel提供了一个FPGA的PCIE IP核,实现了PCIE的三层协议栈,感兴趣的话,你可以在下面链接给出的文档查看一些设计细节:

https://www.intel.cn/content/www/cn/zh/docs/programmable/683111/17-1/pci-express-core-architecture.html

如何通知硬件设备开始DMA操作

在驱动需要发起一个DMA操作时,需要通知硬件做好准备,"通知"是靠读写硬件寄存器来完成的。

读写硬件寄存器取决于处理器架构,在大部分架构中本质上也是一个内存访问(即MMIO),在PCIE设备的枚举和资源分配阶段,会给每个PCIE设备分配它所需要的BAR空间,读写寄存器就是读写这块BAR空间。

RC收到CPU的内存访问请求后,提取该请求中指定的物理地址,进行查表匹配,发现该地址匹配到某个PCIE设备的BAR空间后,则发起PCIE事务,构造TLP并发往该PCIE设备,该设备收到TLP后进行解析,提取信息后发现这是对BAR的操作,该设备内部会对BAR相关模块做出操作,有必要还会构造一个返回的TLP告知CPU处理结果。

如果规定BAR中某一块是和DMA有关的配置,那么就可以通过配置BAR来操作硬件DMA了。

DMA的开始与结束

DMA开始之前,把数据和位置信息准备好,让硬件设备知道去内存的哪个位置取数据或把数据放到内存的哪个位置,通过读写硬件寄存器来通知设备开始DMA。

开始DMA之后,就不需要CPU的参与了,只剩PCIE设备和内存进行交互。

设备和内存的交互同样的是通过TLP来完成的,数据包裹在TLP报文当中运送。

DMA结束之后,PCIE设备会发出中断,通知CPU,随后便是操作系统陷入中断处理程序,进行DMA的善后工作。

DMA读写细节

DMA 写(向内存写入数据): 设备构建一个 Memory Write TLP (MWr)。这个包里包含了目标内存地址和要写入的数据负载(Payload)。这是一个 Posted Transaction(即发即弃),设备发出后不需要等待 CPU 或 Root Complex 的确认回复(除非发生严重错误)。

DMA 读(从内存读取数据): 设备构建一个 Memory Read TLP (MRd)。这个包里只包含目标内存地址和需要读取的数据长度。这是一个 Non-Posted Transaction。Root Complex 收到请求后,会从内存取回数据,并构建一个 Completion with Data TLP (CplD) 发送回设备,这个CplD报文中包含从内存中取出的数据,也就是设备需要的数据。

流程概要总结

首先机器上电,各个硬件设备开始初始化,对于PCIE设备而言,包括寄存器复位、链路训练等。

PCIE设备初始化结束后,系统软件(BIOS/OS)以DFS算法扫描PCIE拓扑,并进行资源分配,例如分配BAR空间。

资源分配的过程中,会在RC中建立CPU物理地址和PCIE总线地址的映射,这样当CPU发起一个内存访问请求时,可以根据该请求的物理地址来判断,如果映射到了一个PCIE总线地址上,则确定是对PCIE设备的访问。

随后便是RC发起真正的PCIE事务,该事务由PCIE三层协议栈逐层处理,由源端(RC)最上层事务层开始,构造TLP,往下进行数据链路层处理,在物理层通过电气信号发出,到目的端(想要访问的设备)物理层接收电气信号,送上数据链路层处理,再送上事务层解析。

如果需要设备返回响应数据,依然是通过TLP的方式,这里变化的只有源端和目的端相互切换。

对于控制PCIE设备,是通过读写设备寄存器实现的,设备寄存器一般都定义在BAR空间中,那么本质上就是MMIO,即CPU给RC提供了一个物理地址,这个物理地址刚好映射到了分配好的PCIE总线地址上,随后便是硬件的实际操作,即TLP的交换。

对于进行DMA操作,是内存控制器和PCIE设备的交互,两者并不是直接交互,也是通过RC充当中间人的角色进行间接交互,例如PCIE设备通过DMA传递数据到内存中,PCIE设备仲裁获取总线使用权,把数据打包成TLP通过PCIE总线交给RC,RC将数据提取出来(解封TLP),随后开启存储器事务,和内存控制器进行交互,把数据写入内存,写入完成后可能会触发中断通知CPU可以进行数据处理了。

pcie.svg

实例剖析

现在我们考虑通过一个开源项目来完整过一遍PCIE设备DMA交互的全流程,该开源项目的Github仓库地址在以下链接给出:

https://github.com/KastnerRG/riffa/tree/master

该项目提供了一个收发数据的用户程序、内核驱动以及用FPGA实现的PCIE设备,FPGA部分我们不考虑分析,我们更关注软件层面的相关细节。

用户程序

先来看一下它提供的用户态程序,我们只关心最核心的与收发数据相关的部分,一些不重要的代码已经被我省略。

c
int main(int argc, char** argv) { ... else if (option == 2) { // Send data, receive data if (argc < 5) { printf("Usage: %s %d <fpga id> <chnl> <num words to transfer>\n", argv[0], option); return -1; } id = atoi(argv[2]); chnl = atoi(argv[3]); numWords = atoi(argv[4]); // Get the device with id fpga = fpga_open(id); if (fpga == NULL) { printf("Could not get FPGA %d\n", id); return -1; } // Malloc the arrays sendBuffer = (unsigned int *)malloc(numWords<<2); if (sendBuffer == NULL) { printf("Could not malloc memory for sendBuffer\n"); fpga_close(fpga); return -1; } recvBuffer = (unsigned int *)malloc(numWords<<2); if (recvBuffer == NULL) { printf("Could not malloc memory for recvBuffer\n"); free(sendBuffer); fpga_close(fpga); return -1; } // Initialize the data for (i = 0; i < numWords; i++) { sendBuffer[i] = i+1; recvBuffer[i] = 0; } GET_TIME_VAL(0); // Send the data sent = fpga_send(fpga, chnl, sendBuffer, numWords, 0, 1, 25000); printf("words sent: %d\n", sent); GET_TIME_VAL(1); if (sent != 0) { // Recv the data recvd = fpga_recv(fpga, chnl, recvBuffer, numWords, 25000); printf("words recv: %d\n", recvd); } GET_TIME_VAL(2); // Done with device fpga_close(fpga); // Display some data for (i = 0; i < 20; i++) { printf("recvBuffer[%d]: %d\n", i, recvBuffer[i]); } // Check the data if (recvd != 0) { for (i = 4; i < recvd; i++) { if (recvBuffer[i] != sendBuffer[i]) { printf("recvBuffer[%d]: %d, expected %d\n", i, recvBuffer[i], sendBuffer[i]); break; } } printf("send bw: %f MB/s %fms\n", sent*4.0/1024/1024/((TIME_VAL_TO_MS(1) - TIME_VAL_TO_MS(0))/1000.0), (TIME_VAL_TO_MS(1) - TIME_VAL_TO_MS(0)) ); printf("recv bw: %f MB/s %fms\n", recvd*4.0/1024/1024/((TIME_VAL_TO_MS(2) - TIME_VAL_TO_MS(1))/1000.0), (TIME_VAL_TO_MS(2) - TIME_VAL_TO_MS(1)) ); } } ... }

核心逻辑是,通过fpga_open打开设备,这个设备指的是内核驱动创建出来的字符设备驱动,也就是我们先前提到的用于用户态与内核态进行交互的方法,其实现如下所示。

c
fpga_t * fpga_open(int id) { fpga_t * fpga; // Allocate space for the fpga_dev fpga = (fpga_t *)malloc(sizeof(fpga_t)); if (fpga == NULL) return NULL; fpga->id = id; // Open the device file. fpga->fd = open("/dev/" DEVICE_NAME, O_RDWR | O_SYNC); if (fpga->fd < 0) { free(fpga); return NULL; } return fpga; }

打开设备之后,申请两块内存,一块用于存储发送的数据sendBuffer,一块用于存储收到的数据recvBuffer

随后用fpga_send将发送数据传递给内核驱动,其实现如下所示,正如我们所预料的,使用ioctl系统调用与内核驱动交互。

c
int fpga_send(fpga_t * fpga, int chnl, void * data, int len, int destoff, int last, long long timeout) { fpga_chnl_io io; io.id = fpga->id; io.chnl = chnl; io.len = len; io.offset = destoff; io.last = last; io.timeout = timeout; io.data = (char *)data; return ioctl(fpga->fd, IOCTL_SEND, &io); }

随后通过fpga_recv等待接收数据,其实现如下所示,核心原理一样是ioctl

c
int fpga_recv(fpga_t * fpga, int chnl, void * data, int len, long long timeout) { fpga_chnl_io io; io.id = fpga->id; io.chnl = chnl; io.len = len; io.timeout = timeout; io.data = (char *)data; return ioctl(fpga->fd, IOCTL_RECV, &io); }

发送数据和接收数据完成后,通过fpga_close关闭先前打开的字符设备,回收资源,并打印输出数据。

内核驱动

首先关注一下字符设备驱动的定义:

c
static const struct file_operations fpga_fops = { .owner = THIS_MODULE, .unlocked_ioctl = fpga_ioctl, };

可以看出关键实现就是fpga_ioctl函数,如下所示。

c
/** * Main entry point for reading and writing on the device. Return value depends * on ioctlnum and expected behavior. See code for details. */ static long fpga_ioctl(struct file *filp, unsigned int ioctlnum, unsigned long ioctlparam) { int rc; fpga_chnl_io io; fpga_info_list list; switch (ioctlnum) { case IOCTL_SEND: if ((rc = copy_from_user(&io, (void *)ioctlparam, sizeof(fpga_chnl_io)))) { printk(KERN_ERR "riffa: cannot read ioctl user parameter.\n"); return rc; } if (io.id < 0 || io.id >= NUM_FPGAS || !atomic_read(&used_fpgas[io.id])) return 0; return chnl_send_wrapcheck(fpgas[io.id], io.chnl, io.data, io.len, io.offset, io.last, io.timeout); case IOCTL_RECV: if ((rc = copy_from_user(&io, (void *)ioctlparam, sizeof(fpga_chnl_io)))) { printk(KERN_ERR "riffa: cannot read ioctl user parameter.\n"); return rc; } if (io.id < 0 || io.id >= NUM_FPGAS || !atomic_read(&used_fpgas[io.id])) return 0; return chnl_recv_wrapcheck(fpgas[io.id], io.chnl, io.data, io.len, io.timeout); case IOCTL_LIST: list_fpgas(&list); if ((rc = copy_to_user((void *)ioctlparam, &list, sizeof(fpga_info_list)))) printk(KERN_ERR "riffa: cannot write ioctl user parameter.\n"); return rc; case IOCTL_RESET: reset((int)ioctlparam); break; default: return -ENOTTY; break; } return 0; }

这是用户程序调用ioctl后,通过层层抽象,最终在驱动中所执行的函数,由此可见发送数据的关键实现为chnl_send_wrapcheck,接收数据的关键实现为chnl_recv_wrapcheck

另外需要注意的是,ioctl提供的参数要想在用户态和内核态相互传递,需要依靠copy_from_usercopy_from_user,显然是不能直接在内核态直接读取用户态当中的数据,用户态也不能直接读取内核态当中的数据,出于安全性考虑,这很好理解。

再来关注一下PCIE设备的定义:

c
#if LINUX_VERSION_CODE < KERNEL_VERSION(4,8,0) static DEFINE_PCI_DEVICE_TABLE(fpga_ids) = #else static const struct pci_device_id fpga_ids[] = #endif { {PCI_DEVICE(VENDOR_ID0, PCI_ANY_ID)}, {PCI_DEVICE(VENDOR_ID1, PCI_ANY_ID)}, {0}, }; MODULE_DEVICE_TABLE(pci, fpga_ids); static struct pci_driver fpga_driver = { .name = DEVICE_NAME, .id_table = fpga_ids, .probe = fpga_probe, .remove = __devexit_p(fpga_remove), };

其中的fpga_ids结构体数组的含义是,通过主从设备号,告知操作系统该驱动所适配的PCIE设备。

fpga_probe函数将会在对应的PCIE设备初始化结束后,被操作系统调用。

在定义所需要驱动结构体后,便是调用对应的注册函数将其传递给操作系统,这些操作一般在内核驱动的初始化函数当中完成,并在结束后释放资源,如下所示。

c
/** * Called to initialize the PCI device. */ static int __init fpga_init(void) { int i; int error; for (i = 0; i < NUM_FPGAS; i++) atomic_set(&used_fpgas[i], 0); error = pci_register_driver(&fpga_driver); if (error != 0) { printk(KERN_ERR "riffa: pci_module_register returned %d\n", error); return (error); } error = register_chrdev(MAJOR_NUM, DEVICE_NAME, &fpga_fops); if (error < 0) { printk(KERN_ERR "riffa: register_chrdev returned %d\n", error); return (error); } #if LINUX_VERSION_CODE < KERNEL_VERSION(6, 4, 0) mymodule_class = class_create(THIS_MODULE, DEVICE_NAME); #else mymodule_class = class_create(DEVICE_NAME); #endif if (IS_ERR(mymodule_class)) { error = PTR_ERR(mymodule_class); printk(KERN_ERR "riffa: class_create() returned %d\n", error); return (error); } devt = MKDEV(MAJOR_NUM, 0); device_create(mymodule_class, NULL, devt, "%s", DEVICE_NAME); return 0; } /** * Called to destroy the PCI device. */ static void __exit fpga_exit(void) { device_destroy(mymodule_class, devt); class_destroy(mymodule_class); pci_unregister_driver(&fpga_driver); unregister_chrdev(MAJOR_NUM, DEVICE_NAME); } module_init(fpga_init); module_exit(fpga_exit);

初始化函数fpga_init中通过pci_register_driverregister_chrdev将定义的设备结构体注册到内核中。

初始化函数的调用时机是驱动被内核加载的时候,被编译集成到内核当中的驱动在内核启动后会自动加载,若没有集成,需要手动加载驱动,使用sudo insmod xxx.ko命令来完成这一操作,在此情况下,调用完insmodfpga_init便会执行。

内核会在硬件设备准备好后才加载驱动,此时内核已经获取到硬件设备的相关信息,这时再通过驱动注册的结构体来匹配对应的硬件设备。

具体到的PCIE设备而言,内核会在启动阶段获取PCIE设备的相关信息,例如主从设备号,所需BAR空间大小等,并为PCIE设备进行资源分配,完成诸如此类的初始化操作后,内核就有完备的设备信息了,此时再去加载各个厂商驱动,通过和获取到的PCIE设备信息进行匹配,匹配上的内核会调用其注册的.probe回调函数。

Linux内核的PCIE子系统也是一个比较复杂的模块,其具体实现中各项流程彼此交织繁杂,需要单独的文章另外讨论,不是我们现在关心的主题,就不过多纠结了。

接下来我们来分析一下fpga_probe函数。

本文作者:Test

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!