1. 为什么要在 Zynq UltraScale+ 上折腾 PCIe Root Complex
1.1 先搞清楚这颗芯片到底能干什么
Zynq UltraScale+ 是 Xilinx 把 ARM Cortex-A53 应用核、Cortex-R5 实时核、Mali-400 GPU 和一大片可编程逻辑(PL)塞进同一颗硅片里的异构 SoC。它跟传统 FPGA 最大的区别在于:PS 侧本身就是一个能跑 Linux 的完整处理器系统,PL 侧则像一块可以任意裁剪的外设扩展区。而 PCIe 控制器就挂在 PS 侧,属于硬核 IP,不需要你在 PL 里用逻辑资源去搭 PHY 和链路层。
这意味着什么?意味着你可以在 Zynq UltraScale+ 上跑一个标准的 Linux 系统,同时让它扮演 PCIe Root Complex 的角色,去枚举、配置、访问挂在 PCIe 插槽上的各种端点设备——网卡、NVMe 盘、采集卡、自定义加速卡都行。这个能力在边缘计算网关、工业数据采集、雷达信号处理、视频转码盒子这类场景里非常实用,因为你可以用一颗芯片同时完成主控和高速外设扩展两件事。
我当初入这个坑,是因为手头一个项目需要从外部 ADC 板卡高速搬数据,USB 和以太网都嫌带宽不够、延迟不稳,最后选了 PCIe 通道。Zynq UltraScale+ 的 PS 侧 PCIe 控制器支持 Gen2 x4(部分型号 Gen3),理论带宽足够,而且 Linux 内核原生支持 DesignWare PCIe 驱动,省去了大量底层开发工作。
1.2 Root Complex 和 Endpoint 到底差在哪
很多人第一次接触 PCIe 会被 RC 和 EP 这两个词绕晕。用个生活化的类比:Root Complex 就像公司里的行政前台,所有外来访客(Endpoint 设备)都得先到前台登记、领工牌、分配房间号,之后才能被内部员工访问。Endpoint 则是那些访客本身,它们不会主动去敲别人的门,只能等前台安排。
在硬件层面,RC 负责发起配置空间读写、分配总线号和地址窗口、管理中断路由;EP 则响应这些请求。Zynq UltraScale+ 的 PS PCIe 控制器可以配置成 RC 模式,这是通过psu_pcie节点的属性来决定的。PL 侧如果也要接 PCIe,通常走 GT 高速收发器加 PCIe 硬核或软核,但那是另一条路,本文聚焦 PS 侧 RC。
注意:PS 侧 PCIe 和 PL 侧 PCIe 是两套独立的资源,别搞混。PS 侧的参考时钟来自专用差分时钟输入引脚,PL 侧则从 GT 参考时钟走。
1.3 适合谁来读这篇内容
如果你手上有 Zynq UltraScale+ 的开发板(比如 ZCU102、ZCU104、或者国产的类似板卡),想让它作为主控去带 PCIe 设备,这篇内容就是给你写的。你需要具备基本的 Linux 驱动概念、会用 Vivado 和 PetaLinux 工具链、能看懂设备树语法。完全没碰过 FPGA 的纯软件工程师也能跟下来,但建议先补一下 Vivado 的 block design 基本操作。
2. 整体方案设计与关键选型考量
2.1 硬件链路怎么搭
整个系统的硬件拓扑其实不复杂:Zynq UltraScale+ 的 PS PCIe 控制器通过 GT 收发器引出到板边的 PCIe 金手指或插槽,外部设备插上去,参考时钟由板上的时钟芯片提供。关键在于几个硬件设计细节:
- 参考时钟:PCIe 规范要求 100MHz 差分参考时钟,抖动要控制在很低的水平。ZCU102 上用的是 Si5332 时钟发生器,如果你自己画板,务必选专用 PCIe 时钟芯片,别拿普通晶振凑合。
- 复位信号:RC 需要给 EP 提供 PERST# 复位,这个信号在 Zynq 上通常由 PL 的 GPIO 或者 MIO 引脚驱动。设备树里要正确描述这个 GPIO,否则内核枚举时可能找不到设备。
- 电源:PCIe 插槽的 3.3V 和 12V 供电要充足,尤其是带大功率加速卡的场景。我见过有人用板载 LDO 给插槽供电,结果一插卡就掉电重启。
2.2 软件栈的分层
软件这边从下往上大致分四层:
- BootROM 和 FSBL:上电后先跑固化在芯片里的 BootROM,加载 FSBL,FSBL 初始化 DDR、时钟、MIO,然后加载 PL 比特流(如果有)和 U-Boot。
- U-Boot:负责进一步初始化外设,加载 Linux 内核和设备树。
- Linux 内核:DesignWare PCIe 驱动负责枚举总线、分配资源、注册设备。
- 用户空间:通过 sysfs 或字符设备访问 PCIe 设备,或者加载对应的功能驱动。
这个分层决定了你调试时的排查顺序:先确认 FSBL 阶段 PCIe 控制器有没有被正确初始化,再看 U-Boot 能不能识别链路,最后才到内核枚举。
2.3 为什么选 PetaLinux 而不是手动搭
Xilinx 的 PetaLinux 工具链把内核、设备树、根文件系统、FSBL、U-Boot 的构建流程都封装好了,一条petalinux-build就能出完整镜像。手动用 Buildroot 或 Yocto 当然也行,但你要自己处理 Xilinx 的内核补丁、设备树源文件、以及各种版本兼容问题,工作量翻好几倍。
我试过用 Yocto 的 meta-xilinx 层手动搭,光是让 PCIe 驱动正确 probe 就花了两天,最后还是回到 PetaLinux。对于大多数项目,PetaLinux 是性价比最高的选择。当然,如果你有特殊的定制需求,比如要裁剪内核到极小体积,那另说。
2.4 设备树的核心地位
在 ARM Linux 里,设备树是硬件描述的唯一真相来源。内核启动时读设备树,知道有哪些外设、地址在哪、中断怎么连、时钟从哪来。PCIe RC 的设备树节点如果写错,轻则设备枚举不到,重则内核直接 panic。
Zynq UltraScale+ 的 PCIe 设备树节点主要包含这几块信息:控制器寄存器基地址、中断号、时钟、复位、PHY 配置、以及device_type = "pci"和ranges属性。ranges属性尤其关键,它定义了 PCIe 地址空间到 CPU 地址空间的映射关系,写错了会导致访问 EP 的 BAR 空间时地址对不上。
3. 核心细节解析与实操要点
3.1 Vivado 侧的 PS 配置
打开 Vivado,创建工程,添加 Zynq UltraScale+ MPSoC 的 IP 核。在 PS 配置界面里,找到 PCIe 相关的设置:
- PCIe Controller:勾选使能,模式选 Root Complex。
- Lane Width:根据你的硬件选 x1、x2、x4。ZCU102 默认 x4。
- Link Speed:Gen1 或 Gen2。Gen2 需要更好的信号完整性。
- Reference Clock:选 100MHz 差分。
- Reset GPIO:指定 PERST# 的引脚。
配置完成后,Vivado 会自动生成对应的地址分配和中断连接。这里有个坑:PS 侧 PCIe 的中断是pl_ps_irq系列,不是普通的 SPI 中断,设备树里要写对中断类型。
实操心得:Vivado 里 PS 配置改完后,一定要重新生成 bitstream 和导出 XSA 文件。PetaLinux 的
petalinux-config --get-hw-description会读 XSA 来更新硬件信息,如果 XSA 是旧的,设备树就会跟实际硬件对不上。
3.2 PetaLinux 工程创建与配置
petalinux-create -t project -n pcie_rc --template zynqMP cd pcie_rc petalinux-config --get-hw-description=/path/to/xsa进入配置菜单后,重点看这几项:
- Subsystem AUTO Hardware Settings→Advanced bootable images storage Settings:确认内核和设备树的加载地址。
- DTG Settings:设备树生成器的配置,确保 PCIe 节点被包含。
- Kernel:确认 DesignWare PCIe 驱动被编译进内核。
petalinux-config -c kernel在内核配置里搜索CONFIG_PCIE_DW和CONFIG_PCIE_XILINX,确保它们是y或m。Zynq UltraScale+ 用的是pcie-xilinx-nwl驱动,不是老的pcie-xilinx,别选错。
3.3 设备树节点的逐字段拆解
PetaLinux 生成的设备树在components/plnx_workspace/device-tree/device-tree/下。找到psu_pcie节点,它大概长这样:
&psu_pcie { status = "okay"; compatible = "xlnx,nwl-pcie-2.11"; reg = <0x0 0xfd0e0000 0x0 0x1000>, <0x0 0xfd480000 0x0 0x1000>, <0x0 0xfd490000 0x0 0x1000>, <0x0 0xa0000000 0x0 0x10000000>; reg-names = "breg", "pcireg", "cfg", "cfg_space"; interrupts = <0 118 4>, <0 119 4>, <0 120 4>, <0 121 4>, <0 122 4>, <0 123 4>; interrupt-names = "misc", "dummy", "intx", "msi1", "msi2", "msi3"; interrupt-map-mask = <0 0 0 7>; interrupt-map = <0 0 0 1 &pcie_intc 0>, <0 0 0 2 &pcie_intc 1>, <0 0 0 3 &pcie_intc 2>, <0 0 0 4 &pcie_intc 3>; msi-parent = <&psu_pcie>; #interrupt-cells = <1>; interrupt-controller; #address-cells = <3>; #size-cells = <2>; device_type = "pci"; bus-range = <0x00 0xff>; ranges = <0x02000000 0x0 0xe0000000 0x0 0xe0000000 0x0 0x10000000>, <0x43000000 0x80 0x00000000 0x80 0x00000000 0x0 0x80000000>; clocks = <&zynqmp_clk 0x5a>; clock-names = "pcie"; power-domains = <&zynqmp_firmware 0x3>; resets = <&zynqmp_reset 0x1d>; reset-names = "pcie"; phys = <&psu_pcie_phy>; phy-names = "pcie-phy"; };逐字段解释:
compatible:匹配驱动,xlnx,nwl-pcie-2.11对应 Zynq UltraScale+ 的 NWL 控制器。reg:四段地址,分别是桥寄存器、PCIe 寄存器、配置空间、配置空间窗口。地址必须跟 Vivado 里分配的一致。interrupts:六个中断,misc 是主中断,intx 是传统中断,msi1/2/3 是 MSI 中断。interrupt-map:把 PCIe 的 INTA/INTB/INTC/INTD 映射到 GIC 中断。ranges:两段,第一段是 32 位非预取内存空间,第二段是 64 位预取内存空间。这里的地址必须跟 CPU 地址空间不冲突。phys:指向 PHY 节点,PHY 配置单独一个节点。
3.4 PHY 节点的配置
PHY 节点通常长这样:
psu_pcie_phy: phy@fd480000 { compatible = "xlnx,zynqmp-pcie-phy"; reg = <0x0 0xfd480000 0x0 0x1000>; clocks = <&zynqmp_clk 0x5a>; clock-names = "ref"; #phy-cells = <0>; };PHY 的寄存器基地址和时钟必须跟硬件一致。如果 PHY 初始化失败,链路根本起不来,内核日志里会看到phy power on failed之类的错误。
避坑指南:有些板子的 PHY 参考时钟来自 PL 侧的 GT 时钟,这时候设备树里要引用对应的时钟节点,不能简单写
zynqmp_clk。我踩过这个坑,查了三天才发现是时钟源写错了。
4. 实操过程与核心环节实现
4.1 从零开始构建完整镜像
假设你已经有了 XSA 文件,完整流程如下:
# 创建工程 petalinux-create -t project -n pcie_rc --template zynqMP cd pcie_rc # 导入硬件描述 petalinux-config --get-hw-description=../xsa/ # 配置内核,确保 PCIe 驱动使能 petalinux-config -c kernel # 配置根文件系统,加入 pciutils 工具 petalinux-config -c rootfs # 构建 petalinux-build # 打包 BOOT.BIN petalinux-package --boot --fsbl --fpga --u-boot --force构建完成后,在images/linux/下会得到BOOT.BIN、image.ub、system.dtb等文件。把它们拷到 SD 卡,设置启动模式为 SD 启动,上电。
4.2 启动日志的关键检查点
串口终端里,U-Boot 阶段应该能看到类似输出:
ZynqMP> pci enum如果 U-Boot 里 PCIe 枚举成功,会打印出总线上发现的设备。如果这里就失败了,说明硬件链路或 FSBL 配置有问题,先别急着进内核。
内核启动后,用dmesg | grep pci查看:
[ 1.234567] nwl-pcie fd0e0000.pcie: host bridge /amba/pcie@fd0e0000 ranges: [ 1.234568] nwl-pcie fd0e0000.pcie: No bus range found for /amba/pcie@fd0e0000, using [bus 00-ff] [ 1.234569] nwl-pcie fd0e0000.pcie: MEM 0xe0000000..0xefffffff -> 0xe0000000 [ 1.234570] nwl-pcie fd0e0000.pcie: PCI host bridge to bus 0000:00 [ 1.234571] pci_bus 0000:00: root bus resource [bus 00-ff] [ 1.234572] pci 0000:00:00.0: [10ee:9034] type 01 class 0x060400看到PCI host bridge to bus 0000:00就说明 RC 初始化成功了。接下来用lspci命令:
lspci -vvv应该能看到挂在总线上的 EP 设备。如果lspci输出为空,但 dmesg 里 RC 初始化成功,那问题多半在链路训练或 EP 侧。
4.3 地址窗口的分配计算
ranges属性里的地址不是随便写的,需要跟 CPU 地址空间规划对齐。假设你的 DDR 占 0x0 到 0x7FFFFFFF,那么 PCIe 内存窗口可以放在 0xE0000000 开始的地方,大小 256MB。这个地址要在 Vivado 的 Address Editor 里确认没有被其他外设占用。
64 位预取窗口通常放在 0x800000000 以上,因为 32 位空间不够用。计算方法是:先确定 EP 的 BAR 空间需求,比如一张 NVMe 盘可能需要 64KB 的 MMIO 空间,一张网卡可能需要 128KB,累加起来再留余量。
实操心得:如果
ranges里的地址跟 DDR 重叠,内核启动时会直接 panic,报Unable to handle kernel paging request。我第一次配的时候把 PCIe 窗口放在了 0x0,结果跟 DDR 撞了,查了半天才发现。
4.4 中断路由的配置
PCIe 中断分传统 INTx 和 MSI/MSI-X 两种。现代设备基本都用 MSI-X,因为传统 INTx 是共享的,效率低。设备树里的msi-parent指向 RC 自己,表示 MSI 中断由 RC 控制器处理。
内核里要确保CONFIG_PCI_MSI使能。如果 EP 驱动申请 MSI 中断失败,先检查这个配置。另外,Zynq UltraScale+ 的 MSI 中断号是固定的,设备树里的interrupts属性要跟 GIC 的 SPI 分配一致。
4.5 实测:挂一张 Intel I210 网卡
我用一张 Intel I210 千兆网卡做验证。插上后,lspci输出:
00:00.0 PCI bridge: Xilinx Corporation Device 9034 01:00.0 Ethernet controller: Intel Corporation I210 Gigabit Network Connection内核自动加载了igb驱动,ip link能看到enp1s0接口。ethtool -i enp1s0显示驱动版本正常。跑iperf3测试,单向能到 940Mbps,基本跑满千兆。
这个过程中,I210 的 BAR 空间被内核自动分配,MSI 中断也正常注册。如果换成需要大 BAR 空间的设备,比如 NVMe 盘,就要确认ranges里的窗口够大。
5. 常见问题与排查技巧实录
5.1 链路起不来怎么办
这是最常见的问题,表现为 dmesg 里看不到PCI host bridge那行,或者 U-Boot 里pci enum报错。排查顺序:
- 参考时钟:用示波器量 100MHz 差分时钟,幅度和抖动是否达标。
- PERST# 复位:确认复位信号在枚举前已经释放,且电平正确。
- GT 收发器:如果用的是 PL 侧 GT,检查 GT 的参考时钟和复位。
- 设备树 PHY 节点:确认 PHY 寄存器和时钟配置正确。
我遇到过一次链路起不来,最后发现是 PERST# 的 GPIO 在设备树里写反了极性,导致 EP 一直处于复位状态。
5.2 设备枚举到了但驱动不加载
lspci能看到设备,但lsmod里没有对应驱动。原因通常是:
- 内核没编译该驱动。
- 设备的 Vendor ID / Device ID 不在驱动的匹配表里。
- 驱动依赖的其他模块没加载。
解决办法:用modprobe手动加载,或者在内核配置里把驱动编进去。如果是自定义设备,需要自己写驱动或者用uio/vfio框架。
5.3 访问 BAR 空间报错
用户空间程序通过mmap访问 BAR 空间时,如果地址映射不对,会收到SIGBUS。检查/sys/bus/pci/devices/0000:01:00.0/resource0文件的大小和权限,确认ranges里的地址窗口覆盖了 BAR 的地址范围。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 链路起不来 | 参考时钟异常 | 示波器量时钟 |
| 链路起不来 | PERST# 未释放 | 量 GPIO 电平 |
| 枚举不到设备 | 设备树 ranges 错误 | 对比 Vivado 地址 |
| 驱动不加载 | 内核未编译驱动 | 检查 .config |
| BAR 访问报错 | 地址窗口不覆盖 | 查 resource 文件 |
| MSI 中断失败 | MSI 未使能 | 检查 CONFIG_PCI_MSI |
| 系统 panic | ranges 与 DDR 重叠 | 查内核日志 |
5.5 独家避坑技巧
- 设备树改动后一定要重新编译 DTB:
petalinux-build -c device-tree单独编译设备树,然后替换image.ub里的 DTB。我见过有人改了设备树源文件但忘了重新打包,调试半天没效果。 - 用
dtc反编译 DTB 验证:dtc -I dtb -O dts -o check.dts system.dtb,看看最终生成的设备树是不是你期望的。 - U-Boot 里先枚举一遍:
pci enum和pci list能在进内核前快速验证硬件链路,省去反复重启的时间。 - 保留多个版本的 XSA:硬件改动后,旧 XSA 对应的设备树可能不兼容,保留备份方便回滚。
6. 性能调优与进阶方向
6.1 提升 PCIe 带宽利用率
Gen2 x4 的理论带宽是 20Gbps,实际能跑到 16Gbps 左右就不错了。提升利用率的方法:
- 增大 DMA 传输块:小块传输的协议开销占比高,尽量用 256KB 以上的块。
- 启用 MSI-X 多向量:让不同队列用不同中断,减少 CPU 竞争。
- 使用
vfio直通:把设备直通给用户空间驱动,绕过内核协议栈。
6.2 多设备场景下的资源分配
如果总线上挂多个 EP,ranges窗口要足够大,中断号也要规划好。Zynq UltraScale+ 的 GIC 支持最多 16 个 MSI 向量,够一般场景用。如果设备特别多,考虑用 PCIe Switch 扩展。
6.3 从 RC 到 EP 的切换
有些场景需要 Zynq 作为 EP 被其他主机访问,比如做加速卡。这时候 PS 侧 PCIe 控制器要配成 EP 模式,设备树节点也要相应修改。EP 模式的驱动是pcie-xilinx-ep,配置方式跟 RC 差别很大,需要单独研究。
我在实际项目里,RC 模式用得最多,因为 Zynq 作为主控更符合大多数边缘计算场景的需求。EP 模式更适合把 Zynq 当成协处理器卖给别人的场景,调试起来也更麻烦,因为你需要另一台主机来做 RC。
6.4 设备树配置的版本管理
设备树文件建议纳入 Git 管理,每次硬件改动都提交一版。PetaLinux 工程里的components/plnx_workspace/device-tree/目录可以单独做成子模块,方便跟硬件设计同步。我现在的做法是:Vivado 工程一个仓库,PetaLinux 工程一个仓库,设备树作为子模块引用,这样硬件和软件的版本对应关系一目了然。
最后再分享一个小技巧:如果你在调试 PCIe 时遇到内核 panic,但串口输出太快看不清,可以在 U-Boot 的bootargs里加earlycon和keep_bootcon,让早期控制台输出保留下来。另外,pci=noaer参数可以关闭高级错误报告,避免一些非致命错误导致系统不稳定。这些参数在petalinux-config -c kernel的Boot options里可以配,也可以直接在 U-Boot 环境变量里改。