PCI 设备驱动

1. PCI 简介

PCI 总线标准是一种将系统外部设备连接起来的总线标准,是 PC 中最重要的总线,实际上是系统的各个部分如何交互的接口。传输速率可达到 133MB/s。在当前的 PC 体系结构中,几乎所有的外部设备采用的各种各样的接口总线,均是通过桥接电路挂接到 PCI 系统上。在这种 PCI 系统中, Host/PCI 桥称为北桥,连接主处理器总线到基础 PCI 局部总线。 PCI 与其他总线的接口称为南桥,其中南桥还通常含有中断控制器、IDE 控制器、USB 控制器和 DMA 控制器等。南桥和北桥组成主板的芯片组。

2. PCI 配置空间

每个 PCI 设备都有自己的配置空间,用于支持即插即用,使之满足现行的系统配置结构。下面对 PCI 配置空间做一下简要介绍。

配置空间是一容量为 256 字节并具有特定结构的地址空间。这个空间又分为头标区和设备有关区两部分。头标区的长度是 64 字节,每个设备都必须配置该区的寄存 器。该区中的各个字段用来唯一地识别设备。其余的 192 字节因设备而异。配置空间的头标区 64 个字节的使用情况如图 1 示。

为了实现即插即用,系统可根据硬件资源的使用情况,为 PCI 设备分配新的资源。因此编写设备驱动程序重点是获得基址寄存器(Base Address)和中断干线寄存器的内容。配置空间共有六个基址寄存器和一个中断干线寄存器,具体用法如下:

PCI Base Address 0 寄存器:系统利用此寄存器为 PCI 接口芯片的配置寄存器分配一段 PCI 地址空间,通过这段地址我们可以以内存映射的形式访问 PCI 接口芯片的配置寄存器。

PCI Base Address 1 寄存器:系统利用此寄存器为 PCI 接口芯片的配置寄存器分配一段 PCI 地址空间,通过这段地址我们可以以 I/O 的形式访问 PCI 接口芯片的配置寄存器。

PCI Base Address 2、3、4、5 寄存器:系统 BIOS 利用这些寄存器分配 PCI 地址空间以支持 PCI 接口芯片的局部配置寄存器 0、1、2、3 的访问。

在所有基址寄存器中,第 0 位均为只读位,表示这段地址映射到存储器空间还是 I/O 空间,如果是“1”表示映射到 I/O 空间,如果是“0”则表示映射到存储器空间。

中断干线寄存器(Interrupt Line):用于说明中断线的连接情况,这个寄存器的值与标准 8259 的 IRQ 编号(0~15)对应。

表 1 PCI 配置空间

3. 设备初始化

PCI 设备驱动程序要完成识别 PCI 器件、寻找 PCI 硬件的资源和对 PCI 器件中断的服务。在驱动程序初始化过程中,使用 HalGetBusData()函数完 成寻找 PCI 设备的工作。在初始化过程中,使用器件识别号(Device ID)和厂商识别号(Vendor ID),通过遍历总线上的所有设备,寻找到指定的 PCI 设备,并获取设备的总线号,器件号与功能号。通过这些配置信息,可以在系统中寻址该设备的资源配置 列表。

在此之后,驱动程序需要从配置空间获取硬件的参数。PCI 设备的中断号、端口地址的范围(I/O)方式、存储器的地址与映射 方式等,都可以从硬件资源列表数据结构中获取。在 Windows NT 中,调用 HalAssignSlotResources()函数来获得指定设备的资源列表数据结构指针,然后通过遍历该列表中的所有资源描述符,获取 该设备的 I/O 端口基地址与长度,中断的中断级、中断向量与模式,存储器基地址与长度等硬件资源数据。

我们设计的 DMA 通信采用总线主控方式进行通信,在 设备初始化时需要对 DMA 适配器进行初始化,使用 HalGetAdapter()获得操作系统分配的适配器对象指针。
示例代码如下:

// 遍历总线,获得指定设备的总线号,器件号与功能号
for (busNumber = 0; busNumber < MAX_PCI_BUSES; busNumber++) {
for (deviceNumber = 0;deviceNumber < PCI_MAX_DEVICES;deviceNumber++) {
slotNumber.u.bits.DeviceNumber = deviceNumber;
for (functionNumber = 0; functionNumber < PCI_MAX_FUNCTION; functionNumber++) {
slotNumber.u.bits.FunctionNumber = functionNumber;
if (!HalGetBusData(PCIConfiguration, busNumber, slotNumber.u.AsULONG,
&pciData,sizeof(ULONG)) ) {
deviceNumber = PCI_MAX_DEVICES;
break;
}

          if (pciData.VendorID == PCI_INVALID_VENDORID) {continue;}

          if (( VendorId != PCI_INVALID_VENDORID) && (pciData.VendorID != VendorId || pciData.DeviceID != DeviceId)) {continue;}
          pPciDeviceLocation->BusNumber = busNumber; 
          pPciDeviceLocation->SlotNumber = slotNumber;
          pPciDeviceLocation = &PciDeviceList->List[++count];
          status = STATUS_SUCCESS;
        } 
    } 
} 
// 获取设备的资源列表数据指针
status = HalAssignSlotResources(RegistryPath,
&pDevExt->ClassUnicodeString,
DriverObject,
DeviceObject,
pDevExt->InterfaceType,
pDevExt->BusNumber,
pDevExt->SlotNumber,
&pCmResourceList );

4. I/O 端口访问

在 PC 机上,I/O 寻址方式与内存寻址方式不同,所以处理方法也不同。I/O 空间是一个 64K 字节的寻址空间,I/O 寻址没有实模式与保护模式之分,在各种 模式下寻址方式相同。在 Windows NT 下,系统不允许处于 Ring3 级的用户程序和用户模式驱动程序直接使用 I/O 指令,对 I/O 端口进行访问,任何对 I/O 的操作都需要借助内核模式驱动 来完成。在访问 I/O 端口时,使用 READ_PORT_XXX 与 WRITE_PORT_XXX 函数来进行读写。I/O 端口基地址使用从配置空间基址寄存器 PCI Base Address 1 中返回的 I/O 端口基地址。

示例代码如下:
RegValue = READ_PORT_ULONG(pBaseAddr+RegOffSet);
WRITE_PORT_ULONG(pBaseAddr+ RegOffset, RegValue);

5. 设备内存访问

Winsows 工作在 32 位保护模式下,保护模式与实模式的根本区别在于 CPU 寻址方式上的不同,这也是 Windows 驱动程序设计中需要着重解决的问题。 Windows 采用了分段、分页机制,使得一个程序可以很容易地在物理内存容量不一样的、配置范围差别很大的计算机上运行,编程人员使用虚拟存储器可以写 出比任何实际配置的物理存储器都大得多的程序。每个虚拟地址由 16 位的段选择字和 32 位段偏移量组成。通过分段机制,系统由虚拟地址产生线性地址。再通过 分页机制,由线性地址产生物理地址。线性地址被分割成页目录(Page Directory)、页表(Page Table) 和页偏移 (Offset) 三个部分。当建立一个新的 Win32 进程时,操作系统会为它分配一块内存,并建立它自己的页目录、页表,页目录的地 址也同时放入进程的现场信息中。当计算一个地址时,系统首先从 CPU 控制器 CR3 中读出页目录所在的地址,然后根据页目录得到页表所在的地址,再根据页表 得到实际代码/数据页的页帧,最后再根据页偏移访问特定的单元。硬件设备读写的是物理内存,但应用程序读写的是虚拟地址,所以存在着将物理内存地址映射到 用户程序线性地址的问题。

从物理内存到线性地址的转换是驱动程序需要完成的工作,可以在初始化驱动程序的进行。在已经获得设备的存 储器基地址后,首先调用 HalTranslateBusAddress()函数将总线相关的内存地址转换成系统的物理地址,然后调用 MmMapIoSpace()函数将系统的物理地址映射到线性地址空间。在需要访问设备内存时,调用 READ_REGISTER_XXX()与 WRITE_REGISTER_XXX ()函数来进行,基地址使用前面映射后的线性地址。在设备卸载时,调用 MmUnmapIoSpace()断开设备内存与线性地址空间的映射。

示例代码如下:
HalTranslateBusAddress(InterfaceType,
BusNumber,
BaseAddress->RangeStart,
&addressSpace,
&cardAddress)

BaseAddress->MappedRangeStart = MmMapIoSpace(cardAddress,
BaseAddress->RangeLength,
MmCached );
……
RegValue = READ_REGISTER_ULONG(pRegister);
WRITE_REGISTER_ULONG(pRegister, pInBuf->RegValue);
……
MmUnmapIoSpace(pBaseAddress->MappedRangeStart, pBaseAddress->RangeLength);

6. 中断处理

中 断的设置、响应与调用在驱动程序中完成。设置中断应该在设备创建时完成,使用从 CmResourceTypeInterrupt 描述符中提取的参数,先调 用 HalGetInterruptVector()将与总线有关的中断向量参数转换为系统的中断向量,然后调用 IoConnectInterrupt() 指定中断服务,注册中断服务函数 ISR(Interrupt Service Routine)的函数指针。

当硬件设备产生中断时,系统 会自动调用 ISR 函数来响应中断。ISR 函数运行的中断请求级较高,主要完成对硬件设备中断的清除,不适合执行过多的代码。在传输大块数据时,需要使用延 迟过程调用(Delay Process Call,DPC)机制。例如,使用 PCI 设备进行 DMA 通信时,在 ISR 函数中完成对指定设备中断的判断以及清除中断,在退出 ISR 前,调用 DPC 函 数;在 DPC 函数中,完成 DMA 通信的过程,并将数据返回给用户程序。

示例代码如下:
DeviceExtension->InterruptLevel = partialData->u.Interrupt.Level;
DeviceExtension->InterruptVector = partialData->u.Interrupt.Vector;
DeviceExtension->InterruptAffinity = partialData->u.Interrupt.Affinity;
if (partialData->Flags & CM_RESOURCE_INTERRUPT_LATCHED)
{
DeviceExtension->InterruptMode = Latched;
} else {
DeviceExtension->InterruptMode = LevelSensitive;
}
……
vector = HalGetInterruptVector(pDevExt->InterfaceType,
pDevExt->BusNumber,
pDevExt->InterruptLevel,
pDevExt->InterruptVector,
&irql,
&affinity );

status = IoConnectInterrupt(&pDevExt->InterruptObject,
(PKSERVICE_ROUTINE)PciDmaISR,
DeviceObject,
NULL,
vector,
irql,
irql,
pDevExt->InterruptMode,
TRUE,
affinity,
FALSE );

7. DMA 通信过程

DMA 通信在驱动程序中实现,需要多个例程才能完成一次 DMA 通信。

1) DriverEntry 例程
构造 DEVICE_DESCRIPTION 结构,并调用 HalGetAdapter,找到与设备关联的 Adapter 对象,并将返回的 Adapter 对象的地址和映射寄存器的数目保存在设备扩展的数据结构中。

示例代码:
// 申请 DMA 的适配器对象
deviceDescription.Version = DEVICE_DESCRIPTION_VERSION;
deviceDescription.Master = TRUE;
deviceDescription.ScatterGather = pDevExt->ScatterGather;
deviceDescription.DemandMode = FALSE;
deviceDescription.AutoInitialize = FALSE;
deviceDescription.Dma32BitAddresses = TRUE;
deviceDescription.BusNumber = pDevExt->BusNumber;
deviceDescription.InterfaceType = pDevExt->InterfaceType;
deviceDescription.MaximumLength = pDevExt->MaxTransferLength;
pDevExt->AdapterObject = HalGetAdapter(&deviceDescription,
&numberOfMapRegisters
);
……

2)Start I/O 例程

该例程请求 Adapter 对象的拥有权,然后把其余的工作留给 AdapterControl 回调例程。

a) 调用 KeFlushIoBuffers 从 CPU 的 Cache 把数据清到物理内存,然后计算映射寄存器的数目和用户缓冲区的大小,及在第一次设备操作中传输的字节数。
b) 调用 MmGetMdlVirtualAddress,从 MDL 中恢复用户缓冲区的虚地址,并存入设备扩展数据结构中。
c) 调用 IoAllocateAdapterChannel 请求 Adapter 对象的拥有权。如果调用成功,其余的设置工作由 AdapterControl 例程去做;如果失败了,则完成本次 IRP 包处理,开始处理下一个 IRP。

3) AdapterControl 例程
该例程完成初始化 DMA 控制器,并启动设备的工作。
a) 调用 IoMapTransfer,装入 Adapter 对象的映射寄存器。
b) 向设备发送合适的命令开始传输操作。
c) 返回值 KeepObject 保留 Adapter 对象的拥有权。

4)中断服务(ISR)例程
在设备中断时,由系统调用。
a) 向硬件设备发出中断响应的指令。
b) 调用 IoRequestDpc 在驱动程序的 DpcForIsr 中继续处理该请求。
c) 返回 TRUE,表示已经服务了本次中断。

5)DpcForIsr 例程
由 ISR 在每个部分数据传输操作的结束时触发,完成当前 IRP 请求。
a) 调用 IoFlushAdapterBuffers,清除 Adapter 对象的 Cache 中的任何剩余数据。
b) 调用 IoFreeMapRegisters,释放所使用的映射寄存器。
c) 检查有未传完的剩余数据,如果有,则计算下次设备操作中需要传输的字节数,调用 IoMapTransfer 重设映射寄存器,并启动设备;如果没有剩余数据,则完成当前 IRP 请求,并开始下一个请求。