从PCI到PCIe:总线架构、配置空间与枚举机制一文讲透
做了这么多年总线相关的开发我经常被问同一个问题现在都是PCIe的时代了为什么还要去啃PCI基础知识我每次的回答都一样——正因为现在是PCIe的时代你才更需要把PCI那一套搞明白。PCIe并没有把PCI的逻辑模型推翻重来它只是在PCI上面换了物理层、加了解析层配置空间、BAR映射、总线枚举、地址路由这套东西几乎原封不动地继承了PCI的基因。那套经典的PCIe体系结构教材开篇就用一整节来讲“PCI基础PCI Basics”当年我也觉得这一章有点多余后来被现实教育了好几次才服气这章其实是整本书的地基。无论是刚入行的新人还是被配置空间和枚举逻辑折磨的驱动开发者把PCI基础读透都能省掉大量现场找坑的时间。这篇就把PCI基础里最重要的几个板块拆开讲总线架构、事务时序、配置空间、仲裁中断最后再看一看PCI到PCIe哪些东西继承、哪些东西重写。我不打算让读者把每个信号都背下来但希望把工作原理和背后的设计意图讲透中间会穿插一些我自己在实际调试中踩过的坑、总结的经验。1. 为什么今天还要回头啃PCI基础1.1 PCI在计算机体系结构中的真实位置在讲PCI之前先得弄明白它在计算机体系结构里站在什么位置。在PCI出现之前外围设备接口一片混乱ISA、EISA、VLB之类的总线标准并存速度慢、配置方式原始设备资源靠跳线和固定地址不支持真正的即插即用。PCI就是针对这些问题来的它要做的事情很明确提供一根标准化的、高性能的、支持多主设备的总线让CPU不用直接认识每一个外设而是通过一个公共接口与所有设备对话。PCI在经典PC架构里的位置大概是这样的CPU通过前端总线连接主桥也叫北桥主桥负责管理内存和PCI总线PCI总线上挂着显卡、网卡、SCSI控制器这类高速设备桥下再通过PCI-to-PCI桥扩展出二级、三级总线挂载更多设备慢速设备则挂在南桥后面的慢速总线上。整个结构是一棵以主桥为根、向下不断分叉的树而这棵树到了PCIe时代只是换了一身衣服——根复杂器Root Complex就是主桥的直系后代。1.2 那些在PCIe问题里现出原形的“PCI老古董”为什么说PCI基础是理解PCIe的钥匙我举几个自己接触到的真实场景。第一个场景某个PCIe设备在系统启动时没有被正确枚举排查第一件事情就是看它的配置头标、Vendor ID和Class Code有没有返回正确值这完全是PCI时代的机制。第二个场景PCIe链路报出“Unsupported Request”错误很多人第一反应是PCIe协议问题其实这个错误本质上是PCI的Master Abort语义在PCIe里的延续——没有设备认领这个请求事务作废。第三个场景PCIe的“Completion Timeout”说到底就是PCI时代非posted事务等待目标响应超时的翻版。这些概念如果只看PCIe文档可能永远都见不到它们的出处但回到PCI基础里一看马上就能对上号。所以我在带人学习PCIe时一般都会先让他们花两三天把PCI基础过一遍效果比直接啃PCIe规范好得多。2. PCI的并行总线架构桥、设备与地址域2.1 PCI总线的基本组成PCI总线上有哪几类角色这个问题看着简单但很多人容易混淆。首先是最重要的主桥Host Bridge它连接CPU和PCI总线CPU发出的访问经过主桥转换成PCI总线事务然后是各种PCI设备这些设备又分成两种身份主设备Bus Master和目标设备Target/Slave。主设备是事务的发起者比如一个支持DMA的网卡目标设备是事务的响应者比如一个寄存器接口的端点。另外还有一类设备叫PCI-to-PCI桥专门用来扩展总线形成层级结构。还有一个非常关键但经常被忽略的角色总线仲裁器。PCI在物理上是一根共享并行总线同一时刻总线上只允许一个主设备占用谁先上、谁后上由仲裁器说了算。仲裁器通常集成在主桥或PCI桥内部每个主设备有自己独立的REQ#和GNT#信号对请求与授权是点对点的而不是像地址数据那样共享。打个比方你就明白了PCI总线就像一条只能单向行驶的老街同一时间只能有一辆车开进去。谁先走由路口交警仲裁器决定车到了目的地能不能接货由货主目标设备说了算。地址期就是车进路口时报出目的地门牌号数据期就是装卸货的过程。2.2 三个地址空间与域转换PCI体系里规定了三种地址空间理解这三者几乎就能理解PCI总线一半的工作方式。第一种是存储器地址空间Memory Space。PCI设备通过BAR寄存器把自己需要的一片地址范围注册到存储器地址空间里。CPU访问这个范围时地址会经过主桥转发成PCI的存储器读/写事务。第二种是I/O地址空间I/O Space。x86体系里CPU有独立的I/O地址空间通过IN/OUT指令访问PCI也保留了对这个空间的支持。第三种是配置地址空间Configuration Space。每个PCI设备都有独立的一块配置空间最小256字节软件通过配置读写事务来查看设备身份、申请资源、开启功能。这三种空间各自独立地址可以重叠但不冲突。这里有一个特别重要的概念叫做“域转换”。CPU侧看到的是处理器地址域也叫Host域PCI侧看到的是PCI总线地址域也叫PCI域。CPU不会把所有的地址都发到PCI总线上只有在主桥地址解码中命中的区域通常是BAR登记的地址才会被转换成PCI事务发下去。也就是说CPU访问PCI设备寄存器要走两层一层是CPU侧页表和缓存属性决定映射到哪个物理地址另一层是主桥决定这个物理地址要转成PCI域里的哪个事务。这个域转换概念在PCIe时代一点都没有过时。PCIe根复杂器内部同样要做Host地址到PCI地址的转换只不过多了个地址窗口配置。2.3 主桥的角色与地址翻译主桥不只是地址转发那么简单。它还要负责维护总线号资源管理Memory Window、I/O Window处理PCI错误上报甚至还要负责字节交换和一致性保证。在配置空间里主桥自己的配置头中也有一组总线号寄存器——Primary Bus Number、Secondary Bus Number、Subordinate Bus Number——这套寄存器是整个系统枚举配置的基础。这里说一个我曾经踩过的坑。当时在一个嵌入式平台上调试PCIe设备CPU侧怎么都访问不到BAR地址读取时返回全F但工具里能看到设备。排查了很久最后发现问题的根因不是设备BAR配错了而是主桥/根复杂器的地址窗口配置不对PCI设备BAR分配到了一个CPU侧没有窗口覆盖的地址区域。这个问题的本质就是域转换没做好。如果你理解不了主桥的“翻译官”角色遇到这种问题就会在一堆表层现象里打转。3. PCI总线事务的时序与信号级细节3.1 从FRAME#到TRDY#一次读事务的完整节奏PCI总线事务是一套信号握手的过程。核心信号有这么几个FRAME#表示当前事务开始和结束IRDY#由主设备驱动表示主设备准备好传数据TRDY#由目标设备驱动表示目标设备准备好传数据DEVSEL#由目标设备驱动表示目标设备认领了这个事务C/BE#放命令类型存储器读、IO写、配置读等AD线则复用地地址和数据。一次完整的PCI读事务大致分这么几个阶段。首先是总线空闲FRAME#保持高电平主设备获得仲裁授权后驱动FRAME#为低进入地址期此时AD线上是目标地址C/BE上是命令码。然后是总线转交期这一点非常关键——因为读事务在地址期之后AD线要从主设备驱动方向换成目标设备驱动方向不能让双方同时驱动同一组信号所以必须插入一个turn-around周期。接着是数据期目标设备会拉低DEVSEL#认领事务主设备拉低IRDY#目标设备拉低TRDY#。只有IRDY#和TRDY#同时有效的那一拍数据才算真正在AD线上传输。数据期可以无限插入等待只要任何一方没有准备好就可以让对应的RDY信号无效。事务结束的信号也很讲究最后一个数据期传输完成后主设备撤销FRAME#但IRDY#要保持有效直到最后一个数据期完成然后总线重新回到空闲状态。这个“FRAME#先撤、IRDY#后撤”的顺序是规范强制要求的违反它会造成总线协议违规。写事务和读事务的时序略有不同。写事务的方向自始至终都是主设备到目标设备所以不需要turn-around周期地址期结束后可以直接进入第一个数据期。这也是为什么写事务的开销通常比读事务低一点。3.2 等待周期、快背靠背与总线占用等待周期是PCI总线处理速度差异的重要手段。CPU或者主桥可能还没准备好数据设备内部总线也可能正忙这时候谁没准备好谁就把自己的RDY信号拉高无效总线就停住等一拍。这有点像两方打电话一方说“你等一下我去拿个东西”另一方就拿着电话干等。还有两个相关概念经常出现在文档里一个是fast back-to-back一个是总线占用时间限制。fast back-to-back是指同一个主设备连续发起多个事务中间不插入空闲总线周期这样能提高吞吐率。但不是所有目标设备都支持PCI规范要求设备在配置空间Status寄存器的相应位声明自己是否支持fast back-to-back软件在枚举时会检查这一位。总线占用时间则是指一个主设备一次占用总线不能无限长规范对timeout有建议值设计时要注意别让某条DMA请求把总线占死把其他设备饿死。3.3 三种终止方式的语义与后果PCI事务并不是总能“善终”有三种异常终止方式语义差别很大我在实战中见过不少人把它们混为一谈。第一种叫Master Abort。主设备发出地址和命令之后等了一段时间规范规定是几个时钟周期都没有看到任何设备拉低DEVSEL#说明总线上没有设备认领这个地址。这时候主设备自己撤销FRAME#事务作废。对于读操作主设备会返回全F软件往往拿到一个0xFFFFFFFF。这种场景最常见的原因就是访问的地址没有设备响应比如BAR没有配置好、设备掉线、或者地址本身非法。第二种叫Target Abort。目标设备在DEVSEL#有效之后又反悔了通过拉低STOP#来终止事务。这属于目标设备明确表示“这个事务我不做了”数据不完整也没有重试的余地。第三种叫Target Retry。目标设备同样拉低STOP#但语义完全不同——它是告诉主设备“我现在暂时做不了你过会儿再试”。主设备收到Retry后会重新发起这个事务。听起来简单但Retry是PCI总线里最重要的流量控制手段之一。很多设备在初始化期间、内部FIFO满的时候、或者配置空间还没完全就绪时都会用Retry来推迟事务。如果没有Retry机制主设备就只能傻等或者直接报错整个系统的鲁棒性会差很多。3.4 一个来自实际调试的Retry教训Retry这个机制纸上看着简单真在调试现场遇到的时候我吃过一次不小的亏。当时在做一个PCIe转传统并行总线的桥设备端到端吞吐率上不去量了一下总线上有大量的事务重试一开始我以为是设备工作不正常反复检查逻辑代码折腾了两天。后来回过头去老老实实翻了PCI基础部分才醒悟这就是Retry机制在起作用。桥设备内部接收路径的FIFO深度设计得太浅目标设备经常处于“暂时无法接收”状态于是频繁发出Retry响应。主设备反复重试看起来就像总线在空转。解决方向根本不是取消Retry——那是一切流量控制的总开关正确的做法是加大FIFO深度、调整内部读请求的仲裁权重减少目标处于忙状态的时间窗口。这个案例我想说明的是当你看到总线上一堆重试时未必是错误很可能是设计和实现的正常反馈。只有理解了Retry的语义和触发条件才不会在错误的方向上白费力气。提示调试时看到设备返回Retry先别急着当错误处理。重点观察它是在什么阶段出现的以及重试的频率是否稳定。如果重试频率稳定且没有伴随错误位大概率是流量控制正常起作用。4. 配置空间与BAR映射PCI的“灵魂”4.1 256字节里的规矩头标类型与关键寄存器如果说事务时序解决了“数据怎么走”的问题配置空间解决的就是“这个设备是谁、它能干什么、它需要什么资源”的问题。PCI的革命性贡献就在这里有了配置空间软件可以在运行时自动发现设备、分配资源真正实现即插即用。每个PCI设备至少提供256字节配置空间。前64字节是预定义的Header区后面是设备自定义区和Capabilities链表。最关键的几个字段0x00处的Vendor ID和0x02处的Device ID用来唯一标识设备0x09字节的Class Code用来告诉系统设备属于哪一类比如网络控制器、显示控制器、桥设备驱动匹配全靠它0x0E字节的Header Type区分Type 0普通设备和Type 1桥设备。有两个寄存器对运行时的行为控制最直接一个是Command寄存器一个是Status寄存器。Command寄存器里的几个位列个表位名称作用0IO Space Enable使能设备对I/O BAR范围的访问1Memory Space Enable使能设备对Memory BAR范围的访问2Bus Master Enable使能设备作为主设备发起事务DMA等6Parity Error Response使能奇偶校验错误的响应8SERR# Enable使能系统错误上报这就是为什么很多驱动里会调用底层接口去“使能设备”——它内部就是在写Command寄存器这几位。Status寄存器的错误位也很有用比如DPE数据奇偶错误、SSE系统错误、RMA接收到主设备终止、RTA接收到目标终止等调试时查一下这几个位往往能直接定位问题方向。4.2 BAR空间探测的完整流程BARBase Address Register基地址寄存器是PCI设备向系统“要资源”的方式。设备需要多少内存空间、多少IO空间不是写在文档里让系统去念的而是用一个非常巧妙的方法让系统自己去量。量的过程如下先读BAR当前的原始值把它保存下来向这个BAR写入全10xFFFFFFFF再读回这个BAR对Memory类型的BAR低4位是只读的类型属性位bit0为0表示Memorybit1表示32位还是64位bit2表示是否prefetchable把低4位掩掉之后把剩下的位取反加1就能得到这个BAR需要的大小对IO类型的BAR同理低2位是类型位把保存的原始值写回去恢复原状系统根据分配策略给这个设备分配一个实际地址写入BAR同时设置对应的使能位。实际操作中有几个坑。第一64位BAR会占用两个连续的BAR寄存器低BAR的bit1:0是10二进制高BAR全部是地址高位分配时要两个一起写如果只写低BAR不写高BAR整个64位地址是废的。第二分配地址必须对齐到BAR大小的边界比如一个BAR需要1MB它的地址就必须落在1MB边界上。第三某些设备在写全1回读时会在保留位上返回非零值驱动要记得掩码。第四BAR读完一定要恢复原值否则设备会处于未配置状态后面的访问全乱。提示BAR探测在多核系统里做的时候要加锁。因为写完0xFFFFFFFF到回读之间有一小段窗口如果另一个CPU或线程同时操作同一个BAR就会读到中间状态甚至把错误的值写回去。这类并发问题极其隐蔽现场调试很容易误判设备有问题。这个流程是所有PCI/PCIe枚举的基石。无论枚举代码多复杂本质上就是在反反复复做上面这几件事读BAR、试探大小、分配地址、写BAR。4.3 Type 0与Type 1配置事务枚举的底层有了BAR还只是设备个体的“自我描述”系统还需要一套机制来遍历总线上所有设备这就涉及到配置事务的Type 0和Type 1之分。Type 0配置事务用于访问当前总线上的设备。每个设备挂在自己的IDSEL线上配置地址译码时AD线的高位做设备号选择低位做寄存器偏移。Type 1配置事务则用于穿越PCI-to-PCI桥去访问下游总线上的设备。桥在收到Type 1配置事务时会检查事务中的目标总线号如果目标总线号等于自己的Secondary Bus Number次级总线号桥就把Type 1转换成Type 0并在下游总线上发起配置访问如果目标总线号在自己的Subordinate Bus Number范围内桥就原样转发到下游总线否则忽略。正是这套规则让操作系统可以像深度优先搜索一样从总线0开始发现一个桥就分配新的总线号下钻一层直到遍历完整个树。枚举之后系统就知道有几个总线、几个设备、每个设备都需要什么资源然后就可以统一分配地址、写BAR、开启Command。这套流程在PCIe时代还在用只是配置事务的物理承载方式改了逻辑语义几乎没变。4.4 为什么PCIe枚举依然依赖这套逻辑我第一次接触PCIe的时候也疑惑过配置空间从256字节扩展到了4KB甚至更大协议栈也从并行总线换成了高速串行链路为什么枚举逻辑还要沿用PCI那套答案其实很直接操作系统和驱动都建立在PCI的配置模型之上如果枚举逻辑重写所有软件生态都要跟着重做代价大到不可接受。PCIe的聪明之处就在这里——物理上重新发明逻辑上尽量兼容。在PCIe里配置访问从I/O端口机制改成了基于内存映射的ECAM机制但Type 0/Type 1的逻辑、BAR探测流程、Command/Status寄存器位定义、Class Code和能力链表全都原样继承。你在PCIe设备里看到的PM Capability电源管理最初就是在PCI 2.x时代定义的PCIe只是把它继续保留并扩展了新能力。理解了PCI配置空间再看PCIe的配置空间你会觉得大部分都是老朋友。5. 仲裁机制与中断模型共享总线上的秩序5.1 集中式仲裁与REQ#/GNT#PCI总线是共享总线所以“秩序”是第一位的问题。PCI采用的方案是集中式仲裁总线上有一个仲裁器通常就在主桥或PCI桥内部每个可能成为主设备的设备都有一对独立的REQ#请求和GNT#授权信号与仲裁器相连。仲裁过程很有意思它不是在一个事务完成后再开始下一个仲裁而是可以隐藏仲裁hidden arbitration——在当前事务的数据传输期间仲裁器就可以对下一个请求进行判断并授权等当前主设备释放总线新的主设备立刻就能上总线中间几乎不浪费时钟周期。这种设计让共享总线在物理约定较低速的前提下还能挤出一些性能。仲裁算法在规范里没有强制规定可以是公平轮转也可以带优先级。这个“没规定”在调试时需要特别注意不要假设自己的设备一定能拿到总线也不要假设两次请求之间相隔多久。我做FPGA设备的时候就遇到过因为主设备仲裁权重设得不好导致某个方向的数据长期饥饿总线空转的情况。PCI这套集中仲裁模型到了PCIe虽然不再需要PCIe是点对点交换但根复杂器内部处理多个请求时的仲裁思想依然沿用了下来。5.2 错误处理奇偶校验与PERR#/SERR#并行总线在物理传输中容易受到干扰PCI用奇偶校验来检查传输正确性。地址期和每个数据期AD和C/BE线上都伴随一个PAR信号保证整组信号中1的个数是偶数。一旦发现奇偶错误数据错误通过PERR#上报地址错误则通过SERR#上报。错误的上报和响应还会写进配置空间的Status寄存器有DPE数据奇偶错误、SSE系统错误、RMA主设备终止接收、RTA目标设备终止接收等位。调试的时候先读一遍Status寄存器和错误位往往能帮你快速判断是访问的地址根本没人认领RMA还是数据在物理链路上就错了DPE免得在驱动逻辑里瞎猜。要提醒的是错误处理不要一刀切地屏蔽。有些开发者为了让系统稳定喜欢把SERR#使能位清掉、把错误中断关掉结果出了故障系统哑巴了。真正合理的做法是开着错误上报量产阶段收集错误统计再针对故障模式做策略处理。5.3 INTx共享中断与MSI的演变早期PCI设备用四根中断线INTA#、INTB#、INTC#、INTD#来上报中断。这四个信号是低有效、开漏输出所以多个设备可以共享同一根中断线。共享在节省引脚的同时也带来一个麻烦中断来了系统驱动得一个个问过去看是不是自己的设备产生的。驱动在注册共享中断时要传入一个设备标识中断处理函数里要检查设备中断状态寄存器如果不是自己设备的中断就返回否则继续处理。处理不好就会出现中断风暴或者中断丢失。后来MSIMessage Signaled Interrupt出现了中断不再通过引脚传送而是通过一次特定的存储器写事务来触发相当于给设备发了一条“中断消息”。MSI解决了共享中断的困扰也绕过了引脚限制。PCIe时代更是把MSI发扬光大几乎每个设备都用MSI或者MSI-X。MSI-X提供了更多的中断向量每个向量可以指向不同的CPU目标这对多队列网卡这类高性能设备至关重要。写驱动时我的习惯是优先申请MSI-X不行退回MSI最后才考虑INTx。系统提供的辅助接口通常会在内部自动帮你处理好这个降级路径但了解背后这些模型能帮你在中断行为怪异时快速定位问题。6. 从PCI到PCIe什么继承了什么重写了6.1 原封不动的逻辑模型PCIe发布的时候设计者做了一个很务实的决定物理层可以彻底重写但逻辑层必须维持兼容。于是我们看到许多PCI时代的东西在PCIe里原封不动地活了下来。配置空间的布局和语义基本没变。设备在枚举时还是读Vendor ID、Device ID、Class Code还是用BAR申请资源还是靠Capabilities链表扩展功能。地址路由的核心思路也一样存储器读写事务、IO事务、配置事务、特殊消息这四种事务类型的雏形全都能从PCI时代找到对应关系。甚至posted和non-posted事务的区分——比如存储器写是posted不需要回应读和配置访问是non-posted必须收到完成——这个思想也是从PCI继承下来的。这也是为什么操作系统里那套扫描总线、使能设备、读取配置空间的接口到今天还在正常工作因为PCIe只是把配置空间的访问路径换了操作对象还是那256字节头标加上扩展能力。6.2 物理层与事务层的彻底重写当然PCIe改变的东西也相当彻底。最明显的是物理层从32/64位并行共享总线变成了多个差分串行通道Lane每个Lane可以独立收发速度和编码方式也完全不同了。共享总线变成了交换网络设备不再争抢总线而是通过Switch进行点到点路由仲裁的需求在物理层消失了。配置机制上PCIe用基于内存映射的ECAM取代了PCI的I/O端口配置机制每个设备配置空间最小4KB可以容纳更多扩展能力。中断方面PCIe基本不靠INTxMSI/MSI-X成为主流。时钟体系也从并行总线时代的同步时钟变成了内嵌时钟的串行编码。这些变化让带宽和可靠性都有了指数级的提升但代价是协议栈变深了调试工具也变复杂了。链路训练、流控、TLP拆分这些PCIe新概念都是在PCI完全没有的层次上生长出来的这也是PCIe显得比PCI难学得多的原因。6.3 学习路线上的一点建议如果你正打算系统性学习PCIe我给的建议很简单别急着扎进TLP、链路训练和Flow Control这些细节。先把PCI基础里的总线模型、三种地址空间、配置空间和BAR机制、事务与终止语义这几块吃透然后你再去读PCIe的文档会发现大部分章节都似曾相识学起来会非常顺畅。实操上不需要专门的实验板一台普通PC就能做很多功课。开机后进入系统用命令行工具看一下设备列表输出对照设备的BAR、Class Code、Capabilities手动读一读配置空间这个过程中你对“PCI基础”的理解会快速落地。说点个人的体会。每个看起来“过时”的协议基础其实都是后面复杂机制的压缩包。PCI基础这一章我在不同阶段翻过不下五遍每次带着的问题不一样读出来的东西也不一样。如果你在做PCIe开发时遇到什么解释不清的诡异现象不妨先回到PCI那一页静下心把事务、配置空间、桥接和错误处理这四个词重新过一遍大概率会有收获。