src/memory.c
DMA memory: hugepages, virt_to_phys, and the packet-buffer mempool.
Walkthrough, interview notes & deep dive
Userspace DMA Management. The memory.c file provides the foundational memory orchestration for the ixy driver. In a standard kernel-space driver, the kernel handles the mapping of virtual addresses to physical bus addresses for the NIC. However, because ixy is a userspace driver, it must manage its own DMA-able memory. This involves allocating large chunks of memory that are guaranteed to stay in physical RAM (pinning), translating those virtual addresses into physical ones that the NIC hardware can understand, and organizing that memory into a high-performance pool of packet buffers.
Hugepages and TLB Efficiency. To achieve line-rate performance, ixy relies on hugepages, typically 2MB in size (defined by HUGE_PAGE_BITS 21). Standard 4KB pages are too small for high-speed networking; a single 10GbE burst could touch hundreds of 4KB pages, causing frequent Translation Lookaside Buffer (TLB) misses as the CPU constantly walks page tables. By using HUGE_PAGE_SIZE, ixy significantly reduces TLB pressure. Furthermore, a hugepage is guaranteed to be physically contiguous within its 2MB boundary, which simplifies DMA transfers for packets that might otherwise span multiple 4KB page boundaries.
void* virt_addr = (void*) check_err(mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS | MAP_HUGETLB | MAP_HUGE_2MB, -1, 0),
"mmap hugepage");
The virt_to_phys Translation. Since the NIC hardware has no access to the CPU's MMU or page tables, it operates entirely on physical addresses. The virt_to_phys function is critical: it translates a userspace pointer into a raw physical address by reading /proc/self/pagemap. It calculates the offset into the pagemap file based on the virtual address, reads the corresponding 64-bit entry to extract the Page Frame Number (PFN), and then combines that PFN with the original page offset to produce the final physical address.
int fd = check_err(open("/proc/self/pagemap", O_RDONLY), "getting pagemap");
check_err(lseek(fd, (uintptr_t) virt / pagesize * sizeof(uintptr_t), SEEK_SET),
"getting pagemap");
uintptr_t phy = 0;
check_err(read(fd, &phy, sizeof(phy)), "translating address");
// bits 0-54 are the page number (PFN)
return (phy & 0x7fffffffffffffULL) * pagesize + ((uintptr_t) virt) % pagesize;
Memory Allocation and Pinning. The memory_allocate_dma function is the primary entry point for acquiring DMA-capable memory. It uses mmap with the MAP_HUGETLB flag to request hugepages from the OS. Crucially, when not using VFIO, it calls mlock on the allocated region. This "pins" the memory, preventing the Linux kernel from swapping it to disk or moving it during a compaction cycle. If the physical address of a buffer changed while the NIC was mid-DMA, it would result in silent data corruption or a system crash. The function returns a struct dma_memory containing both the virtual address for CPU use and the physical address for NIC use.
The Packet Buffer Mempool. Rather than calling a slow, non-deterministic allocator like malloc for every incoming packet, ixy uses memory_allocate_mempool to create a pre-allocated pool of fixed-size buffers. This pool is a single large contiguous DMA allocation carved into pkt_buf entries. Each pkt_buf contains metadata (like the physical address and a pointer back to the mempool) followed by the actual data buffer. This layout ensures that the hardware can always find the physical target for any buffer in the pool using simple arithmetic.
Mempool Management via Free-stack. To keep track of which buffers are currently available for receiving new packets, the mempool maintains a simple stack of indices (mempool->free_stack). When the driver needs buffers to replenish an RX ring, it calls pkt_buf_alloc_batch, which pops indices off the stack and calculates the corresponding virtual addresses. When the application logic is finished processing a packet, it calls pkt_buf_free, which simply pushes the index back onto the stack. This O(1) operation is cache-friendly and avoids the overhead of complex memory management.
uint32_t pkt_buf_alloc_batch(struct mempool* mempool, struct pkt_buf* bufs[], uint32_t num_bufs) {
if (mempool->free_stack_top < num_bufs) {
num_bufs = mempool->free_stack_top;
}
for (uint32_t i = 0; i < num_bufs; i++) {
uint32_t entry_id = mempool->free_stack[--mempool->free_stack_top];
bufs[i] = (struct pkt_buf*) (((uint8_t*) mempool->base_addr) + entry_id * mempool->buf_size);
}
return num_bufs;
}
DMA Constraints and Hardware Safety. For a NIC to perform DMA, the target memory must be physically contiguous and pinned. If a packet buffer were to cross a page boundary where the next physical page is not adjacent, the NIC's DMA engine (which often just increments a physical address) would write into unrelated memory. Hugepages mitigate this by providing 2MB of guaranteed contiguity. Without mlock, the kernel could decide to swap a page out; when the NIC attempts to write to that physical address, it would be writing to a page that may have been reassigned to another process, causing a massive security and stability failure.
Interview angles
- Virtual vs Physical addresses: Why does the driver need both? The CPU uses virtual addresses to read/write packet data, but the NIC hardware lacks an MMU and requires physical (or IOVA) addresses to perform DMA transfers.
- Why use Hugepages? They reduce TLB misses, which is critical at 10GbE+ speeds, and they provide 2MB blocks of physically contiguous memory, reducing the risk of buffers spanning non-contiguous page boundaries.
- Why a free-stack instead of malloc? Allocation and deallocation must be extremely fast and deterministic. A stack of pre-allocated indices provides O(1) performance and avoids fragmentation, which is essential for the datapath.
Going deeper
uintptr_t phy = 0;
check_err(read(fd, &phy, sizeof(phy)), "translating address");
close(fd);
// bits 0-54 are the page number
return (phy & 0x7fffffffffffffULL) * pagesize + ((uintptr_t) virt) % pagesize;
The virt_to_phys logic relies on the Linux /proc/self/pagemap interface. Each 64-bit entry contains metadata in the high bits: bit 63 is "Page Present," bit 62 is "Page Swapped," and bits 0-54 represent the Page Frame Number (PFN). Masking with 0x7fffffffffffffULL is critical; failing to strip these flags would result in an invalid physical address being passed to the NIC's DMA engine, causing an IOMMU fault or a PCIe completion timeout.
uint32_t id = __sync_fetch_and_add(&huge_pg_id, 1);
char path[PATH_MAX];
snprintf(path, PATH_MAX, "/mnt/huge/ixy-%d-%d", getpid(), id);
int fd = check_err(open(path, O_CREAT | O_RDWR, S_IRWXU), "open hugetlbfs file");
// ... mmap ...
unlink(path);
To support legacy toolchains like GCC 4.8, the driver uses the __sync atomic built-in rather than C11 stdatomic.h. The sequence of open, mmap, and unlink is a classic Unix idiom for temporary shared memory. By unlinking immediately after the mapping is established, the driver ensures that the hugepages are automatically reclaimed by the kernel when the process terminates, even if it crashes, preventing "ghost" memory leaks on the hugetlbfs mount.
if ((VFIO_CONTAINER_FILE_DESCRIPTOR == -1) && HUGE_PAGE_SIZE % entry_size) {
error("entry size must be a divisor of the huge page size (%d)", HUGE_PAGE_SIZE);
}
In non-IOMMU mode, the NIC requires physical contiguity for each packet buffer. If a pkt_buf were allowed to span two 2MB hugepages, the hardware would attempt to DMA into a contiguous physical range, but the two pages might be discontiguous in RAM. By forcing entry_size to be a divisor of HUGE_PAGE_SIZE, the driver guarantees that no buffer ever straddles a page boundary, ensuring base_addr + index * size is always a valid physical offset.
Harder interview questions
- The `pkt_buf_free` function lacks any locking or atomics. How does this driver handle multi-threaded TX/RX? The current implementation is strictly single-threaded per mempool. In a senior-level design, you would either use a lockless ring buffer (SPSC) for the free-stack or per-core local caches to avoid contention on the global stack pointer while maintaining O(1) performance.
- Why is `VFIO_CONTAINER_FILE_DESCRIPTOR` marked `volatile`? Since this global is accessed across different compilation units (like
libixy-vfio.candmemory.c),volatileprevents the compiler from optimizing out checks or caching the value in a register. In a multi-device setup, one NIC's initialization might set this value, and subsequent NICs must see the update immediately to share the same IOMMU container. - What is the performance implication of the `virt_to_phys` call inside the `memory_allocate_mempool` loop? It triggers an
open,lseek, andreadsyscall for every single buffer. While this isO(N)at startup, it is a significant bottleneck for large mempools. A senior optimization would be to calculate the PFN once per 2MB page and use pointer arithmetic for all buffers residing within that same page. - What happens if the NIC attempts a DMA transfer and the memory is swapped to disk? The
mlockcall is vital here. DMA engines use physical addresses and have no knowledge of the OS page tables. If memory were swapped, the NIC would overwrite whatever data now occupies that physical frame, leading to silent memory corruption.mlock"pins" the pages in RAM. - Why does the VFIO path use `(uintptr_t) buf` as the physical address? When using VFIO with an IOMMU, the driver typically sets up an identity mapping where the IOVA (I/O Virtual Address) equals the Virtual Address. This simplifies the driver's bookkeeping because it no longer needs to translate addresses for the hardware; it just passes the pointer it's already using.
Gotchas
- Root Privileges: Reading
/proc/self/pagemaprequiresCAP_SYS_ADMINor root. The driver will fail silently or crash duringvirt_to_physif run as a standard user, even if hugepages are accessible. - Double Free:
pkt_buf_freedoes not check if a buffer is already in thefree_stack. Double-freeing a packet will corrupt thefree_stack_topindex and eventually lead to multiple descriptors pointing to the same memory. - Mount Point: The hugepage allocation hardcodes
/mnt/huge. If the system has hugepages reserved but mounted elsewhere (e.g.,/dev/hugepages), the driver will fail to initialize.
From ixy to a production driver
What changes in Linux ixgbe: an in-kernel Intel ixgbe driver never opens /proc/self/pagemap or hands raw physical addresses to the NIC. It asks the kernel DMA layer for device-visible addresses: descriptor rings come from coherent DMA memory (dma_alloc_coherent()), while packet payloads are mapped for streaming DMA with dma_map_single() / dma_map_page() and stored as dma_addr_t. Coherent memory suits rings both CPU and device update; streaming mappings may need dma_sync_single_for_cpu/_device() around ownership changes on non-coherent systems. The IOMMU and platform DMA ops own translation to IOVA/bus address, so the driver only records the returned dma_addr_t. Setup negotiates addressing via dma_set_mask_and_coherent(), and swiotlb bounce buffers cover devices that cannot reach a page. The 82599 datasheet still mandates 128-byte alignment for descriptor rings, but the DMA allocators satisfy that, not address guessing.
What changes in page and packet memory: kernel networking drivers allocate pages through the kernel allocator and increasingly use page_pool recycling for RX pages, then attach received data to sk_buff objects, often with build_skb(). There is no mlock() equivalent in the fast path because ordinary kernel pages are not pageable user memory. Recycling is integrated with NAPI, GRO, XDP, cache locality, and the sk_buff lifetime model; ixy deliberately replaces all of that with a fixed hugepage-backed carve-out and a tiny struct pkt_buf.
What changes in DPDK: DPDK keeps the userspace-driver model but industrializes the same ideas. EAL reserves hugepage memory, chooses/handles IOVA mode (IOVA as PA or IOVA as VA), and uses VFIO for IOMMU-protected DMA mapping. Packet buffers are rte_mbuf objects allocated from rte_mempool, with per-lcore mempool caches to reduce shared-ring contention; NIC PMDs move bursts with APIs such as rte_eth_rx_burst(). That is the production version of ixy's memory_allocate_mempool(), pkt_buf_alloc_batch(), and pkt_buf_free(): still O(1)-style buffer recycling, but NUMA-aware, multi-core aware, and integrated with mbuf metadata.
What ixy omits on purpose: memory.c is teaching code, not a general DMA subsystem. It has one global VFIO container fd, no NUMA placement, no per-core caches, no multi-producer/consumer safety on the free stack, no retry on transient mmap() failures, and a naive virt_to_phys() that opens/reads pagemap once per buffer. Modern kernels also restrict pagemap PFNs to privileged callers (root or CAP_SYS_ADMIN), another reason production stacks avoid this design. In an interview the point is not just "ixy is simpler" but why: kernel drivers never need raw physical addresses because DMA is expressed in IOVA space, NUMA locality changes latency and PCIe traffic, and a real multi-core packet pool needs lockless or cached allocation rather than one shared, unlocked stack.
Sources
Source
#include "memory.h"
#include "driver/device.h"
#include "log.h"
#include <fcntl.h>
#include <linux/limits.h>
#include <linux/mman.h>
#include <linux/vfio.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
#include "libixy-vfio.h"
// we want one VFIO Container for all NICs, so every NIC can read from every
// other NICs memory, especially the mempool. When not using the IOMMU / VFIO,
// this variable is unused.
volatile int VFIO_CONTAINER_FILE_DESCRIPTOR = -1;
// translate a virtual address to a physical one via /proc/self/pagemap
static uintptr_t virt_to_phys(void* virt) {
long pagesize = sysconf(_SC_PAGESIZE);
int fd = check_err(open("/proc/self/pagemap", O_RDONLY), "getting pagemap");
// pagemap is an array of pointers for each normal-sized page
check_err(lseek(fd, (uintptr_t) virt / pagesize * sizeof(uintptr_t), SEEK_SET), "getting pagemap");
uintptr_t phy = 0;
check_err(read(fd, &phy, sizeof(phy)), "translating address");
close(fd);
if (!phy) {
error("failed to translate virtual address %p to physical address", virt);
}
// bits 0-54 are the page number
return (phy & 0x7fffffffffffffULL) * pagesize + ((uintptr_t) virt) % pagesize;
}
static uint32_t huge_pg_id;
// allocate memory suitable for DMA access in huge pages
// this requires hugetlbfs to be mounted at /mnt/huge
// not using anonymous hugepages because hugetlbfs can give us multiple pages with contiguous virtual addresses
// allocating anonymous pages would require manual remapping which is more annoying than handling files
struct dma_memory memory_allocate_dma(size_t size, bool require_contiguous) {
if (VFIO_CONTAINER_FILE_DESCRIPTOR != -1) {
// VFIO == -1 means that there is no VFIO container set, i.e. VFIO / IOMMU is not activated
debug("allocating dma memory via VFIO");
void* virt_addr = (void*) check_err(mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS | MAP_HUGETLB | MAP_HUGE_2MB, -1, 0), "mmap hugepage");
// create IOMMU mapping
uint64_t iova = (uint64_t) vfio_map_dma(virt_addr, size);
return (struct dma_memory){
// for VFIO, this needs to point to the device view memory = IOVA!
.virt = virt_addr,
.phy = iova
};
} else {
debug("allocating dma memory via huge page");
// round up to multiples of 2 MB if necessary, this is the wasteful part
// this could be fixed by co-locating allocations on the same page until a request would be too large
// when fixing this: make sure to align on 128 byte boundaries (82599 dma requirement)
if (size % HUGE_PAGE_SIZE) {
size = ((size >> HUGE_PAGE_BITS) + 1) << HUGE_PAGE_BITS;
}
if (require_contiguous && size > HUGE_PAGE_SIZE) {
// this is the place to implement larger contiguous physical mappings if that's ever needed
error("could not map physically contiguous memory");
}
// unique filename, C11 stdatomic.h requires a too recent gcc, we want to support gcc 4.8
uint32_t id = __sync_fetch_and_add(&huge_pg_id, 1);
char path[PATH_MAX];
snprintf(path, PATH_MAX, "/mnt/huge/ixy-%d-%d", getpid(), id);
// temporary file, will be deleted to prevent leaks of persistent pages
int fd = check_err(open(path, O_CREAT | O_RDWR, S_IRWXU), "open hugetlbfs file, check that /mnt/huge is mounted");
check_err(ftruncate(fd, (off_t) size), "allocate huge page memory, check hugetlbfs configuration");
void* virt_addr = (void*) check_err(mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_HUGETLB, fd, 0), "mmap hugepage");
// never swap out DMA memory
check_err(mlock(virt_addr, size), "disable swap for DMA memory");
// don't keep it around in the hugetlbfs
close(fd);
unlink(path);
return (struct dma_memory) {
.virt = virt_addr,
.phy = virt_to_phys(virt_addr)
};
}
}
// allocate a memory pool from which DMA'able packet buffers can be allocated
// this is currently not yet thread-safe, i.e., a pool can only be used by one thread,
// this means a packet can only be sent/received by a single thread
// entry_size can be 0 to use the default
struct mempool* memory_allocate_mempool(uint32_t num_entries, uint32_t entry_size) {
entry_size = entry_size ? entry_size : 2048;
// require entries that neatly fit into the page size, this makes the memory pool much easier
// otherwise our base_addr + index * size formula would be wrong because we can't cross a page-boundary
if ((VFIO_CONTAINER_FILE_DESCRIPTOR == -1) && HUGE_PAGE_SIZE % entry_size) {
error("entry size must be a divisor of the huge page size (%d)", HUGE_PAGE_SIZE);
}
struct mempool* mempool = (struct mempool*) malloc(sizeof(struct mempool) + num_entries * sizeof(uint32_t));
struct dma_memory mem = memory_allocate_dma(num_entries * entry_size, false);
mempool->num_entries = num_entries;
mempool->buf_size = entry_size;
mempool->base_addr = mem.virt;
mempool->free_stack_top = num_entries;
for (uint32_t i = 0; i < num_entries; i++) {
mempool->free_stack[i] = i;
struct pkt_buf* buf = (struct pkt_buf*) (((uint8_t*) mempool->base_addr) + i * entry_size);
if (VFIO_CONTAINER_FILE_DESCRIPTOR != -1) {
// "physical" memory is iova address which is identity mapped to vaddr
buf->buf_addr_phy = (uintptr_t) buf;
} else {
// physical addresses are not contiguous within a pool, we need to get the mapping
// minor optimization opportunity: this only needs to be done once per page
buf->buf_addr_phy = virt_to_phys(buf);
}
buf->mempool_idx = i;
buf->mempool = mempool;
buf->size = 0;
}
return mempool;
}
uint32_t pkt_buf_alloc_batch(struct mempool* mempool, struct pkt_buf* bufs[], uint32_t num_bufs) {
if (mempool->free_stack_top < num_bufs) {
warn("memory pool %p only has %d free bufs, requested %d", mempool, mempool->free_stack_top, num_bufs);
num_bufs = mempool->free_stack_top;
}
for (uint32_t i = 0; i < num_bufs; i++) {
uint32_t entry_id = mempool->free_stack[--mempool->free_stack_top];
bufs[i] = (struct pkt_buf*) (((uint8_t*) mempool->base_addr) + entry_id * mempool->buf_size);
}
return num_bufs;
}
struct pkt_buf* pkt_buf_alloc(struct mempool* mempool) {
struct pkt_buf* buf = NULL;
pkt_buf_alloc_batch(mempool, &buf, 1);
return buf;
}
void pkt_buf_free(struct pkt_buf* buf) {
struct mempool* mempool = buf->mempool;
mempool->free_stack[mempool->free_stack_top++] = buf->mempool_idx;
}
// reads the global VFIO container
int get_vfio_container() {
return VFIO_CONTAINER_FILE_DESCRIPTOR;
}
// globally sets the VFIO container and returns the set value
void set_vfio_container(int fd) {
VFIO_CONTAINER_FILE_DESCRIPTOR = fd;
}