ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PCIe接口嵌入式开发实战:链路训练、BAR映射与驱动调试要点

PCIe接口嵌入式开发实战:链路训练、BAR映射与驱动调试要点 搞嵌入式的兄弟基本都绕不开PCIe这关。别看你平时打交道最多的是UART、I2C、SPI这几个慢速接口一旦系统里要挂NVMe固态盘、万兆网卡或者要上FPGA加速卡做高速数据采集PCIe几乎就是首选很多时候甚至是唯一选项。我最初被PCIe折磨是在一个工控主板上做视频采集卡驱动当时对这套高速串行总线的认知基本为零只能从lspci输出开始一点一点啃协议手册后面又折腾过PCIe Switch、FPGA XDMA通道才算把这套体系的脉络给捋顺。这篇文章我就从一个嵌入式软硬件开发者的视角把PCIe接口的基础链路、枚举机制、驱动对接、调试避坑这几块内容掰开揉碎了讲适合刚接触PCIe、或者做过一点驱动但没系统梳理过整体流程的读者。1. 先搞清楚PCIe到底是个什么东西1.1 从PCI到PCIe的演进逻辑老工程师应该都知道PCI总线。PCI是并行总线32位或64位数据线工作频率33MHz或者66MHz理论带宽最高也就533MB/s左右。更关键的是并行架构在高速率下面临严重的信号完整性问题时钟同步、反射、串扰把频率往上推的代价越来越大。后来出现过一个过渡方案叫PCI-X但本质上还是并行那套思路只是把频率和位宽拉高功耗和布线难度也跟着涨并没有真正解决问题。PCIe相当于另起炉灶采用串行点对点差分信号没有共享总线通道可以按需扩展。这个转变可以类比成PCI是很多设备挤在一条大马路上跑谁都要排队PCIe是每个设备都有自己的专用高速路觉得不够快就再加几条车道。车道就是Lane数量越多带宽越高这就是x1、x4、x8、x16这些说法的由来。PCIe的核心特征可以总结成四点串行差分信号每个Lane由两对差分线组成一对发送、一对接收。点对点连接链路只存在于两个设备之间不存在总线共享和仲裁。可扩展通道数一个设备可以根据需要设计成x1、x2、x4、x8、x16。软件兼容性保留了PCI时代的配置空间模型操作系统依旧用类似的机制去枚举和驱动设备。最后这一点特别重要它让PCIe在软件侧没有出现“颠覆式”变化老驱动框架可以平滑迁移。1.2 速率等级与实际带宽计算PCIe按照代际划分速率从Gen1一路走到现在的Gen6嵌入式领域最常见的是Gen2和Gen3新一点的平台开始支持Gen4。这里要记住一个关键点协议标称的GT/s是每秒传输的比特数不等于实际可用带宽因为链路有编码开销。代际编码方案每Lane速率x1实际带宽x4实际带宽x16实际带宽Gen18b/10b2.5GT/s250MB/s1GB/s4GB/sGen28b/10b5.0GT/s500MB/s2GB/s8GB/sGen3128b/130b8.0GT/s约985MB/s约3.94GB/s约15.75GB/sGen4128b/130b16GT/s约1.97GB/s约7.88GB/s约31.5GB/s8b/10b编码意味着每传输10个bit只有8个bit是有效数据所以Gen2的5GT/s实际带宽也就是500MB/s。Gen3开始改用128b/130b编码开销只有约1.6%所以8GT/s的速率还能拿到接近985MB/s的有效带宽。对嵌入式开发来说这个计算很实用。很多人看到SoC标称PCIe Gen3 x4就以为能跑到4GB/s实际物理上限只有3.94GB/s再扣除协议头开销和设备的读写效率NVMe顺序读能稳定在3.3到3.6GB/s已经算很好了。后面我还会再提到这个经验值。1.3 PCIe与嵌入式常见接口的定位差异在嵌入式板卡上接口类型很多样。UART、I2C、SPI属于慢速板内通信USB和以太网属于中速外设扩展而PCIe天然定位在高速、低延迟、可内存映射的方向。我整理过一个对照表方便新手理解接口类型典型速率典型场景UART串行异步几Mbps调试串口、低速传感器I2C板内同步几十到几百KbpsEEPROM、PMIC、温度传感器SPI板内同步几十MbpsFlash、ADC、显示屏USB 3.0串行主从5Gbps外设扩展、U盘、摄像头千兆以太网串行网络1Gbps网络通信PCIe Gen3 x4串行点对点约32GbpsNVMe硬盘、加速卡、高速采集卡PCIe最不可替代的地方在于它能做内存映射访问和DMA大规模搬运。设备的BAR空间映射到CPU地址空间之后CPU可以直接读写设备寄存器数据搬运则交给DMA引擎CPU可以撤出来处理其他业务。这和UART、SPI那种靠中断或者轮询逐字节搬运的玩法完全是两个纬度。可以理解为前者是快递员一件一件搬后者是直接用传送带整箱整箱地拉效率完全不一样。2. 嵌入式开发中最该关心的PCIe协议细节2.1 先把PCIe三层结构和LTSSM搞清楚PCIe协议栈分三层事务层Transaction Layer、数据链路层Data Link Layer和物理层Physical Layer。最上层的事务层产生各种TLP报文比如Memory Read、Memory Write、Configuration Read、Completion等数据链路层负责报文的校验、确认和重传保证端到端可靠性物理层则处理电气信号、编码和链路训练。链路训练状态机LTSSM是排障时绕不开的概念。链路两端上电后会依次经历Detect、Polling、Configuration、L0这几个主要状态。Detect阶段检测对方是否在线Polling阶段交换速率和通道信息Configuration阶段协商最终的链路宽度和极性进入L0之后才算正常工作数据开始传输。如果中间某个握手环节失败链路就停在低速状态或者直接不训练这就是为什么你要先理解这个状态机再看硬件问题。说点实际经验我在调试时最常用来判断链路是否起来的信号就是L0态。如果设备卡在Configuration或者Detection反复跳基本可以断定是物理层问题可以先查差分线、时钟、复位再去怀疑软件配置。2.2 拓扑结构Root Complex、Switch、Endpoint、BridgePCIe拓扑是树状结构从上到下依次是Root Complex、Switch、Endpoint。Root Complex是CPU侧的根部负责发起配置事务和内存事务一般集成在SoC内部。Endpoint是具体的功能设备比如NVMe控制器、网卡、FPGA加速卡。Switch则用来扩展端口内部由一个上游端口和多个下游端口组成每个端口本质是一个PCIe桥。Bridge的使用场景在嵌入式里也比较常见比如老设备还在用PCI接口就需要PCIe转PCI桥来接入新平台。这类芯片在系统里呈现为一个Type 1配置头设备下面挂着子总线和Endpoint。在Linux下lspci -t可以直观看到整棵拓扑树。根节点一般是00:00.0后续总线号逐级增加。开发时养成看这个输出的习惯很多资源分配问题一眼就能发现。2.3 配置空间、BDF与BAR每个PCIe设备都有独立的配置空间完整大小是4KB其中低256字节和PCI的配置空间兼容PCIe扩展能力通过Capability结构挂在后面。配置空间里最关键的信息包括Vendor ID、Device ID、Class Code、Revision ID、Header Type以及基地址寄存器BAR。BDF就是总线号:设备号:功能号。例如03:00.0表示总线3上的设备0功能0。操作系统通过遍历总线、读取每个设备的配置空间来发现设备这也是枚举的基本动作。BAR是配置空间里最核心的字段。设备内部通常有一段寄存器或者内存空间需要暴露给CPU访问BAR就保存着这段空间在PCIe地址域里的基址。问题在于BAR的大小不是写死在芯片里的枚举器要先向BAR写入全1再读回来读到的低bit是掩码由此推算出空间大小然后根据系统可用的地址范围分配一个基址写回。这里有两个坑必须提醒第一64位BAR会占用BAR0和BAR1两个寄存器很多新手只读BAR0导致地址解析错误第二IO空间在x86平台还有意义但在嵌入式Linux里很多内核配置已经关闭IO空间开发时优先用Memory空间。2.4 枚举过程到底在干什么很多文章把PCIe枚举讲得云里雾里其实本质就是一个深度优先遍历。我把过程拆开上电复位PERST#释放后RC开始扫描总线0。RC依次往总线0上每个设备的配置空间发起Configuration Read。如果读取Vendor ID和Device ID有效说明该位置存在设备。读Header Type如果是Type 1说明这是一个桥或者Switch端口桥的下级还有总线。RC会配置桥的Secondary Bus Number和Subordinate Bus Number然后继续向下扫描。如果设备是Endpoint不需要再向下扫继续找下一个设备。全部设备发现完后OS开始分配资源给每个桥分配总线号给Endpoint的BAR分配地址区域给桥配置内存窗口最后使能设备。在嵌入式Linux里这段过程会打印在dmesg里形如pci_bus 0000:01:...如果日志里根本没有PCIe总线相关输出第一反应应该是物理链路没有起来而不是驱动没加载。如果你的平台跑的不是通用Linux而是裸机环境或者RTOS厂商BSP里一般会提供一套类似的枚举库函数原理完全一致只是日志输出方式不同。2.5 中断机制INTx与MSI/MSI-XPCIe设备的中断方式分两大类INTx沿用PCI时代的共享中断线共四根引脚。在嵌入式平台通常接到中断控制器的一个SPI上需要在设备树里配置interrupt-map。INTx的缺点是多个设备可能共享一条中断线驱动里查中断来源是常规操作还容易出现中断风暴。MSI/MSI-X是更现代的方案。设备通过向特定地址写一个报文来产生中断不占物理中断线可以分发到不同CPU核心。MSI最多支持32个向量MSI-X则扩展到了上千个非常适合NVMe、万兆网卡这种多队列设备。我做驱动有一个习惯能走MSI就不走INTx。MSI能避免共享中断的相互干扰还能把中断负载分散到多核。但要注意两点一是老一点的SoC内部RC可能只支持MSI不支持MSI-X看内核日志能看到相关提示二是设备树里msi-parent必须配好否则驱动申请MSI失败后只能退回到INTx。3. 实操从硬件到驱动手把手对接一个PCIe设备3.1 硬件设计阶段先确认这几个点很多人上来就写驱动结果板子不认设备回头查硬件浪费的时间更多。我建议先过一遍下面几个硬件要素参考时钟。PCIe链路工作时需要100MHz差分参考时钟常见有两种模式Common Clock和Separate Reference Clock。PCB上采用哪种模式会影响展频配置设计时一定要和RC侧对齐。AC耦合电容。TX、RX差分信号线上必须串接电容典型值是0.1uF位置靠近发送端。这个电容承担共模匹配作用忘焊或者焊错位置会导致链路训练失败。PERST#复位时序。上电后PERST#要保持低电平一段时间时序不满足会导致设备始终处于复位态。很多盘不认、卡不认的问题最后都定位在这个引脚。差分阻抗。PCIe差分线一般控85欧姆差分阻抗具体以芯片手册为准。走线阻抗不连续会让眼图变差轻则协商掉速重则完全训练不起来。提示裸机或者Uboot阶段就能直接验证链路是否起来。比如在Uboot下执行pcie link status命令能看到协商出来的速率和宽度。这个阶段验证通过再进Linux排查要省事得多。3.2 嵌入式Linux下设备树该怎么配在嵌入式Linux里PCIe控制器对应一个设备树节点。不同SoC的compatible名不同但核心属性是类似的我写一个通用模板pcie0 { status okay; num-lanes 4; phys pcie0_phy; phy-names pcie; power-domains pd_pcie; vpcie-supply vcc_3v3; max-link-speed 3; /* 3代即Gen3 */ reset-gpio gpio5 8 GPIO_ACTIVE_HIGH; msi-parent its; bus-range 0x00 0xff; };几个关键字段简单解释num-lanes告诉RC最多使用多少通道max-link-speed限制最高代际reset-gpio控制PERST#引脚msi-parent指向中断控制器bus-range定义总线号范围。设备树写错的典型症状是驱动probe卡死或者dmesg里报failed to initialize link training。遇到这种问题我习惯先把max-link-speed降成Gen1、num-lanes改成1做排除测试。如果降速后能识别设备大概率是PCB信号质量问题软件上再怎么改都遮不住。3.3 驱动开发怎么做Linux内核里写PCI驱动的骨架非常固定可以说是一个模板套到底static const struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x1234, 0x5678) }, { 0 } }; MODULE_DEVICE_TABLE(pci, my_pci_ids); static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { resource_size_t bar0; void __iomem *regs; int irq, ret; ret pci_enable_device(pdev); if (ret) return ret; bar0 pci_resource_start(pdev, 0); pci_request_mem_regions(pdev, mydev); regs ioremap(bar0, pci_resource_len(pdev, 0)); /* 优先申请MSI中断 */ ret pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (ret 0) dev_warn(pdev-dev, MSI alloc failed, use INTx\n); irq pci_irq_vector(pdev, 0); request_irq(irq, my_irq_handler, 0, mydev, data); pci_set_master(pdev); /* 使能DMA主控 */ pci_set_drvdata(pdev, data); return 0; } static void my_pci_remove(struct pci_dev *pdev) { /* 释放资源 */ } static struct pci_driver my_pci_driver { .name mydev, .id_table my_pci_ids, .probe my_pci_probe, .remove my_pci_remove, }; module_pci_driver(my_pci_driver);有几个细节容易踩坑pci_enable_device没做之前不能访问BAR空间。这个顺序错了大概率直接崩溃。要支持DMA就一定要调pci_set_master。很多新手写完probe发现数据传输不动结果就是总线主控没开。如果做电源管理还得考虑pci_save_state和pci_restore_state否则系统suspend/resume后设备状态就丢了。开发阶段多打印寄存器状态、检查BAR读取是否正常比闷头调业务逻辑高效得多。3.4 调试与性能测试三板斧第一板斧是lspci -vvv看链路状态和资源分配lspci -vv -s 03:00.0重点看LnkSta行比如Speed 8GT/s, Width x4就说明链路协商到了Gen3 x4。再看BAR区域和MSI Capability判断映射是否正确。第二板斧是setpci直接操作配置空间。在某些驱动还没加载的场景下setpci是排障利器setpci -s 03:00.0 COMMAND0x06 setpci -s 03:00.0 0x10.l0x80000000上面第二行是把设备BAR0临时改成0x80000000用来验证地址映射逻辑。第三板斧是性能测试。NVMe盘用fio测带宽自定义网卡或者DMA设备用dd或者在驱动里统计实际搬运速率。测试时关注平均值而不是峰值持续稳定性才是嵌入式系统真正需要的。4. 实战中踩过的坑4.1 链路训练失败lspci根本找不到设备这是最打击新手的现象内核启动到PCIe枚举阶段毫无输出lspci也看不到设备。排查顺序我一般是这样用示波器看PERST#时序确认复位有没有正常释放。检查100MHz参考时钟是否存在频率和展频状态是否正确。检查差分对焊接和AC耦合电容特别是手工焊接的样板。在Uboot下强制Gen1、单通道做排除实验。我之前遇到一个FPGA做Endpoint的案例板子deubg了很久都识别不到最后用逻辑分析仪抓PERST#才发现是被前级电源IC拉低复位一直没释放。这种问题从软件角度看毫无头绪只有回到硬件测量才能定位。4.2 识别成功但协商速率和宽度不满现象是设备能看到但链路协商结果只有x1 Gen2明明插的是x4 Gen3设备。常见原因有三个PCB只布了x1的线连接器物理上也只有x1这种在半高卡和窄板卡上非常常见。参考时钟配置不对比如展频没开启或者模式不匹配导致链路训练反复重试后降级。差分信号质量差眼图闭环PCIe自动协商退到低速。处理方式先查硬件设计再通过setpci修改Target Link Speed和Target Link Width做排除。很多FPGA和SoC允许限制协商目标利用这一点可以快速判断到底是物理层问题还是配置问题。4.3 中断不正常驱动只能走轮询模式如果设备本来支持MSI但dmesg里出现MSI/MSI-X not enabled的提示多数是设备树里msi-parent没配或者RC驱动没有使能MSI。还有一类情况是中断全部落在同一个CPU核上导致系统响应变差这时需要调整IRQ affinity把不同设备的中断分散到多个核心。INTx共享中断的问题更隐蔽。某个设备驱动崩溃或者意外拔出可能会把中断线长期拉低导致同一条中断线上的其他设备全部瘫痪。这种一个设备拖死一条中断线的场景在嵌入式主板上遇到过好几次。4.4 DMA传输出错CPU读不到预期数据DMA问题通常表现为数据写到错误地址、CPU读到全0或者数据错乱。排查时要抓住几个关键点设备DMA的地址域和CPU访问的物理地址是否一致IOMMU/SMMU开关状态不同结果差异很大。DMA地址位数是否足够。设备只支持32位地址时BAR的高位地址会被截断数据直接写飞。Linux驱动里优先用dma_alloc_coherent分配一致内存不要用裸的__pa()去转内核虚拟地址。出现SWIOTLB相关报错时先确认是不是物理内存超出设备寻址范围触发了bounce buffer回退机制。4.5 常见问题速查表现象排查方向常用工具lspci看不到设备物理链路、PERST#、时钟、AC耦合电容Uboot命令、示波器、逻辑分析仪链路协商不满带宽PCB走线、参考时钟配置、物理通道数setpci、眼图测试MSI中断不工作设备树msi-parent、RC能力、驱动flagdmesg、/proc/interruptsDMA数据乱飞地址映射、IOMMU、数据结构对齐devmem、dmesg、ftrace设备加载即崩溃BAR映射错误、寄存器长度不符单独读BAR、mmiotrace5. 从PCIe还能延伸出去的能力5.1 FPGA上的PCIe玩法XDMA与AXIFPGA做PCIe设备时一般不用从零去实现TLP协议直接用厂商IP核。Xilinx的XDMA是典型代表它把PCIe协议栈封装成AXI接口用户逻辑只需要读写AXI总线和处理DMA描述符即可。驱动侧也有成熟的DMA框架可以对接。XDMA的内部寄存器空间和DMA描述符表都在BAR里主机通过配置寄存器控制数据搬运。用这套方案做高速数据采集卡、软件无线电、图像采集设备都很顺。需要注意的是有些IP对BAR空间读写有对齐要求比如门铃寄存器要求32位或64位访问违反访问规则会导致中断无法触发排查起来很隐蔽。5.2 PCIe Switch把一个口掰成多个用嵌入式系统需要挂多路NVMe或者多路网卡但SoC提供的RC端口数量有限这时候就要上PCIe Switch。Switch不仅能扩展端口还能做故障隔离、热插拔支持和电源管理存储服务器和AI推理机上几乎都是标配。调Switch设备时重点检查上游端口和下游端口的bus-range分配很多枚举异常都是桥的窗口配置错了导致下游设备无法被CPU访问。5.3 衍生形态Mini PCIe与M.2接口的差异很多人在选型时纠结Mini PCIe和M.2到底有什么区别其实核心在于协议和供电定义的差异。Mini PCIe是老一代无线网卡和SSD常用的形态物理接口上同时引出了PCIe信号、USB信号和SMBus实际使用时可以跑PCIe协议也可以跑USB协议具体看设备定义。M.2则是更现代的形态支持PCIe/NVMe协议也兼容SATA和USB关键看Key定义。B Key和M Key的物理防呆槽不同支持的协议也不同BM Key的设备通常兼容两种插槽。尺寸上有2230、2260、2280等规格M.2固态盘最常见的就是2280。还有很多人问PCIe半高挡板尺寸。标准全高挡板高度约120mm半高Low Profile挡板高度约79.2mm宽度基本都在18.8mm左右。选机箱时如果不注意这个尺寸卡插得进去但挡板盖不上很尴尬。5.4 性能实测的一些经验数据我自己在嵌入式平台上测过不少NVMe设备PCIe Gen3 x4接口下的经验值顺序读稳定在3.3到3.6GB/s顺序写在2.8到3.2GB/s。如果读带宽明显低于3GB/s先查是不是协商到了x2如果写带宽比读低一截重点看设备固件的写缓存策略和DMA描述符队列深度。带宽测试不是只看峰值要看持续传输时的稳定性。嵌入式系统很多是7x24小时跑的热设计、降频策略、DMA缓冲管理都可能导致掉速。开发阶段多做几轮长时间压力测试比任何理论推演都靠谱。最后再分享一个我自己养成的习惯任何PCIe问题先用Uboot或者固件层确认物理链路再去Linux里查枚举和驱动这个顺序能至少节省一半排错时间。另外手头多备几张不同型号的PCIe设备卡对快速定位到底是RC问题还是Endpoint问题很有帮助。PCIe这套体系看着复杂但只要把链路训练、枚举、BAR映射这几个核心点吃透后面做驱动和性能调优都会顺手很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进