PCI Express (PCIe) configuration space is a memory region associated with every PCIe device. It holds important information and configuration registers for the device, including vendor IDs, device IDs, revision numbers, and extended capability registers. Accessing this space is crucial for device initialization, configuration, and debugging purposes.
In Windows, particularly with the Windows Driver Model (WDM), safe and controlled access to the PCIe configuration space is achieved using specific IRPs (I/O Request Packets) and interfaces provided by the operating system. This guide provides an in-depth explanation of how to build a PCIe configuration space reader, including code samples and detailed steps to handle configuration space access effectively.
Each PCIe device has a standardized configuration space, typically 256 bytes for traditional devices and up to 4096 bytes for devices supporting extended capabilities. This region includes:
Since the configuration space is memory-mapped, reading it without proper synchronization and using correct privilege levels may cause system instability. Windows provides several APIs and IRPs such as IRP_MN_READ_CONFIG and IRP_MN_WRITE_CONFIG to help drivers manage these operations safely.
To read the PCIe configuration space, our driver needs to send an IRP with the minor function IRP_MN_READ_CONFIG. The underlying PCI bus driver processes this IRP and retrieves the appropriate configuration information.
In more modern Windows environments, the BUS_INTERFACE_STANDARD interface is used. This interface offers functions like GetBusData and SetBusData for accessing configuration registers from within a kernel-mode context.
When developing a PCI config space reader, consider the following:
Start by writing the DriverEntry function which serves as the entry point for all WDM drivers. Initialize necessary structures, assign the unload routine, and prepare communication with the PCI bus driver.
/* Include necessary headers */
#include <ntddk.h>
#include <wdm.h>
#include <pci.h>
/* Driver entry point function */
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
// Set the unload routine for cleanup during driver unload
DriverObject->DriverUnload = UnloadDriver;
// Additional initialization can be done here
// For example, initializing global data structures or custom device extensions
return STATUS_SUCCESS;
}
/* Unload routine for cleaning up driver resources */
VOID UnloadDriver(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
// Perform cleanup necessary when the driver is unloaded.
}
The key to reading the PCI configuration space is the IRP with the IRP_MN_READ_CONFIG minor function. In this routine, the driver constructs an IRP, sets up the stack location, and then calls the device driver via IoCallDriver. The system waits for the IRP completion using synchronization objects.
/* Function to read PCIe configuration space using IRP */
NTSTATUS ReadPciConfigSpace(
HANDLE DeviceHandle, // Handle representing the PCI device
ULONG Offset, // Offset into the configuration space
PVOID Buffer, // Buffer to store the configuration data
ULONG Length // Number of bytes to read
)
{
IO_STATUS_BLOCK iosb;
NTSTATUS status;
KEVENT event;
// Initialize an event for synchronization purposes.
KeInitializeEvent(&event, NotificationEvent, FALSE);
// Allocate the IRP based on the device's stack size
PIRP irp = IoAllocateIrp(DeviceHandle->StackSize, FALSE);
if (!irp)
{
return STATUS_INSUFFICIENT_RESOURCES;
}
// Mark the IRP as non-cached and set a default status
irp->Flags = IRP_NOCACHE;
irp->IoStatus.Status = STATUS_NOT_IMPLEMENTED;
irp->UserEvent = &event;
// Prepare the IRP stack location for configuration read
PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(irp);
stack->MajorFunction = IRP_MJ_PNP;
stack->MinorFunction = IRP_MN_READ_CONFIG;
stack->Parameters.ReadWriteConfig.Address = Offset;
stack->Parameters.ReadWriteConfig.Length = Length;
stack->Parameters.ReadWriteConfig.Data = Buffer;
// Send the configured IRP down to the PCI bus driver
status = IoCallDriver(DeviceHandle, irp);
// Wait until the IRP is completed
KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
// Return status of the read operation
return status;
}
Below is an integrated sample code that incorporates both the DriverEntry and the read function. This template should serve as a foundation for further customization depending on the PCI device specifics and additional functionalities required.
/* Complete Sample Code for a Basic PCIe Config Space Reader */
#include <ntddk.h>
#include <wdm.h>
#include <pci.h>
/* Function prototypes */
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath);
VOID UnloadDriver(PDRIVER_OBJECT DriverObject);
NTSTATUS ReadPciConfigSpace(
HANDLE DeviceHandle,
ULONG Offset,
PVOID Buffer,
ULONG Length
);
/* Main driver entry point */
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
// Set the unload routine
DriverObject->DriverUnload = UnloadDriver;
// Additional initialization code here.
// This may involve setting up device objects, symbolic links etc.
// This minimal implementation returns success.
return STATUS_SUCCESS;
}
/* Driver unload function for cleanup */
VOID UnloadDriver(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
// Release any allocated resources if necessary.
}
/*
* ReadPciConfigSpace: Reads PCIe configuration space.
* Parameters:
* DeviceHandle - Handle to the PCI device.
* Offset - Offset into the configuration space from which to read.
* Buffer - Pointer to the output buffer for configuration data.
* Length - Number of bytes to read.
*/
NTSTATUS ReadPciConfigSpace(
HANDLE DeviceHandle,
ULONG Offset,
PVOID Buffer,
ULONG Length
)
{
IO_STATUS_BLOCK iosb;
NTSTATUS status;
KEVENT event;
// Initialize event for synchronization.
KeInitializeEvent(&event, NotificationEvent, FALSE);
// Allocate IRP based on device’s stack size.
PIRP irp = IoAllocateIrp(DeviceHandle->StackSize, FALSE);
if (irp == NULL)
{
return STATUS_INSUFFICIENT_RESOURCES;
}
// Set up IRP parameters: mark as non-cached and use event for IRP completion.
irp->Flags = IRP_NOCACHE;
irp->IoStatus.Status = STATUS_NOT_IMPLEMENTED;
irp->UserEvent = &event;
// Get the next IRP stack location.
PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(irp);
stack->MajorFunction = IRP_MJ_PNP;
stack->MinorFunction = IRP_MN_READ_CONFIG;
stack->Parameters.ReadWriteConfig.Address = Offset;
stack->Parameters.ReadWriteConfig.Length = Length;
stack->Parameters.ReadWriteConfig.Data = Buffer;
// Send the IRP to the appropriate device driver.
status = IoCallDriver(DeviceHandle, irp);
// Wait until the IRP is finished.
KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
// Return the result of the operation.
return status;
}
Some PCIe devices support configuration spaces larger than 256 bytes. To accommodate this:
Adjust the IRP parameters accordingly if you intend to read beyond the first 256 bytes.
Kernel-mode driver operations must ensure that asynchronous calls, like sending IRPs, do not lead to race conditions. In our implementation, a kernel event is used to wait for IRP completion. By using the KeInitializeEvent and KeWaitForSingleObject APIs, we ensure that the read operation is complete before proceeding.
Every call to an API that accesses hardware must validate success or failure codes. For example, check the return status from IoAllocateIrp and IoCallDriver. Also, when dealing with hardware registers, make sure to validate that the offset and length fall within valid ranges to prevent buffer overflows or corrupt data.
Modern Windows environments use a layered driver model. The PCI bus filter driver intercepts calls between the device driver and the PCI bus driver. By developing your code as a miniport or filter, you ensure that you have proper access to the configuration space while still observing the system’s security protocols.
The table below outlines common configuration registers along with their respective offsets and descriptions:
Register Name | Offset (Hex) | Description |
---|---|---|
Vendor ID | 0x00 | Identifies the vendor of the device |
Device ID | 0x02 | Specific device identification |
Command Register | 0x04 | Controls device settings and command execution |
Status Register | 0x06 | Reports device status including error information |
Base Address Registers (BARs) | 0x10-0x24 | Define memory or I/O space requirements |
Modern versions of Windows implement strict security protocols for device access. Direct PCI configuration access from user-mode applications is restricted to prevent unauthorized hardware manipulation. Therefore, such operations must be performed in kernel mode, adhering to the Windows Driver Model (WDM) guidelines.
Virtualization-based security (VBS) further restricts modifications to device configuration registers, hence ensure that your driver properly checks for privilege levels and system compatibility.
Before deploying your PCI config space reader in production environments, thoroughly test your code. Use debugging tools such as Kernel Debugger (KD) and WinDbg, as well as utilities that can simulate PCI environments. Often, hardware emulators or dedicated diagnostic tools are used to verify that your driver functions correctly.
Establish reliable logging within your driver to capture IRP completion statuses and any errors encountered during the configuration space access.
Although our primary focus is reading the PCIe configuration space, it is possible to extend the solution to write operations using IRP_MN_WRITE_CONFIG. The procedures are similar, though the write operation requires great caution because erroneous writes can disrupt system stability.
When implementing write support, follow similar steps as in the read function but substitute the minor function with IRP_MN_WRITE_CONFIG and ensure that the contents of the output buffer are correct before writing.
As hardware evolves, you might need to integrate support for more advanced features such as SR-IOV. In such cases, consider: