Skip to content

Networking (NIC Driver Model)

The Drivers category already covers storage, input, and video; a network interface controller (NIC) is a different kind of device from all three, but the drivers behind virtio-net, e1000, and most other NICs converge on the same general shape: a pair of descriptor rings, one for transmission and one for reception, that a driver fills and drains rather than issuing individual per-packet commands the way ATA PIO issues one command per sector.

A TX ring is an array of descriptors, each one pointing at a buffer of packet data the driver has already prepared, that the driver hands to the controller by advancing a producer index; the controller works through the ring independently, transmitting each packet in turn and marking its descriptor done once the data has actually gone out over the wire.

struct nic_tx_desc {
uint64_t buffer_addr; // physical address of packet data
uint16_t length;
uint8_t status; // set by the NIC once transmitted
uint8_t cmd; // set by the driver: end-of-packet, etc.
};
void nic_transmit(struct nic *dev, void *packet, size_t len) {
struct nic_tx_desc *desc = &dev->tx_ring[dev->tx_tail];
desc->buffer_addr = virt_to_phys(packet);
desc->length = len;
desc->cmd = TX_CMD_EOP;
dev->tx_tail = (dev->tx_tail + 1) % TX_RING_SIZE;
mmio_write32(dev->regs + TDT_OFFSET, dev->tx_tail); // notify the NIC
}

An RX ring works in the opposite direction: the driver pre-fills it with empty, ready-to-receive buffers before any traffic arrives at all, and the controller fills one in as each packet comes in over the wire, generating an interrupt to tell the driver a buffer now holds real data rather than the driver having to poll for arrivals.

Because the RX ring only has as many buffers as the driver put into it, an interrupt handler has to do two things for every received packet, not just one: hand the filled buffer’s contents off to whatever code processes incoming packets, and immediately replace that ring slot with a fresh, empty buffer before the controller wraps back around to it.

void nic_rx_interrupt(struct nic *dev) {
while (dev->rx_ring[dev->rx_head].status & RX_STATUS_DONE) {
struct nic_rx_desc *desc = &dev->rx_ring[dev->rx_head];
process_packet(phys_to_virt(desc->buffer_addr), desc->length);
desc->buffer_addr = virt_to_phys(alloc_packet_buffer()); // replace it
desc->status = 0;
dev->rx_head = (dev->rx_head + 1) % RX_RING_SIZE;
}
}

A driver that forgets the replacement step, or replaces a buffer too slowly, runs the ring dry: once every RX descriptor is marked done and none are refilled, the controller has nowhere left to write the next incoming packet and has to drop it, a real and common source of packet loss under load that has nothing to do with the network itself and everything to do with how quickly the driver’s own interrupt handler keeps the ring supplied with fresh buffers.

VirtIO already covers the virtqueue structure virtio-net’s TX and RX rings are actually built from, and a driver targeting it inherits every advantage that generic design already provides over emulating a specific piece of physical hardware faithfully: no vendor-specific register quirks, no legacy compatibility modes carried forward from earlier chip generations, and a descriptor format designed from the outset to be simple for both guest and host to implement correctly. This is exactly why virtio-net is commonly the first network driver a hobby kernel writes: it’s the network device every common hypervisor already exposes to a virtual machine by default, and its driver is a comparatively thin layer over virtqueue mechanics VirtIO already covers in full, rather than a from-scratch reverse-engineering effort against a real chip’s register interface.

This article stops at the driver boundary deliberately: everything above it, Ethernet framing, ARP, IP, TCP/UDP, and the rest of a networking stack that interprets what a received buffer’s bytes actually mean, is a separate, considerably larger topic of its own, out of scope here. A NIC is discovered through PCI the same way any other PCI device is, with its BAR mapped either as MMIO registers (the common case for a modern controller) or occasionally I/O ports for an older or simpler device, before ring setup can even begin; a driver has to configure the physical (or virtual) location of both rings, their sizes, and enable the controller’s transmit and receive logic through its own device-specific initialization sequence before any packet can be sent or received at all.

  1. ^ Intel, PCIe GbE Controllers Open Source Software Developer’s Manual (the e1000 family): a representative real-hardware descriptor-ring interface documented in full.
  • PCI: how a NIC is discovered and its registers mapped before ring setup can begin.
  • VirtIO: the virtqueue structure virtio-net’s own TX/RX rings are built directly on.
  • ATA/IDE in PIO Mode: a contrasting driver model built around per-command polling rather than descriptor rings.