1. 初识Linux PCIe EPF驱动:它是什么,能做什么?

如果你正在捣鼓嵌入式设备,尤其是那些需要和主机(比如一台x86的PC或者一个更强大的处理器)通过PCIe总线高速通信的板卡,那你很可能已经听说过“EPF驱动”这个词了。我第一次接触这个概念时也是一头雾水,感觉内核文档里那些术语离实际操作很远。简单来说,在PCIe的世界里,通常有一个主动发起通信的“主机端”(Root Complex,简称RC),和一个被动响应的“设备端”(Endpoint,简称EP)。Linux内核为了管理设备端,抽象出了两套框架:EPC(Endpoint Controller)和EPF(Endpoint Function)。

你可以把EPC想象成你板卡上那个实实在在的PCIe硬件控制器,比如Synopsys的DesignWare Core。而EPF驱动,则是跑在这个控制器之上、实现具体业务功能的软件。比如,你的设备可能是一个数据采集卡,那么EPF驱动就负责定义这个卡有几个内存区域(BAR)、如何响应主机的读写命令、以及如何把采集到的数据高效地传回主机。内核里自带的 pci-epf-test 驱动,就是一个绝佳的“教学示例”,它模拟了一个具有基本读写和DMA拷贝功能的PCIe设备。通过剖析它,我们就能摸清EPF驱动从出生(注册)到干活(数据传输)的完整脉络。

所以,这篇文章就是带你手把手走一遍这个流程。我们会基于Linux Kernel 5.15的源码,但重点不是罗列代码,而是理解每一步“为什么”要这么做,以及在实际项目中你“怎么”去定制它。无论你是刚开始涉足PCIe设备开发的嵌入式软件工程师,还是对Linux驱动框架感兴趣的内核爱好者,相信这篇实战指南都能帮你把那些抽象的概念落到实处。

2. 驱动注册:给你的EPF设备一个“身份证”

万事开头难,写驱动的第一步永远是告诉内核:“嘿,我这儿有个新驱动要上岗了”。对于EPF驱动,这个“报到”过程就是向PCIe端点函数框架进行注册。

2.1 构建驱动核心结构体

注册的核心是定义一个 struct pci_epf_driver。这就像是驱动的一张名片,内核通过它来识别和管理你的驱动。我们直接看 pci-epf-test.c 里是怎么做的:

static struct pci_epf_ops ops = {
    .unbind = pci_epf_test_unbind,
    .bind = pci_epf_test_bind,
};

static struct pci_epf_driver test_driver = {
    .driver.name = "pci_epf_test",
    .probe = pci_epf_test_probe,
    .id_table = pci_epf_test_ids,
    .ops = &ops,
    .owner = THIS_MODULE,
};

这里有几个关键字段,我结合自己的踩坑经验解释一下:

  • driver.name:驱动的名字,会在 /sys/bus/pci_epf/drivers/ 下面出现。取个清晰的名字很重要,后期调试时一眼就能找到。
  • probe:这是老熟人了。当系统发现一个EPF设备(通常由设备树或模块参数创建)与这个驱动匹配时,就会调用这个函数。它主要负责分配驱动私有的数据结构体(struct pci_epf_test)并进行初始化。
  • ops:这是EPF驱动的“操作集”,是精髓所在。其中 bind 和 unbind 是最重要的两个回调。bind 会在EPF设备成功连接到EPC控制器(可以理解为硬件链路和逻辑关联都建立好了)时被调用,我们绝大部分的硬件资源分配和配置都在这里进行。unbind 则是在断开连接时做清理工作。
  • id_table:用于匹配设备和驱动的表。在test驱动里,它通常是空的,意味着匹配所有同名设备。但在实际项目中,你可能需要根据设备树中的兼容性字符串(compatible string)来匹配特定的硬件。

2.2 向内核完成注册

有了名片,接下来就是去“人事部”登记了。这通常在驱动的初始化函数里完成:

static int __init pci_epf_test_init(void)
{
    int ret;
    // 创建一个工作队列,用于处理异步命令(后面会讲到)
    kpcitest_workqueue = alloc_workqueue("kpcitest", WQ_MEM_RECLAIM | WQ_HIGHPRI, 0);
    if (!kpcitest_workqueue)
        return -ENOMEM;

    // 核心注册函数
    ret = pci_epf_register_driver(&test_driver);
    if (ret) {
        destroy_workqueue(kpcitest_workqueue);
        pr_err("Failed to register pci epf test driver --> %d\n", ret);
        return ret;
    }
    return 0;
}
module_init(pci_epf_test_init);

pci_epf_register_driver 这个函数会将我们的 test_driver 添加到内核维护的驱动列表中。一旦注册成功,当有对应的EPF设备被创建时,内核就会自动调用其 probe 函数。这里有个细节,驱动里创建了一个工作队列 kpcitest_workqueue。这是因为PCIe设备与主机的通信往往是异步的,主机下发命令后,设备端可能需要一些时间处理,处理完再通知主机。使用工作队列可以将这些耗时操作放到后台线程执行,避免阻塞关键的中断处理流程。

3. 资源绑定与配置:为数据传输搭建舞台

驱动成功注册并 probe 之后,设备还处于“待机”状态。真正的硬件初始化和资源分配,发生在 bind 回调函数中。这个过程就像给你的设备分配办公室、电话线和办公用品。

3.1 探知硬件能力

bind 函数做的第一件重要事情,就是查询底层的EPC控制器到底支持哪些功能。这是通过 pci_epc_get_features 实现的:

epc_features = pci_epc_get_features(epc, epf->func_no, epf->vfunc_no);
if (!epc_features) {
    dev_err(&epf->dev, "epc_features not implemented\n");
    return -EOPNOTSUPP;
}
linkup_notifier = epc_features->linkup_notifier;
core_init_notifier = epc_features->core_init_notifier;
test_reg_bar = pci_epc_get_first_free_bar(epc_features);

获取到的 struct pci_epc_features 结构体包含了丰富的硬件信息:

  • linkup_notifier:一个布尔值,指示EPC硬件是否支持在PCIe链路训练成功后主动通知EPF驱动。如果支持,驱动可以注册一个通知回调,这样就能在链路就绪后立刻开始工作,而不是盲目轮询。
  • msi_capable / msix_capable:指示控制器是否支持MSI或MSI-X中断方式。这对于高性能设备至关重要,因为相比于传统的中断线,MSI/MSI-X延迟更低,效率更高。
  • bar_fixed_size 和 reserved_bar:这是最容易踩坑的地方之一。有些EPC控制器的硬件设计可能固定了某个BAR的大小,或者某些BAR被保留用于特殊用途(比如内部配置寄存器)。驱动必须查询这些信息,避免去分配一个硬件根本不支持的BAR。pci_epc_get_first_free_bar 就是帮你找到第一个可用的BAR编号。

3.2 分配BAR空间

知道硬件能力后,下一步就是为你的设备申请“内存窗口”,也就是BAR空间。这是主机与设备通信的主要通道。主机通过读写PCI配置空间中的BAR地址,来访问设备上的这段内存。在EPF驱动中,我们使用 pci_epf_alloc_space 函数来分配:

base = pci_epf_alloc_space(epf, test_reg_size, test_reg_bar,
                           epc_features->align, PRIMARY_INTERFACE);
if (!base) {
    dev_err(dev, "Failed to allocated register space\n");
    return -ENOMEM;
}
epf_test->reg[test_reg_bar] = base;

这个函数内部,最终会调用 dma_alloc_coherent 来分配一段物理地址连续的内存。为什么是 dma_alloc_coherent?因为这段内存很可能要被DMA控制器访问(无论是主机端的DMA访问设备内存,还是设备端的DMA访问主机内存),必须保证缓存一致性。分配成功后,函数会返回映射好的内核虚拟地址(base),同时将对应的物理地址记录在 epf->bar[bar] 结构体中。这个物理地址,最终会被写入到硬件控制器的对应BAR寄存器里,告诉主机“我的内存在这里”。

3.3 配置核心硬件寄存器

分配好内存空间只是准备好了“场地”,接下来还要把设备的“身份信息”和“联系方式”告诉硬件控制器。这是在 pci_epf_test_core_init 函数中完成的,它主要做了三件事:

  1. 写入配置空间头:调用 pci_epc_write_header,将设备的Vendor ID、Device ID、Class Code等基本信息写入控制器的配置空间。当主机枚举PCIe设备时,读到的就是这些信息。
  2. 设置BAR:调用 pci_epf_test_set_bar -> pci_epc_set_bar。这一步至关重要,它把上一步 pci_epf_alloc_space 得到的物理地址,以及这块内存的大小、类型(32位还是64位,Memory还是IO)配置到控制器的BAR寄存器中,并建立控制器内部的Inbound ATU(地址转换单元)。简单理解,Inbound ATU的作用是:当主机发来一个访问某个BAR地址的请求时,控制器需要知道这个PCIe总线地址应该转换成哪个内部的物理地址(就是我们刚分配的那块内存的地址)。
  3. 配置中断:如果硬件支持,调用 pci_epc_set_msi 或 pci_epc_set_msix 来设置MSI/MSI-X的中断向量数量和相关信息。这样设备在需要通知主机时,才能正确地触发中断。

我们以Synopsys DesignWare (DW) 控制器的实现为例,看看 pci_epc_set_bar 到底做了什么:

static int dw_pcie_ep_set_bar(struct pci_epc *epc, u8 func_no, u8 vfunc_no,
                  struct pci_epf_bar *epf_bar)
{
    ...
    // 1. 配置Inbound ATU:将PCIe地址映射到本地物理地址
    ret = dw_pcie_ep_inbound_atu(ep, func_no, bar,
                     epf_bar->phys_addr, as_type);
    ...
    // 2. 将BAR的大小和属性写入控制器的DBI(设计总线接口)空间
    dw_pcie_writel_dbi2(pci, reg, lower_32_bits(size - 1)); // 写入大小
    dw_pcie_writel_dbi(pci, reg, flags); // 写入属性(如可预取、内存类型)
    ...
}

这个过程清晰地展示了软件驱动如何与硬件控制器协作:驱动提供资源和配置参数,控制器硬件负责完成具体的地址转换和寄存器设置。

4. 实战数据传输:CPU拷贝与DMA引擎对决

一切准备就绪,终于到了最激动人心的环节——数据传输。pci-epf-test 驱动实现了一个经典的 COMMAND_COPY 命令,完美展示了两种不同的数据传输方式:CPU拷贝和DMA拷贝。理解这两种方式的区别和适用场景,对优化设备性能至关重要。

4.1 命令处理机制

在test驱动中,第一个BAR(BAR0)被用作命令寄存器窗口。主机端的测试驱动(pci_endpoint_test.c)会将命令、源地址、目的地址、长度等参数写入这个窗口,然后触发一个命令寄存器写操作。EPF驱动通过轮询或中断方式检测到命令后,调用对应的处理函数。

4.2 CPU拷贝模式:简单直接

我们先看CPU拷贝的路径,它更易于理解。在 pci_epf_test_copy 函数中,如果不使用DMA,则会走以下分支:

if (use_dma) {
    // DMA路径,稍后分析
    ...
} else {
    // CPU拷贝路径
    void *buf;
    buf = kzalloc(reg->size, GFP_KERNEL);
    if (!buf) {
        ret = -ENOMEM;
        goto err_map_addr;
    }
    memcpy_fromio(buf, src_addr, reg->size);
    memcpy_toio(dst_addr, buf, reg->size);
    kfree(buf);
}

这里的 src_addr 和 dst_addr 是什么?它们是通过 pci_epc_mem_alloc_addr 和 pci_epc_map_addr 获得的内核虚拟地址。

  • pci_epc_mem_alloc_addr:从EPC控制器管理的“Outbound”内存区域中分配一小块。这块内存位于设备侧,但其物理地址可以被设备控制器用来发起对主机侧内存的访问。
  • pci_epc_map_addr:建立映射关系。它将主机端传来的一个PCIe总线地址(reg->src_addr),映射到刚刚分配的设备侧物理地址(src_phys_addr)。这个映射是通过配置控制器的 Outbound ATU 实现的。这样一来,当设备CPU访问 src_addr(对应的设备侧物理地址)时,控制器会将其转换为对主机PCIe总线地址 reg->src_addr 的访问请求,从而读写到主机内存。

所以,CPU拷贝的实质是:

  1. 设备CPU从映射了主机源内存的虚拟地址(src_addr)读取数据到本地缓冲区(buf)。
  2. 设备CPU再将缓冲区数据写入映射了主机目的内存的虚拟地址(dst_addr)。
  3. 数据流经历了:主机内存 -> PCIe总线 -> 设备控制器 -> 设备CPU缓存 -> 设备内存(buf)-> 设备CPU缓存 -> 设备控制器 -> PCIe总线 -> 主机内存。路径长,且大量占用CPU资源。

4.3 DMA拷贝模式:解放CPU

如果硬件支持DMA,并且命令中设置了DMA标志,则会走另一条路:

if (use_dma) {
    if (!epf_test->dma_supported) {
        dev_err(dev, "Cannot transfer data using DMA\n");
        ret = -EINVAL;
        goto err_map_addr;
    }
    ret = pci_epf_test_data_transfer(epf_test, dst_phys_addr,
                        src_phys_addr, reg->size);
    ...
}

pci_epf_test_data_transfer 函数内部会调用DMA引擎的API。这时,设备CPU只需要设置好DMA传输的源物理地址(src_phys_addr)、目的物理地址(dst_phys_addr)和长度,然后启动DMA。DMA控制器会直接在设备本地总线(可能是AXI)上,完成从源地址到目的地址的数据搬运。注意,这里的源和目的地址,都是设备侧的物理地址,它们分别通过Outbound ATU映射到了主机端的两块不同内存。

DMA拷贝的优势非常明显:

  • CPU占用率极低:CPU只需发起传输,等待完成中断即可,期间可以处理其他任务。
  • 高带宽:专业的DMA控制器通常具有更高的总线利用率和突发传输能力。
  • 适合大块数据:传输1MB以上的数据时,性能优势是数量级的。

4.4 主机侧的配合

一个完整的传输需要主机和设备两边配合。主机侧的 pci_endpoint_test_copy 函数做了对称的操作:

  1. 使用 kzalloc 分配源和目的缓冲区。
  2. 使用 dma_map_single 获取缓冲区的DMA总线地址。这个地址就是设备视角的PCIe总线地址。
  3. 将这个总线地址、数据大小、命令(COMMAND_COPY)和标志位写入设备对应的BAR空间寄存器。
  4. 触发命令,并等待设备完成中断。
  5. 验证数据CRC,完成传输。

这里的关键在于 dma_map_single。它确保了主机内存缓冲区对于设备DMA访问是“可见”的(即缓存一致性处理得当),并返回了用于DMA操作的总线地址。这个地址被写入设备寄存器后,就构成了我们前面提到的 reg->src_addr 和 reg->dst_addr。

5. 调试技巧与常见问题排查

开发EPF驱动,调试是家常便饭。我把自己积累的几个实用技巧和常见坑点分享给你,希望能帮你节省时间。

5.1 利用内核调试工具

首先,确保内核配置了 CONFIG_PCI_EPF_TEST 和 CONFIG_PCI_ENDPOINT_TEST。加载 pci-epf-test 和 pci_endpoint_test 驱动后,你会看到 /sys/kernel/debug/pci_endpoint/ 目录下出现测试节点。通过 cat 和 echo 命令可以手动触发读写、拷贝等测试,这是验证驱动基础功能最快的方法。

其次,lspci -vvv 是你的好朋友。在主机上运行这个命令,仔细查看你的Endpoint设备是否被正确枚举。重点关注:

  • BAR空间是否被正确分配了地址(非0值)。
  • Memory Space Enable 位是否被置位。
  • 设备的Vendor ID和Device ID是否正确。

5.2 常见问题与解决思路

  1. 设备无法被主机发现

    • 检查硬件链路:首先用示波器或逻辑分析仪确认PCIe的参考时钟和差分信号是否正常。
    • 检查EPC控制器初始化:确保EPC控制器驱动(如 dw-pcie-ep)已正确加载,并且 pci_epc_add_epf 被成功调用,将EPF和EPC关联起来。
    • 检查 bind 流程:在驱动的 bind 函数中增加 dev_info 打印,确认是否执行到了 pci_epc_write_header。如果没执行,可能是EPC的 start 函数未被调用,或者链路训练失败。
  2. 主机访问设备BAR时出错(UE错误或读回全F)

    • 检查BAR空间分配:在 pci_epf_alloc_space 后,打印分配的物理地址和虚拟地址,确认不为NULL。
    • 检查ATU配置:这是最复杂的一环。你需要对照EPC控制器的编程手册,确认 dw_pcie_ep_inbound_atu 函数配置的地址、大小、目标地址是否正确。一个常见的错误是ATU配置的区域大小小于BAR声明的大小。
    • 检查BAR寄存器配置:在 pci_epc_set_bar 后,通过读取控制器的DBI空间,确认BAR寄存器的值是否正确写入了大小和属性。
  3. DMA传输失败或数据错误

    • 确认DMA通道:在 pci_epf_test_init_dma_chan 函数中,检查是否成功申请到了DMA通道(dma_request_channel)。
    • 检查地址映射:DMA传输使用的是物理地址。确保传递给DMA引擎的 src_phys_addr 和 dst_phys_addr 是 pci_epc_mem_alloc_addr 返回的地址,而不是虚拟地址。
    • 缓存一致性:这是DMA问题的万恶之源。确保主机侧使用 dma_alloc_coherent 或 dma_map_single 来提供缓冲区。设备侧,如果你自己分配缓冲区给DMA用,也要使用 dma_alloc_coherent。对于使用 kmalloc 或 get_free_pages 分配的普通内存,必须在使用DMA前正确调用 dma_map_single,并在DMA传输完成后调用 dma_unmap_single。
  4. MSI/MSI-X中断无法触发

    • 检查EPC能力:首先确认 epc_features->msi_capable 为真。
    • 检查配置:在 pci_epc_set_msi 执行后,通过 lspci -vvv 查看设备配置空间中的MSI Capability结构是否被正确设置(Message Address/Data)。
    • 检查硬件路由:有些SoC内部,PCIe控制器的MSI中断需要被映射到特定的GIC(中断控制器)SPI上。确保设备树中关于MSI中断父节点的配置是正确的。

调试是一个需要耐心和系统性的过程。建议从下至上,先确保物理链路和EPC控制器基础驱动工作正常,再逐步调试EPF驱动的各个阶段。多利用 dev_dbg、pr_debug 添加详细的日志,并结合内核的 ftrace 或 perf 工具进行性能分析和问题定位。记住,每一次踩坑和解决问题的过程,都是对PCIe和Linux驱动框架理解的一次深化。

更多推荐