Article Community improvements enabled

Building an Intel Dual-Band Wi-Fi Driver: Firmware, DMA, and Rings

Daniel McCarthy published 1 month ago 14 min read 395 views
New post
Daniel McCarthy remains the original author. Improvements are attributed to their editors and reviewed by the original author before publication.

Writing a driver for an Intel dual-band wireless adapter is very different from bringing up a simple Ethernet controller. The host does not directly implement every radio operation. Instead, the kernel driver prepares the PCIe device, builds DMA queues, uploads firmware, and exchanges commands and notifications with that firmware.

This architecture appears in adapters such as the 3160, 3165, 3168, 7260, 7265, 8260, and 8265. Exact capabilities and initialization details vary by generation, so a production driver needs per-device configuration. The overall engineering model, however, is consistent enough to study as one system.

The central idea is:

network stack
     |
     v
wireless policy and 802.11 state
     |
     v
firmware command / notification layer
     |
     v
PCIe transport, DMA rings, interrupts
     |
     v
wireless controller

Keeping these layers separate is the difference between a driver that can be debugged and a driver that becomes one enormous initialization function.

First understand the device split

An Intel wireless package may contain both Wi-Fi and Bluetooth functions, but they are not necessarily controlled through the same transport. The Wi-Fi side is commonly exposed as a PCIe function, while Bluetooth may appear through USB. A Wi-Fi driver should not assume that loading Wi-Fi firmware also completes Bluetooth device setup.

Within the Wi-Fi function, responsibility is divided again:

  • The host owns PCI setup, memory mapping, DMA allocation, interrupts, and integration with the kernel's networking stack.
  • Device firmware owns a large part of radio control, calibration, scanning, scheduling, and protocol-specific behavior.
  • Nonvolatile device data describes antennas, regulatory information, supported bands, and other board-specific properties.

The driver is therefore a transport and state coordinator as much as it is a network-interface driver.

Build a device profile before touching hardware

Do not scatter checks for individual PCI IDs throughout the code. Resolve the device ID once and attach a profile:

struct wifi_device_profile {
    uint16_t pci_device_id;
    unsigned generation;
    const char *firmware_name;
    unsigned minimum_fw_api;
    unsigned maximum_fw_api;
    uint32_t feature_flags;
    uint32_t dma_address_bits;
};

The profile should answer questions such as:

  • Is this a 7000-series or 8000-series transport?
  • Which firmware image and API range are acceptable?
  • Does the device use one or two embedded CPUs?
  • Which scan command format is supported?
  • Is firmware paging required?
  • How many queues and stations are available?
  • Which DMA address width can descriptors represent?

Reject unknown hardware instead of guessing. Two adapters with similar marketing names can have different transport or firmware requirements.

PCI setup and the control register window

The PCI probe routine should perform the ordinary bus work before running any wireless-specific sequence:

  1. Confirm the vendor and device ID.
  2. Enable memory-space access and bus mastering.
  3. Map the memory BAR containing the controller registers.
  4. Establish the DMA mask supported by the device and operating system.
  5. Allocate an interrupt vector, preferably MSI or MSI-X when supported.
  6. Leave device interrupts masked until all software state is ready.

A small set of controller registers is especially useful during early bring-up:

Offset Register role
0x000 Hardware-interface preparation and readiness
0x008 Main interrupt status and acknowledgement
0x00c Main interrupt mask
0x010 Flow-handler interrupt status
0x020 Device reset control
0x024 General control and hardware radio-kill state
0x028 Hardware revision
0x088 Host-to-device mailbox state
0x0a8 MAC shadow-register control

Wrap register access rather than spreading volatile pointer operations across the driver:

static inline uint32_t csr_read(struct wifi_dev *dev, size_t offset)
{
    return mmio_read32(dev->csr_base + offset);
}

static inline void csr_write(struct wifi_dev *dev,
                             size_t offset,
                             uint32_t value)
{
    mmio_write32(dev->csr_base + offset, value);
}

The real wrappers may need bus barriers or a readback to flush posted writes. Make that policy part of the wrapper so every caller receives the same ordering guarantees.

Treat initialization as a state machine

Wireless initialization contains resets, asynchronous firmware notifications, and several failure points. Represent it as explicit states:

PCI_READY
   -> HW_PREPARED
   -> DMA_READY
   -> FW_UPLOADING
   -> FW_ALIVE
   -> NVM_READY
   -> PHY_READY
   -> RUNTIME_READY

Each transition should have a timeout and a cleanup path. A timeout message should identify the state, the event being awaited, and relevant register values. "Device failed" is not actionable; "timed out waiting for firmware alive after upload completion" is.

The broad sequence is:

  1. Request controller ownership and wait for the ready indication.
  2. Notify the controller that the host driver is alive.
  3. Allocate and initialize all DMA-visible structures.
  4. Reset and start the transport while preserving radio-kill reporting.
  5. Parse and upload the initialization firmware.
  6. Wait for the firmware-alive notification.
  7. Read nonvolatile configuration and run PHY calibration.
  8. Load or enter the runtime firmware environment.
  9. Configure queues, channel information, and the wireless stack interface.

The order matters. Do not enable a DMA engine until every descriptor it can fetch is valid.

Preparing the controller

The first hardware-specific transition asks the controller to prepare for driver ownership. Set the appropriate prepare bit, then poll the ready indication with a bounded deadline. Avoid an infinite loop even if the hardware documentation suggests readiness is guaranteed.

After readiness, set the mailbox bit that tells the device the host is operational. A typical helper looks conceptually like this:

int wait_for_bits(struct wifi_dev *dev,
                  size_t reg,
                  uint32_t mask,
                  uint32_t expected,
                  uint64_t timeout_us)
{
    uint64_t deadline = monotonic_us() + timeout_us;

    do {
        if ((csr_read(dev, reg) & mask) == expected)
            return 0;
        cpu_relax();
    } while (monotonic_us() < deadline);

    return -ETIMEDOUT;
}

In real code, handle timer wraparound and use the kernel's standard polling primitive if one exists.

DMA memory is a contract, not ordinary allocation

The transport needs several DMA-visible regions. Older members of this family commonly require structures such as:

  • a 128 KiB firmware-transfer staging buffer aligned to at least 16 bytes;
  • scheduler data with a 1024-byte alignment requirement;
  • a 4 KiB keep-warm page aligned to its size;
  • transmit and receive rings aligned to 256 bytes;
  • a small receive-status area with at least 16-byte alignment;
  • one or more packet buffers referenced by ring descriptors.

Use the operating system's DMA API rather than converting a virtual pointer into a presumed physical address. The DMA API must establish:

  • an address the device can represent;
  • required physical contiguity or scatter/gather mappings;
  • cache coherency or explicit synchronization;
  • lifetime pinning while the device owns the buffer;
  • IOMMU mappings when an IOMMU is active.

Track both CPU and device addresses:

struct dma_region {
    void *cpu_address;
    uint64_t device_address;
    size_t size;
    size_t alignment;
};

These addresses are not interchangeable. The CPU initializes descriptors through cpu_address; hardware receives device_address.

For every ring, define an ownership rule. The host must finish writing a descriptor and execute the required memory barrier before advancing the producer index. It must not reuse that slot until the device has reported completion.

Reset and interrupt discipline

A software reset returns transport state to a known point. After asserting reset, honor the required delay before continuing. Then clear pending interrupt status before unmasking anything.

Early in startup, enable only the events required to progress, particularly radio-kill changes and firmware-transfer completion. Enabling all interrupt sources before queues exist creates races with uninitialized software state.

The interrupt handler should remain small:

  1. Read interrupt status.
  2. Mask or acknowledge the asserted sources correctly.
  3. Record fatal transport or firmware errors immediately.
  4. Schedule deferred receive and completion processing.
  5. Restore the intended interrupt mask.

Do not parse large firmware messages or deliver packets to the network stack directly from the hard-interrupt path.

Parse firmware as untrusted input

Firmware files for this device family use a header followed by typed records. A record contains a type, a length, and a payload. Records can describe instruction sections, data sections, capabilities, supported API changes, CPU layout, paging requirements, and debugging information.

Even though firmware is installed by the system, the kernel parser must defend itself from a truncated or incompatible file. A safe parsing loop has this shape:

while (remaining >= sizeof(struct fw_tlv_header)) {
    const struct fw_tlv_header *tlv = (const void *)cursor;
    uint32_t type = little_endian_u32(tlv->type);
    uint32_t length = little_endian_u32(tlv->length);

    cursor += sizeof(*tlv);
    remaining -= sizeof(*tlv);

    if (length > remaining)
        return -EBADMSG;

    int error = consume_firmware_record(image, type, cursor, length);
    if (error)
        return error;

    size_t padded = align_up_checked(length, 4);
    if (padded > remaining)
        return -EBADMSG;

    cursor += padded;
    remaining -= padded;
}

if (remaining != 0)
    return -EBADMSG;

Validate the file signature, firmware API range, section count, section sizes, destination addresses, and arithmetic used to compute aligned lengths. Unknown optional record types can usually be skipped; missing mandatory sections must fail the load.

Keep parsing separate from uploading. The parser should produce a validated in-memory description of initialization, runtime, and paging sections. The transport should never interpret raw file offsets while programming DMA.

Uploading firmware

Firmware is transferred through a dedicated DMA path rather than copied directly into a normal MMIO window. Large sections are divided into chunks no larger than the staging buffer, commonly 128 KiB.

For each chunk:

  1. Copy the validated bytes into the staging region.
  2. Synchronize the buffer for device access if required.
  3. Program the destination offset, device address, and length.
  4. Start the transfer engine.
  5. Wait for its completion event with a timeout.
  6. Acknowledge completion before reusing the staging buffer.

Some firmware images contain separators between embedded CPUs or between ordinary and paging sections. The parser should turn these markers into structured section groups. Never submit a separator as if it were device code.

The release-from-reset sequence differs between hardware generations. Hide this behind transport operations:

struct transport_ops {
    int (*prepare)(struct wifi_dev *dev);
    int (*configure_queues)(struct wifi_dev *dev);
    int (*upload_firmware)(struct wifi_dev *dev,
                           const struct fw_image *image);
    int (*start_embedded_cpus)(struct wifi_dev *dev);
};

This keeps the firmware and wireless-policy layers free from generation-specific register choreography.

The alive notification is a synchronization point

Releasing the embedded CPU does not mean firmware is ready. The driver must wait for an alive notification delivered through the receive path. That creates an important dependency: enough receive transport must already work to receive firmware events before ordinary networking can begin.

When the alive message arrives, validate its length and status before trusting any addresses or capability fields it contains. Then:

  • record that firmware is running;
  • initialize runtime scheduler information;
  • reset command sequence tracking;
  • configure firmware paging if the image requests it;
  • allow the next initialization state to proceed.

If the firmware reports a fatal status, preserve its diagnostic information before resetting the device. Firmware error tables and transport registers are often the only clues available.

Reading device configuration and calibrating the radio

The nonvolatile data is not decoration. It determines what the particular board is legally and physically able to do. Parse it into a stable driver-owned structure containing:

  • valid transmit and receive antenna masks;
  • supported 2.4 GHz and 5 GHz channels;
  • disabled or passive-only channels;
  • radio SKU and band capabilities;
  • calibration and board-specific parameters;
  • the hardware MAC address, when supplied there.

Do not expose every channel merely because the radio can tune to it. The channel map must combine device data, firmware capabilities, and the operating system's regulatory rules.

After loading configuration, send the firmware commands for antenna selection, coexistence policy where applicable, and PHY configuration. Calibration completion arrives asynchronously. Only transition to runtime-ready after the expected notifications have been received.

Commands, responses, and notifications

Firmware communication is message-oriented. The host places a command in a transmit queue, and the firmware returns a response or later notification through the receive path.

Design two related mechanisms:

  1. Pending commands are matched to responses by queue and sequence/index information.
  2. Notifications are dispatched by message group and opcode to registered handlers.

A pending command record can contain:

struct pending_command {
    bool in_use;
    uint16_t sequence;
    void *response_buffer;
    size_t response_capacity;
    completion_t completed;
    int status;
};

Never wait for a completion while holding the same lock the receive path needs to signal it. Every synchronous command needs a deadline. On timeout, log the queue indexes, interrupt state, and firmware state before beginning recovery.

Receive-ring mechanics

The receive ring is an array of device-visible addresses pointing to packet buffers. Supported ring sizes are typically powers of two, which allows wrapping with a mask:

next = (current + 1) & (ring_size - 1);

Before giving a slot to hardware:

  1. Allocate or recycle a suitably sized receive buffer.
  2. Map it for DMA from the device.
  3. Write its device address into the descriptor.
  4. Synchronize the descriptor and buffer as required.
  5. Advance the posted-buffer index only after a write barrier.

The controller writes both received 802.11 data and firmware responses into this path. Deferred receive processing must validate each message length before reading its header, classify the message, and then either dispatch a firmware event or pass a frame upward with channel, signal-strength, rate, and error metadata.

Replenish consumed buffers promptly. If the driver advances the hardware tail beyond descriptors that contain valid buffers, the controller can DMA into an invalid address.

Transmit queues and scheduler accounting

Transmit support combines a command buffer, one or more DMA descriptors, and firmware scheduler bookkeeping. Each active transmit queue commonly contains a power-of-two set of slots, often 256. The hardware may support dozens of logical queues, but a driver should create only the queues it understands and needs.

For a data frame:

  1. Select the logical queue based on traffic class and station.
  2. Reserve a ring slot without overwriting an in-flight entry.
  3. Build the firmware transmit command.
  4. Map the frame fragments for DMA.
  5. Fill the transfer descriptor with checked address and length fields.
  6. Update scheduler byte-count information.
  7. Execute a write barrier.
  8. Ring the device doorbell by advancing the producer pointer.

Keep a software record for every slot so completion can unmap DMA, release the packet, update statistics, and wake a stopped network queue.

Command headers exist in more than one format. Select the legacy or wider group-aware header from negotiated firmware capabilities rather than assuming one layout for every opcode.

Scanning is a firmware transaction

Once initialization is complete, scanning is one of the best first end-to-end features to implement. Build the channel list from validated regulatory data, then choose the scan-command version reported by firmware capabilities.

A scan request includes more than a list of channels. It may contain:

  • active and passive dwell times;
  • directed SSIDs;
  • probe-request content;
  • band and antenna selection;
  • per-channel flags;
  • random-address policy;
  • completion and abort behavior.

Mark the scan active before submitting the command so an immediate completion notification cannot race ahead of software state. On completion or abort, clear the state exactly once and notify the wireless stack outside the transport lock.

Respect the hardware radio-kill state

The general control register exposes the physical radio-kill switch. A cleared permission bit means radio transmission is blocked; a set bit means the switch permits radio operation.

Radio kill is not a generic initialization failure. Keep its interrupt enabled, record the current state, and prevent commands that would activate the transmitter. When the state changes, notify the networking layer and resume only the transitions that are safe to repeat.

The state can change during firmware startup, so check it at defined boundaries rather than only once during probe.

A practical bring-up sequence

Trying to associate with an access point on the first test run makes failures almost impossible to localize. Advance through observable milestones:

Milestone 1: register access

  • Read a stable hardware revision.
  • Confirm MMIO writes with safe readback operations.
  • Perform a reset and observe expected state changes.

Milestone 2: interrupts

  • Trigger or observe the radio-kill interrupt.
  • Verify acknowledgement does not cause an interrupt storm.
  • Record main and flow-handler status on every unexpected event.

Milestone 3: firmware upload

  • Parse the image without touching hardware.
  • Log validated sections and destinations.
  • Transfer one chunk at a time and verify completion.
  • Receive and validate the alive notification.

Milestone 4: configuration

  • Read nonvolatile data.
  • Print the antenna masks and allowed channel map.
  • Complete PHY initialization and calibration.

Milestone 5: command transport

  • Send a harmless query command.
  • Match the response to the correct pending entry.
  • Exercise timeouts and firmware-error recovery deliberately.

Milestone 6: receive and scan

  • Keep the receive ring full under sustained notifications.
  • Complete an active scan.
  • Report discovered networks without attempting association.

Milestone 7: data traffic

  • Add station context and transmit an unencrypted management frame.
  • Implement completion cleanup.
  • Add association, key programming, power management, and recovery incrementally.

Each milestone should survive repeated reset and reload cycles before the next one begins.

Debugging rules that save days

Firmware-driven devices fail asynchronously, so record context before resetting it away.

Useful diagnostics include:

  • current initialization state and last completed transition;
  • hardware revision and selected device profile;
  • main and flow-handler interrupt status;
  • radio-kill state;
  • firmware version and negotiated API;
  • command queue producer, consumer, and pending sequence values;
  • receive-ring posted and completed indexes;
  • the opcode and length of the last notifications;
  • DMA addresses, sizes, and alignments without dumping sensitive packet data;
  • firmware error-table contents when available.

Common causes of a silent device include a missing memory barrier, a CPU virtual address placed in a DMA descriptor, a descriptor address truncated to the wrong width, an incorrect ring wrap calculation, interrupts acknowledged in the wrong order, or a firmware event awaited before the receive path is operational.

Add fault injection early. Force firmware-transfer timeouts, malformed TLV lengths, ring exhaustion, command timeouts, and radio-kill transitions. Recovery code that is never tested will eventually run on a user's machine.

Keep the architecture layered

A maintainable driver has boundaries that follow responsibility:

  • PCIe transport: MMIO, DMA, interrupts, resets, rings.
  • Firmware loader: file validation, typed records, section selection.
  • Command engine: submission, sequence tracking, responses, notifications.
  • Device configuration: NVM data, antennas, channels, generation features.
  • Wireless layer: scan, station state, keys, rates, and network-stack callbacks.

The transport should not decide regulatory policy. The scan code should not program raw DMA registers. The interrupt handler should not contain association logic.

These Intel adapters are complex because the host and firmware jointly operate the device. Once that boundary is made explicit, the work becomes a sequence of smaller engineering problems: establish ownership, create valid DMA state, load verified firmware, wait for defined events, and move frames through rings without violating ownership. That is the foundation on which scanning, association, encryption, power management, and reliable recovery can be built.

Discussion 0

No comments yet. Start a thoughtful discussion.

Join the discussion

You need an account to contribute.

Sign in