1. 从一张“异常”的链路拓扑说起
做服务器或者嵌入式平台开发的朋友,大概率遇到过这种场面:板子上明明只有一个PCIe Root Complex,但调试时用lspci一看,竟然出现了两棵独立的PCIe总线树;或者两块CPU板卡通过高速连接器背靠背扣在一起,系统起来之后发现“CPU A看到了CPU B的PCIe设备”,但总线号错乱、DMA地址对不上,甚至两个Root Complex互相“抢地盘”。
这个现象背后,多半就是标题里那两位:PCIe Crosslink。
我第一次接触Crosslink,是在一块带FPGA的加速卡上。当时想实现“主机CPU和FPGA各自作为Root Complex,通过PCIe直连互访”,结果被总线枚举、BAR空间映射、地址转换这几个环节折腾得够呛。后来翻协议规范、看内核代码、改设备树,才算把这条链路跑通。回头再看,Crosslink在PCIe体系里其实是一个挺“反直觉”的玩法——它把传统“一主多从”的总线模型,硬生生改成了“双主互连”的对等模型。
这篇文章我会从Crosslink是什么、为什么需要它、实际怎么配、会遇到哪些坑这几个角度,把这条链路从头到尾拆一遍。适合正在做PCIe驱动、FPGA加速卡、双路服务器互联、或者单纯想搞懂“两个Root Complex能不能直接对话”的人参考。
2. Crosslink是什么,以及它解决了什么问题
2.1 传统PCIe拓扑里的“绝对主从”
要理解Crosslink,得先回到PCIe的根上。PCIe总线是树形拓扑,一颗树只有一个根节点,也就是Root Complex(RC)。RC往下挂的是各种Switch、Endpoint(EP),整个系统里所有设备共享同一个地址域,CPU通过RC发起的配置请求、内存读写请求,都被协议规定为“从根到叶”的方向。
这种设计的好处是简单、稳定、好枚举。操作系统启动时,PCIe核心代码从Bus 0开始,一层层往下扫描,发现设备就分配BDF(Bus/Device/Function)、配置BAR、建立资源映射,整棵树的地址空间是统一规划的。
但问题也随之而来:如果两套独立的PCIe系统想直接互联,怎么办?
典型的场景是双CPU服务器。两块CPU各自带自己的PCIe控制器,它们之间通常走QPI/UPI这类专用的CPU互联总线,PCIe设备各管各的,互不干扰。但有些场景没有UPI可用,只有PCIe通道——比如两块廉价的开发板、两台独立的PC、或者一块CPU加一块FPGA,它们想通过PCIe见面。
2.2 Crosslink的双Root模型
PCIe规范里定义了一种特殊能力,叫Crosslink。它允许两个Root Complex直接连接,而不是像传统拓扑那样必须一个当RC、另一个当EP。
在Crosslink模式下,链路两端各自都是RC。但PCIe协议又规定:同一时刻,链路两端必须有一个是“上游”角色,另一个是“下游”角色。这个角色不是固定的,而是通过链路训练时双方协商出来的——谁当上游,谁就负责管理这条链路上的总线枚举,谁就是这一侧的“主”。
这就引出了Crosslink最核心的三个特征:
- 双RC共存:两端各自保留自己的Root Complex,连接后形成两个独立RC域。
- 动态角色协商:链路初始化时,两端通过配置握手决定哪个RC作为上游、哪个作为下游,非对称连接时通常是带宽高的一端做上游。
- 跨域访问依赖地址转换:两个RC各自有独立的地址空间,直接访问对端设备时,需要做地址映射或使用DMA重映射。
注意:Crosslink和PCIe Switch/NTB(Non-Transparent Bridge)是两回事。Switch是透明桥,天然做地址转发;NTB是给两个主机系统做隔离互联的;Crosslink则是“点对点双RC直连”,没有中间交换芯片,成本最低、时延最小,但实现难度全在软件和地址管理上。
2.3 为什么很多场景不用传统“RC + EP”模式
有人会问:既然Crosslink这么麻烦,直接把一端配成EP,老老实实当从设备不就行了?
确实,在很多加速卡场景里,FPGA或者ASIC就是老老实实做EP,主机是RC,这是最成熟、最省事的方案。但有些场景里,EP模式不够用:
- 对等互访:两端都需要主动发起访问,不希望有“主从”之分。比如两台服务器通过PCIe直连做集群内部通信,如果一端只能当EP,那它的CPU就无法主动访问对端内存,所有数据交换都得靠对端RC驱动,性能和心理上都接受不了。
- 共享存储:两个CPU都想访问同一块PCIe NVMe硬盘或者同一片内存窗口,EP模式没法做到“双方都能看到并管理这个设备”。
- 故障切换:双机热备场景里,主机可能随时切换,谁当主谁当备不能固化在硬件里,需要链路层动态决定。
- FPGA之间直连:两块FPGA板卡都跑着自己的软核或者硬核PCIe控制器,它们之间想高速交换数据,又不愿意降级成EP——毕竟FPGA里的PCIe硬核一般都能配成RC,浪费这个能力太可惜。
所以Crosslink解决的核心问题可以概括成一句话:在不引入额外交换硬件的前提下,让两个独立的PCIe系统实现对等的高效互联。
3. Crosslink的链路训练与角色协商机制
3.1 链路训练状态机与Crosslink支持位
PCIe链路训练(Link Training)负责物理层的速率协商、通道配置和链路状态确认。标准链路训练里,两端设备通过TS1/TS2序列交换能力信息,其中一个关键字段就是Crosslink支持位。
当两端的RC都宣称自己支持Crosslink时,链路训练会进入一条特殊分支。在不支持Crosslink的普通场景里,链路两端一个是RC(Upstream),另一个是EP(Downstream),角色是固定的;而在Crosslink模式下,两端都是RC,此时需要通过协商确定哪一端在本次链接中扮演“上游端口”(Upstream Port)。
这个过程大致是:
- 链路两端都发送带有Crosslink支持位的TS1序列。
- 如果两端都检测到对方支持Crosslink,则进入Crosslink协商流程。
- 双方根据配置寄存器(通常是设备特定的)决定谁是上游、谁是下游。
- 上游端口负责发起配置请求,下游端口响应这些请求。
- 整个配置过程完成后,链路进入L0状态,正常传输数据。
在我实际调试FPGA和X86主机直连时,遇到过一种情况:FPGA侧逻辑没有正确设置Crosslink使能位,结果链路训练始终回退到普通模式,FPGA被系统识别成一个“不太正常的EP”,BAR空间都出问题。后来加上Crosslink位,链路训练才正常切到对等模式。
3.2 谁当上游?——角色选择的实际考量
从协议角度,Crosslink的角色协商要求“支持Crosslink的两端都能作为上游或下游”。但从系统设计角度,谁都希望做上游,因为上游RC负责管理配置空间,掌握总线的“话语权”。
实际项目中我总结了几条选择依据:
- 性能需求:哪一端需要更主动地访问对端资源,就让它做上游。因为上游发起的配置请求更直接,DMA映射建立也更方便。
- 系统管理需求:如果一端是完整的操作系统(如Linux主机),另一端是裸机FPGA或嵌入式系统,通常让“更复杂、更容易出问题”的那一端做下游,让稳定性更高的系统做上游,方便后面排查问题。
- 硬件连线限制:有些板卡上PCIe连接器有方向限制,比如A卡的端口物理上被设计成仅支持上游,那角色就没得选。
实操心得:在Linux环境下,你可以通过lspci -vvv查看端口的能力位(DevCap2里的Crosslink Supported),以及当前链路训练出来的角色(在Link Status里)。如果发现角色和预期不符,多半是硬件配置寄存器写错了,而不是链路协商失败。
3.3 速率协商与带宽匹配
Crosslink链路同样遵循PCIe的速率协商规则:两端取共同支持的最高速率和最大通道数。比如一端是Gen3 x16,另一端是Gen3 x8,最终协商结果就是Gen3 x8。
这块有个容易踩的坑:Crosslink两端如果都配置成RC,但物理通道数不一致,可能触发链路训练失败或者降速到PCIe 1.0。我遇到过一块FPGA板卡的PCIe硬核默认只配置了x4通道,但主板端是x16插槽,两者协商半天失败,最后直接把FPGA固件里的通道数改成x8才稳下来。
所以在做Crosslink设计时,建议在链路训练阶段打印两端的LTSSM状态和速率协商结果,确认最终工作在预期的速率和宽度上。FPGA厂商(Xilinx/Intel)的PCIe IP核例化界面里都有速率、通道数、Crosslink使能选项,第一次做的时候把这些参数确认清楚再生成比特流。
4. 地址空间与跨RC访问的硬核工程问题
4.1 两个RC,两套地址域
Crosslink调试中最让我头疼的,不是链路起不来,而是链路起来之后地址对不上。
普通PCIe系统里,整个系统只有一个RC,所有设备(Endpoint、桥、甚至RC自己)都在同一个物理地址空间里寻址。但Crosslink两端各自是RC,各自管理自己的内存地址映射。CPU A访问CPU B侧的设备时,地址经过RC A翻译后,要穿越Crosslink链路,到达RC B,再由RC B翻译成自己域内的地址,这个“双重翻译”是Crosslink系统里最容易出错的地方。
举个实际例子:我在一块板卡上用FPGA做RC A,X86主机做RC B。主机想读取FPGA内部的一个寄存器,地址是0x4000_0000。如果我在FPGA侧配置的BAR空间是0x0000_8000_0000,但主机侧RC B的Crosslink窗口并没有映射到这个地址,那么主机发起的读请求就会“丢”在总线上,表现为读超时或返回全F。
4.2 地址窗口与BAR配置细节
解决这个问题,核心思路是在两端RC的设备树或BIOS配置里,互相开一个“透明窗口”。
具体来说:
- RC A要将自己的某一段物理地址空间(比如从0xC000_0000开始的256MB)声明为“Crosslink下游窗口”,这个窗口内的所有访问,都会被转发到RC B的总线域。
- RC B同理,也要开一段窗口,把访问转发到RC A。
这听起来简单,实际配置起来全是细节:
- 窗口大小必须对齐:PCIe地址窗口通常要求对齐到窗口大小,比如256MB窗口的起始地址必须是0x...000(低28位为0)。如果两端窗口大小不一致,转发时地址会被截断或错位。
- BAR空间分配要避开冲突:两端RC各自的设备、内存映射如果占用了同一段地址,Crosslink窗口就必须避开。比如RC A的DDR在0x1000_0000,那这段地址就不能再映射到对端。
- 配置寄存器要显式使能:很多PCIe控制器里,跨RC访问的窗口不是默认开启的,需要在控制器的配置寄存器里主动打开“Crosslink Enable”或者“Upstream/Downstream Window Enable”位。
提示:如果你用的是Linux系统,且两端RC都有完整内核,可以借助PCIe的pci=realloc=on启动参数,在系统枚举完所有设备之后重新分配总线号和BAR空间。Crosslink场景下,BIOS分配的资源经常不合理,内核重新分配往往能救回来。但注意,这个操作不适用于裸机FPGA或者没有PCI子系统管理的嵌入式端。
4.3 DMA访问与Cache一致性
跨RC的DMA是另一个重灾区。传统PCIe DMA里,EP发起DMA写,数据最终写入RC侧的DDR,RC侧CPU再读这份数据,Cache一致性由硬件制动保证。但Crosslink场景下,EP在RC A侧,DMA的目标是RC B侧的DDR,这就涉及两个RC域之间的Cache一致性管理。
很多PCIe控制器对跨RC的DMA访问支持得并不完善。我实际的解法是:
- 如果两侧都是X86或者支持硬件Cache一致性的平台,优先走硬件一致性协议(如果有的话),让DMA引擎和RC控制器自己处理缓存同步。
- 如果是FPGA + X86的组合,干脆在软件里规避:FPGA侧DMA写完数据后,主动发一个中断给主机,主机收到中断后再去读取DDR,读取前做内存屏障或invalidate缓存操作。
- 如果是纯FPGA对FPGA,两边都不涉及操作系统Cache,那就直接用简单的内存屏障或者读写同步寄存器,反而最简单。
这个部分没有万能药,每一对RC的组合都有各自的地址翻译和缓存一致性行为差异,只能靠实测摸索。
5. 实操:两个Linux主机通过PCIe Crosslink直连
5.1 硬件准备与连接方式
如果手头有两台标准的X86服务器/台式机,想直接试验Crosslink,先要确认主板的PCIe控制器是否支持Crosslink能力。大部分服务器级芯片组(如Intel的C621/C741、AMD的TRX40/WRX80)以及部分高端桌面芯片组是支持的,但不是所有主板都开放了这个功能。
连接方式上,最直接的是用一根PCIe线缆,把两台机器的PCIe x16插槽连起来。这种线缆市面上有成品,但要注意选支持双向带宽的型号,且线缆质量影响高速信号质量,劣质线缆会导致链路训练时速率上不去。
如果是开发板或者FPGA板卡,通常是通过板载的高速连接器(比如Samtec的UEC、或者PCIE金手指转接板)互联,这时候要特别注意引脚定义和参考时钟的接线。Crosslink要求两端使用独立的参考时钟(SRIS)或者共享同一个参考时钟(SRNS),接线方式决定了链路能否锁定。我第一次拿两块FPGA板卡做Crosslink,就是因为参考时钟一个接了一个没接,链路训练一直失败,半天后才查出来。
5.2 Linux系统下的链路确认方法
链路物理通了之后,先在Linux下确认链路状态。这一步很重要,因为如果物理层都没稳定,后面所有软件调试都是空中楼阁。
按下面顺序检查:
# 查看PCIe设备树 lspci -tv # 查看Crosslink端口的详细信息 lspci -vvv -s <bus:device.function> # 查看链路速率和通道数 sudo lspci -vvv -s <bus:device.function> | grep -A 20 "LnkCap\|LnkSta"如果Crosslink配置成功,你在lspci -tv里会看到两个RC,各自下面挂着自己的设备,并且有一个“PCI Bridge”或者“PCI-to-PCI bridge”的设备出现在对方的总线域里——这个桥就是Crosslink链路的“虚拟化”表现。
如果链路没起来,常见表现是:
- lspci里根本看不到对方设备;
- lspci能看到桥设备,但链路训练速率是2.5 GT/s(Gen1),说明速率协商失败;
- 读取对端设备的配置空间超时返回全F。
5.3 配置跨RC对等访问(小实验)
假设两台机器的RC分别是RC_A和RC_B,我们希望RC_A的CPU能够直接访问RC_B内存里的一段物理地址。我这边实测有效的步骤如下:
第一步:确认Crosslink窗口寄存器支持
在两台机器上分别查看PCIe控制器的能力寄存器:
setpci -s <rc_bus> CAP_EXP+0x30.w0x30偏移处的低位如果是1,说明该端口声明支持Crosslink。
第二步:给RC_A配置Crosslink下游窗口
在RC_A的BIOS/UEFI设置里,找到PCIe子系统配置选项(不同主板叫法不同),查找类似“PCIe Crosslink Window”或“Non-Transparent Bridge Window”的选项,Enabled之,并设置窗口的基地址和大小。
注意:很多主板的BIOS层面不直接暴露这个选项,你需要摸清控制器的寄存器手册,在系统启动早期通过ACPI或直接写MMIO来配置。这也是为什么很多人直接放弃X86主板,转而用FPGA/嵌入式平台做Crosslink——软件控制权完全在自己手里。
第三步:RC_B侧同理配置
RC_B也需要配置一个窗口,两个窗口的大小和地址映射方式必须一致,否则地址翻译错乱。
第四步:验证读写
在RC_A上,用devmem2或简单的mmap程序,读取RC_B侧映射过来的内存地址。如果读出来的数据符合预期(比如RC_B的DDR里预先写入的标志数),说明Crosslink链路已经通了。
# RC_B侧,向某物理地址写入标志数据 devmem2 0x80000000 w 0xDEADBEEF # RC_A侧,读取对应映射地址 devmem2 0xC0000000 w如果读到0xDEADBEEF,恭喜,你的第一个Crosslink跨域读写成功了。
5.4 FPGA作为RC时的配置要点
用FPGA做Crosslink的RC端,和X86主板逻辑上类似,但实操细节更多。以Xilinx UltraScale+系列的Integrated Block for PCIe为例,IP配置界面里:
- 在“PCIe: Link”选项下,把“Reference Clock”设置为“Shared”或“Independent”,根据对端接口方式选择。
- 在“PCIe: ID”选项下,把“Device/Vendor ID”设置成一个不冲突的唯一值。
- 在“PCIe: Class Code”里,可以把设备类设置成“Bridge Device”,这样系统会把它当桥而不是端点来枚举。
生成比特流后,还要编写逻辑处理配置请求和地址路由。FPGA侧的地址路由逻辑是整个Crosslink系统里最灵活也最容易出错的部分。我的做法是:在FPGA内部建立一个简单的地址路由表,根据地址高位判断该访问是对本端BAR空间的访问,还是需要转发到对端RC的访问,然后分别路由。
比如:
- 地址0xC000_0000到0xCFFF_FFFF → 本地BRAM/寄存器
- 地址0xD000_0000到0xDFFF_FFFF → 转发到对端RC域
这个路由逻辑用Verilog写起来不复杂,但要保证地址匹配的优先级和时序,避免出现同一地址同时命中两条规则的歧义。
6. 避坑指南:我踩过的那些Crosslink坑
6.1 参考时钟配置不当导致链路不稳定
这是Crosslink场景下“频率最高”的问题。两端RC各自独立,参考时钟如果不一致或者不干净,链路质量会很差。
我第二次调Crosslink时,主板A的100MHz参考时钟是从本地晶振分出来的,主板B的参考时钟是从系统PLL分出来的,两者标称都是100MHz,但实际偏了几百ppm。虽然PCIe协议允许一定范围内的时钟频偏(用弹性缓冲消除),但Crosslink场景里链路训练更敏感,最终表现就是链路能训练到Gen3,但长时间大流量读写会偶发CRC错误或者直接Link Down。
解法是尽量走SRNS(Shared Reference Clock No Spread)模式,让两端共用同一个参考时钟源;如果做不到,就确保两个时钟源都干净、稳定,不启用展频。
6.2 地址翻译错误导致读返回全F
前面提到过,Crosslink的地址翻译是双重翻译。很多时候链路是好的,但就是读不到数据,返回全F。定位方法很简单:在RC_A读取映射地址前,先在RC_B端用逻辑分析仪或者FPGA内部ILA抓一下,看有没有收到来自RC_A的访问请求。
- 如果RC_B没收到请求,说明RC_A的窗口配置没生效,地址在RC_A的Root Complex内部就被丢弃了。
- 如果RC_B收到了请求但返回的数据不对,说明RC_B侧的返回路径(Completion)路由有问题。
我当时排查完后发现,是FPGA里的Completion路由逻辑只处理了“本端BAR空间”的读请求,没有处理“转发到对端”的读返回,导致RC_A始终等不到Completion,最后超时返回全F。这个问题从现象上看和窗口配置错误一模一样,很容易误判。
6.3 BIOS/固件对Crosslink的“不支持”
X86平台上,即使芯片组硬件支持Crosslink,出厂BIOS未必开放相关选项。很多主板BIOS里连“PCIe Crosslink”的字样都找不到,更别提配置界面了。
遇到这种情况,有几个方向可以试:
- 更新BIOS到最新版本,部分厂家后续版本会开放更多PCIe高级选项。
- 用AMIBCP或者类似工具修改BIOS的隐藏选项(操作有风险,不推荐在关键设备上尝试)。
- 绕开X86主板,直接用FPGA/嵌入式平台做Crosslink验证,毕竟在这种平台里所有PCIe控制器的寄存器都暴露给开发者,控制力强很多。
- 如果只是想做双主机对等互联,也可以考虑用PCIe NTB(Non-Transparent Bridge)芯片或者支持NTB的Switch,这算是“换赛道”的做法,不非得死磕Crosslink。
6.4 驱动层面的中断与DMA亲和性问题
即使Crosslink链路通了,驱动里如果实现的是“本地RC的中断控制器”,那RC_A的CPU就收不到RC_B侧EP发起的中断请求,除非把中断通过Crosslink链路封装成虚拟中断消息转发过来。
解决思路通常是在FPGA侧做一层“中断聚合转发”:RC_B侧的EP产生中断时,FPGA捕获中断信号,然后以RC_B自身的名义向RC_A发一个MSI中断,RC_A的驱动收到这个MSI后,再去对应地址读取真正的数据。这个方案我在一个项目中用过,效果稳定。
DMA亲和性也有类似问题。RC_A的CPU发起DMA读,目标地址在对端RC_B的DDR里,DMA引擎如果默认把数据写到RC_A的某个本地缓冲区,那没问题;但如果设计成“DMA直接从对端读数据到本端CPU的Cache”,那大概率会遇到缓存一致性问题,表现是读出来的数据偶尔是旧的。解决办法是DMA完成后做显式的缓存刷新,或者在硬件层面放弃Cache一致性,走无缓存映射的地址区间。
6.5 Crosslink与PCIe Hotplug的共存问题
Crosslink链路物理上通常是固定连接,但有些场景设计成可热插拔(比如两块板卡通过前面板连接器对接)。这时候要用PCIe热插拔机制来管理系统对链路的感知。
实测过程中,热插拔和Crosslink的兼容性并不好。PCIe热插拔设计初衷是以EP为主,Crosslink是双RC对等模型,热插拔事件的上报链路不够清晰。如果要做热插拔,建议在硬件层面增加“链路存在检测”信号,软件侧单独处理,不要依赖标准PCIe热插拔流程。
7. 参考资料与后续扩展方向
最后分享几个对我们实际开发有帮助的资料方向:PCIe Base Specification里关于Crosslink的章节,开头那一段写得很克制,但信息量很大,建议精读;Linux内核的drivers/pci/probe.c和drivers/pci/bus.c代码里,对Crosslink端口的枚举和处理逻辑是开源的,可以直接读代码理解角色协商后的软件视图;各FPGA厂商的PCIe IP用户手册(Xilinx PG213、Intel a10_pcie等)里都专门有一节讲Crosslink配置;另外就是各类服务器主板的高级PCIe选项说明,虽然各家文档风格差异大,但关键名词都是通用的。
这项技术后续的扩展方向也值得关注:一是CXL(Compute Express Link)正在把PCIe的基础能力向上扩展,CXL的许多概念的底层依然是PCIe物理层和管理模型;二是PCIe 6.0引入的PAM4编码和新的链路管理机制,对Crosslink这种对等连接模式会有新的约束;三是FPGA/ASIC平台在支撑AI加速器互联时,Crosslink这种低成本高带宽的对等直连会成为越来越多方案里的基础模块。
我自己做PCIe相关开发这几年,最大的体会是:PCIe协议看着厚厚一本,但真正决定一个系统好不好用的,往往不是协议条文本身,而是你对“地址怎么映射”“数据怎么路由”“中断怎么到达”这几个基本问题的理解深度。Crosslink刚好把这三个问题全部逼到了极致,把这套链路调通一次,PCIe的底层能力基本也就摸清了。如果这篇文章能帮你少走几步弯路,那我就没白写。