This is the theory of operation, written so I can answer "why is it done this way" for any part of it. File names refer to src/. Where a function is ported from Linux mt76 or from itlmt7925, the source function is named in a comment next to it and NOTICE.md lists it per file.
The MT7925 is a PCIe device (14C3:7925) with one 64-bit memory BAR. Apple's Wi-Fi stack (IO80211Family) is private and tied to Broadcom hardware, so MtkWlan does not use it. Instead the driver does all of 802.11 itself and presents an IOEthernetController to macOS. The system sees an Ethernet card named en1, runs DHCP on it, and knows nothing about networks, passphrases or signal strength. That is why there is no Wi-Fi menu and why tools/mtkwifi exists.
Two classes:
| Class | Base | Files | Owns |
|---|---|---|---|
MtkWlan (the core) |
IOService |
MtkWlan, MtkRegs, MtkDma, MtkFw, MtkScan, MtkJoin, MtkSec, MtkNet, MtkBa, MtkPower, MtkUserClient | PCI attach, BAR0, MSI, DMA rings, the MCU, firmware, scan, join, WPA2, 802.11 TX/RX, block ack, power tables, the work loop and its 1 ms timer, all Mtk* registry properties |
MtkWlanEth (the front end) |
IOEthernetController |
MtkWlanEth | the IOEthernetInterface (en1), a gated output queue of 64 packets, the medium, link status, the MAC address |
The personality matches IOClass MtkWlan on IOPCIPrimaryMatch 0x792514c3. After bring-up the core creates the front end by class name, attaches it as its child and starts it. The two talk through a plain C table (MtkCoreApi.h), fetched with callPlatformFunction("mtk-core-ops-v2", ...). The table is versioned both ways (version, size) so that a different front end, built as a separate kext, can attach later without a C++ link against the core. The core offers tx, set_enabled, get_mac, get_link_info, scan, results, join, disconnect, status; the front end gives back rx_alloc, rx, rx_flush, link, tx_space, scan_done, join_result.
Everything runs from MtkWlan::start on the provider's start thread, then hands over to the 1 ms timer. Each step publishes a registry property (MtkState and one MtkW<n>... value per step) because the early work was done in macOS Recovery, where the kernel log is tiny and the release kernel ignores msgbuf=. ioreg -r -c MtkWlan -l -w0 still is the main diagnostic.
- PCI and registers (
MtkWlan.cpp,MtkRegs.cpp). Enable memory and bus master, map BAR0. The chip's register space is reached through two remap windows (L1 and L2) exactly asmt7925/pci.cdoes;MtkRegs.cppkeeps the same helper shape. Then the driver ownership handshake frommt792x_core.c: the firmware owns the chip at power-on and the host has to claim it before touching WFDMA. - WFDMA rings and MSI (
MtkFw.cpp,MtkDma.cpp). A WFSYS reset, then the TX and RX rings (mt792x_dma.c,dma.c): descriptors in IOKit DMA memory (IODMACommand, 32-bit addressable), one MCU command ring, one data ring, the MCU event ring and the data RX ring. MSI is enabled and the handler only counts and acknowledges; all real work happens in the timer. This is the one place the driver is deliberately unlike Linux: RX is polled every millisecond from the work loop, which costs a little latency and keeps every path under one gate. - Firmware (
MtkFw.cpp). Two blobs from linux-firmware, embedded at build time bytools/gen_firmware.pywith their SHA-256 (checked againstfirmware/SHA256SUMS, and published in the registry): the patch (WIFI_MT7925_PATCH_MCU_1_1_hdr.bin) through the patch semaphore, then the RAM code (WIFI_RAM_CODE_MT7925_1_1.bin) region by region, then the "firmware start" command, as inmt7925/mcu.candmt76_connac_mcu.c. Done when the firmware reports ready in the MCU state register. - Station setup (
MtkScan.cpp,hwInit). UNI commands over the MCU ring: NIC capability (which tells the firmware version, the MAC address from EEPROM and the HW type), the EEPROM read, the regulatory domain (US), DEV_INFO and the first BSS_INFO for a station interface. The short command sequence after firmware load follows itlmt7925, which was the first driver to get this card up on macOS; every value was checked against Linux and where the two differ the driver follows Linux. - Power tables (
MtkPower.cpp, 0.7.0). The RAM firmware carries country limit tables (CLC). The driver finds the region matching the EEPROM HW type and sends theUSrules with UNI SET_POWER_LIMIT, then per-rate limits (SKU) for every channel, three channels per message, asmt7925_mcu_set_clcandmt7925_mcu_rate_txpower_banddo. Without this the firmware transmits at a conservative default and upload speeds stay low.MtkPower = falseinInfo.plistskips it. - Front end (
MtkWlanEth.cpp). Started right after bring-up, before any network exists, with the link published as valid and down. configd then sees "no carrier" and waits, instead of starting DHCP on a dead interface. The MAC address is the firmware's with the locally administered bit set, so the interface address and the on-air address match.
From here on the timer runs: every millisecond it drains the RX rings, dispatches MCU events, and services the scan, join, security, connection manager and block-ack machines in turn.
scanStart sends a hardware scan (UNI SCAN, all channels including DFS on 5 GHz, with the SSIDs of saved networks as directed probes). The firmware returns beacons and probe responses as frames on the MCU ring; rxFrame classifies them, mtkParseFrame pulls out SSID, channel, capability, RSN or WPA element and the HT/VHT elements. Each network goes into a cache of 48 entries keyed by BSSID with its security type (mtk_rsn_parse in MtkSupp.c decides whether the driver can join it: AKM PSK with pairwise CCMP, group CCMP or TKIP), RSSI from the RCPI in the RX descriptor, and a last-seen time. Entries unseen for 60 s are dropped. Beacons heard while associated keep the cache fresh. Scans start only while idle or connected, never during an authentication, and no join starts while a scan runs.
joinStart / joinService is a state machine with deadlines and four tries per phase:
- ROC: ask the firmware to stay on the AP's channel (remain-on-channel).
- Authentication: open-system auth frame, wait for the response.
- Association: association request with the supported rates, HT and VHT capabilities (80 MHz, 2 streams) and, for WPA2, the RSN element (group cipher = the AP's, pairwise CCMP, AKM PSK). Wait for the response, read the AID.
- Handshake (WPA2 only): see below.
- Up: BSS_INFO with the connected state, STA_REC with the HT/VHT/rate TLVs in the order Linux sends them (the firmware rejects other orders), the EDCA parameters from the association response, then the firmware does rate control. The port opens and the front end reports link up at the negotiated speed.
A deauthentication from the AP during the join counts as a failure. Right after our handshake message 2 it is reported as "wrong passphrase" (that is how most APs answer a bad key); otherwise as "AP left". Both back off.
Three files, with no OS calls in the first two so they can be built and tested on the host:
MtkCrypto.c: SHA-1, HMAC-SHA1, PBKDF2 (passphrase to PMK), the 802.11 PRF (PMK to PTK), AES-128 and RFC 3394 key unwrap (for the GTK). Checked bytests/mtk_wpa_test.cagainst the FIPS, RFC and IEEE test vectors.MtkSupp.c: the supplicant state machine for the 4-way handshake and the group key handshake. The rules it enforces come from IEEE 802.11-2020 12.7.6 and from reading hostap'swpa_supplicant: a frame is dropped unless its replay counter is above the last one that carried a verified MIC; message 3's ANonce must equal message 1's, its MIC must verify, and the RSN element inside it must equal the AP's beacon element (downgrade check); message 4 is sent before the PTK is installed; a key equal to the installed one is never installed again (the KRACK rule); one SNonce per handshake. The host tests run a simulated authenticator through the normal, retransmit, wrong-key and rekey cases and compare every derived value with an independent Python implementation (tests/oracle.py).MtkSec.cpp: the glue. EAPOL frames from the AP are queued byrxFrameand fed to the supplicant from the timer. The supplicant's callbacks only queue a frame to send or stash a key; nothing in them touches the rings or the firmware.secInstallServicethen installs the keys from the timer in the order Linux and itlmt7925 use: wait until message 4 is on the air (its TX-free seen, 100 ms bound), send BSS_INFO with the WPA2 security values, the pairwise key (STA_REC_KEY_V3 on WCID 1), the group key (WCID 19; 16 bytes CCMP or a 32-byte TKIP key with the MIC halves swapped), then open the port. A later group-key handshake installs the new GTK the same way without touching the port.
Port gating is the driver's, not the firmware's. While the port is closed txEthernet drops everything but EAPOL. Once the PTK is installed every data frame carries the Protected bit and TXD3 PROTECT_FRAME, and the firmware adds the CCMP header and packet number. On receive, protected data must have been decrypted by the hardware with the expected cipher (RXD2 SEC_MODE), plaintext data on a WPA2 association is dropped, and the CCMP packet number (from RX descriptor group 1) is checked when the frame leaves the block-ack reorder buffer in order, as mt76_check_ccmp_pn does: one counter per key and TID, reset by a PTK install, seeded from the key RSC by a GTK install. Every drop has a counter in MtkW2Sec.
The PMK is derived in user space by mtkwifi add, stored root-only, and handed to the driver through the user client at join time. The driver never publishes a key, PMK or passphrase anywhere.
cmService decides what to join. A wanted network (from mtkwifi join or a front end's join) is kept: rejoined 500 ms after a loss, retried with backoff (5, 10, 20, 40, 60 s) after failures, rescanned every 5 s while out of range (15 s after the first 30 s). When nothing is wanted or the wanted network has been out of range for 30 s, the driver may join an open network whose name is listed exactly in MtkAutoOpenSsids, but not before 20 s after the first scan (so the autojoin daemon gets its turn first) and not while a user-space scan is pending. While on that fallback it keeps looking for the wanted network after every scan (one every 30 s) and switches back when it reappears. mtkwifi disconnect turns the fallback off until the next join.
Ethernet frames from the output queue become 802.11 data frames: txEthernet builds the 802.11 header (ToDS, our MAC, the BSSID) and the LLC/SNAP header, then the TX descriptor (write_txwi and write_hw_txp from mt76) and puts the frame on the data ring. TX completions arrive as TX-free events with the sampled rate, power and try count, which feed the MtkW8* counters. In flight is capped at 32 frames and the output queue at 64, which is what took upload latency from over a second to tens of milliseconds in 0.5.1.
Received data frames are translated back to Ethernet (rxFrame, then rx on the front end with packets the front end allocated through rx_alloc). EAPOL goes to the supplicant queue, block-ack action frames to the block-ack machine, deauthentication to a pending-teardown flag that the timer acts on, beacons to the cache. A keepalive null frame goes out when nothing has been sent for a while, and a rejoin starts after a link loss.
MtkBa.cpp. On the receive side the driver accepts the AP's ADDBA request, gives the firmware a recipient STA_REC_BA, and reorders by sequence number with the algorithm from mt76's agg-rx.c (release head, release on timeout at HZ/10, BlockAckReq handling). Frames waiting in the buffer are the front end's mbufs; dropped ones are freed. On the transmit side, once data flows the driver sends an ADDBA request for TID 0 and, on success, an originator STA_REC_BA; the firmware then builds A-MPDUs itself. MtkRxBa and MtkTxBa in Info.plist switch either side off, in which case ADDBA requests are declined.
MtkWlanUserClient (MtkUserClient.cpp) is created per process that opens the service. Its five methods (scan, results, join, disconnect, status) run under the core's work loop gate through runAction; they set flags or copy snapshots and never issue a firmware command themselves, so a call waits at most for one command to finish (3 s worst case). Scan, join and disconnect need admin; status and results do not. The data shapes are fixed-size integers in MtkUserApi.h, the same in the kernel and in a 64-bit user process.
One gate. The core makes the work loop; the front end returns the same loop from getWorkLoop(), so its output queue and the IONetworkController command gate serialise with the timer.
| Path | Context |
|---|---|
hwInit (NIC capability, domain, power) |
provider start thread, before the timer runs |
tick: ring drains, pending teardown, secService, joinService, scanService, cmService, block ack, keepalive, link publication |
core work loop, timer action; the timer is re-armed first |
uniCmd (every firmware command) |
tick or bring-up thread only; a call from inside dispatchRx fails with -13 and is counted; three timeouts in a row set fwDead and stop the machines |
rxFrame |
classifies, queues and delivers only |
| user client calls | workLoop->runAction; set flags or copy snapshots |
| supplicant callbacks | queue an EAPOL frame or stash a key; never poll, never command |
tx from the output queue, set_enabled from enable/disable |
the core gate |
| MSI handler | primary interrupt: count and acknowledge |
Callbacks must not sleep and must not call back into tx or set_enabled. The front end's stop removes its callbacks under the gate before detaching en1, so a running tick never calls into a dying front end.
ioreg -r -c MtkWlan -l -w0 shows MtkState (the current step as text), MtkScan (one line per cached network: SSID, channel, RSSI, security, ! when not joinable), MtkW1Bss / MtkW1Scan / MtkW1Join (the machines' counters and last results), MtkW2Sec (handshake messages seen, bad MICs, replays, reinstall skips, missing PNs, ICV errors, cipher mismatches, unprotected / port / translation drops, key installs), MtkCm (connection manager decisions), MtkW2Cmd (in-dispatch violations, timeouts, failures, dead), the command results MtkW2BssSec / MtkW2PtkInstall / MtkW2GtkInstall, and the older MtkW4* to MtkW9* sets (scan, join, data path, rates, block ack). Never a key.
- WPA3-SAE, PSK-SHA256 (EAPOL descriptor version 3), management frame protection, fast transition and enterprise authentication.
- Roaming between BSSIDs of one SSID.
- HE (802.11ax) and EHT; the association is VHT at 80 MHz.
- A native front end that gives macOS a real Wi-Fi interface (menu, AirDrop, Continuity). The ops table is shaped for it; the kext does not exist yet.
- Sleep and wake. The laptop this runs on has sleep disabled.
- Recovery after
fwDead: three firmware command timeouts in a row stop the machines until a reboot. - Matching a SCAN_DONE or ROC grant event to the request that caused it; the machines use deadlines instead.
- Interrupt-driven RX.
bash tests/run.sh builds MtkCrypto.c and MtkSupp.c with the host clang and UBSan, runs the vectors and the simulated handshakes, and compares the printed values with tests/oracle.py. On the machine, tools/wifi.sh records the interface state, runs a router ping, a download and an upload through Cloudflare's speed endpoint with ping alongside, and saves every Mtk* property. The numbers in the README come from that script.