前言
最近在写E1000网卡的驱动,这其中涉及到了PCI总线的相关内容。但是网上大部分关于PCI的文章都只局限在概念上的描述,并没有给出具体的例子来解释。这其实也是情理之中的,因为PCI总线规范就像是一个抽象的接口,其具体怎么实现是与具体的设备有关的,这也是学习硬件最让人头痛的地方:有时难以区分概念与实现的边界,例如,对于CPU是如何区分访存和MMIO这个问题(两者在CPU看来都是对一个物理地址进行访问),作为概念层的x86体系结构规范并没有明确规定这个该如何实现,而只是在手册里面提到了这样一个概念;而其具体实现方式在不同的CPU型号之间是不同的,例如在Intel Xeon系列CPU中就是通过一个叫做SAD(Source Address Decoder)的硬件来完成的。
Anyway,本文旨在记录一些我在学习中遇到的问题,以及这些问题的答案,希望能够给到读者一些帮助。
如果需要更加深入地理解PCI总线工作原理,建议配合《PCI Express体系结构导读》(王齐 著)使用。
PCI设备是如何读写的?
PCI是总线规范,其读写是通过总线事务来完成的,简单来说就是,按照一定的约定,向总线上写入事务类型,地址等参数,然后再使用数据线传输数据。(具体过程可以参考《PCI Express体系结构导读》)
(需要注意的是,这里的地址其实是PCI域的地址,和存储器域的地址并不等价,两者要通过HOST主桥做转换,但是由于在x86下,存储器域地址和PCI域地址在数值上是相等的,所以本文不再区分这个概念了,统一使用地址这个名词,这部分具体见《PCI Express体系结构导读》)
如何遍历PCI总线来发现存活设备?
实现思路:可以通过枚举所有可能的Bus Number, Device Number 和 Function Number来探测所有的存活设备。对于一组特定的Bus, Dev和Func,如果这个功能存在,那么在其配置空间中的Vendor ID和Device ID就是有效值,可以由此来判断。
如何访问配置空间?
访问配置空间的方式与具体的体系机构有关,例如在 MPC8548 处理器的 HOST 主桥中,与 PCI 设备配置空间相关的寄存器由CFG_ADDR、CFG_DATA 和 INT_ACK 寄存器组成。系统软件使用 CFG_ADDR 和 CFG_DATA 寄存器访问PCI 设备的配置空间,软件通过向CFG_ADDR寄存器中写入地址,然后访问CFG_DATA寄存器,当CFG_ADDR的EN位为1时,HOST 主桥将对这个寄存器的访问转换为 PCI 配置读写总线事务并发送到 PCI 总线上。而在x86体系结构下,CFG_ADDR 和 CFG_DATA是通过2个IO端口来实现的,CONFIG_ADDRESS地址是0xcf8,CONFIG_DATA地址是0xcfc,也就是说,可以通过IN和OUT指令对这两个端口进行读写来实现对配置空间的访问。
虽然访问CFG_ADDR的方式与具体的体系结构有关,但是CFG_ADDR的格式是由PCI Spec规定好了的,其具体含义如下图所示:
Bus Number,Device Number与设备被插在主板上哪个PCI插槽有关,其编号方式示意图如下(不完全严谨,但是这个不用细究,只需要知道这两个值可以唯一确定一个PCI插槽即可):
Function Number表示PCI设备上的功能号,一个PCI设备可以最多有8个功能(但是一般的PCI设备都是单功能,只是PCI规范提供了扩展的一种可能性)。
Register Number的含义见下图(配置空间也是PCI Spec规定了的内容,这里只展示了PCI设备的配置空间,PCI桥的配置空间略有差别):
例如,如果我想要访问某个设备某个功能的Revision ID,那么Register Number就设置为0x08,然后读取一个4字节的数据,取其中的第一个字节即可。
BAR寄存器如何工作的?
这也是初学者容易迷惑的地方,这一章节将尝试回答如下问题:
- BAR寄存器保存的地址是什么地址?有什么用?
- BAR寄存器中的值是谁负责分配的?
BAR寄存器的作用?
向BAR寄存器中写入了一个地址就相当于标记了这段地址是属于这个BAR的了,以后所有对这个地址的访存操作都会转发到这个设备,由这个设备进行操作。
例如,如果我有一个E1000网卡,我把这个网卡配置空间的BAR0设置为了0xabcde000,那么当我向0xabcde002的位置写入数据的时候,E1000网卡就会收到这个写数据的操作,并进行相应的动作。而具体访问这个地址会造成什么结果,这个是和具体的设备相关的,例如,对于E1000网卡,BAR0对应的是E1000相关寄存器,即如果BAR0设置为了0xabcde000,那么访问0xabcde000到0xabcdefff就等价于访问了E1000网卡的寄存器,如下图所示:
而具体每个地址对应到哪个寄存器,每个寄存器是什么作用,则需要继续查阅E1000的手册,下图是部分寄存器的偏移量以及名称:
(注:BAR寄存器有IO模式和MEM模式,MEM模式就是上面所述的情况,可以直接通过访存来实现,而IO方式则需要通过IN和OUT指令来访问IO端口来实现,这里不再赘述了)
BAR寄存器的大小?
接下来的问题是:这里我只设置了一个Base Address,我怎么知道这个区域的大小呢?例如,我把BAR0设置为了0xabcde000,那么为什么对地址0xbbcde000的访问不会转发到这个设备来呢?
这其实是通过一个规定来实现的,即如果BAR空间的大小为M,那么BAR寄存器中地址的低
l
o
g
2
M
log_2M
log2M位一定是0。例如,BAR1对应的空间大小是
2
12
2^{12}
212字节,那么BAR1的值一定是0xfffff000,0xabcde000之类的,不可以是0xabcde010,因为需要保证这个地址的二进制位的低12位是0。
而且这种规定还是由硬件来实现的,也就是说,如果这个空间大小是
2
12
2^{12}
212字节,那么即使我向这个BAR寄存器里面写0xfffffff,最终这个寄存器里的值只会是0xfffff000,硬件会自动把低12位给强制置零。事实上,软件也是通过这个小trick来获取到这个BAR空间的大小的。
(而且推测硬件也是通过这种方式来快速匹配总线事物的目标设备是不是自己,因为如果这个BAR空间大小是
2
12
2^{12}
212字节,那么只需要把地址线的高20位和BAR寄存器的高20位做比较即可得出结论)
(具体可参考这篇Stackoverflow Post)
(事实上,IO模式还是MEM模式,这也是由硬件定好的,软件是不可能通过写寄存器来更改的)
怎么就知道是访问这个设备了?
现在又有一个问题:我只是在E1000网卡的BAR0寄存器设置了一个值,然后我使用MOV $0xabcde002,%eax
指令(假设虚拟地址0xabcde002对应的物理地址就是0xabcde002),CPU就会去找E1000网卡了。那么CPU是怎么知道这个信息的?
这涉及到总线的工作原理了,读0xabcde002这个指令不是单独发给E1000网卡的,而是广播给了总线上的所有设备,主设备会把访存地址发送到地址线上,然后每个PCI设备都拿地址线上的地址和自己的BAR寄存器匹配,如果匹配上了,就按照控制线上的指示进行操作,并通过数据线来传递数据。(具体可见这篇博客文章)
此时还有一个问题,CPU拿到MOV $0xabcde002,%eax
指令的时候,只知道要去访问物理地址0xabcde002,那它为什么不去访问存储器的对应位置,而是把访存请求发到了E1000设备?
答案是:有相应的硬件设备来进行这种路由操作,不同型号的CPU对于这个功能的实现不尽相同,例如(参考资料【3】【4】),对于Intel Xeon系列的CPU,其内部有一个叫做SAD(Source Address Decoder)的硬件,这个硬件保存了对于MMIO区域的配置,负责把MMIO请求转发到PCI主桥中。
(注意,上文提到的0xabcde002指的都是物理地址,实际上在开启分页后,CPU处理的都是虚拟地址,虚拟地址需要通过MMU转换为物理地址)
更通用地来讲,对于x86架构,这部分工作应该是由北桥(North Bridge)来完成的,北桥应该负责把访问PCI设备的请求转发到PCI总线上。
MMIO究竟是怎么实现的?
有了上面的铺垫,还原MMIO的全过程就很简单了。
这里假设我把E1000网卡的BAR0设置为了0xabcde000,MEM类型;现在想要访问E1000网卡中offset为0的寄存器,且已知虚拟地址0xffabe000经过MMU变换成物理地址之后是0xabcde000,那么现在只需要一个movl $0xffabe000, %eax
指令,就可以把想要的值存储到eax中。
上述整个过程具体分解如下:
- 0xffabe000这个地址通过MMU转换为物理地址0xabcde000
- CPU把读物理地址0xabcde000的请求发到北桥
- 北桥看出这是一个MMIO区域的地址,所以把请求发到PCI总线
- PCI总线上的E1000网卡匹配成功,把数据发送到总线的数据线上
- 数据逐层向上传递到CPU
逐级向下转发是如何实现的?
PCI桥也有自己的Base和Limit寄存器,可以记录这个PCI桥所管辖的地址范围,所以可以实现向下转发,具体见这篇文章。
BAR寄存器地址是谁分配的?
通过上面的描述,可以看出,BAR寄存器地址分配是一个难度比较高的任务,因为需要保证每个BAR寄存器的值之间一定不能有冲突,否则就会出现2个PCI设备同时响应一个总线事务的混乱局面,那么这个BAR寄存器值是谁分配的呢?
答案是BIOS等firmware在启动时分配的,而我们写操作系统时只需要读取firmware预分配好的值,然后直接利用就行,这个分配工作不需要操作系统来完成,具体见这篇Stackoverflow Post。
参考资料
【1】《PCI Express体系结构导读》(王齐 著)
【2】Intel E1000 Manual
【3】Physical Address Decoding in Intel Xeon v3/v4 CPUs: A Supplemental Datasheet
【4】Intel Xeon 7500 Datasheet