Skip to content

PCI

PCI (Peripheral Component Interconnect) is a bus standard for attaching expansion hardware (disk controllers, network cards, graphics cards, USB host controllers) to a computer’s CPU and memory subsystem. Every PCI function exposes a standardized 256-byte block of configuration space identifying the device and its resources, allowing an operating system to enumerate installed hardware without prior knowledge of what is present.

PCI 1.0 was introduced in 1992 as a shared parallel bus, originally clocked at 33 MHz with a 32-bit data path, giving a theoretical peak bandwidth of 132 MB/s. Later revisions extended this to a 64-bit path and a 66 MHz clock, doubling the width and nearly doubling the clock rate for a combined peak of 528 MB/s, while remaining electrically and software-compatible with earlier 32-bit, 33 MHz devices. Because every device on a physical PCI segment shares the same set of wires and takes turns driving them under an arbitration scheme, a single segment supports only a limited number of electrical loads, commonly cited as ten, which is one of the practical reasons a typical motherboard splits its PCI slots and onboard devices across more than one segment, joined by bridges.

PCI-X, aimed primarily at servers, kept the same shared-bus electrical model while raising clock rates further and shrinking segments to fewer devices each, trading flexibility for bandwidth. PCI Express, introduced in 2003, replaced the shared bus entirely with switched, point-to-point serial links called lanes; a slot may offer one lane or many, each independently full-duplex, and what used to be a bridge’s second bus is now a port of a switch. None of this changes the configuration model described in this article: a PCI Express function still answers to a bus/device/function address and exposes the same standard header, which is why software written against conventional PCI continues to work, largely unmodified, on PCI Express hardware.

Conventional PCI also defined two signaling voltages, 5 volt and 3.3 volt, which were not interchangeable; a card and slot built for different voltages could be damaged if forced together. The physical connector addressed this with keying notches positioned differently depending on which voltage the card was designed for (or, for universal cards supporting both, a notch position accepting either), preventing an incompatible card from being inserted at all rather than relying on software or the user to catch the mismatch.

Configuration space is not accessible through ordinary memory loads and stores on conventional PCI; reaching it requires a defined access mechanism.

The mechanism specified since PCI’s introduction uses two 32-bit I/O ports, CONFIG_ADDRESS (0xCF8) and CONFIG_DATA (0xCFC). Software writes a packed address to the first port, then reads or writes four bytes through the second:

static inline uint32_t pci_read32(uint8_t bus, uint8_t dev, uint8_t fn, uint8_t offset) {
uint32_t address = (1u << 31)
| ((uint32_t)bus << 16)
| ((uint32_t)dev << 11)
| ((uint32_t)fn << 8)
| (offset & 0xFC);
outl(0xCF8, address);
return inl(0xCFC);
}

A read directed at an address with no device present returns all bits set (0xFFFFFFFF); a write to the same address is silently discarded. Device presence is inferred from this convention rather than from any explicit query mechanism.

An earlier method, retained only until PCI 2.0 deprecated it, worked differently: an 8-bit port at 0xCF8 both enabled the mechanism and selected a function, a second port at 0xCFA selected a bus number, and configuration space was then reached through a 4,096-port I/O range (0xC000–0xCFFF) encoding a device number and register offset directly (a 12-bit range that could address only sixteen devices), and survives only on 80486 and early Pentium-era hardware.

Determining which mechanism a given machine supports requires consulting firmware directly. BIOS firmware reports this through an INT 0x1A software interrupt call; UEFI firmware provides no equivalent call, and support is typically inferred from a protocol query followed by an ACPI table lookup. Neither method is guaranteed to succeed, and software falling back to direct probing must account for legacy chipsets that alias high I/O port addresses to low ones: probing port 0xCF8 on a system with no PCI bus at all risks addressing an unrelated ISA device located at port 0xF8.

PCI Express defines a memory-mapped alternative, located through the ACPI MCFG table, where each function’s configuration space (now 4 KB) is directly addressable at base + (bus << 20) + (device << 15) + (function << 12) + offset. The legacy ports keep working for the first 256 bytes even on such a system, which is why most kernels implement them first. A machine with more than one host bridge lists more than one base address in MCFG, one per PCI segment; addressing then needs a segment number alongside bus, device, and function, defaulting to zero everywhere a single host bridge is enough.

On the physical bus underlying conventional PCI, selecting a target device during a configuration cycle involves a signal separate from the address encoding described above: each device’s IDSEL (Initialization Device Select) pin, wired to a specific address line during the address phase of the cycle, plays the role of a chip-select line and determines which physical device on the bus responds to a given device number. This detail is invisible to software running on top of either access mechanism (the device number in CONFIG_ADDRESS is translated into the correct IDSEL assertion entirely by the motherboard’s wiring), but it explains why device numbers on a physical PCI bus are not arbitrary software constructs; they correspond to a fixed hardware wiring decision made when the board was designed.

Locating installed hardware is a search problem over up to 65,536 possible bus/device/function combinations, almost all of which correspond to no device.

A search that mirrors the machine’s actual topology starts at bus 0 and only visits a further bus once a bridge there reports it, via the bridge’s Secondary Bus Number field:

void scan_bus(uint8_t bus) {
for (uint8_t dev = 0; dev < 32; dev++) {
if (pci_read16(bus, dev, 0, 0x00) == 0xFFFF) continue;
handle_function(bus, dev, 0);
if (pci_read8(bus, dev, 0, 0x0B) == 0x06 && pci_read8(bus, dev, 0, 0x0A) == 0x04) {
scan_bus(pci_read8(bus, dev, 0, 0x19)); // secondary bus
}
// multi-function handling covered further below
}
}

This relies on those bus numbers already being correct, which in turn relies on whoever configured the hierarchy (almost always firmware, before any kernel runs) having assigned them depth-first: giving a bridge’s secondary bus the next free number, numbering everything behind it recursively, and only then closing out its subordinate field with the highest number reached. That assignment is essentially always trustworthy on machines with standard BIOS or UEFI firmware, which is why a kernel rarely needs to redo it.

A less selective search instead tests every one of the 65,536 combinations directly and checks for 0xFFFF. This examines addresses the real topology could never populate, but is simpler to implement correctly, and is used by a substantial number of operating systems for that reason.

A device located this way often needs to be correlated with a corresponding object in the ACPI namespace before power management, hot-plug, or certain interrupt routing methods can be used with it. ACPI represents each PCI device or bridge encountered while walking its namespace as a device object carrying an _ADR method, which returns that object’s device and function number packed into a single value; matching this against the bus/device/function address found during enumeration is what allows firmware-provided methods such as _PRT to be associated with the correct physical device rather than applied generically.

The bytes at an occupied address follow a layout fixed for every device, beginning with the same sixty-four-byte header:

OffsetSizeFieldDescription
0x0016-bitVendor ID
0x0216-bitDevice ID
0x0416-bitCommandDisabled at reset; see the Command and Status registers
0x0616-bitStatusBit 4 indicates a capability list is present
0x088-bitRevision ID
0x09–0x0B24-bitClass codeProgramming interface, subclass, class
0x0C–0x0D(n/a)Cache Line Size / Latency TimerLegacy bus-arbitration parameters
0x0E8-bitHeader typeSelects the layout past byte 0x0E; bit 7 flags multiple functions
0x0F8-bitBISTBuilt-in self test

The class code at 0x0B and subclass at 0x0A identify what kind of device this is before checking anything vendor-specific:

ClassCategorySelected subclasses / programming interfaces
0x01Mass storage controller0x01 IDE, 0x06 SATA (Prog IF 0x01 = AHCI), 0x08 NVM (Prog IF 0x02 = NVMe)
0x02Network controller0x00 Ethernet
0x03Display controller0x00 VGA-compatible
0x04Multimedia controller0x00 video, 0x01 audio
0x05Memory controller0x00 RAM, 0x01 flash
0x06Bridge device0x00 host bridge, 0x04 PCI-to-PCI bridge, 0x07 CardBus bridge
0x08Base system peripheral0x00 PIC, 0x01 DMA controller, 0x02 timer, 0x06 IOMMU
0x0CSerial bus controller0x03 USB (Prog IF distinguishes UHCI/OHCI/EHCI/xHCI)
0x0DWireless controller0x11 Bluetooth, 0x20/0x21 802.11

The host bridge encountered at the very start of any scan (bus 0, device 0, function 0) is itself class 0x06, subclass 0x00. Vendor IDs, unlike class codes, are centrally assigned and become recognizable quickly: 0x8086 is Intel, 0x1022 AMD, 0x10DE NVIDIA, 0x1AF4 the virtio family QEMU and most hypervisors use for paravirtualized devices.

What comes after byte 0x0E depends on the header type in bits 0–6 of offset 0x0E. Type 0x00, an ordinary device, defines up to six Base Address Registers, a subsystem vendor/device ID pair, and a Capabilities Pointer. Type 0x01, a PCI-to-PCI bridge, defines the bus numbers and address ranges behind it instead; Secondary Bus Number is the field the search above relies on. Type 0x02, a CardBus bridge, is largely a historical curiosity on hardware built after the mid-2000s, defining two memory and two I/O windows rather than a plain bridge’s one of each.

Independently of header type, a configuration transaction carries a type distinction of its own. A bridge receiving a transaction whose bus number matches its own secondary bus converts it to a type 0 transaction addressed directly below it; a transaction whose bus number falls further out is forwarded unchanged as a type 1 transaction, to be handled the same way by a further bridge. This mechanism allows configuration addressing to route correctly through an arbitrarily deep hierarchy of bridges, without any individual bridge requiring knowledge of the full topology.

A type 0x01 header defines not only the bus numbers behind a bridge but also the memory and I/O address ranges the bridge is permitted to forward there, each expressed as a base and a limit rather than a base and a size. The I/O window is stored with 4 KB granularity: only the upper 12 bits of a 16-bit address are held in the register, with the lower bits assumed to be all zero for the base and all one for the limit, while the memory window uses 1 MB granularity in the same way. A transaction only crosses the bridge if its address falls within the resulting range; any device configured behind the bridge must therefore be assigned an address inside these windows; a base greater than the corresponding limit signals an empty range with nothing forwarded at all.

The Revision ID field at offset 0x08, though only a single byte, is frequently consulted by production drivers to work around hardware defects specific to an early manufacturing run of a chip, a known erratum affecting only silicon revisions below a particular value, for instance, causing a driver to apply an extra delay or avoid a particular register sequence on older revisions while using the direct path on newer ones. This makes the field considerably more load-bearing in practice than its single-byte size would suggest, and is one of the more common reasons production driver code branches on a value read from configuration space rather than treating a device model as electrically uniform across every unit ever produced.

A device does not store where its registers live: it reports how much space it needs through up to six Base Address Registers, and something else assigns the actual location. Bit 0 of a BAR distinguishes memory space (0) from I/O space (1); for memory, bits 2:1 say 32-bit or 64-bit (a 64-bit BAR spends the next BAR slot on its upper address half), and bit 3 marks the region prefetchable.

Extracting an already-assigned address means masking off those low bits, and the mask depends on the BAR’s type: bar & 0xFFFFFFF0 for a 32-bit memory BAR, the same mask on the low half combined with the full 32 bits of the next BAR for a 64-bit one, and bar & 0xFFFFFFFC for I/O space, which reserves only its bottom two bits. Because a BAR is naturally aligned to its own size, only the bits above that size are ever writable: a device needing 16 MB reads back 0xFF000000 before decoding, with just its upper 8 bits actually settable.

Determining how much space a BAR requires, as opposed to reading an already-assigned address, requires probing it directly: the device’s I/O and memory decode is disabled first (some hardware otherwise treats the probe as an unintended access), all 1s are written to the register, the result is read back, and the original value is restored.

uint32_t original = pci_read32(bus, dev, fn, bar_offset);
pci_write32(bus, dev, fn, bar_offset, 0xFFFFFFFF);
uint32_t probed = pci_read32(bus, dev, fn, bar_offset);
pci_write32(bus, dev, fn, bar_offset, original);
uint32_t size = (~(probed & ~0xFu)) + 1;

A BAR reporting 0xFFFF0000 after masking, for instance, yields 0x00010000 (64 KB) once inverted and incremented, which is both the size of the region and the alignment its eventual base address must respect. Whatever assigns that address, firmware, or occasionally the kernel itself, writes it back into the BAR once chosen.

Mapping the result matters as much as finding it: ordinary MMIO registers should be mapped uncacheable, since a stale cached read would silently defeat the point of reading live hardware state, but framebuffer memory is a common exception, mapped write-combining instead so the CPU can batch pixel writes into larger transactions.

A seventh, differently-purposed window sits at offset 0x30: the expansion ROM base address, holding firmware images like a network card’s PXE boot code rather than runtime registers, gated by its own separate enable bit that defaults off.

While conventional PCI treats memory-space and I/O-space BARs as two equally valid options, PCI Express hardware has increasingly moved away from I/O space entirely: a growing share of modern devices, particularly those with no legacy compatibility requirement to preserve, expose only memory-mapped registers and leave every I/O-space BAR unimplemented. This reflects I/O space’s origins as an x86-specific concept with no equivalent on most other architectures, whereas memory-mapped registers require no architecture-specific instruction support and behave identically whether accessed from a driver written for x86, ARM, or any other platform PCI Express has been adapted to.

None of a device’s memory or I/O regions respond until software enables them. Command register bit 1 (Memory Space Enable) and bit 0 (I/O Space Enable) gate whether its BARs decode bus accesses at all (a fully configured BAR can sit completely unresponsive if the matching bit was never set), and bit 2 (Bus Master Enable) separately gates whether the device may initiate a DMA transfer at all, silently, with no error if a driver forgets it.

The rest of the Command register covers less commonly needed behavior:

BitMeaning
3Special Cycle Enable
4Memory Write and Invalidate Enable
5VGA Palette Snoop Enable
6Parity Error Response
8SERR# Enable
9Fast Back-to-Back Enable
10Interrupt Disable (suppresses the legacy INTx# signal described below)

The paired Status register, mostly read-only aside from a handful of write-one-to-clear error bits, reports transaction-level conditions rather than anything device-specific: bit 4 (capabilities present), bits 11–14 (various abort and error conditions), and bit 15 (a detected parity error) among them.

The write-one-to-clear behavior on the Status register’s error bits exists specifically to avoid a race condition inherent to a simpler read-modify-write clearing scheme: if clearing an error bit required reading the register, masking off the bit in software, and writing the result back, a second error occurring between the read and the write would be silently lost when the write overwrote it with a stale, already-cleared value. Writing a 1 to acknowledge a specific bit, by contrast, clears only that bit regardless of what else changed in the register in the meantime, leaving any error that arrived during the acknowledgment itself still set and visible on the next read.

A device using the original signaling scheme asserts one of four physical lines, INTA# through INTD#, recorded in its Interrupt Pin field at offset 0x3D. Those lines are shared across devices, so a driver on a shared line has to check its own status bits on every interrupt to confirm the event was its own. Figuring out which CPU input a given slot’s line actually reaches is likewise a firmware question, not a PCI one: systems predating ACPI expose it through a $PIR table found by scanning low memory, ACPI systems through a per-bridge _PRT method.

Message Signaled Interrupts remove the need for that routing entirely: a device raises an interrupt with an ordinary memory write of a fixed value to a fixed address, delivered by the chipset as an interrupt with its own vector, independent of any shared line or routing table. Locating this capability, and others like it, requires walking a linked list inside configuration space, present only if Status register bit 4 is set. The list begins at the offset given by the Capabilities Pointer (0x34); each entry holds a Capability ID and a Next Pointer to the following entry, terminated by a next pointer of zero:

uint8_t offset = pci_read8(bus, dev, fn, 0x34) & 0xFC;
while (offset != 0) {
uint8_t id = pci_read8(bus, dev, fn, offset);
uint8_t next = pci_read8(bus, dev, fn, offset + 1);
// id: 0x01 power management, 0x05 MSI, 0x10 PCI Express, 0x11 MSI-X
offset = next;
}

MSI (capability ID 0x05) defines a Message Control register (an enable bit, requested and granted vector counts, a 64-bit addressing flag) alongside the address and data values the operating system programs. Its successor MSI-X (ID 0x11) relocates the vector table into one of the device’s own BARs, supporting up to 2048 independently maskable entries rather than the handful available directly inside MSI’s own structure.

Beyond removing the need for line-sharing, message-signaled delivery also solves a problem legacy INTx lines have no answer to at all: directing a specific interrupt to a specific CPU in a multiprocessor system. Because a legacy interrupt line is a physical wire ultimately routed through a single input on the interrupt controller, redirecting it to a different processor requires reprogramming the routing itself. An MSI or MSI-X vector’s target CPU, by contrast, is encoded directly in the address portion of the memory write the device performs, which the operating system controls when it programs the capability, making it straightforward for a driver or scheduler to steer a device’s interrupts toward whichever processor is currently handling that device’s workload, a technique commonly referred to as interrupt affinity.

PCI Express functions carry a second capability list, distinct from the one described above, occupying the configuration space beyond byte 256 that only memory-mapped access can reach. Extended capabilities use a similar linked-list structure (each entry gives a 16-bit capability ID and a 12-bit offset to the next entry) but describe features that have no equivalent on conventional PCI. Advanced Error Reporting (extended capability ID 0x0001) is a representative example: rather than the single Detected Parity Error and Signaled System Error bits available in the standard Status register, it exposes separate, individually maskable registers for correctable errors (recoverable link-level events that don’t need software intervention), uncorrectable errors, and a further split between fatal and non-fatal cases, giving considerably finer-grained diagnostics than conventional PCI’s error reporting allows.

Other extended capabilities address concerns that only arise once many independent devices share a switched fabric rather than a single shared bus. Access Control Services governs whether traffic between two functions attached to the same switch is permitted to pass directly between them or must be redirected up to the root complex first, relevant to isolating virtual machines from each other when devices are assigned directly to guests. Resizable BAR allows a function to advertise several different sizes it is capable of operating with for a given BAR, rather than the single fixed size ordinary BAR probing reports, letting software request a smaller allocation than the device’s maximum if the full size is not needed.

Past the sixty-four-byte common header, nearly everything is optional, discovered through the same capability list used above rather than fixed offsets. Power management (capability ID 0x01) is a common example, defining a control/status register that moves a function between D0 (fully on) and D3 (effectively off, with context lost unless the lower-power D3hot variant is supported).

Between those two extremes, the specification also defines two intermediate states, D1 and D2, though implementation of either is optional and left to the individual device; a function is only required to implement D0 and D3, and many implement nothing in between. Where D1 and D2 are supported, they generally trade a smaller power saving for a faster, less disruptive transition back to D0 than D3 offers, giving a device more granularity between “fully active” and “fully off” than the two mandatory states alone provide.

A single physical card is also not limited to one function: bit 7 of function 0’s Header Type byte flags this, and if set, an enumerator has to probe functions 1 through 7 rather than treat a 0xFFFF result there as proof nothing more exists. A non-zero function may carry an entirely different device ID, class code, or even header type from function 0.

Several capabilities are defined only for PCI Express and extend rather than replace the mechanisms described above. Hot-plug support allows a slot’s presence and power-fault signals to notify software of hardware changes occurring after boot. SR-IOV allows a physical function to expose multiple lightweight virtual functions, each individually assignable to a separate virtual machine. ARI relaxes the eight-function-per-device limit for devices, most commonly SR-IOV endpoints, that require more than eight functions.

The steps above can be illustrated by tracing how a kernel would locate and prepare a typical SATA controller.

An enumerator scanning bus 0 encounters a function reporting class code 0x01, subclass 0x06 (a mass storage controller, SATA subclass) with Prog IF 0x01, identifying it specifically as an AHCI-compliant controller rather than an older IDE-style interface. Its Vendor ID reads 0x8086, identifying Intel as the manufacturer; the Device ID that accompanies it is specific to the exact chipset and is looked up against a vendor-maintained table only if driver behavior needs to vary between chipset revisions, which an AHCI driver written to the standard register interface generally does not need to do.

Reading the Header Type finds 0x00, a normal device, so BAR5 (by AHCI convention, the ABAR, or AHCI Base Address Register) is probed: written with all 1s, read back, and the result inverted and incremented to determine that the controller requires 4 KB of memory-mapped register space. Firmware has typically already assigned a real address here, which the kernel reads directly rather than reassigning, and maps uncacheable in its page tables, since AHCI’s registers must reflect live hardware state on every access.

Before that mapping is useful, the kernel sets Command register bits 1 (Memory Space Enable) and 2 (Bus Master Enable): AHCI relies on DMA for all data transfer, so omitting bit 2 would leave every subsequent command silently ineffective. Interrupt delivery is then established by walking the capability list for an MSI or MSI-X entry; finding one lets the driver register a dedicated vector rather than sharing one of the four legacy INTx lines with whatever else happens to sit behind the same bridge.

If the enumerator later encounters a second SATA controller elsewhere in the tree (a discrete add-in card behind a PCI-to-PCI bridge, say, in addition to the chipset’s own onboard controller), nothing about the process above changes for it: it is discovered at whatever bus number the bridge scan reaches, identified by the same class code and Prog IF regardless of which physical bus it sits on, and configured through the same BAR-probing and Command-register sequence independently of the first. This independence is precisely what the class code and header structure make possible: a driver bound to “AHCI-compliant mass storage controllers” as a category, rather than to one specific address, attaches equally well to either instance without needing to know how many exist in advance.

Omitting Bus Master Enable before programming a DMA-capable device is a frequent source of a device that appears unresponsive without any error: the transfer simply never happens. Probing a BAR’s size without disabling decode first can trigger a bus fault on some chipsets, since the all-1s value briefly makes the device claim a large, likely-conflicting address range. BAR0 isn’t guaranteed to hold a device’s primary register window, so a driver should identify the correct BAR by its reported properties rather than assume its position.

Writes to memory-mapped registers are also posted: a store instruction retires once the write leaves the CPU, without guaranteeing it has reached the device yet. Following a critical write with a read from the same device (which can’t complete until the write ahead of it has) flushes it before software depends on the result, such as before arming an interrupt the device might raise almost immediately.

A further class of error stems from hot-pluggable PCI Express hardware being physically removed while a driver still holds it mapped and believes it configured: any subsequent access to the device’s now-vacant BARs returns the same all-ones pattern used to signal an absent device during enumeration, which a driver that only checked for this pattern during initial discovery may misinterpret as a legitimate register value rather than as evidence the device is gone. Kernels intending to support hardware removal at runtime generally need an explicit surprise-removal path, driven by the hot-plug capability’s own presence-detect signaling rather than by noticing malformed register reads after the fact, since by the time reads start returning all ones, any in-flight DMA the device was midway through has already failed silently as well.

  1. ^ PCI-SIG, PCI Local Bus Specification, revision 3.0 (defines configuration space layout, the type-0/type-1 transaction distinction, and command/status semantics).
  2. ^ PCI-SIG, PCI-to-PCI Bridge Architecture Specification, revision 1.2 (defines bridge configuration transaction forwarding and bus-number assignment).
  3. ^ OSDev Wiki, “PCI” (a hobbyist reference with additional register-level implementation detail).
  4. ^ PCI-SIG, MSI-X ECN to the PCI Local Bus Specification (defines the MSI-X capability structure and vector table format).
  • The IDT: how an interrupt vector is dispatched once it arrives.
  • PIC & APIC: legacy INTx lines are routed through one of these before reaching the CPU.
  • AHCI: the register interface behind the SATA controller this article’s worked example enumerates.
  • Port I/O versus Memory-Mapped I/O: the two mechanisms this article’s legacy config ports and MCFG alternative are concrete examples of.
  • VirtIO: the paravirtualized device family identified by the vendor ID above.
  • Networking: a NIC discovered the same way as any other PCI device, before its TX/RX rings can be configured.
  • NVMe: a device discovered and BAR-mapped the same way, using an entirely different queue-based command model once mapped.
  • Audio (AC97/HDA): another device family discovered and mapped through this same mechanism.
  • Network Boot (PXE): the boot code the expansion ROM above holds for a network card.