+++ title = '''One byte too early''' description = '''roro9stack's housekeeping milestone: the SD card that "refused" writes turns out to be a driver asking one byte too soon, fixed and reported upstream; networks without DHCP get fixed addresses, DNS and NTP; and a System App shows tasks, memory and network use live, starting with a main loop that eats a whole core.''' date = 2026-10-06T02:30:00+02:00 [extra] topics = '''ESP32-S3 · SD card · FreeRTOS''' read_label = '''Read who was really at fault →''' uid = '''sd: present, 0 write faults''' dek = "A side milestone for [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer, between the radio and the mesh: make what exists solid. Three jobs on the list. The SD card that [refused a write now and then](/devlog/roro9stack-lora/) took four wrong guesses and one missing byte. Fixed IP addresses took an evening and a safety net. And the system monitor's first act was to report that the firmware had been burning an entire CPU core doing nothing, which I'd have preferred to hear from someone else." byline = '''designed by interrogation, rounds six and seven: 23 questions, and a bug report with my name on it''' [extra.sign] label = "Wrong guesses before the right one" note = "The radio, a timeout, the bus speed, a CRC bug. None of them." count = "4" tone = "red" [[extra.cast]] name = "The card" role = "Samsung microSDHC, 8 GB, June 2013" text = "Thirteen years old, product name \"00000\", which is the most honest thing a memory card has ever said about itself. Accused for two days of refusing writes. Innocent." [[extra.cast]] name = "The driver" role = "sd_diskio.cpp, 891 lines, from the Arduino framework" text = "Talks to the card over SPI. Gives up on a write without saying why, and tests for \"ready\" one byte before the card has had time to say \"busy\"." [[extra.cast]] name = "The network" role = "10.39.39.0/24, gateway at .1" text = "The only one I have to test on, with exactly one device on it. Which makes 10.39.39.13 the safest free address in Brussels." [[extra.cast]] name = "The main loop" role = "100% of core 1, at rest" text = "Polls the keyboard, ticks the services, redraws when needed, and comes straight back for more. Has never once considered sitting down." +++ ## TL;DR - **The SD card was never refusing writes.** The framework's driver asked the card for its status one byte too early, got garbage, and reported an error. Two dummy bytes fixed it: 30 uploads of 1.7 MB in a row with no failure, against 3 in 10 before. Reported upstream as [arduino-esp32#12970](https://github.com/espressif/arduino-esp32/issues/12970). - **Fixed IPv4 addresses**, per network, for networks with no DHCP, plus DNS and NTP servers in Settings with public defaults. - **A System App:** tasks, memory, network traffic per service and system state, live, in any build. - **What the System App found first:** the main loop uses a whole CPU core at rest. Not fixed yet; it has an issue of its own. - Three releases: **v0.6.1**, **v0.7.0** and **v0.8.0**. The code is [on my Gitea](https://git.twis.la/twisla/roro9stack/src/tag/v0.8.0), and the plan with every measurement is [docs/milestones/S1.md](https://git.twis.la/twisla/roro9stack/src/tag/v0.8.0/docs/milestones/S1.md). ## Why a housekeeping milestone The [radio milestone](/devlog/roro9stack-lora/) ended waiting for hardware: the mesh needs a second node, and I don't have one yet. It also left a card that failed about one upload in five, and a backlog of some twenty ideas I'd dictated into the issue tracker in one sitting. So the backlog got sorted into milestones, and the first one is the boring kind: **S1, system basics**. The card can be trusted. The device works on any network. You can see what the system is doing. Nothing glamorous, all of it wanted before a radio starts writing to that card around the clock. ## The cast {{ cast() }} ## The card that never said no [The last post](/devlog/roro9stack-lora/) left it here: about once in five uploads, a write to the card failed, the retry code papered over the hole with zeros, and I'd fixed the papering but not the failing. The card "refuses a write now and then", I wrote, like it was weather. ### Four wrong guesses **The radio.** It shares the card's SPI bus, so it was the obvious suspect. Cleared in [the last post](/devlog/roro9stack-lora/): the failures came with the radio asleep too. **A timeout.** The driver waits at most 500 ms for a busy card and then gives up, silently. Cards do pause for housekeeping. So I timed every write. Good ones took up to 107 ms. The failing one came back after **4 ms**. Nothing waits 500 ms in 4 ms. **The bus speed.** 20 MHz over wires that also run up into the radio's cap: maybe bits were getting flipped. At 10 MHz the uploads were 15% slower and one in ten still failed, after 7 ms this time. A failure that takes twice as long at half the speed is an exchange being rejected, not a signal problem. **A CRC bug.** Reading the driver, I found something that looked like the answer. After each data block the card replies with one of three values: `0x05` accepted, `0x0B` CRC error, `0x0D` write error. The driver masks the reply so its lowest bit is always 1, then checks whether it equals `0x0A` to decide to resend. It can never equal `0x0A`. So a block the card rejects is never resent. A real bug, sitting in the framework for anyone to trip over. I was sure. It wasn't that either. ### Asking the driver The driver fails without logging anything, so the only way to know was to make it talk. That meant owning it. You can't just drop a file with the same name into the project: PlatformIO links the framework library's objects directly, and the linker complained about every function twice. What works is a project library with the same name, which takes the framework's place. So `lib/SD` is now [my copy of the framework's SD library](https://git.twis.la/twisla/roro9stack/src/tag/v0.8.0/docs/adr/0007-own-copy-of-the-sd-driver.md): committed once exactly as it ships, then with my changes on top, so the difference stays one readable commit. It took three tries to build. I forgot the library's sixth file, the one with the CRC routines. Then my new code landed inside an `extern "C"` block and got the wrong name. The compiler was patient about it, in the way compilers are. With a record of where each write gave up, twelve uploads produced four failures, **all at the same step**: every data block accepted, the write properly ended, and then the status check came back as `0xFF` three times and `0x1F` once. Those aren't status values. They're what a wire looks like when nobody's driving it. ### One byte {{ diagram(src="sd-ready.svg", min_width=620, caption=`The end of a multi-block write. The driver's test for "ready" landed in the one byte before the card says "busy".`) }} After the token that ends a multi-block write, a card takes about one byte of clock before it signals busy. The driver deselects the card, selects it again, and reads a single byte to see if it's ready. Usually, by then, the card has raised busy and the driver waits. About once in 1,500 writes it hasn't yet: the byte reads `0xFF`, "ready", and the driver asks for the card's status while the card is still writing. The reply is garbage, the driver calls it an error, and the filesystem is told the write failed. It hadn't. **The data was on the card every time.** The check was wrong, not the write. The reference SD driver that everyone copies from sends one dummy byte after selecting the card, with a comment saying why. The Arduino one doesn't. [Two dummy bytes](https://git.twis.la/twisla/roro9stack/commit/3f2650c), one after the end-of-write token and one after selecting, and: - 30 uploads of 1.7 MB in a row, each read back and checked by SHA-256: **no failure**. - Ten of those with the radio listening on the same bus. - Before: 3 failures in 10. The CRC bug is still there, by the way. It's real, nothing here triggers it, and I don't fix code paths I can't test. It's written down, and it's in the report. ### Telling upstream A bug in a framework that thousands of projects use deserves a report, and a report deserves the card's make. Which I didn't know: the card is anonymous on the outside, and I had no other device to read it with. The card knows, though. Every SD card carries an identity register: maker, product name, revision, serial number, date. So the firmware got an `sd card` command that asks, through the driver I now own: ``` sd card: SDHC/SDXC, 7.9 GB, Samsung (0x1B) "SM" "00000" rev 1.0, serial …, made 2013-06 ``` A Samsung from 2013 whose product name is five zeros. It went into the report with the measurements, the two-line fix and the second bug: [arduino-esp32#12970](https://github.com/espressif/arduino-esp32/issues/12970). No reply yet. Until a fixed release exists, my copy has to be kept in step with the framework by hand, which is the price of the fix and has [an issue to keep me honest](https://git.twis.la/twisla/roro9stack/issues/39). ## Networks that don't hand out addresses Not every network has a DHCP server: a lab bench, a direct cable to a router, a network where someone assigns addresses from a spreadsheet. Until now the Cardputer couldn't join any of them. Twelve questions settled how: - **Per network.** Each saved network is Automatic or Fixed. The right address at home is the wrong one at work. - **A prefix, not a mask.** Typing `24` beats typing `255.255.255.0` on this keyboard, and it can't be malformed. - **The gateway is optional.** A bench network with no way out is a legitimate network. - **DNS and NTP are global**, with public defaults: 9.9.9.9 then 1.1.1.1 for names, `pool.ntp.org` then `time.cloudflare.com` for time. A switch, "Always use my DNS", covers networks whose resolver you'd rather not trust. - **IPv4 only.** IPv6 was offered as a "later"; I said not even that. {{ figure(src="wifi.png", alt="Four Cardputer screens at 2x in a grid. Top left: Settings, Wi-Fi: Wi-Fi On, Status knbg-guests (-48 dBm), DNS and NTP, Add a network, Add a hidden network, then the saved networks knbg-guests (selected) and Longcat. Top right: the page of knbg-guests: IP address Fixed, Address 10.39.39.12, Prefix 24 (255.255.255.0), Gateway 10.39.39.1, Forget this network. Bottom left: DNS and NTP: DNS 1 9.9.9.9, DNS 2 1.1.1.1, Always use my DNS Off, NTP 1 pool.ntp.org, NTP 2 time.cloudflare.com. Bottom right: connection details: knbg-guests, -47 dBm, Address 10.39.39.12/24 (DHCP), Mask 255.255.255.0, Gateway 10.39.39.1, DNS 10.39.39.1 (DHCP), NTP pool.ntp.org, answered, NTP time.cloudflare.com", width=976, height=556, landscape=true, full=true, caption=`Settings > Wi-Fi, a network's own page, the DNS and NTP servers, and the connection's details with where each value came from.`) }} Enter on a saved network used to ask "Forget network?", which was a rude thing to ask first. Now it opens the network's page. Switch it to Fixed and the page fills in the address, prefix and gateway the network is giving you right now, since the commonest reason to want a fixed address is to keep the one you have. Nothing is applied until you leave the page, so a half-typed address is never used. What's typed gets checked, [in code tested on the PC](https://git.twis.la/twisla/roro9stack/src/tag/v0.8.0/lib/net/src/ipv4.cpp#L37), with a reason for every refusal: "10.39.39.0 is the network's own address", "The gateway 10.39.40.1 isn't in 10.39.39.0/24". ### A safety net for remote hands There's a catch in testing this over Wi-Fi: a wrong address cuts the branch you're sitting on. The Debug Console and updates both come over that connection. So Debug Builds got a trial: `wifi ip … try 60` applies a setting and goes back to the previous one after 60 seconds unless confirmed. Then I gave it a deliberately wrong gateway. The device went silent, and a minute later it was back, unassisted. Without the trial, someone would have had to pick the device up and fix it on its own keyboard. Two things the network stack does behind your back, found by reading and avoided by checking every 30 seconds: - A DHCP renewal quietly puts DHCP's DNS servers back. - It also clears every NTP slot it didn't fill itself. And one I caught before it ran: my first version treated "just connected" like "the settings changed", and would have rejoined the network forever to fetch DNS servers it already had. Tested on the only network I have: fixed at 10.39.39.12, then 10.39.39.13 (the device is alone there, so the neighbouring address was free by definition), internet working through the configured DNS both times, back to DHCP, and both NTP servers answering. Not tested, and written down as such: NTP servers offered by DHCP, because mine offers none. ## What the system is doing Every milestone so far ran on measurements that needed a Debug Build and a laptop: memory floors, stack sizes, TLS dips. The System App puts them on the device. {{ figure(src="system.png", alt="Four Cardputer screens at 2x in a grid. Top left: the Overview: Core 0 at 47%, Core 1 at 64%, Memory 49 KB free, lowest 14, Network 33 B/s in, 4.1 KB/s out, Battery 97%, 4.17 V, Uptime 28 min 32 s, 43 C, and a graph of both cores over two minutes. Top right: Tasks sorted by stack left: IDLE0 with 232 bytes, spk_task with 264 and IDLE1 with 336, all three in orange, then ipc1, ipc0, irc and radio. Bottom left: Memory: 53.0 KB free, lowest 14.1, largest free block 31.0 KB, with a graph that runs near 75 KB then drops to about 50 KB, above dotted lines at 55, 40 and 20 KB. Bottom right: Network: knbg-guests, -49 dBm, 10.39.39.12/24 DHCP, gateway 10.39.39.1, then IRC 19 KB in and 1.1 KB out, Gemini 162 KB in and 72 B out, Debug Console 3.3 KB in and 750 KB out, Updates 0", width=976, height=556, landscape=true, full=true, caption=`The System App: the overview, tasks sorted by how little stack they have left, memory while IRC connects over TLS, and traffic per service.`) }} Five views, Tab between them. It samples once a second, keeps two minutes of history, and keeps nothing at all while it's closed. **Memory** draws free heap against the three floors from the Gemini milestone. The screenshot shows IRC connecting over TLS: 25 KB gone in a second. The graph only bottoms out near 50 KB, but "lowest 14.1" says the handshake went far deeper between two samples. Right after that I asked for a Gemini page, and the firmware refused: "Not enough memory (44 KB free): stop IRC or retry". That's the start floor from two milestones ago doing its job, and the first time I've seen the cause on the device's own screen while it happened. **Network** counts bytes per service. That took one small [wrapper class](https://git.twis.la/twisla/roro9stack/src/tag/v0.8.0/src/platform/counted_client.h#L14) around each service's connection instead of a dozen edits. It counts in the two calls everything else goes through. The counts are exact: fetching a 164,970-byte Gemini page counted 164,986 bytes in, which is the page plus its 16-byte header line, and 42 out, which is a 40-character URL and a line ending. **Tasks** flags anything with under 512 bytes of stack left. Three tasks qualify today, all the framework's own, one of them with 232 bytes. I've chosen to find that reassuring. ### The loop that read 2% The Tasks view shows each task's share of a core over the last second. On its first honest run it showed the main loop at 2%, core 1's idle task at 0%, and core 1 at 100% load. Ninety-eight percent of a core, belonging to nobody. {{ diagram(src="runtime.svg", min_width=620, caption="FreeRTOS adds to a task's run time when the task is switched out. A task that's never switched out never gets its time added.") }} FreeRTOS adds to a task's run-time counter at the moment the task is switched out. The main loop is the task taking the sample. When nothing else wants its core, it's never switched out, so its counter stands still while it runs flat out. The fix is arithmetic: the task doing the sampling [gets whatever is left of its core](https://git.twis.la/twisla/roro9stack/src/tag/v0.8.0/lib/system/src/task_stats.cpp#L32) once everything else is counted. The console's `tasks` command had its own version of this. My first rewrite took two samples a quarter of a second apart, inside the command, and reported the loop at 1%. Of course it did: the loop was asleep, waiting for the command to finish measuring it. It now takes one sample, lets the loop run for a second, and prints after the second. With both fixed, the figure is plain: ``` task st pri stack cpu% core loopTask R 1 2872 100.0 1 IDLE1 r 0 352 0.0 1 load: core 0 2 %, core 1 100 % ``` **The main loop uses a whole core at rest.** It polls, ticks, redraws and comes straight back, forever. An hour earlier I'd measured it at 81%, which was an average since boot, hobbled by the same counter. It's a battery cost, a heat cost, and a suspect for the [radio noise](/devlog/roro9stack-lora/) from the last post. It's also not fixed: letting the loop sleep touches key response, redraw timing and every service's tick, so it gets [its own issue](https://git.twis.la/twisla/roro9stack/issues/40) and its own measurements. The monitor's job was to make it impossible to ignore, and it did that in its first minute. ## By the numbers {% table() %} | | | | --- | --- | | Releases | 3: v0.6.1, v0.7.0, v0.8.0 | | Design questions | 23 (Q105 to Q127) | | Tests | 396, 31 of them new | | Firmware | 1.74 MB of 3.3 MB, 35 KB more than v0.6.0 | | Wrong guesses about the card | 4 | | Bytes missing from the driver | 2 | | Uploads without a failure since | 30 of 30 (it was 7 of 10) | | A failed write, before | every 1,500 or so | | Free address used for testing | 10.39.39.13 | | Seconds cut off by a wrong gateway, on purpose | 60 | | Bytes miscounted in a 164,970-byte fetch | 0 | | Share of a core the main loop uses at rest | 100% | {% end %} ## Where it stands {% steps() %} 1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/). 2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/). 3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/). 4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/). 5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/). 6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.0, this post. 7. Next: the main loop learns to rest, and the radio noise gets hunted with the tools that now exist. M4, the mesh, still waits for a second node. {% end %} {% signoff() %} The card was innocent, the network was simple, and the monitor's first finding was about its own author. A good milestone for humility, and for a 13-year-old memory card with no name. {% end %}