Computer Architecture · Accelerators
Device DMA and the IOMMU Boundary
What a device must be given before it can write to host memory, how an IOMMU turns a device address into a machine address, and what that translation costs.
A device that writes to memory bypasses the processor entirely. It issues a transaction on the interconnect carrying an address, and the memory system services it. Nothing in that path consults the page tables the processor uses, checks whether the address belongs to the process that programmed the device, or asks whether the device is permitted to write there at all. A bus-mastering device with an unchecked address range can overwrite any byte in the machine, including the kernel’s own code. The IOMMU exists to close that hole, and the price of closing it is a second address translation on the device’s path.
The problem with raw physical addresses
The simplest driver model is to hand the device a physical address. The driver allocates a buffer, translates its virtual address to a physical one, programs the address into a descriptor, and the device writes there. This works, and for a long time it was how devices were driven. It fails in four distinct ways.
The buffer must be physically contiguous for a device that writes a linear stream, but physical memory is fragmented and a multi-page allocation of any size will not be contiguous. Drivers work around this with scatter-gather lists: the descriptor names several discontiguous fragments. That works for the device, and it does nothing about the protection problem.
The address is a machine address, so the driver must know it, which means the driver’s view of memory is a physical map rather than a virtual one. On a 32-bit device, an address above the 4 GiB boundary cannot be expressed at all, and the driver must either bounce data through a low buffer or rely on platform features that remap the window. Bounce buffers double the memory traffic for the transfer.
The buffer’s lifetime is not enforced. Nothing stops a driver from freeing the pages while the device still holds the address, and nothing stops a buggy or malicious device from writing outside the range it was given.
And in a virtualized system, a guest driver cannot be trusted with machine addresses at all, because a guest that knows a machine address can write to the host’s memory.
What translation buys
An IOMMU sits between the device’s transaction and the memory controller. It intercepts every DMA request, translates the device-supplied address using a page table that the operating system maintains for that device, checks permissions, and forwards the translated request. The device’s address is an I/O virtual address, and it is private to the device — or to a group of devices — rather than being a machine address.
This changes the driver’s contract in a way that is easy to underestimate. The driver now maps buffers into a device address space with an explicit mapping call, and it can map a set of discontiguous pages into a contiguous device address range. A device that requires contiguous DMA addresses can be given them even though the underlying pages are scattered, because the translation is per-page and the device never sees the fragmentation. Bounce buffers disappear. Address limitations of the device disappear, because the device address space is sized by the IOMMU rather than by the device’s address pins. And a device can be given only the mappings it needs, so an out-of-range write faults instead of corrupting memory.
The unit of protection is the domain, sometimes called an address space or a protection domain. Each domain has its own page table and its own set of mappings. A device is attached to exactly one domain at a time, and devices in the same domain can read each other’s mappings — which is why devices that can perform peer-to-peer transfers are usually placed in the same domain, and why grouping devices correctly is a security decision rather than a convenience.
#include <linux/dma-mapping.h>
#include <linux/slab.h>
/* The lifetime a driver must respect, expressed with the generic DMA API. */
struct mapped_buf {
void *cpu_addr; /* what the driver reads through */
dma_addr_t dev_addr; /* what the device is told */
size_t len;
};
int map_buffer(struct device *dev, struct mapped_buf *b, size_t len)
{
b->cpu_addr = kmalloc(len, GFP_KERNEL);
if (!b->cpu_addr)
return -1;
b->len = len;
/* the IOMMU makes the pages look linear to the device */
b->dev_addr = dma_map_single(dev, b->cpu_addr, len, DMA_FROM_DEVICE);
if (dma_mapping_error(dev, b->dev_addr)) {
kfree(b->cpu_addr);
return -1;
}
return 0;
}
void unmap_buffer(struct device *dev, struct mapped_buf *b)
{
/* the device must have stopped using dev_addr before this point */
dma_unmap_single(dev, b->dev_addr, b->len, DMA_FROM_DEVICE);
kfree(b->cpu_addr);
}
The ordering requirement in the comment is the whole difficulty of DMA programming. The driver must know the device has finished with an address before unmapping it, and the only reliable evidence of that is a completion the device has published. An interrupt, a completion-queue entry, or a status bit that the device sets after its last write to the buffer all qualify. A delay, a poll of an unrelated register, or an assumption about how long a transfer takes do not.
The cost of the second translation
An IOMMU lookup is a page-table walk performed on the device’s behalf, and it has the same structure as a processor walk: a multi-level radix tree, several dependent memory reads, and a cache to make it affordable. The cache is usually called an IOTLB, and its behaviour determines the cost.
A hit in the IOTLB adds little to a transaction. A miss costs a walk of several levels, each of which is a memory access, and the miss also occupies the IOMMU’s walker, of which there are a limited number. Devices that issue many small transactions with poor locality — a descriptor fetch per request, a completion write per request — generate walk traffic disproportionate to the data they move. The mitigation is the same as on the processor side: larger pages. A device mapping backed by 2 MiB pages needs one leaf entry per 2 MiB instead of one per 4 KiB, which cuts the number of entries by a factor of 512 and the walk depth for a given buffer size correspondingly.
Two design details reduce the cost further. The first is that translations can be cached at the device itself, through an address translation service in which the device requests a translation and receives a translated address plus a permission tag it attaches to later requests. This is a device-side cache of translations, and it needs an invalidation protocol, because the operating system can change a mapping at any time and the device’s cached copy must be revoked before the change takes effect.
The second is that the IOMMU can be bypassed for ranges that are known to be identity-mapped. Some implementations allow a device to be placed in an untranslated mode, in which case protection is whatever the interconnect provides, which is usually nothing. Turning translation off is a performance decision that trades the entire protection argument for the walk cost, and it is only defensible for a device that is trusted and whose driver has been audited.
Invalidation, and why it is the hard part
Changing a mapping requires more than writing a new page-table entry. Any cached translation for the old entry must be invalidated, and the invalidation must complete before the old mapping can be reused. The sequence is: stop the device from issuing new requests to the range, invalidate the device’s cached translations, wait for the invalidation to be acknowledged, then free the pages.
The wait is the expensive part. An invalidation is broadcast to the caches that might hold the entry, and the acknowledgement arrives after every one of them has responded. On a large system with many devices and many caches, this is a round trip whose latency is measurable in microseconds rather than cycles. Workloads that map and unmap per I/O operation therefore pay far more than workloads that establish long-lived mappings and reuse them, which is one reason high-performance drivers map their entire buffer pool once at initialization and never unmap it.
There is a second invalidation hazard specific to devices that cache translations themselves: the device may have already translated an address and be about to issue a request with the old value. The protocol must ensure that a device’s in-flight requests are accounted for before the invalidation is declared complete, which typically means draining the device’s queues or using a fence that orders the invalidation against requests already accepted.
Where the boundary is drawn
The IOMMU is not a substitute for a correct driver. It bounds the damage: a device that writes outside its mappings faults, and the fault is reported with the offending address and the device that produced it. It does not prevent a device from writing to a wrong but valid mapping, and it does not prevent a driver from unmapping a buffer the device is still using, because the unmap is a legitimate operation that the IOMMU will honour. The protection is against wild addresses, not against wrong-but-mapped ones.
The fault itself is reported to the driver or the platform as an unrecoverable DMA error. On some systems it is fatal to the device; on others the offending transaction is dropped and the device continues. Either way the data the device was supposed to write is lost, and a driver that does not check for this will silently produce a truncated result.
The relationship between this machinery and the descriptor protocol in Descriptor Rings and the Producer-Consumer Contract is direct: the addresses in a descriptor are device addresses, valid only while the mapping that created them exists. And the mapping lifetime is bounded by the same completion that bounds the descriptor’s ownership, which is why the two mechanisms are usually designed together rather than separately. Devices that share address spaces with a process, rather than owning a private one, push the same questions further; that is the subject of Shared Virtual Addressing and Peer-to-Peer Transfers.