Six months ago I assumed bringing up Wi‑Fi on a tablet would be roughly like on a dev board: there is a chip, there is a bus, the driver sends commands, the chip replies. On an ESP32 dev board that took 950 lines of Rust and a few days.
On a Samsung Galaxy Tab S6 (Snapdragon 855) the same thing took 32,000 lines, about ten reboots of the entire chip without a single error message, and one power‑management IC that I had to turn into a witness.
Below is the whole chronology, with logs.
TL;DR: on the Snapdragon 855 a “Wi‑Fi chip” does not exist as a separate device. All of the 802.11 logic runs in the cellular modem's firmware, and for that firmware to agree to work the host must bring up four servers for it, hand it memory through TrustZone, vote for seven power rails and two clocks, and get every byte right.
Any mistake is not a panic and not a return code; it is a silent SoC reset from the secure world.
Who I am and why. We are a small team writing our own OS in Rust from scratch: not Linux and not a fork, our own no_std kernel, scheduler, storage, graphics. The tablet was an instrument: a way to see exactly what the chips demand of the host when there are no vendor blobs in between.
Wi‑Fi was my part. I wrote this from the bring‑up journal and the commit history, so the order of events here is the real one, dead ends included.
Contents: what we had on the dev board; what turned out to be inside the tablet; how I learned to see the cause of a silent reset; nine stages in order and the ten resets that came with them; what remains undone.
What we had on the dev board
950 lines, one UART, and TLS computed by the module.
It started with an F4VE board: an STM32F407, an ILI9341 display over SPI, a resistive touch panel, and next to them an ESP32 module with the stock ESP‑AT firmware connected to the microcontroller over a single UART. Wi‑Fi looked like this: write AT+CWJAP="ssid","pass" to the port, read WIFI GOT IP. Write AT+CIPSTART="SSL","host",443, and the microcontroller talks HTTPS to a server without a single byte of cryptography, because the module does TLS.
The entire driver was a string parser and a dozen commands. Network scan, join, captive portal, over‑the‑air update all worked within days. Later the ESP32 became a standalone board of the same OS, and the same 950 lines simply linked against Espressif's Wi‑Fi stack.
Then I wanted a real device with a screen, a battery and a case. The Galaxy Tab S6 fit: Snapdragon 855, unlockable bootloader, console over USB. The plan was literally “do the same thing”. The display and the touch panel resisted within reason. Wi‑Fi resisted differently.
What turned out to be inside the tablet
There is no separate Wi‑Fi chip. There is a protection domain in the modem.
The first thing I did was open the stock device tree. I expected a Wi‑Fi controller on PCIe. I found qcom,icnss@18800000, and next to it qcom,mss@4080000 with PAS id 4. WLAN on this chip is a protection domain inside the MPSS modem subsystem: one Hexagon DSP, one firmware modem.mbn, and inside it WLAN.HL.3.2.0.c3-00910. The WCN3990 turned out to be a radio front end on the system NoC, that is, on the same bus as memory.
The firmware is signed by Qualcomm, loaded and verified by TrustZone. I cannot read it, cannot change it, cannot look at its registers either: reading QDSP6SS from the normal world is a bus error. What it does have is a list of demands on the host, and here it is.
| What the firmware demands | What it is | What happens if you don't provide it |
|---|---|---|
| DMA into our memory | 12 copy engines, direct RAM access channels | No Wi‑Fi at all |
File server tqftp | TFTP over internal IPC | Writes server_check.txt; without a successful write WLAN won't start |
Disk server rmtfs | Sectors of the modemst1/2, fsg, fsc partitions | Without it the modem dies at second 40 with efs sync failed |
Domain map pd-mapper | A list of “who lives where” | Without a reply to kernel/elf_loader the WLAN domain never comes up |
Memory hand‑over memshare | Transferring RAM regions to the modem via the secure world | Without the MSA region the firmware refuses to initialise |
| IPC: SMEM, SMP2P, GLINK, QRTR, QMI | Shared memory and message queues | All control goes through this |
In stock Android all of this is done by rmtfs, tqftpserv, pd-mapper and the icnss driver, and you have never heard of them because they start from init before the home screen appears. I had none of them. So: write them.
How I learned to see the cause of a silent reset
72 bytes in the power‑management IC and a second core as a watchdog.
The first serious failure looked like this. The USB console goes quiet. A second later the tablet shows the bootloader logo and starts over. The last line in the log is about the previous step, and that is all. The panic handler was never called. The exception vector never fired. Not a single interrupt. The PMIC says exactly what it says after any ordinary reboot.
That is how the SoC responds to a host error if the error was noticed by TrustZone or the hypervisor: not with an exception at EL1, but with a reboot of the whole chip. No evidence remains. JTAG on a production tablet is not available. I needed a witness that survives a reset.
The only memory that survives a reset is the non‑volatile SDAM cells in the PMIC, reachable over SPMI. I set aside 72 bytes there for “last words” and 16 bytes for “trials”.
The mechanism is simple: before every step that can hang the core or cause a reset (an smc call into the secure world, the first read of SMEM, a register access on a block whose clock might be off), the code sets a mark with the step's name: scm pas, smem toc, glink attach, wlfw cap. After the step the mark is cleared.
If the chip rebooted, the next boot reads SDAM first thing and sees which mark was left standing.
The second Cortex‑A76 core works as a watchdog: if the first core does not respond for eight seconds, the second writes a verdict with the address where the first got stuck into the same 72 bytes. So the first command after every reboot became sys.boot, and its output looks roughly like this (abridged):
> sys.boot
last_reset: reset
previous:
reason: reset "the last run was up when it ended, and left no words"
died_at: +9.4 s
step: wlfw fw_ready wait
note: a step marked as one that can stall the core; the mark was never cleared
crashes: 8 total (panic 1, watchdog 1, reset 6)A mark proves one thing: a step with that name began and did not finish before the reset. Who reset the chip and why, it does not say. But one‑step resolution turned out to be enough to solve all ten cases.
Nine stages and ten resets
The order is real. Dead ends are left in.
Stage 1. Power and clocks
On a Snapdragon, rails and clocks are not switched on; they are voted for. Every rail is a name from the cmd-db database, every vote goes through RPMh to the AOP controller, a separate Cortex‑M core inside the SoC, and the AOP aggregates the votes of all clients. The host is one voter among several. The modem needs seven rails, two reference clocks and a load_state modem on command.
Reset #3 came from exactly here, although it took me a long time to understand. Both cores silent, no marks, PMIC clean, the hardware watchdog bit. It turned out that on handing control to the modem I was dropping my proxy vote for cx.lvl to zero. The vdd_cx rail powers all of the SoC's digital logic, not just the modem.
While the modem was busy and voting for itself, the aggregate held on its vote. As soon as the modem went idle, CX collapsed and the whole interconnect stopped. The correlation “modem busy: alive, modem idle: reset” was the proof. Fix: a floor, cx.lvl never below NOM.
Stage 2. Loading the signed image
The modem is loaded through PAS (Peripheral Authentication Service). The host gives the secure world the image metadata with init_image, lays out the segments in a 160 MiB carve‑out, and calls auth_and_reset. TrustZone verifies the signature and starts the Hexagon. Here I collected two resets.
Reset #1: ten seconds after the first call to the modem, core idle. The reset came from time, not from an action, so someone was touching memory after us. The only shared thing I touched was SMEM, and I was dutifully doing cache maintenance on it with dc civac.
Two control builds settled it: without the mutex and without any calls to the modem the reset is still there; without the cache maintenance instructions it is gone. Memory shared with the modem must be a non‑cacheable window, and no dc on it, ever.
Reset #2 was trickier: 6–10 seconds after modem start, all marks cleared, meaning every PAS step had completed. What does TrustZone keep reading after auth_and_reset? The image metadata. And mine lived in a Vec on the heap. The heap freed the buffer and reused it, and a write landed in a page TrustZone by then considered its own.
The reference Linux kernel has a trace of the same thing: qcom_scm_pas_metadata_release is called only after auth, since 5.18. The metadata moved to a static non‑cacheable 2 MiB window, LENT_PAS, outside the heap.
Stage 3. IPC from the bottom up
Next, the communication stack with the modem: SMEM (2 MiB of shared memory with an item table), SMP2P (state bits, the very slave-kernel), GLINK (FIFO queues over SMEM), QRTR (the service router) and QMI (the request protocol). I wrote every layer myself, checking against mainline Linux. The rule I reached quickly: everything the modem wrote into shared memory is untrusted. An offset, a length, an address in QMI is checked against the window before it is dereferenced.
Reset #4 was not an SoC reset but looked similar: the modem read empty EFS headers, “restored” its file system from the golden copy and died on Graceful Restart after 20 seconds. I spent a long time looking for a bug in my disk server.
The shadow on disk was valid. So the modem was reading something other than what I wrote. The LENT window for the buffer started at 0x8530_0000, not on a 2 MiB boundary, and the first megabyte fell into a cacheable block of the identity map. I moved it to 0x8520_0000 and added const alignment checks for every window.
Stage 4. Four servers
Now the modem could talk, and the first thing it did was look for services on QRTR: rmtfs (id 14), tqftp (4096), pd-mapper, memshare. And use them as a client.
The disk server rmtfs gives the modem sectors of the modemst1, modemst2, fsg partitions. This is EFS, the modem's persistent storage, which lives on the host's disk and survives a factory reset. I decided not to hand over the real disk: on OPEN the partition is read into RAM once, all RW_IOVEC go to the copy, there is no write path to disk in the code. The modem sees its writes “land”; the disk never changes.
This is where reset #5 happened, the most telling one. This tablet has no fsg partition in its GPT at all. I answered OPEN /boot/modem_fsg with no such partition, the way rmtfs from the qrtr utilities for Linux does. The modem died exactly 23 milliseconds later, every time. The timeline from my own server:
> ipc.hosts
mss (qrtr node 0)
rmtfs OPEN /boot/modem_fs1 ok read hdr 512 B @ +0
rmtfs OPEN /boot/modem_fs2 ok read hdr 512 B @ +0
rmtfs OPEN /boot/modem_fsg REFUSED no such partition
smp2p slave-kernel 6 -> 7 +23 ms
rproc running -> crashed (SSR reason: <empty>)There is no reason in SMEM because the modem dies before its own error service has started. Fix: fsg is opened blank, as a shadow of zeros the size of modemst1. To EFS zeros mean “erased golden copy”; the firmware knows that state and moves on. Why a refusal is fatal in 23 ms while a missing server only kills it after 40 seconds, I don't know; the code is closed.
Reset #6: services 66 and 43 are on QRTR, but WLFW (0x45) never appears, and the modem dies at second 40. The WLAN domain inside the modem was waiting for two things: a pd-mapper reply to kernel/elf_loader and a successful write of server_check.txt through tqftp. Separately, without rmtfs the efs sync killed it at second 40. Until all four servers are up before the modem starts, there is no Wi‑Fi.
Reset #7: assign_mem for the MSA region rejected by the secure world. MSA is the self‑authentication region the host transfers to the modem's ownership through memshare. I had taken the address 0x8bc0_0000 from a dtsi of another release. In the tablet's live tree that address held hypervisor memory; the real pil_wlan_fw_region was at 0x8c20_0000. Lesson: addresses only from the live device tree, no offline copies.
Stage 5. The WLFW handshake
When service 0x45 finally appeared, the handshake with the WLAN firmware over QMI began: IND_REGISTER, CAP (returned the firmware version), MSA_INFO, BDF_DOWNLOAD (five segments of board data), CAL_REPORT, and then wait for FW_READY_IND.
Instead came reset #8, the very one I had written the SDAM marks for. The phase stood at wlfw fw_ready wait. That is the step after the last request, so the firmware had accepted all my commands, and the reset happened while I was waiting for its reply. Suspicion shifted from “I sent something wrong” to “the firmware did something at my request and hit an obstacle”.
At first I suspected MSA: checked the address live, changed the size from 1.5 to 1 MiB per qcom,wlan-msa-memory. The reset stayed. So not memory.
What else does the reference enable before this step? In ath10k_snoc_clk_enable, two clocks: cxo_ref_clk_pin (rfclka2 in the tree) and qdss via the AOP. After CAL_REPORT the firmware initialises the radio and touches a block whose clock the host never enabled; accessing an unclocked block is a bus error and a reset. I voted for both clocks. FW_READY_IND arrived eight seconds after modem start.
Stage 6. Copy engines, HTT, WMI
From here on it is territory familiar from ath10k: 12 copy engines with descriptor rings, HTT for data, WMI for control. The firmware reads and writes these rings via DMA.
I handed it exactly 4 MiB and hoped someone else would put up the fence, because stream 0x640 in apps_smmu is marked qcom,smmu-s1-bypass in the stock tree: the addresses in the descriptors are physical. Trying to enable stage‑1 translation along the way broke initialisation, and I postponed it, marked “not done”.
Stage 7. WPA
I wrote the supplicant in safe Rust with no unsafe: the 4‑way handshake, PTK and GTK derivation. And then the keys go into the firmware with VDEV_INSTALL_KEY, because traffic is encrypted by hwcrypto inside the modem. Even when the handshake is computed on the host, the traffic key lives with the modem. That is not a quirk of ours; it is the same on any OS on this chip.
Stages 8 and 9. Small things that also reboot the chip
Reset #9: I tried to read the TrustZone log from IMEM. The tz-log node in the tree claims 0x146bf720 + 0x3000, while the parent IMEM is 0x146bf000 + 0x1000. The node's window extends past the end of its parent; a read past the end is the reset. Samsung encrypts the log anyway.
Reset #10 I never observed and closed preventively: a Normal mapping, even non‑cacheable, allows speculative reads ahead, and the Cortex‑A76 reads ahead eagerly. Over hyp_mem and other cores' carve‑outs the XPU would see that. The reserve became Device memory, starting one megabyte before hyp_mem.
All ten resets in one table
For those who came for the table.
| # | Symptom | Cause | Fix |
|---|---|---|---|
| 1 | Reset ~10 s after the first call to the modem, core idle | We were doing cache maintenance (dc civac) on SMEM, memory shared with the modem | SMEM as a non‑cacheable window, no cache maintenance on anything shared |
| 2 | Reset 6–10 s after modem start, all marks cleared | Signed‑image metadata lived in a Vec; the heap reused the buffer, a write landed in a TrustZone‑protected page | A separate static 2 MiB LENT_PAS window outside the heap |
| 3 | Both cores silent, PMIC clean, hardware watchdog bit | We dropped our vote for the cx.lvl rail to zero; it powers all digital logic of the SoC | A floor: cx.lvl never below NOM |
| 4 | Modem reads empty EFS headers and dies after ~20 s | The rmtfs buffer window did not start on a 2 MiB boundary; the first megabyte fell into a cacheable block | Alignment of all windows, const asserts |
| 5 | Modem fatal exactly 23 ms after OPEN /boot/modem_fsg | This tablet has no fsg partition; we refused, as Linux rmtfs does | Open fsg blank: zeros mean “erased golden copy” to EFS |
| 6 | WLFW service (0x45) never appears; modem fatal at ~40 s | The WLAN domain waited for pd-mapper and tqftp; separately, without rmtfs the EFS sync killed it | Bring up all four servers before starting the modem |
| 7 | assign_mem for MSA rejected by the secure world | I took the region address from a dtsi of another release; in the live tree that is hypervisor memory | Read the address only from the live device tree |
| 8 | Reset after CAL_REPORT, phase wlfw fw_ready wait | After calibration the firmware touches a block whose clock the host never enabled (rfclka2, qdss) | Vote for both clocks before the handshake |
| 9 | Reset while reading the TrustZone log | The tz-log node claims a window 4 bytes wider than its parent IMEM | Never read past the parent's reg; Samsung encrypts the log anyway |
| 10 | Not observed | Normal mapping allows speculative reads over hypervisor memory | Reserve as Device memory, one megabyte before hyp_mem |
What worked and what didn't
32,000 lines, eight seconds to FW_READY, one open item.
Wi‑Fi works: scan, WPA2 join, DHCP, TLS on top, over‑the‑air OS update. From modem start to FW_READY_IND, eight seconds. Code: about 32,000 lines against 950 on the ESP32, roughly a third of it the four servers for the modem and validation of everything it sends.
What is not done. Stage‑1 SMMU translation for the Wi‑Fi stream: mainline Linux does it on the same chip; mine is in the plan. Until then the modem firmware writes into my memory by physical address, and the only fences are Samsung's hypervisor and Qualcomm's XPU, not mine. Persistent EFS: a shadow instead of a disk means some cellular functions won't work without saved state; for a tablet without a SIM that is a price I pay knowingly.
What it taught me, briefly. On a modern SoC the radio is not a peripheral but a co‑processor with DMA, a file system and its own IPC, and the nine stages were not chip configuration but service of its demands. If you work with Snapdragon under Linux, the downstream stack does all of this for you, and does it correctly. It is just useful to know what exactly it does.
If you are interested in a specific stage with register addresses, ask in the comments and I will expand. If you find a mistake, tell me and I will fix it.
The Parasite in the TabletOn the F4VE breadboard Wi‑Fi was 950 lines behind a UART. On a Snapdragon 855 tablet it became 32,000: nine stages, a dozen silent SoC resets, four servers we run for the chip. The 'Wi‑Fi chip' is half a modem with DMA, a file system and its own radio — a tenant with the landlord's keys. What it can do, whom it concerns (every Snapdragon Android, Windows laptops, iPhones with a Qualcomm modem), and how to isolate it transparently.
A Reset Without WitnessesThe SoC answers a host error not with an error but with a reboot — and leaves no evidence: DRAM is wiped, the PMIC looks routine, the TrustZone log is encrypted. How we built a witness in 72 bytes of PMIC memory and turned ten silent resets into a table of causes.
The Megabyte That Killed the ModemOne cacheable megabyte in a window we lent to the modem made it read zeros where we had written headers, 'restore' its file system and crash. Why cacheability and 2 MiB alignment are security conditions on a SoC with other masters — and how the compiler now checks them.
23 Milliseconds to LieThe modem asked for a partition that does not exist on this tablet. An honest 'no such partition' killed it in 23 ms; an empty partition of zeros did not. Why the firmware tests the host at every step, and why isolation must be transparent: correct in form, empty in substance.