每次排查 PCIe 问题,我都会和同事先说一句:先别急着改驱动,先把链路搞出来。驱动写得再好,LTSSM 停在 Polling 状态,后面全是白搭。这篇文章不是从 PCIe 协议第一章开始讲的长文,而是把我在 U-Boot 阶段定位 PCIe 问题的常用思路、命令和踩过的坑整理成一份可以直接参考的笔记。目标就一个:让你在 uboot 里尽快看到设备、尽快进系统,不被底层链路问题卡住。
我自己做嵌入式底层启动和内核驱动有几年时间,碰得最多的就是三类问题:uboot 阶段偶尔丢设备、量产时掉速或降 lane、以及用 pcie 网卡或 NVMe 盘做启动设备时不稳定。U-Boot 里的 PCIe 子系统说白了就是一把快刀,它要做的事情不多,但每一件事都直接影响内核能不能正常接管。这篇文章适合正在移植 uboot、或者被 PCIe 启动问题折磨的软件工程师,也适合硬件工程师拿去对照做信号排查。
1. 先把定位搞清楚:U-Boot 里的 PCIe 到底负责什么
1.1 它和内核 PCIe 驱动的本质区别
很多人在 uboot 里用pci命令能看到设备,就觉得 PCIe 没问题。这是一个非常典型的误判。内核里的 PCIe 驱动栈远比 U-Boot 完整,中断分配、MSI/MSI-X、IOMMU/SMMU 映射、错误恢复(AER)、热插拔事件、电源管理这些都归内核管。U-Boot 里的 PCIe 子系统只做三件事:把链路训练起来、把设备枚举出来、把基本资源分掉。你可以把它理解成“把客厅收拾好,等主人回来自己处理细活儿”。
所以,一个设备在 U-Boot 里能被枚举到,说明链路通信是通的,但并不能保证它在内核里能正常工作。反过来,如果 U-Boot 阶段就枚举不到,或者枚举时断断续续,那多半就是硬件问题或者初始化时序问题,不是换个内核版本能解决的。这个判断我用过很多次,基本没翻过车。
这背后的原因也简单:U-Boot 运行在裸机环境,没有中断处理器,没有页表,没有复杂的电源管理框架。它的 PCIe 代码路径非常直接,控制器驱动先把 Root Complex 初始化,然后通过配置空间读写扫描设备,最后给设备的 BAR 分配地址空间。这套流程如果用一句话说,就是“能用就行”。但“能用”和“稳定”之间,恰好隔着大量硬件细节。
1.2 从代码地图看子系统组成
先别急着改代码,花十分钟把 U-Boot 里 PCIe 相关文件过一遍,后面排查会快很多。不同版本的驱动路径略有差异,但核心文件基本固定。
| 路径 / 文件 | 作用 |
|---|---|
include/pci.h | PCIe 核心数据结构和 API 定义,BDF 表示、配置访问接口、控制器结构体 |
drivers/pci/pci-uclass.c | DM 框架下 PCI 设备的类管理层,负责设备绑定、扫描、资源分配 |
drivers/pci/pci.c | 经典 PCI 枚举、配置空间读写、BAR 分配的核心实现 |
drivers/pci/pcie_dw.c | DesignWare 控制器驱动,很多 SoC 的 PCIe 控制器都基于它 |
cmd/pci.c | pci命令行工具,现场排查第一利器 |
arch/.../dts | 板级 PCIe 节点,控制器 reg、ranges、bus-range 都在这里 |
老版本 U-Boot 和带 DM 的新版本在代码结构上差别很大。如果你拿到一个 2018 年左右的 uboot,大概率还是旧的非 DM 框架,用struct pci_controller管理整个 PCIe 控制器,枚举时直接调用pci_hose_scan_bus。而 2020 年之后的版本基本都走 DM 了,控制器是一个udevice,通过uclass管理。遇到问题先搞清楚自己面对的是哪一套,别用新版本的配置宏去套旧代码。
1.3 初始化时序和常见启动路径
以常见的 DM 版本为例,U-Boot 启动过程中 PCIe 相关流程大概是这样的:
- 板级初始化阶段调用
pci_init(),触发 PCIe 控制器驱动 probe。 - 控制器驱动做链路初始化,包括 refclk 使能、PERST 复位时序处理、LTSSM 训练。
- 链路进入 L0 后,U-Boot 开始扫描总线,为每个设备建立
udevice。 - 如果打开
CONFIG_PCI_SCAN_SHOW,启动日志会打印扫描到的设备。 - 之后启动设备(比如 NVMe 盘、网卡)会主动 probe 对应子系统的设备。
- 最终 bootm 启动内核前,可能会把 PCIe 相关的资源通过 fdt fixup 传给内核。
这里有一个关键点:U-Boot 在跳转内核时,并不会像操作系统那样把整个 PCIe 状态“移交”给内核。内核启动后会自己重新初始化控制器、重新枚举。U-Boot 最大的作用是在内核起来之前,把链路训练稳定,让关键的启动设备可用,同时提供一套独立的诊断手段。
2. 枚举过程拆解:PCIe 子系统的“流水线”怎么走
2.1 链路训练:枚举之前根本没有设备
很多人以为枚举就是拿一个循环去读配置空间,读到非 0xFFFF 就算找到了设备。理论上是这样,但有个前提:链路必须已经稳定。PCIe 是点对点串行连接,Host 和 Endpoint 要通过 LTSSM 完成状态机握手,协商速率、lane 数量、极性校准,最后进入 L0 状态,配置空间才可访问。
用生活里打电话来类比:两个人约好通话,先要拿起听筒拨号、协商编码格式,真正接通后才能说正事。链路训练就是拨号,配置空间访问就是通话。你驱动写得再好,一方没开机、线没插好、或者时钟没对上,电话永远拨不出去。
在 U-Boot 调试现场,我见到最多的链路训练失败状态就是卡在 Polling 或 Configuration。这个信息不一定有日志,很多时候要读控制器的链路状态寄存器才能看到。比如 DesignWare 控制器的PCIE_PORT_LINKSTS寄存器,里面能读出当前 LTSSM 状态、协商速度和 lane 宽度。看到状态停在 Polling,基本就是物理层有东西不对。
2.2 总线扫描与设备发现
链路训练完成之后,枚举的“流水线”才开始走。整个过程可以拆成五步:
- 从 Root Complex 的 bus 0 出发,遍历 devfn(设备号 0~31,功能号 0~7),相当于把总线上的 256 个“格子”都问一遍。
- 对每个格子读配置空间的 Vendor ID 和 Device ID,读到
0xFFFF表示没有设备,读到有效值就是有设备。 - 读 Header Type,判断是 Endpoint(0x0)、PCIe-PCIe Bridge(0x1)还是其他类型。
- 如果是 Bridge,按
bus-range给它分配下一级总线号,然后递归扫描次级总线。 - 对 Endpoint 处理 BAR,分配内存或 IO 空间,建立地址映射。
在 U-Boot 里,这个过程的函数实现主要在drivers/pci/pci.c和pci-uclass.c里。旧版本一个pci_hose_scan_bus函数能递归扫完整个树,新版本则通过 DM 的 probe 过程完成。理解这个递推结构很重要,因为 PCIe Switch 就是一级一级的桥,任何一个中间桥的 bus 号分配出错,后面的设备全部“失联”。
2.3 BAR 资源分配:最容易出暗坑的地方
BAR(Base Address Register)是 U-Boot 阶段最容易出问题的地方。枚举到设备后,U-Boot 会通过“写全 1、读回大小”的方式,计算出每个 BAR 需要多少地址空间,然后把它分配到控制器配置的地址窗口里。
这里面有几个坑,我一个个说。
第一,64 位 BAR 的分配。有些设备(尤其现代 NVMe 盘和显卡)的 BAR 是 64 位的,占两个 BAR 寄存器。如果 U-Boot 没有打开CONFIG_SYS_PCI_64BIT,或者 DTS 里ranges没有包含高地址区间,设备就可能被分配到一个它根本够不着的位置,结果是枚举能看到,但后续读写全挂。遇到这类问题,先看pci header输出的 BAR 值,看它是不是一个超过 4GB 的高地址。
第二,桥的窗口没开。PCIe Bridge 本身有一组 Memory Base/Limit 寄存器,用来声明下游设备占用的访问窗口。有时候 Endpoint 的 BAR 分得没错,但中间 Bridge 的窗口没覆盖到,CPU 想访问下游设备时会直接“绕路失败”。这个问题在带 PCIe Switch 的板子上特别典型,也最容易让人绕圈子。
第三,U-Boot 的资源分配和内核经常不一致。U-Boot 会把 BAR 放在它认为合适的地方,但内核启动后会重新枚举、重新分配。所以你在 U-Boot 里看到某个 BAR 地址是0x80000000,不代表进内核后还是这个地址。如果只是启动阶段用一下,不用太纠结;但如果 U-Boot 阶段要通过 DMA 访问设备,就得留意地址是否在控制器可访问的范围内,否则 DMA 会跑飞。
3. 实操配置:从 DTS 到 Kconfig 的完整接入
3.1 Kconfig 里需要关注哪些开关
相比内核,U-Boot 的 PCIe 配置宏要少很多,但每一个都很关键。以 DesignWare 控制器的平台为例,下面这一组是基础:
CONFIG_PCI=y CONFIG_DM_PCI=y CONFIG_CMD_PCI=y CONFIG_PCIE_DW=y CONFIG_PCI_SCAN_SHOW=y CONFIG_SYS_PCI_64BIT=y CONFIG_NVME=yCONFIG_PCI_SCAN_SHOW这个开关我建议调试阶段一定要开。它会在启动时把扫描到的设备列出,省得每次手动敲命令。CONFIG_SYS_PCI_64BIT则要看你板子上有没有 64 位 BAR 的设备,不开的话,大地址空间设备会非常痛苦。
还有几个历史遗留的宏,比如CONFIG_PCI_PNP(自动分配 BAR)和CONFIG_PCI_NOSCAN(不自动扫描)。如果是老版本 U-Boot,CONFIG_PCI_PNP没开,BAR 不会自动分配,设备在枚举后依然不可用。新版本里这个行为被整合进 DM 枚举流程,但不代表所有平台都默认合理。拿到一个陌生板子,先看 defconfig 和 README 里有没有相关说明。
3.2 一个典型的 PCIe DTS 节点怎么理解
设备树里的 PCIe 节点看着复杂,其实核心就几个属性。我拿一个通用节点拆开解释:
pcie0: pcie@fa000000 { compatible = "vendor,pcie-rc"; reg = <0x0 0xfa000000 0x0 0x1000>, <0x0 0xfa001000 0x0 0x1000>; #address-cells = <3>; #size-cells = <2>; device_type = "pci"; ranges = <0x02000000 0x0 0x80000000 0x0 0x80000000 0x0 0x10000000>; bus-range = <0x0 0xff>; status = "okay"; };reg描述控制器本身占用的寄存器空间和把配置空间映射到 CPU 地址的窗口。device_type = "pci"告诉系统这是一个 PCIe Host Bridge。ranges是最容易看错的地方。它表示的是 PCIe 总线地址到 CPU 地址的映射关系。0x02000000表示非预取内存空间,后面三组数字分别是高地址单元、PCI 地址、CPU 地址、地址长度。翻译过来就是:PCI 侧0x80000000开始的 256MB 空间,映射到 CPU 侧同一地址。bus-range是允许使用的总线号范围。如果你板子上有 Switch,或者需要挂多个设备,空间要留足;很多奇怪问题都是这里写的bus-range = <0x0 0x0>,一扩展就全乱。
调试时建议把ranges的地址窗口先调大一点,比如映射 1GB 甚至 2GB,避免 BAR 分配时撞墙。等稳定后再收窄。
3.3 控制器驱动接入的关键动作
U-Boot 里的 PCIe 控制器驱动,核心工作其实不在“枚举”,而在“把链路搞出来”。不同 SoC 的控制器寄存器差异很大,但初始化动作基本都一样:
- 使能 refclk 参考时钟,确认 100MHz 时钟稳定输出。
- 拉高 PERST 复位信号,让 Endpoint 从复位中释放。
- 配置控制器为 Root Complex 模式,使能 PHY。
- 等待 LTSSM 进入 L0,判断链路速度和 lane 宽度。
- 把配置空间访问窗口打开,让 CPU 能读取设备。
我踩过最痛的坑是复位时序。板子上电后,refclk 稳定需要时间,电源 rail 爬升也需要时间,但 U-Boot 跑得太快,PERST 一早就拉高,Endpoint 还没来得及准备好,链路就卡住了。表面看是“偶发识别不到设备”,实际上是时序竞争。
解决方式也很朴素:在释放 PERST 之前,加一个足够的延时,等 refclk 和电源稳定。很多参考板都会在驱动里留一个udelay或者通过 GPIO 控制 PERST,这个延时不是随便写的,要结合示波器实测的电源稳定时间。量产板上,宁可多等几十毫秒,也不要抢那几微秒的启动时间。
4. 现场排查:pci 命令和打印就是你的“最小系统”
4.1 pci 命令速查
U-Boot 自带的pci命令是现场排查第一工具。前提是CONFIG_CMD_PCI=y,否则命令根本没有。最常用的四个子命令:
=> pci enum => pci => pci header 1.0.0 => pci display 1.0.0pci enum手动触发一次枚举。某些配置下 U-Boot 启动时不会自动扫描,或者扫描失败后想重来一次,这个命令最直接。pci不带参数会列出当前所有总线上的设备,输出里能看到 BDF(总线号:设备号:功能号)、厂商 ID 和设备 ID。
pci header用来查看某个设备的配置头,包括 Vendor ID、Device ID、Class Code、BAR 值。这是判断设备是否正常初始化的关键。pci display则是把整个 256 字节配置空间以原始字节形式打印出来,适合自己解析多值情况。
我在实际调试时最常用的一套操作是:先pci enum,立刻pci,如果空说明链路没起来;如果能列出但启动设备找不到,则用pci header看该设备的 BAR 分配情况。这一套走完,大致能定位问题在链路层、枚举层还是驱动层。
4.2 怎么从打印日志里判断 PCIe 是否真的稳定
光看设备在不在列表里还不够。我一般还会结合启动日志和dm tree输出判断控制器是否 probe 成功、设备是否绑定到正确驱动。
=> dm tree这个命令会打印当前设备模型里的所有设备树节点,包括 PCIe 控制器和它底下的 PCI 设备。如果控制器出现了但设备没有绑定,说明扫描阶段就出了问题;如果设备绑定到了pci_bus_generic而不是具体驱动,那就要检查 compatible 是否写对。
日志层面,CONFIG_PCI_SCAN_SHOW打开后,U-Boot 启动时会打印类似这样的信息:
PCIe link up, gen3 x4 Scanning PCI devices on bus 0 Bus 0: 01.00 0 144d:a822能看到“link up”和进扫描列表,基本可以确认链路和枚举都正常。如果日志里只有“scanning”但列表为空,重点排查链路状态寄存器和 PERST 时序。如果连“link up”都没有,第一步就要量参考时钟。
4.3 什么时候必须动用示波器和协议分析仪
软件手段只能帮你定位“哪一层出了问题”,解决“信号为什么有问题”最终还是要靠硬件工具。我个人经验:一旦确认链路训练失败或者量产偶发掉卡,别再反复改软件参数碰运气,直接用示波器量三样东西:refclk 的波形、PERST 的时序曲线、以及电源 rail 的上电时序。
比如 refclk,只有频率对没用,还要看幅度、抖动和是否稳定。PCIe 参考时钟通常要求 100MHz,带展频的话还有特定调制要求。示波器上如果发现 refclk 上升沿毛刺多,或者 PERST 释放时间比 refclk 稳定时间早了几百微秒,问题的方向就很清楚了。
如果条件允许,上协议分析仪直接抓 LTSSM。它能精确告诉你链路卡在哪个状态、是哪一端主动发起去速率、lane 是在哪个阶段掉的。我见过最神奇的案例,是端设备在 Configuration 阶段因为 lane 极性不对反复重试,协议分析仪一眼看出,软件调了一周才发现是差分线接反了一组。
5. 高频故障:掉速、掉卡、AER 的典型现场与解法
5.1 Link Training 失败的几种现场
每个板子的链路失败都像“同一种病,不同的炎症部位”。我按症状列一个速查表,方便照方抓药。
| 现场 | 优先怀疑 | 建议动作 |
|---|---|---|
| 完全没有任何设备,日志里无 link up | refclk 没起、PERST 没释放 | 示波器量时钟和复位,确认 PERST 确实拉高 |
| 偶发识别不到,上电顺序不同表现不同 | 电源 rail 稳定时间和 PERST 竞争 | 修改驱动延时,拉长 PERST 释放前的等待 |
| gen3 训练不过,降到 gen1 能过 | 均衡参数、信号完整性余量不足 | 先固定 gen1 验证链路本质,再调 PHY 均衡 |
| lane 只能 x1,明明插的是 x4 卡 | 差分线断线、接收端端接缺失、mSATA/PCIe 转换接触 | 查原理图走线,量四对差分信号,不要先改软件 |
| 枚举成功,但访问配置空间偶发超时 | 控制器配置窗口没映射完全 | 检查 DTS 的 reg 和 ranges 是否覆盖完整 |
第一个要排除的永远是信号完整性和时序,不要一上来就怀疑驱动。PCIe 对信号质量的要求比一般嵌入式总线苛刻得多,一句话总结:硬件不稳定时,软件能让它稳定是运气,不能让它稳定才是常态。
5.2 掉速、降 lane 与 AER:U-Boot 阶段能做什么、不能做什么
掉速和降 lane 是量产现场最头疼的问题。设备在开发板上一路 gen3 x4 跑得好好的,换到量产机箱里变成 gen1 x1,甚至干脆不认盘。这类问题通常是信号链路边界状态:参考时钟抖动偏大、连接器接触不良、均衡参数不匹配,或者相邻信号串扰。
U-Boot 阶段能做的是两件事:一是通过链路状态寄存器确认当前协商的速度和 lane 数,二是临时把控制器固定到低速度档位,判断信号余量。我之前在调试时,会把 gen3 先固定成 gen2,再固定成 gen1,逐级往上测。gen1 能稳定、gen3 掉速,大概率是均衡参数或者 PCB 阻抗问题。
AER 则是另一个话题。U-Boot 本身基本不处理 AER,它甚至连像样的错误中断服务都没有。所以如果你发现内核里报 AER,尤其是 Unsupported Request 或 Corrupted Data,别急着骂内核,先回想一下 U-Boot 阶段对设备做了什么。有一种很常见的情况:U-Boot 枚举时对设备发了它不支持的配置请求,设备返回 UR,但 U-Boot 当时没有感知,错误状态滞留在设备里,内核接管后一访问就报错。
对于这种情况,我的建议是:U-Boot 阶段额外主动读一次 AER 相关的 capability 寄存器,把错误状态清掉。如果控制器平台支持pci_aer_clear之类的接口就用接口,没有就写一个简单的配置空间读写,把 AER Uncorrectable Error Status 寄存器写 1 清零。这个操作很多人忽略,但它能解决相当一部分“内核阶段莫名 AER”的怪问题。
5.3 热插拔、Switch、网卡等场景的补充
U-Boot 默认不管理系统级热插拔功能。它没有完整的热插拔事件中断处理,也没有 surprise removal 恢复能力。如果你的产品依赖 pcie 热插拔,那就明确一点:热插拔动作需要在操作系统启动之后完成。U-Boot 阶段你要保证的是“设备上电前就在位,并且链路已经训练稳定”,而不是期待 U-Boot 在启动过程中感知一个正在插入的设备。
但这不代表热插拔和 U-Boot 完全无关。我遇到过一类问题:设备在 U-Boot 阶段正常枚举,但因为使用了 PCIe Switch,枚举时总线号分配和 Switch 下游设备的资源窗口处理不当,导致后续插入新设备时根本没有可用总线号。这种情况下,打开bus-range的余量是最直接的解法。U-Boot 里多留点总线号不改硬件,成本很低,收益很大。
另外,如果用 PCIe 网卡做网络启动,比如比较常见的 Realtek 千兆网卡,要注意 32 位系统或者 32 位地址窗口的限制。网卡的 DMA 地址如果落在 32 位之外,在 U-Boot 里做 TFTP 启动会非常玄学,能 ping 通但传输慢、卡顿、甚至直接死机。检查一下 DTS 的ranges是不是只映射了低 32 位地址空间,必要时把地址窗口设高,或者换用 32 位 DMA 的网卡。
6. 我的避坑清单和个人建议
6.1 排查前先问自己的五个问题
我把自己多次来回折腾总结成五个必问题,每次 PCIe 出问题都按这个顺序过一遍,能省很多时间:
- refclk 是否真的稳定输出?示波器量过吗?频率对不等于稳定。
- PERST 复位时序和电源上电顺序是否明确?驱动里加的延时是不是拍脑袋写的?
- 链路协商到了什么状态?当前速度和 lane 数是多少?有没有现场寄存器快照?
- 枚举到设备后,BAR 是否分配到可访问的地址范围?有没有 64 位 BAR 落在窗口外?
- 这个现象是必现还是偶发?偶发问题的根因大概率在时序,不在逻辑。
前两个问题解决物理层,第三个问题确认链路,第四个问题解决地址空间,第五个问题决定排查策略。很多同事改了一上午代码,最后发现是 PERST 拉高太快,连寄存器快照都没留。
6.2 和硬件同事配合时,软件能给的“实锤”
软件和硬件扯皮的时候,最忌讳说“感觉”、“好像”、“偶尔”。要拿实锤。我最常用的一招,是在 U-Boot 里写一个小脚本,连续做多次复位枚举,把每次的链路状态寄存器和设备扫描结果打印出来,存成日志。
这个脚本甚至可以自动化:循环执行pci enum,等待、复位控制器、再枚举,把结果 append 到内存或者通过串口输出。如果一百次里有三次枚举失败,并且失败时的链路状态寄存器都停在同一个值,硬件工程师基本无话可说,只能乖乖配合你查信号。
另外一个实用小技巧:生产测试时,可以用CONFIG_PCI_SCAN_SHOW的启动日志做批量筛查。把每次开机的日志抓到上位机,判断有没有出现设备丢失,比人工盯 print 输出高效得多。这招我在产线验证 NVMe 启动盘的不稳定问题时用过,跑一晚上能拿到几百次开机的统计数据,比什么都强。
6.3 最后分享一个小技巧
调试 U-Boot PCIe 过程中,我养成了一个习惯:每次开机都手动记录从pci enum到设备可见的时间间隔。这个时间虽然用秒表测不准,但可以从串口日志的时间戳上大致看出来。正常链路从复位到 L0 通常在毫秒级,如果明显偏慢,比如花了上百毫秒甚至更多,链路重试的次数就异常。
曾经有块板子偶发启动慢,现象是系统能起、但比别人慢十几秒,查来查去最后发现是链路在 Configuration 阶段反复重试,协商 gen3 失败回落 gen1,再重新训练,整个过程重复了几轮。这种问题在启动日志里一点错误痕迹都没有,只有时间戳暴露了异常。从那次之后,我给自己的板卡都默认在 U-Boot 阶段强制固定一个协商速度档位,先保证稳定,再追求最高速率。速度这种事情,内核起来之后有的是机会重新协商,不用在 bootloader 阶段强行冲顶。