Linux PCI I/O Port Access
A hands-on lecture from EmbeddedPathashala’s free Linux kernel development course — learn how PCI drivers request and access legacy I/O port BARs on modern kernels.
If you’ve been following EmbeddedPathashala’s free linux kernel development course, you already know from the previous PCI lecture how a driver claims and maps a memory-mapped BAR with pci_iomap(). But not every PCI device exposes its registers through memory space. Some devices — legacy sound cards, IDE/ATA controllers, certain NICs, and a good chunk of x86 platform hardware — still expose an I/O port BAR, and PCI I/O port access in the Linux kernel uses a completely different instruction path than memory-mapped I/O. This lecture explains what that path is, how the kernel abstracts it, and how to write a driver that talks to it correctly on the latest kernel.
What You Will Learn
- Why PCI I/O port space is architecturally different from MMIO
- How request_region() and request_mem_region() are unified by resource flags
- The inb/inw/inl/outb/outw/outl port-access API family
- Portability concerns on architectures without native port I/O
- Writing and testing an original I/O-port PCI driver on QEMU
Prerequisites
This lecture assumes you’ve already gone through the earlier PCI BAR and MMIO lecture in this free linux device drivers course — specifically pci_resource_flags(), pci_request_regions(), and pci_iomap(). You should also be comfortable building and loading out-of-tree kernel modules and running a QEMU virtual machine for driver testing.
Two Address Spaces, One Bus
The PCI (and PCIe) specification defines three distinct address spaces a Base Address Register can claim: memory space, I/O space, and configuration space. We’ve already covered configuration space and memory space (MMIO) in earlier lectures. I/O space is the odd one out — it’s a legacy carryover from the original x86 ISA bus, where the CPU has two entirely separate address buses: one for regular memory, and a second, much smaller (64 KB on x86) address range reached only through dedicated IN and OUT machine instructions.
This matters to a driver author because you cannot dereference an I/O port address like a pointer. There is no C-level *ptr for port space — the CPU literally does not route that address through the normal load/store path. The kernel has to expose a separate function family for it, and that family is architecture-specific under the hood even though the API is portable.
On architectures that never had a separate I/O bus — ARM64, RISC-V, most modern SoCs — the kernel still lets you call inb()/outb(), but it quietly maps the port range into a reserved slice of memory space and translates the call for you. As a driver author you almost never need to care which path is taken; you just need to request the resource correctly and use the matching accessor family.
Requesting an I/O Port Region
Before touching any port, a driver must reserve the range so no other driver collides with it. The generic (non-PCI) kernel API is request_region(), the I/O-space counterpart of request_mem_region() used for MMIO. Both live in include/linux/ioport.h and both return an opaque struct resource * that must be released with the matching release_region() or release_mem_region() call.
/* Generic (non-PCI) reservation APIs */
struct resource *request_region(resource_size_t start,
resource_size_t n,
const char *name);
struct resource *request_mem_region(resource_size_t start,
resource_size_t n,
const char *name);
For PCI devices specifically, you almost never call these directly. As covered in the previous lecture, pci_request_region() and pci_request_regions() already inspect pci_resource_flags() for you and dispatch to the correct low-level primitive:
unsigned long flags = pci_resource_flags(pdev, bar);
if (flags & IORESOURCE_IO)
/* kernel internally calls request_region() */
;
else if (flags & IORESOURCE_MEM)
/* kernel internally calls request_mem_region() */
;
This is why, on the modern kernel, a single call to pci_request_regions(pdev, DRV_NAME) safely reserves every BAR a device exposes — mixed I/O and memory BARs included — without the driver having to branch on resource type itself.
The Port Accessor Family: inb/inw/inl/outb/outw/outl
Once a port range is reserved, reading and writing it uses a dedicated family of six functions declared in <asm/io.h> (pulled in transitively through <linux/io.h>):
| Function | Direction | Width |
|---|---|---|
u8 inb(unsigned long port) | Read | 1 byte |
u16 inw(unsigned long port) | Read | 2 bytes |
u32 inl(unsigned long port) | Read | 4 bytes |
void outb(u8 val, unsigned long port) | Write | 1 byte |
void outw(u16 val, unsigned long port) | Write | 2 bytes |
void outl(u32 val, unsigned long port) | Write | 4 bytes |
Notice the naming logic: the letter suffix encodes the width (byte, word, long), and the in/out prefix encodes direction from the CPU’s point of view — in reads from the device into the CPU, out writes from the CPU to the device. This is the mirror image of ioread32()/iowrite32() used for MMIO, and the two families are never interchangeable — calling ioread32() on a port address (or vice versa) is undefined behavior and one of the most common bugs in first-time PCI drivers.
The kernel also ships repeat-string variants — insb(), insw(), insl(), outsb(), outsw(), outsl() — that transfer a buffer of consecutive values in one call, useful for bulk register/FIFO drains where the hardware auto-increments internally.
Getting the Port Base Address from a BAR
You never hardcode a port number in a modern PCI driver — the port base is whatever the BIOS/firmware assigned at boot, retrieved through pci_resource_start():
unsigned long io_base = pci_resource_start(pdev, bar);
unsigned long io_len = pci_resource_len(pdev, bar);
Every subsequent inb()/outb() call then uses io_base + offset as the port argument, where offset is the device’s documented register layout.
Building an Original I/O-Port PCI Driver
To make this concrete, here’s an original demo driver, ep_pci_ioport_demo, written for this lecture. It targets QEMU’s emulated Intel e1000 NIC (vendor 0x8086, device 0x100e), which exposes both an MMIO BAR and a genuine I/O-space BAR — making it a convenient, freely available target for testing real I/O port code without needing physical legacy hardware.
#include <linux/module.h>
#include <linux/pci.h>
#include <linux/io.h>
#define DRV_NAME "ep_pci_ioport_demo"
static unsigned long ep_io_base;
static int ep_pci_ioport_probe(struct pci_dev *pdev,
const struct pci_device_id *id)
{
int err, io_bar = -1, i;
unsigned long flags;
err = pcim_enable_device(pdev);
if (err)
return err;
/* Find the first BAR that is I/O space, not memory space */
for (i = 0; i < PCI_STD_NUM_BARS; i++) {
flags = pci_resource_flags(pdev, i);
if (flags & IORESOURCE_IO) {
io_bar = i;
break;
}
}
if (io_bar < 0) {
dev_info(&pdev->dev, "no I/O port BAR on this device\n");
return -ENODEV;
}
err = pci_request_region(pdev, io_bar, DRV_NAME);
if (err)
return err;
ep_io_base = pci_resource_start(pdev, io_bar);
dev_info(&pdev->dev, "I/O BAR%d base = 0x%lx, len = %llu\n",
io_bar, ep_io_base, pci_resource_len(pdev, io_bar));
/* Read the first status register as a demonstration */
dev_info(&pdev->dev, "reg[0x00] = 0x%02x\n", inb(ep_io_base));
pci_set_drvdata(pdev, (void *)(long)io_bar);
return 0;
}
static void ep_pci_ioport_remove(struct pci_dev *pdev)
{
int io_bar = (long)pci_get_drvdata(pdev);
pci_release_region(pdev, io_bar);
dev_info(&pdev->dev, "ep_pci_ioport_demo removed\n");
}
static const struct pci_device_id ep_ioport_ids[] = {
{ PCI_DEVICE(0x8086, 0x100e) },
{ }
};
MODULE_DEVICE_TABLE(pci, ep_ioport_ids);
static struct pci_driver ep_pci_ioport_driver = {
.name = DRV_NAME,
.id_table = ep_ioport_ids,
.probe = ep_pci_ioport_probe,
.remove = ep_pci_ioport_remove,
};
module_pci_driver(ep_pci_ioport_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("EmbeddedPathashala");
MODULE_DESCRIPTION("Original demo: PCI I/O port BAR access");
Running It on QEMU
Launch a QEMU guest with an emulated e1000 NIC so an I/O-space BAR actually exists to probe:
qemu-system-x86_64 \
-kernel bzImage \
-initrd rootfs.cpio.gz \
-nic user,model=e1000 \
-append "console=ttyS0" \
-nographic
Inside the guest, build and load the module against the running kernel headers:
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
insmod ep_pci_ioport_demo.ko
dmesg | tail
Expected output:
ep_pci_ioport_demo 0000:00:03.0: I/O BAR1 base = 0xc040, len = 64
ep_pci_ioport_demo 0000:00:03.0: reg[0x00] = 0x00
Unload it the same way as any PCI driver:
rmmod ep_pci_ioport_demo
MMIO vs Port I/O — When to Use Which
| Aspect | Memory-Mapped I/O | I/O Port Space |
|---|---|---|
| Address range | Large, up to 64-bit | 16-bit on x86 (64 KB total) |
| Access functions | ioread*() / iowrite*() | inb/inw/inl / outb/outw/outl |
| Reservation call | request_mem_region() | request_region() |
| Portability | Native everywhere | Emulated on non-x86 arches |
| Modern hardware trend | Preferred / default | Legacy, being phased out |
Modern PCIe devices overwhelmingly prefer memory-mapped BARs because they scale better and don’t burn through the tiny 64 KB x86 I/O window. If a device you’re bringing up exposes both an MMIO and an I/O BAR for the same functionality, always prefer the MMIO one in new driver code — the I/O BAR is usually kept only for legacy software compatibility.
Common Mistakes
Mixing accessor families
Calling ioread8() on a port address, or inb() on an MMIO address, compiles fine but produces silently wrong reads on some architectures. Always match the accessor to the resource flag you checked.
Skipping the resource-flag check
Assuming a BAR is always I/O space (or always memory space) breaks the moment the driver runs against a device revision that changed its BAR layout. Always branch on pci_resource_flags().
Forgetting to release the region
If you called the non-managed pci_request_region(), you must call pci_release_region() in your remove callback — using the pcim_-prefixed managed variants avoids this class of bug entirely, as covered in the previous BAR lecture.
Best Practices
- Prefer managed pcim_* APIs over raw pci_request_region() where available
- Always branch on pci_resource_flags() rather than assuming BAR type
- Use the insb/insw/insl string variants for bulk FIFO transfers
- Keep port offsets as named macros, never magic numbers
- Avoid new I/O-port designs; prefer MMIO for any new hardware
Summary and Key Takeaways
PCI I/O port access is a legacy but still very real code path on the Linux kernel, distinct from memory-mapped I/O at the CPU instruction level. The kernel gives you a symmetric, portable API — request_region() paired with inb/inw/inl/outb/outw/outl — mirroring the MMIO pair of request_mem_region() and ioread*/iowrite*. For PCI devices specifically, pci_request_regions() already picks the right primitive for you based on pci_resource_flags(), so the main discipline for a driver author is: check the flag, use the matching accessor, and always release what you reserve. This rounds out the resource-access half of PCI driver development in this free linux kernel development course — the next lecture in the series moves on to interrupt vector retrieval with pci_irq_vector().
Frequently Asked Questions
What is the difference between inb() and ioread8()?
inb() accesses PCI I/O port space using the CPU’s dedicated IN/OUT instructions (or their architecture-specific emulation). ioread8() accesses memory-mapped I/O space through a normal pointer returned by ioremap()/pci_iomap(). They are not interchangeable.
Do ARM64 or RISC-V kernels support inb()/outb()?
Yes. These architectures don’t have a native separate I/O bus, so the kernel maps the port range into a reserved memory region and the inb/outb calls are translated transparently. The driver-facing API stays portable.
Can a single PCI device expose both a memory BAR and an I/O BAR?
Yes, this is common on older or dual-mode devices such as network cards. pci_request_regions() reserves all BARs correctly regardless of type, and you must access each with the accessor family matching its own flag.
Why is x86 I/O port space limited to 64 KB?
It’s a hardware limit inherited from the original 16-bit address lines dedicated to the IN/OUT instruction bus in the x86 architecture, unrelated to the much larger memory address bus.
Should new PCIe drivers use I/O port BARs at all?
Generally no. Modern PCIe devices are encouraged to expose only memory-mapped BARs; I/O space is retained mainly for backward compatibility with legacy operating systems and firmware.
What happens if I forget to call pci_release_region()?
The reserved I/O or memory range stays marked busy in the kernel’s resource tree, so a future driver load (or another driver) that needs the same range will fail to request it until the machine is rebooted.
Is this lecture part of a full free Linux device drivers course?
Yes — this is one lecture in EmbeddedPathashala’s ongoing free linux device drivers course covering the PCI subsystem from configuration space through BAR access and interrupt handling.
Continue the Free Linux Kernel Development Course
Next up: retrieving IRQ vectors with pci_irq_vector() and wiring up a full interrupt handler.
Next Lecture Browse All Lectures