调PCIe驱动的人大概都经历过这样的场景:设备明明插在槽里,lspci就是不认;或者认了,BAR分配失败,dmesg里躺着一行BAR 0: no space for [mem size 0x100000];再或者设备认了、地址也分了,一读就Completion Timeout,整条链路像被人掐住了喉咙。这些现象看着五花八门,往根上刨,八九不离十都落在同一件事上——包没走对路。PCIe体系里,一个TLP从发起方到目标方,中间要经过Root Complex、若干Switch、若干桥,每一跳都要做一次路由判决。判对了,你什么都感觉不到;判错了,包要么被静默丢弃,要么绕回来给你一个UR。这篇就围绕PCIe的三种路由方式——地址路由、ID路由、隐式路由,把它们的判定依据、寄存器支撑、Linux内核里的落地路径,以及实际调试里踩过的坑,一条条摊开讲。适合已经能写简单PCI驱动、但一遇到枚举失败和地址窗口冲突就发懵的读者,也适合做自研PCIe端点、需要自己填配置空间的人。
1. 路由判决到底发生在包的哪个位置
1.1 TLP头里那几个bit决定了全部命运
PCIe的TLP头里有两个字段是路由的入口:Fmt和Type。Type[4:0]这个5位字段直接在规范里划好了地盘:Memory Read/Write是00000和00001,IO Read/Write是00010,Configuration Type 0是00100,Type 1是00101,Completion是01010和01011,Message是10rrr这一组。接收方拿到包,第一件事就是解Type,然后决定查哪张表。
这个设计的意思很直白:路由方式不是包外面贴的标签,而是包类型自带的属性。你不可能发一个"既能按地址路由又能按ID路由"的包,类型一旦确定,走哪套逻辑就定了。地址路由的包进窗口比较器,ID路由的包进总线号比较器,隐式路由的包进端口内部状态机。三套逻辑各管各的,互不干扰。
Fmt字段则是告诉接收方这个头是3DW还是4DW,带上层有没有数据。64位地址的Memory请求必然是4DW头,32位地址就是3DW。这个也会影响解析,因为地址字段的位置不同。
1.2 为什么留着三套而不是统一成一套
刚接触的时候我也有过这个疑问:既然地址路由这么好用,为什么不把所有访问都做成地址路由?答案藏在枚举流程里。
配置空间访问的对象是"还没被分配地址的设备"。设备刚上电,BAR里是垃圾值,你根本不知道它要多少空间、该给它分哪段地址,这时候你怎么用地址去访问它?只能靠Bus/Device/Function这三个编号定位。所以ID路由是鸡生蛋问题的解法,它必须先于地址路由存在。
Completion又是另一种情况。完成包要回到发起者,但发起者可能是个根本没有BAR的桥,也没有独立的地址空间,你怎么给它发?只能靠记下它的Requester ID和Tag,原路返回。这是隐式路由存在的理由。
Message更极端,像Assert_INTx这种虚拟中断线消息,它压根没有目标地址,也没有目标ID,只是"往上告诉RC一声"。所以规范给了它一套独立的3bit路由编码,000到RC、001按地址、010按ID、011从RC广播、100本地终止、101汇聚到RC。
三套机制是三种不同约束下的最优解,不是历史包袱。理解了这一点,后面看寄存器就不会觉得它们是散的了。
1.3 Switch不是一根筋的转发器
很多人对Switch有个误解,以为它就是个多端口Hub,进来什么就往外吐什么。实际完全不是。Switch的每个端口都有独立的ingress逻辑,收到包先做类型判断,再查对应的路由表,命中才转发,不命中直接回UR或者丢弃。
更关键的是,Switch在配置空间层面表现为"一堆PCI桥的集合"。Upstream Port是一个Type 1桥,有Primary/Secondary/Subordinate三个总线号;每个Downstream Port也是Type 1桥,各自有自己的一套总线号。Switch内部还有一条虚拟总线,Downstream Port就挂在这条虚拟总线上,端口编号直接决定了它在虚拟总线上的Device Number。
这个结构决定了Switch天然支持总线号路由——因为它在硬件上就是按桥的逻辑实现的。你在配置空间里看到的那些Bus Number寄存器,不是摆设,是真正参与判决的比较基准。
2. 地址路由:一层套一层的窗口解码
2.1 BAR里的地址是怎么被写进去的
设备侧的地址窗口就是BAR。上电时BAR的值是未定义的,规范要求主机先写全1,再读回来算大小,最后写入真正分配的基地址。这个过程看着简单,细节不少。
以32位Memory BAR为例:往BAR写0xFFFFFFFF,读回来假设是0xFFFFF000,说明低12位是只读0,不可写,那么窗口大小就是0x1000,即4KB。算法是取反加一:(~0xFFFFF000) + 1 = 0x1000。
64位BAR要占两个BAR槽。低32位BAR的bit[2:1]编码为10b表示64位,00b表示32位。写的时候两个槽都要写0xFFFFFFFF,读回来拼成64位再算。假设读回0xFFFFFFFF_FFFFC000,那窗口就是16KB。
IO BAR的判断更简单,bit0 = 1就是IO空间。bit1保留,bit[31:2]是地址。IO BAR不支持64位,也不支持预取。
这里有个实操里常踩的坑:有些自研IP核的BAR实现不严谨,写全1之后读回来的值不符合"低位为0"的规则,或者干脆是只读的。遇到这种,别硬套公式,直接对着手册看它声明的窗口大小,再看枚举日志里内核算出来的是多少,两边对不上就以手册为准,在驱动里绕过去。
2.2 桥上的三组窗口寄存器
Type 1配置空间头里有三组窗口寄存器,它们是地址路由的骨架。
| 窗口类型 | Base偏移 | Limit偏移 | 粒度 | 编码方式 |
|---|---|---|---|---|
| IO | 0x1C | 0x1D | 4KB | bit[7:4] 存地址[15:12],bit[3:0]=0001b表示支持32位IO |
| Memory | 0x20 | 0x22 | 1MB | bit[15:4] 存地址[31:20] |
| Prefetchable Memory | 0x24 | 0x26 | 1MB | bit[15:4] 存地址[31:20],bit[3:0]=0001b表示支持64位 |
64位预取窗口还要配0x28和0x2C两个Upper寄存器,存地址的高32位。
举个具体例子。假设某个Downstream Port下面挂了个设备,需要一段0xE0000000到0xE0FFFFFF的16MB非预取内存窗口。那么Memory Base寄存器要写0xE000(地址0xE0000000的[31:20]是0xE00,左移4位放进去),Memory Limit寄存器写0xE0F0(0xE0FFFFFF的[31:20]是0xE0F)。注意Limit的编码是"窗口最后一个1MB块的起始地址",不是"窗口结束地址+1",这点经常有人写反。
IO窗口的编码容易出错。假如要给0x1000到0x1FFF这4KB,IO Base写0x11,IO Limit写0x11。高4位1对应地址[15:12] = 0x1,低4位1是"支持32位IO"的标志位。
2.3 窗口嵌套是硬约束,不是建议
地址路由的核心规则只有一条:下游窗口必须是上游窗口的子集。设备的BAR要落在Switch Downstream Port的窗口里,Downstream Port的窗口要落在Upstream Port的窗口里,Upstream Port的窗口要落在RC根总线的窗口里。任何一层断层,包到那一层就被拦下。
这个约束导致一个很现实的问题:改一个窗口,可能要连带改一整条链。所以Linux内核对固件已经分配好的资源,默认采取"能用就不动"的策略。pci_bus_size_bridges()先自底向上把每个桥下需要的空间汇总,pci_bus_assign_resources()再自顶向下切分。如果固件给的窗口够用,内核基本不碰;不够用,才会触发重排,而且重排经常是整个子树一起动。
我在一块自研板卡上遇到过一次:上游Switch的Memory窗口只开了64MB,下面挂了三张卡,两张各要32MB,第三张死活分不到。lspci -vvv一看,第三张卡的BAR全是0x00000000,dmesg里一串no space。解决办法是在启动参数里加pci=realloc,让内核忽略固件的分配结果重新算一遍,把上游窗口扩到256MB。代价是启动慢了几百毫秒,但设备全认出来了。
3. ID路由:总线号是配置空间的地基
3.1 Type 0 和 Type 1 的分工
配置请求分两种,区别就在头里有没有Bus Number字段。
Type 0是"发给当前总线上某个设备"的请求,头里只有Device Number和Function Number,没有Bus Number。端点设备只认Type 0,因为端点不知道自己在哪条总线上,它也没必要知道。
Type 1是"路过"的请求,带完整的Bus Number。桥收到Type 1之后做三件事:如果Bus Number等于自己的Secondary Bus Number,把Type 1转成Type 0,清掉Bus Number字段,发到下游;如果Bus Number落在(Secondary, Subordinate]这个区间,原样透传Type 1;如果落在区间外,丢弃并回UR。
"Type 1 转 Type 0"这个动作是整个ID路由里最容易被忽略的环节。它解释了为什么端点永远看不到带总线号的配置请求,也解释了为什么你给自研设备写配置空间时只需要按Type 0填设备号和功能号——总线号是桥的事,跟你无关。
反过来说,如果你在做的是个桥设备,那Primary/Secondary/Subordinate这三个寄存器必须填对,填错了整条子树都访问不到。
3.2 Switch端口的总线号接力
下面这棵拓扑在实际项目里非常典型:RC下面挂一个Switch,Switch的一个Downstream Port挂端点,另一个Downstream Port挂二级Switch,二级Switch下面再挂两个端点。
| 设备 | Primary Bus | Secondary Bus | Subordinate Bus |
|---|---|---|---|
| 一级Switch Upstream Port | 0 | 1 | 5 |
| 一级Switch Downstream Port A | 1 | 2 | 2 |
| 一级Switch Downstream Port B | 1 | 3 | 5 |
| 二级Switch Upstream Port | 3 | 4 | 5 |
| 二级Switch Downstream Port C | 4 | 5 | 5 |
看这张表能读出几个信息。Upstream Port的Secondary Bus是Switch内部虚拟总线,编号1;两个Downstream Port都挂在这条虚拟总线上,端口A的Device Number是0,端口B是1。端口A下面直接是端点,所以Secondary等于Subordinate,都是2。端口B下面还有子树,Subordinate要给到整棵子树的最大值5。
还有一点:Subordinate Bus不一定等于所有下游总线的最大值加一。规范要求它是一个上界,可以留空。热插拔场景下,留空是必须的,否则新插进来的设备拿不到总线号。
3.3 枚举顺序与总线号预留
Linux的枚举入口是pci_scan_bridge(),它对每个桥做递归:先给当前桥分配一个Secondary Bus号(取当前的max_bus加一),然后递归扫描这条新总线,扫完之后把Subordinate设成扫到的最大值。
这里有个顺序陷阱。如果枚举时某个Downstream Port下面什么都没插,内核扫完发现没设备,可能就把刚分配的总线号回收了。等到后来热插拔插上设备,这条总线已经不在资源表里,设备就枚举不出来。启动参数pci=assign-busses可以强制重新走一遍总线号分配,能救回一部分情况,但更根本的做法是靠固件在启动时预留。有些平台的固件会在ACPI表里给每个Slot留出总线号范围,内核读到之后就不会回收。
热插拔的资源预留还有两个参数:pci=hpmemsize=和pci=hpiosize=,分别指定给热插拔预留的内存和IO空间大小。这两个参数在刀片服务器和扩展箱上用得比较多,普通台式机上很少有人关心,但真遇到"插上不认"的时候,它们是第一批要试的。
4. 隐式路由:没有地址也没有ID,包照样要回家
4.1 Completion靠Requester ID和Tag回程
Completion TLP头里有两个关键字段:Requester ID(发起者的Bus/Dev/Fun,16位)和Tag(8位,开启Extended Tag后是16位)。这两个字段拼起来,就是完成包回家的门牌号。
Switch在转发读请求的时候,会在出口端口的内部表里记一笔:这个Requester ID加这个Tag,是从哪个端口进来的。等完成包从下游回来,按表反查,送回对应端口。这张表叫outstanding request表,容量有限,满了之后上游再发请求就会被反压。
这解释了为什么一个PCI Function的并发读请求数量是有上限的。8位Tag最多256个,开了Extended Tag是65536个。但实际能开多少还要看设备支不支持,Device Control寄存器里的Extended Tag Field Enable位控制这件事。
Completion的Status字段也值得说。SC是成功,UR是不支持的请求(地址路由没命中、配置访问了不存在的设备),CRS是配置请求重试状态,CA是完成中止。CRS在枚举阶段特别常见——设备上电后固件还没加载完,收到配置请求就回CRS,主机要重试,规范要求至少重试到1秒。如果你自己做的设备在上电后需要一段时间初始化,记得实现CRS返回,否则主机扫一次拿不到就放弃了。
4.2 Message的路由编码和广播语义
Message的Type[2:0]直接编码了路由方式,这个前面提过。实际项目里最常见的Message有这几类:
Assert_INTx/Deassert_INTx:虚拟中断线,路由编码000,发往RCPM_Active_State_Nak:电源管理协商,路由编码000ERR_COR/ERR_NONFATAL/ERR_FATAL:错误上报,路由编码000,发往RCSet_Slot_Power_Limit:热插拔,路由编码011,从RC广播Vendor_Defined:厂商自定义,路由方式看具体用途
这里有个高频混淆点:MSI和MSI-X不是Message TLP。它们本质上是Memory Write,走地址路由,写到RC里的一段特定地址(一般是LAPIC的地址区间)。很多人看到"Message Signaled Interrupt"这个名字就以为是Message类型,实际不是。这个误解会导致在排查中断收不到的时候查错方向。
真正走Message的是传统的INTx虚拟线。在PCIe时代,INTx已经被MSI取代得差不多了,但老设备、虚拟化环境、某些兼容性场景里还在用。
4.3 隐式路由失效长什么样
隐式路由出问题,症状很有辨识度:配置空间能读,BAR能分配,但一访问设备内存就超时。
配置空间读走ID路由,跟Completion回程是两套逻辑(配置完成的回程也靠Requester ID,但配置请求量小,不容易触发outstanding表满)。BAR访问走地址路由,读请求发下去,完成包要靠outstanding表回来。表满了、或者Requester ID被改写了,完成包就回不来,主机等超时,报Completion Timeout。
Requester ID被改写的典型场景是ARI。开启ARI之后,Device Number和Function Number合并成8位的Function Number,原来16位RID里的Dev/Fun字段含义变了。如果Switch没有正确支持ARI,完成包按老规则解析就会送错端口。
另一个场景是ACS Redirect。ACS(Access Control Services)在Downstream Port上,如果开启了Redirect,所有P2P请求都会被强制导到RC再下来,本来在Switch内部直连的路径被绕了一大圈,延迟变高,outstanding表压力也变大。
5. 在内核里验证路由:一套可复现的调试流程
5.1 先看拓扑,再看窗口
调试路由问题的第一步永远是看清拓扑。lspci -tv输出的是树形结构,能一眼看出谁挂在谁下面:
lspci -tv输出大概长这样:
-[0000:00]-+-00.0 +-01.0-[01]----00.0 +-02.0-[02-05]----00.0-[03-05]--+-00.0-[04]----00.0 | \-01.0-[05]----00.0方括号里的数字就是总线号范围。[02-05]表示这个桥的Secondary是2、Subordinate是5。如果这个范围和你的预期不符,基本就能定位到枚举阶段出了问题。
接着看具体桥的窗口:
lspci -vvv -s 00:02.0重点看这几行:
Bus: primary=00, secondary=02, subordinate=05, sec-latency=0 I/O behind bridge: 00001000-00001fff [size=4K] Memory behind bridge: e0000000-e0ffffff [size=16M] Prefetchable memory behind bridge: 00000000e1000000-00000000e1ffffff [size=16M]这三行就是地址路由的全部依据。如果Memory behind bridge显示<unassigned>或者size是0,那下面的设备肯定分不到地址。
5.2 一段遍历总线打印窗口的模块代码
光靠lspci看静态信息不够,有时候需要在驱动里动态确认。下面这段代码遍历所有PCI总线,把每条总线的三类窗口打出来:
#include <linux/module.h> #include <linux/pci.h> static const char *win_name[3] = { "IO ", "MEM ", "PMEM" }; static void dump_one_bus(struct pci_bus *bus) { struct pci_dev *dev; int i; pr_info("bus %02x range [%02x-%02x]\n", bus->number, bus->number, bus->busn_res.end); for (i = 0; i < 3; i++) { struct resource *r = bus->resource[i]; if (!r) continue; pr_info(" %s : %pR\n", win_name[i], r); } list_for_each_entry(dev, &bus->devices, bus_list) { pr_info(" dev %02x:%02x.%x vendor=%04x device=%04x\n", dev->bus->number, PCI_SLOT(dev->devfn), PCI_FUNC(dev->devfn), dev->vendor, dev->device); } } static int __init route_dump_init(void) { struct pci_bus *bus; list_for_each_entry(bus, &pci_root_buses, node) dump_one_bus(bus); return 0; } static void __exit route_dump_exit(void) { } module_init(route_dump_init); module_exit(route_dump_exit); MODULE_LICENSE("GPL");%pR是内核里专门打印resource的格式符,输出是[mem 0xe0000000-0xe0ffffff]这种形式,比自己拼字符串省事。bus->resource[0]是IO窗口,[1]是Memory,[2]是Prefetchable Memory。这三个指针在总线枚举完成之后才有效,所以这段代码要放在late_initcall或者设备探测时跑,不能放在更早的阶段。
如果窗口打印出来是空的,往回查pci_bus_size_bridges()那一步的日志,看需求有没有算进去。
5.3 用户态直接读BAR验证地址路由
想确认某个设备的BAR是不是真的能访问,最直接的办法是从用户态读。
cd /sys/bus/pci/devices/0000:01:00.0 cat resource输出是几行start end flags的文本,对应每个BAR。拿到起始地址之后,把驱动unbind掉,直接读resource0:
echo 0000:01:00.0 > /sys/bus/pci/drivers/xxxx/unbind dd if=resource0 bs=4 count=4 2>/dev/null | hexdump -C能读出非全F的数据,说明地址路由通了;读到全F或者直接报错,说明包没到设备或者完成包没回来。注意unbind这一步不能省,驱动占着设备的时候直接读会冲突,轻则读到脏数据,重则触发AER错误。
读配置空间用setpci:
setpci -s 01:00.0 0x10.l读的是偏移0x10的32位BAR,.l表示32位。0x10.w是16位,0x10.b是8位。这个工具在脚本里批量检查BAR分配情况的时候特别好用。
5.4 几个救命的启动参数
调试路由问题,启动参数是成本最低的试验手段。下面这几个是实际项目里用得最多的:
| 参数 | 作用 | 典型使用场景 |
|---|---|---|
pci=assign-busses | 强制重新分配总线号 | 热插拔设备枚举不到 |
pci=realloc | 重新分配资源窗口 | BAR分配失败、no space |
pci=nocrs | 忽略固件给的窗口信息 | 固件窗口和实际设备需求冲突 |
pci=use_crs | 使用固件窗口(默认行为) | 显式确认用的是固件配置 |
pci=resource_alignment= | 指定BAR对齐要求 | 设备要求大对齐但内核给不出来 |
pci=noaer | 关闭AER错误上报 | 排查阶段屏蔽错误风暴干扰 |
pci=hpmemsize= | 预留热插拔内存 | 扩展箱、刀片机箱 |
这些参数可以组合,但一次别加太多。加一个、重启、看日志、对比,才能知道是哪个起了作用。全加上去一次就好了,下次再出问题你还是不知道原因。
6. 排查实录:路由类问题的现象与定位路径
6.1 设备完全不出现
现象是lspci里连设备影子都没有,dmesg里可能有一行pcieport: slot ... link down或者干脆什么都没打。
第一步确认物理层:lspci -vvv看上游端口的Link Status寄存器,Link Training位是不是1。如果是0,链路没训练起来,跟路由一点关系都没有,去查参考时钟、PERST#复位时序、差分对极性。
第二步看Slot Status。如果Presence Detect是1但Link Training是0,说明卡插好了但链路起不来,多半是信号完整性问题或者两端速率协商不了。
第三步才轮到路由。如果链路起来了,但设备不出现,看上游端口的Secondary Bus Number是不是0。有些情况下固件没配,内核也没扫,Secondary Bus停在0,下面的设备永远访问不到。加pci=assign-busses试一次。
6.2 BAR分配失败
这是最常见的一类。dmesg里的关键字是no space for [mem size ...]或者failed to assign。
先看需求:lspci -vvv里每个BAR后面有(size=...),把子树下所有设备的需求加起来。
再看供给:上游桥的窗口有多大。需求和供给一对比,缺多少一目了然。
缺口的补法有三种。加pci=realloc让内核重排,这是最省事的;如果固件没开Above 4G Decoding,去固件设置里打开,让64位BAR能分到4G以上的地址空间,这是最彻底的;实在不行,把设备的BAR需求调小,改驱动或者改IP核配置。
64位BAR被切到32位空间是个隐蔽的坑。表观现象是lspci里BAR显示成两段,或者size算出来只有一半。根因是Above 4G没开,内核只能把64位BAR塞进32位空间,塞不下就截断。这种时候设备能枚举出来,但访问高地址会跑飞。
6.3 Completion超时与错误风暴
现象是读BAR超时,dmesg里刷屏的AER: Corrected error或者Uncorrectable (Completion Timeout)。
先看AER日志里的Requester ID和Responder ID,这两个字段能直接告诉你包是从哪发到哪的。如果Requester ID是RC,Responder ID是目标设备,说明请求下去了,完成包没回来。
往下查三层。设备侧:是不是收到了请求但没能力回?自研设备常见,outstanding表开得太小,或者根本没有实现完成包生成逻辑。Switch侧:outstanding表是不是满了?ACS是不是开了Redirect?上游侧:MPS(Max Payload Size)配置是不是和下游不一致?MPS不一致的时候,大包会被切分或者丢弃,表现和路由错误一模一样。
MPS这个问题值得单独说。PCIe要求整条链路上所有设备的MPS必须一致,实际配置的时候经常有设备不支持大的MPS,内核为了兼容会往下调,但如果某个Switch的端口配置没同步,就会出现"小包能过、大包丢"的诡异现象。查法是lspci -vvv里看DevCtl: MaxPayload 128 bytes这一行,逐个设备对比。
7. 写在手册角落里的几个细节
7.1 ARI改变了ID路由的坐标系
ARI(Alternative Routing-ID Interpretation)做的事情很简单:把原来16位RID里的8位Bus、5位Device、3位Function,改成8位Bus加8位Function。Device Number字段被吃掉了,一个总线上最多能挂256个Function,而不是32个设备各8个功能。
这个改动直接影响ID路由。开启ARI之后,Switch的Downstream Port在做ID比较的时候,不再看Device Number,只看Function Number的低8位。配置请求头里的字段布局也跟着变。
SR-IOV的VF之所以能大规模铺开,靠的就是ARI。一个PF带几十上百个VF,全挤在同一条总线上,没有ARI根本放不下。VF的RID由VF Offset和VF Stride算出来,都落在PF所在的bus上,靠Function Number区分。
调试SR-IOV的时候如果发现VF枚举不出来,先确认上游Switch和RC支不支持ARI,再看SR-IOV Capability里的ARI Capable Hierarchy位有没有置上。这个位不置,内核不会给VF分配RID。
7.2 ACS决定了P2P流量走不走Switch内部
ACS在Downstream Port上,一组控制位决定P2P请求能不能在Switch内部直接转发。默认情况下,如果两个Endpoint挂在同一个Switch的不同Downstream Port下,它们之间的通信是可以直接在Switch内部完成的,不经过RC。
ACS Redirect位一开,所有P2P请求强制往上送到RC再下来。这个功能在虚拟化场景里是必须的,因为不同虚拟机之间的设备不能直连,否则隔离就破了。但开了之后延迟会明显上升,outstanding表压力也大。
判断ACS有没有开,看lspci -vvv里的Access Control Services能力块,ACSCap那一行。如果Redirect是ReqRedir+,说明重定向开了。这时候如果发现P2P带宽上不去,先怀疑这个。
7.3 Type 1桥的Subordinate别填成Secondary
这是自研桥设备最容易犯的错。Subordinate Bus Number的作用是划定"这棵子树的总线号上界",配置请求的Bus Number落在这个闭区间内才会被转发。如果Subordinate填得跟Secondary一样,那下面再挂一级桥,第二级的总线号就落在区间外,配置请求直接被丢弃。
表现是:第一级设备能认,第二级设备认不到,lspci里只能看到半棵树。
正确填法是枚举完成后,把Subordinate设成整棵子树里出现过的最大总线号。如果预留给热插拔,还要往上加几个。内核的pci_scan_bridge()会自动做这件事,但你自己写桥的固件或者IP核配置寄存器的时候,得手动保证。
7.4 预取属性不是随便标的
BAR的bit3是Prefetchable标志位。标了预取的BAR,主机可以放心地做预读、合并写、缓存,因为读它没有副作用。没标预取的BAR,每次读都必须真的发到设备上,不能缓存。
把有副作用的寄存器区标成Prefetchable,后果是读一次可能触发多次硬件动作,或者读到过期数据。反过来,把大块显存标成非预取,性能会掉一大截,因为每次访问都要走完整的TLP往返。
判断标准很朴素:这段地址空间里有没有"读一次就清标志"、"读一次就弹出FIFO"这类行为。有就不能标预取,没有就应该标。很多自研板卡把整段BAR都标成非预取,图省事,结果DMA性能只有理论值的三分之一,查了半天才发现是这个位的问题。
我个人在做板级调试时的习惯是,先把lspci -vvv的完整输出存一份到文件里,改一次配置就存一份,用diff对比。路由问题往往不是单一原因,而是总线号、窗口、MPS、ACS 这几个因素叠加出来的。单看某一次的输出很难判断哪个是变量,有了基线文件,改动前后的差异一目了然。这套笨办法在几块自研板卡上救过我不少次,比凭记忆猜靠谱得多。