Devlog · ESP32-S3 · SD card · FreeRTOS

sd: present, 0 write faults

One byte too early

A side milestone for 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 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.

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.
  • 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, and the plan with every measurement is docs/milestones/S1.md.

Why a housekeeping milestone

The radio milestone 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

The cardSamsung microSDHC, 8 GB, June 2013
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.
The driversd_diskio.cpp, 891 lines, from the Arduino framework
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".
The network10.39.39.0/24, gateway at .1
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.
The main loop100% of core 1, at rest
Polls the keyboard, ticks the services, redraws when needed, and comes straight back for more. Has never once considered sitting down.

The card that never said no

The last post 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: 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: 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

The card last blockanswers 0x05: accepted Stop Tran0xFD not busy yetreads 0xFF busy: programming the blocksreads 0x00 readyreads 0xFF The driver, as shipped Stop Tran,then reselect ready?0xFF: yes CMD13: status?0xFF or 0x1F: "error" the write "failed" (it hadn't) With two dummy bytes Stop Tran dummy,dummy ready? 0x00: no. Waits.up to 500 ms; it took at most 107 CMD13: status?0x00: written About once in 1,500 writes, the first byte after reselecting came before the card had raised busy. Time runs left to right; not to scale.
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, 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. 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.

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.
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
Settings > Wi-Fi, a network's own page, the DNS and NTP servers, and the connection's details with where each value came from. Full size.

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, 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.

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
The System App: the overview, tasks sorted by how little stack they have left, memory while IRC connects over TLS, and traffic per service. Full size.

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 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.

The loop shares core 1 running its counter added when it's switched out 60% The loop has core 1 to itself running its counter 2% never switched out, so nothing is added: idle 0% + loop 2% = 2% of a core that's 100% busy The missing 98% belongs to the task still running: the one taking the sample.
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 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 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 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

Releases3: v0.6.1, v0.7.0, v0.8.0
Design questions23 (Q105 to Q127)
Tests396, 31 of them new
Firmware1.74 MB of 3.3 MB, 35 KB more than v0.6.0
Wrong guesses about the card4
Bytes missing from the driver2
Uploads without a failure since30 of 30 (it was 7 of 10)
A failed write, beforeevery 1,500 or so
Free address used for testing10.39.39.13
Seconds cut off by a wrong gateway, on purpose60
Bytes miscounted in a 164,970-byte fetch0
Share of a core the main loop uses at rest100%

Where it stands

  1. M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools. v0.1.0 to v0.2.1, the first post.

  2. Updates and debugging over the air. v0.3.0, Look, no cables.

  3. M2: GNSS. v0.4.0, Seventeen satellites.

  4. G1: Gemini. v0.5.0, A browser in the RAM IRC left over.

  5. M3: the LoRa radio, listening. v0.6.0, The loudest thing it hears is itself.

  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.

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.