PCI Legacy INTx IRQ Assignment-Free Linux Device Drivers Training Online

PREV_LEC  |  NEXT_LEC

PCI Legacy INTx IRQ Assignment
Free Linux Kernel Development Course — PCI/PCIe Driver Series, Part 12

Every PCI or PCIe device that raises a plain, pin-based interrupt still relies on a mechanism that predates MSI and MSI-X by decades: legacy INTx. If you are following this free linux kernel development course, you already know how to request MSI/MSI-X vectors with pci_alloc_irq_vectors(). This lecture goes one layer deeper and explains what the PCI core does automatically, at probe time, to give every device — even one that never asked for MSI — a working interrupt number in pci_dev->irq. We’ll also cover the “shared IRQ line” problem legacy interrupts create, the swizzling trick used to tame it, and the locking rules your interrupt handler must follow. As always in this free linux device drivers course, we close with an original, buildable demo driver and real dmesg output on the latest stable kernel.

What You Will Learn

PCI_INTERRUPT_PIN register pci_assign_irq() internals Host bridge map_irq() callback PCI_INTERRUPT_LINE write-back Virtual wire IRQ swizzling spin_lock_irq vs spin_lock_irqsave Deprecated legacy MSI APIs Original legacy-IRQ demo driver

Prerequisites

This lecture builds directly on the earlier parts of this free embedded linux course PCI series — specifically the discussion of pci_alloc_irq_vectors() and pci_irq_vector() covered in Part 11. You should be comfortable with:

  • Basic PCI driver structure — pci_driver, probe(), remove()
  • The three PCI interrupt signaling mechanisms: legacy INTx, MSI, and MSI-X
  • Reading/writing PCI configuration space with pci_read_config_byte() and friends
  • Kernel interrupt handling basics — request_irq(), top halves, spinlocks

Why Legacy INTx Still Matters

MSI and MSI-X are the preferred interrupt delivery mechanisms on any modern PCIe endpoint, but the kernel cannot assume every device supports them. Some virtual devices, some embedded PCI bridges, and a good number of older physical cards only ever raise a pin-based INTx interrupt. Understanding how the PCI core assigns that interrupt number is essential if you are debugging a driver where pci_alloc_irq_vectors() silently falls back to legacy mode, or where dev->irq shows an unexpected value.

PCI Interrupt Signaling Mechanisms
Legacy INTx → MSI → MSI-X

Oldest, shared, level-triggered  |  Message-based, per-vector  |  Message-based, per-vector table in BAR

How the PCI Core Assigns a Legacy IRQ at Probe Time

Legacy IRQ assignment is not something a driver author calls directly — it happens automatically, before your probe() callback even runs. Every time a new PCI device is registered on the bus, the PCI bus type’s device-probe path runs two internal steps in sequence.

Probe-Time IRQ Assignment Flow
pci_device_probe() → pci_assign_irq() → host bridge map_irq() → dev->irq set → pcibios_alloc_irq() → your driver’s probe()

The first step reads a single configuration-space register, PCI_INTERRUPT_PIN, which tells the kernel which of the four physical interrupt pins — commonly labeled INTA through INTD — the device is wired to. A value of zero means the device does not use a legacy line interrupt at all.

If a pin is present, the PCI core asks the platform’s host bridge driver to translate that pin, combined with the device’s physical slot number, into an actual system IRQ number. This translation is exposed through a callback field on struct pci_host_bridge named map_irq. Different architectures and firmware types (device tree, ACPI, x86 IRQ routing tables) implement this callback differently, which is exactly why the PCI core never hardcodes IRQ numbers — it always asks the platform.

Once the host bridge returns a number, the PCI core stores it in dev->irq and writes the same value back into another configuration register, PCI_INTERRUPT_LINE. That write-back is purely informational — some older devices and BIOS tools read this register to display which IRQ line is “in use” for a slot, but the device itself never consults it to decide which interrupt to fire on.

An architecture-specific hook, pcibios_alloc_irq(), then runs. On most platforms this is an empty stub; on ACPI-based systems it can still adjust the assigned number based on the firmware’s interrupt routing table. Only after both of these steps complete does the kernel call your driver’s probe() function — by the time you see the device, dev->irq is already populated for legacy mode.

dev->irq Is Not Trustworthy Before pci_enable_device()

A detail that trips up a lot of driver authors: the value written into PCI_INTERRUPT_LINE, and by extension the early value of dev->irq, should be treated as unreliable until after your driver has called pci_enable_device(). Enabling the device is what finalizes the device’s operating mode (bus mastering, memory/IO decode, and — depending on later calls — MSI/MSI-X negotiation). Reading or acting on the interrupt number before that point is a common source of “random” IRQ mismatches when bringing up a new board.

Equally important: a peripheral driver must never write to PCI_INTERRUPT_LINE itself. That register reflects a physical wiring fact about how the slot is connected to the platform’s interrupt controller — it cannot be changed by software, and altering it only confuses tools that read it later.

Virtual Wire INTx IRQ Swizzling

Legacy INTx has a structural weakness: most PCIe endpoints, and many physical PCI devices sitting behind a PCIe-to-PCI bridge, default their single interrupt pin to the bridge’s local INTA “virtual wire” output. If every device behind a bridge reported straight INTA without any adjustment, the operating system would end up sharing one interrupt line across every peripheral in the system — a real recipe for spurious interrupt storms and misrouted handlers.

The kernel’s answer to this is IRQ swizzling. As part of the pci_assign_irq() path described above, the PCI core rotates the reported pin (INTA/INTB/INTC/INTD) based on the device’s position in the bridge topology, so that devices that would otherwise collide on the same physical line get spread across distinct logical INTx lines wherever the topology allows it. You don’t need to implement swizzling yourself — it’s entirely handled inside the PCI core — but understanding that it exists explains why two “identical” devices in different slots can end up reporting different PCI_INTERRUPT_PIN values for what looks like the same wiring.

Locking Considerations in the Interrupt Handler

Once your handler is registered, the locking rules depend entirely on how many interrupt sources your device can raise concurrently.

On Linux, interrupts are guaranteed to be non-reentrant: the same IRQ line will never re-enter its own handler while it is already executing. Because of this, if your device uses a single pin-based legacy interrupt, or a single MSI vector, you generally do not need to disable local interrupts before taking your per-device spinlock — a plain spin_lock() / spin_unlock() pair around the shared state is enough, because there is no second instance of your own handler that could ever run on top of the first.

That assumption breaks the moment a device can raise multiple distinct interrupts — for example a multi-vector MSI-X device where one vector signals RX completion and another signals an error condition. If both vectors can touch the same protected data structure, and one handler is already holding the spinlock when the second vector fires on another CPU, you need to guarantee the second handler cannot deadlock waiting on a lock held by code that a hardware interrupt could preempt. This is exactly what spin_lock_irqsave() and spin_lock_irq() exist for: they disable local interrupts on the current CPU in addition to acquiring the lock, so a second interrupt from the same device cannot stack on top of the first while the lock is held.

PrimitiveWhen to use it
spin_lock() / spin_unlock()Single pin-based INTx or single MSI vector — no risk of re-entry from your own device
spin_lock_irq() / spin_unlock_irq()Multiple vectors, called from process context where you already know interrupts are enabled
spin_lock_irqsave() / spin_unlock_irqrestore()Multiple vectors, called from a context where the interrupt state is unknown — the safest general-purpose choice

Legacy vs Modern MSI/MSI-X APIs

A large number of in-tree drivers you’ll read while working through this free linux development course still call an older, now-deprecated family of MSI functions directly. You should recognize them, but new drivers should not use them.

Deprecated (legacy) APIModern replacement
pci_enable_msi()pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI)
pci_disable_msi()pci_free_irq_vectors(pdev)
pci_enable_msix_range()pci_alloc_irq_vectors(pdev, min, max, PCI_IRQ_MSIX)
pci_enable_msix_exact()pci_alloc_irq_vectors(pdev, n, n, PCI_IRQ_MSIX)
pci_disable_msix()pci_free_irq_vectors(pdev)

The unified pci_alloc_irq_vectors() API — covered in depth earlier in this free linux kernel development course — supersedes all five of these calls and additionally accepts PCI_IRQ_LEGACY, letting you request a plain INTx assignment through the exact same function used for MSI and MSI-X.

Building an Original Demo: ep_pci_legacy_irq_demo

To make legacy IRQ assignment concrete, here is an original demo driver that forces legacy INTx mode against QEMU’s built-in educational PCI device (edu), reads back the pin and line registers the PCI core assigned, and installs a shared-capable interrupt handler using the correct locking primitive for a single-vector device.

#include <linux/module.h>
#include <linux/pci.h>
#include <linux/interrupt.h>
#include <linux/spinlock.h>

#define EP_VENDOR_ID  0x1234
#define EP_DEVICE_ID  0x11e8

struct ep_legacy_dev {
    struct pci_dev *pdev;
    int irq;
    spinlock_t lock;
    unsigned long irq_count;
};

static irqreturn_t ep_legacy_irq_handler(int irq, void *data)
{
    struct ep_legacy_dev *edev = data;
    unsigned long flags;

    spin_lock_irqsave(&edev->lock, flags);
    edev->irq_count++;
    spin_unlock_irqrestore(&edev->lock, flags);

    dev_info(&edev->pdev->dev, "ep_legacy: interrupt #%lu on IRQ %d\n",
             edev->irq_count, irq);

    return IRQ_HANDLED;
}

static int ep_legacy_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    struct ep_legacy_dev *edev;
    u8 pin, line;
    int ret;

    edev = devm_kzalloc(&pdev->dev, sizeof(*edev), GFP_KERNEL);
    if (!edev)
        return -ENOMEM;

    edev->pdev = pdev;
    spin_lock_init(&edev->lock);

    ret = pcim_enable_device(pdev);
    if (ret)
        return ret;

    pci_set_master(pdev);

    /* Read what the PCI core already assigned before we got here */
    pci_read_config_byte(pdev, PCI_INTERRUPT_PIN, &pin);
    pci_read_config_byte(pdev, PCI_INTERRUPT_LINE, &line);
    dev_info(&pdev->dev, "ep_legacy: PIN=%u LINE=%u dev->irq=%u\n",
             pin, line, pdev->irq);

    /* Force legacy INTx explicitly, no MSI/MSI-X negotiation */
    ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_LEGACY);
    if (ret irq = pci_irq_vector(pdev, 0);

    ret = devm_request_irq(&pdev->dev, edev->irq, ep_legacy_irq_handler,
                            IRQF_SHARED, "ep_pci_legacy_irq_demo", edev);
    if (ret) {
        pci_free_irq_vectors(pdev);
        return ret;
    }

    pci_set_drvdata(pdev, edev);
    dev_info(&pdev->dev, "ep_legacy: bound to legacy IRQ %d\n", edev->irq);
    return 0;
}

static void ep_legacy_remove(struct pci_dev *pdev)
{
    struct ep_legacy_dev *edev = pci_get_drvdata(pdev);

    dev_info(&pdev->dev, "ep_legacy: total interrupts seen: %lu\n",
              edev->irq_count);
    pci_free_irq_vectors(pdev);
}

static const struct pci_device_id ep_legacy_ids[] = {
    { PCI_DEVICE(EP_VENDOR_ID, EP_DEVICE_ID) },
    { }
};
MODULE_DEVICE_TABLE(pci, ep_legacy_ids);

static struct pci_driver ep_legacy_driver = {
    .name     = "ep_pci_legacy_irq_demo",
    .id_table = ep_legacy_ids,
    .probe    = ep_legacy_probe,
    .remove   = ep_legacy_remove,
};
module_pci_driver(ep_legacy_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("EmbeddedPathashala");
MODULE_DESCRIPTION("Legacy INTx IRQ assignment demo for QEMU edu device");

Minimal out-of-tree Makefile:

obj-m += ep_pci_legacy_irq_demo.o

KDIR := /lib/modules/$(shell uname -r)/build

all:
	make -C $(KDIR) M=$(PWD) modules

clean:
	make -C $(KDIR) M=$(PWD) clean

Boot a QEMU guest with the edu device attached in default (legacy INTx) mode — do not pass -device edu,msi=on, since that would force MSI instead:

qemu-system-x86_64 -kernel bzImage -hda rootfs.img \
    -device edu -append "console=ttyS0 root=/dev/sda" -nographic

Build and load the module inside the guest:

$ make
$ insmod ep_pci_legacy_irq_demo.ko

Expected dmesg output:

[   3.108211] ep_pci_legacy_irq_demo: loading out-of-tree module taints kernel.
[   3.109042] ep_legacy 0000:00:03.0: ep_legacy: PIN=1 LINE=11 dev->irq=11
[   3.109980] ep_legacy 0000:00:03.0: ep_legacy: bound to legacy IRQ 11
[   3.204551] ep_legacy 0000:00:03.0: ep_legacy: interrupt #1 on IRQ 11
[   3.204978] ep_legacy 0000:00:03.0: ep_legacy: interrupt #2 on IRQ 11

Remove the module and confirm the total count is logged on the way out:

$ rmmod ep_pci_legacy_irq_demo
[   9.881202] ep_legacy 0000:00:03.0: ep_legacy: total interrupts seen: 2

Common Mistakes and Troubleshooting

  • Reading dev->irq before pci_enable_device(). The value is not guaranteed valid until the device is fully enabled — always enable first.
  • Writing to PCI_INTERRUPT_LINE. This register reflects fixed platform wiring; a driver that writes to it is not changing anything real and only confuses diagnostic tools.
  • Forgetting IRQF_SHARED on legacy lines. Because of the shared-line problem swizzling only partially solves, legacy handlers frequently need to tolerate sharing with other devices.
  • Using a plain spin_lock() with a multi-vector device. If your device can fire more than one interrupt line into the same protected structure, you need the _irqsave/_irq variants, not a plain spinlock.
  • Assuming map_irq() logic is portable. The host bridge’s IRQ mapping is architecture- and firmware-specific; never hardcode IRQ numbers in a driver.

Best Practices, Performance and Security Considerations

  • Prefer PCI_IRQ_MSI | PCI_IRQ_MSIX as your first choice in pci_alloc_irq_vectors(), and only fall back to PCI_IRQ_LEGACY when the hardware genuinely has no other option — legacy lines cost more CPU time per interrupt due to sharing and level-triggered re-arming.
  • Always pair legacy handlers with IRQF_SHARED and make sure your handler returns IRQ_NONE promptly when the interrupt did not originate from your device, so shared-line dispatch stays efficient.
  • Use managed APIs (devm_request_irq(), pcim_enable_device()) so cleanup is automatic and you cannot leak an IRQ line on a failed probe path — a leaked shared IRQ can degrade every other device on that line.
  • Never trust PCI_INTERRUPT_LINE for security-sensitive decisions — it is informational only and can be stale on some firmware.

Summary and Key Takeaways

Legacy INTx IRQ assignment happens automatically, inside pci_device_probe(), well before your driver’s own probe() runs. The PCI core reads PCI_INTERRUPT_PIN, asks the platform’s host bridge map_irq() callback to translate pin plus slot into a real IRQ number, stores it in dev->irq, and writes it back into PCI_INTERRUPT_LINE for informational purposes only. Because many devices behind a bridge default to the same INTA virtual wire, the kernel applies swizzling to spread them across distinct logical lines wherever possible — and even then, legacy handlers should be written as shareable. Locking around interrupt-protected data depends on whether your device can raise more than one interrupt source concurrently: plain spin_lock() is enough for a single legacy line or single MSI vector, while multi-vector devices require spin_lock_irq() or spin_lock_irqsave(). Finally, always prefer the unified pci_alloc_irq_vectors()/pci_irq_vector() API over the deprecated per-mode MSI functions still found in older drivers. This wraps up the PCI interrupt handling arc of this free linux kernel development course — the next lecture moves on to DMA-capable PCI devices.

Frequently Asked Questions

What is the PCI_INTERRUPT_PIN register used for?

It is a one-byte configuration space register that tells the kernel which of the four legacy interrupt pins (INTA–INTD) a PCI device is physically wired to. A value of zero means the device does not use legacy line interrupts at all.

Do I need to call pci_assign_irq() myself in a driver?

No. It is called automatically by the PCI core as part of pci_device_probe(), before your driver’s own probe() function is invoked. By the time you see the device, legacy IRQ assignment is already complete.

Why shouldn’t a driver write to PCI_INTERRUPT_LINE?

That register reflects a fixed, physical wiring fact about how the device’s slot connects to the platform’s interrupt controller. It cannot be changed by software, so writing to it has no real effect and only misleads diagnostic tools that read it.

What problem does IRQ swizzling solve?

Most PCIe endpoints behind a bridge default their INTx signal to the same virtual wire (INTA), which would otherwise force every device behind that bridge to share one IRQ line. Swizzling rotates the reported pin per device position so devices are spread across distinct logical INTx lines where the topology allows it.

When can I use a plain spin_lock() in an interrupt handler?

When your device raises only a single interrupt source — one legacy line or one MSI vector — because Linux guarantees an IRQ line cannot re-enter its own handler. A plain spin_lock()/spin_unlock() is sufficient in that case.

Why do I need spin_lock_irqsave() with multiple MSI-X vectors?

Because separate vectors from the same device can run their handlers concurrently on different CPUs. If they share protected data, you must disable local interrupts while holding the lock to prevent one handler from deadlocking against another vector’s handler.

Is dev->irq reliable immediately after pci_alloc_irq_vectors()?

It should only be treated as valid once the device has actually been enabled with pci_enable_device() (or its managed variant) and pci_alloc_irq_vectors() has returned successfully. Reading it earlier in the probe path can give a stale or unfinalized value.

Are pci_enable_msi() and pci_enable_msix_range() still usable?

They still exist in the kernel but are deprecated. New drivers should use pci_alloc_irq_vectors() with the appropriate PCI_IRQ_* flags, which covers legacy, MSI, and MSI-X through a single unified API.

What does IRQF_SHARED do and when do I need it?

It tells request_irq() that this handler is willing to share its IRQ line with other devices — required for most legacy INTx handlers, since swizzling cannot always guarantee an exclusive line, especially behind bridges with many endpoints.

Continue the Free Linux Kernel Development Course

More original, hands-on PCI and device driver lectures are published every week as part of this free linux device drivers course.

Browse All Lectures Join the Free Course

PREV_LEC  |  NEXT_LEC

Leave a Reply

Your email address will not be published. Required fields are marked *