<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>TinyComputers.io (Posts about firmware)</title><link>https://tinycomputers.io/</link><description></description><atom:link href="https://tinycomputers.io/categories/firmware.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Original site content © 2022–2026 Tiny Machines Workshop, LLC, except where otherwise noted. Some rights reserved.</copyright><lastBuildDate>Sun, 06 Sep 2026 02:36:29 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Two Addresses, One Page: Finding the Firmware Bug Behind NetBSD Corruption on the Milk-V Mars</title><link>https://tinycomputers.io/posts/two-addresses-one-page-finding-the-firmware-bug-behind-netbsd-corruption-on-the-milk-v-mars.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/two-addresses-one-page-finding-the-firmware-bug-behind-netbsd-corruption-on-the-milk-v-mars_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;23 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Two Addresses, One Page: Finding the Firmware Bug Behind NetBSD Corruption on the Milk-V Mars&lt;/h2&gt;
&lt;p&gt;The page was supposed to contain a fixed pattern. Instead it began with this:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;pkgsrc/print/tex-convbkmk-doc
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That string had just come out of a compressed pkgsrc archive. It belonged in a tmpfs file. It had no business appearing in a permanent page of NetBSD kernel memory, much less in one of 256 pages I had allocated for the sole purpose of catching corruption. The kernel still believed it owned the page. Its allocation record was unchanged. Every audit I had added to the page allocator, pmap, direct-copy path, and SD controller reported zero events.&lt;/p&gt;
&lt;p&gt;The page had a physical address of &lt;code&gt;0x23e6d6000&lt;/code&gt;. A live tmpfs page containing the same pathname sat at &lt;code&gt;0x13e6d6000&lt;/code&gt;. The difference was exactly &lt;code&gt;0x100000000&lt;/code&gt;: four gibibytes.&lt;/p&gt;
&lt;p&gt;In July I wrote &lt;a href="https://tinycomputers.io/posts/four-partitions-and-a-borrowed-bootloader-netbsd-on-the-milk-v-mars.html"&gt;Four Partitions and a Borrowed Bootloader&lt;/a&gt;, the story of getting NetBSD-current to boot on my Milk-V Mars. That article ended with a working SSH prompt and one unresolved caveat: sustained writes could panic the kernel. I thought the board was running NetBSD with a RISC-V virtual-memory bug waiting to be fixed upstream.&lt;/p&gt;
&lt;p&gt;I was wrong. At least one pair of physical addresses was reaching the same CPU-visible memory. NetBSD could track both pages perfectly and still lose, because the distinction disappeared below the caches. Finding that took a reliable reproducer, several instrumented kernels, a small custom QEMU machine, a serial console, a correction to my own public diagnosis, and two one-line firmware fixes involving two different device trees.&lt;/p&gt;
&lt;h3&gt;Booting was only the first half&lt;/h3&gt;
&lt;p&gt;The first article covers the bring-up in detail, but the shape of it matters here. A Mars SD card needs four partitions. The JH7110 BootROM finds an SPL by a special GPT type GUID. SPL initializes DDR and loads an OpenSBI/U-Boot FIT from a second GUID. U-Boot starts NetBSD's EFI loader from a FAT partition, and that loader reads &lt;code&gt;/netbsd&lt;/code&gt; from an FFS root filesystem.&lt;/p&gt;
&lt;p&gt;NetBSD supplied the last two pieces. I borrowed mainline U-Boot 2025.01 from Debian's VisionFive 2 package for the first two, then scripted the whole image in &lt;a href="https://github.com/ajokela/milkv-netbsd"&gt;&lt;code&gt;milkv-netbsd&lt;/code&gt;&lt;/a&gt;. That got the machine through all four stages, but it initially handed NetBSD a VisionFive 2 device tree. The board's EEPROM is blank, so U-Boot never constructed &lt;code&gt;fdtfile&lt;/code&gt;; its empty default actually deleted the variable. Compiling this into U-Boot selected the right payload device tree:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;CONFIG_DEFAULT_FDT_FILE="starfive/jh7110-milkv-mars.dtb"
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The Mars device tree fixed the Ethernet PHY configuration. Packet loss went from roughly 80 percent to zero, and the board became a stable SSH target. I called that the one-line ending to the device-tree saga.&lt;/p&gt;
&lt;p&gt;Then I extracted pkgsrc.&lt;/p&gt;
&lt;p&gt;The first failure was an assertion in &lt;code&gt;pmap_segtab.c&lt;/code&gt; from the pagedaemon. Nick Hudson fixed a separate deferred pmap activation bug in NetBSD's RISC-V code, and that assertion disappeared. The machine still corrupted memory. Another run died in the UBC list code. A Rust bootstrap extraction reached the same failure. Compiling Rust entirely in tmpfs eventually produced a fatal load fault with &lt;code&gt;0x808080...&lt;/code&gt; smeared through a pointer.&lt;/p&gt;
&lt;p&gt;Those variants removed several convenient suspects. The failure happened with one CPU online, so it was not an ordinary SMP race. It happened with Ethernet down, so it was not the network controller writing through a bad DMA address. It happened on tmpfs as well as FFS, with plenty of free memory, and it did not happen under &lt;code&gt;qemu -M virt&lt;/code&gt;. Four workers repeatedly extracting and deleting pkgsrc in a large tmpfs turned the mystery into a reproducer that usually failed in about a minute.&lt;/p&gt;
&lt;p&gt;That was enough to debug, though not yet enough to understand.&lt;/p&gt;
&lt;h3&gt;A very convincing wrong answer&lt;/h3&gt;
&lt;p&gt;UBC is NetBSD's unified buffer cache, the layer that connects file data to virtual memory. Early in boot, &lt;code&gt;ubc_init()&lt;/code&gt; allocates arrays used to track its mappings and hash buckets. Those pages remain kernel-owned for the life of the system. In one crash, exactly one 4 KiB page of a UBC array had been overwritten while the pages on either side remained intact. In another, the damaged page was full of data from the archive being extracted.&lt;/p&gt;
&lt;p&gt;The obvious story was that UVM had lost track of a physical page. Perhaps a boot-time reservation overlapped a range later passed to &lt;code&gt;uvm_page_physload()&lt;/code&gt;. Perhaps one physical page had acquired two &lt;code&gt;struct vm_page&lt;/code&gt; descriptors. UBC would still think it owned its permanent allocation while tmpfs received the same page and filled it with file data. Page-aligned damage, two logical owners, one physical page: every observation seemed to fit.&lt;/p&gt;
&lt;p&gt;I sent that diagnosis to NetBSD's &lt;code&gt;port-riscv&lt;/code&gt; list. The page-aligned evidence was real. The conclusion was wrong.&lt;/p&gt;
&lt;p&gt;The first instrument, &lt;code&gt;UBC_PAGE_GUARD&lt;/code&gt;, recorded the physical pages behind those permanent arrays and watched for the allocator to return one again. Its fatal version could call &lt;code&gt;panic()&lt;/code&gt; while allocator locks were held. When the board began wedging silently during autoconfiguration, I blamed the guard for finding the bug too early to print it. A nonfatal version wedged too. So did an untouched kernel. The instrument had become another plausible explanation for a failure it did not cause.&lt;/p&gt;
&lt;p&gt;This was expensive because the original firmware could not reset the Mars reliably. A reboot ended at a PMIC error and every new test needed a physical power cycle. Before spending more of those, I built a faster place to make mistakes.&lt;/p&gt;
&lt;h3&gt;Build a witness before another theory&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;mars-virt&lt;/code&gt; is a small QEMU hybrid: Mars RAM placement, its four hart IDs, the U74 architectural subset, and its 4 MHz timer, combined with virtual I/O. It is deliberately not a JH7110 emulator. It cannot model the board's DDR controller or physical cache behavior, and it never reproduced the corruption. Its job was to boot instrumented NetBSD kernels in minutes and tell me when the instruments lied.&lt;/p&gt;
&lt;p&gt;That happened immediately. The first full PGAUDIT run reported a double free. Following the event through UVM showed that my allocation hook sat after a legitimate failure path: UVM freed a page and returned &lt;code&gt;NULL&lt;/code&gt; before the hook had recorded the allocation. Moving the hook to the point where a page had definitely left the free lists made the event disappear under the same load. The emulator saved a hardware boot and, more usefully, kept a false positive out of the final evidence.&lt;/p&gt;
&lt;p&gt;PGAUDIT grew into a set of independent witnesses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a boot-time audit compared every physical segment for overlap;&lt;/li&gt;
&lt;li&gt;a PA-indexed bitmap watched normal and page-list allocations for double allocation, double free, and pages outside the tracked range;&lt;/li&gt;
&lt;li&gt;owner records kept the previous and current allocating program counters;&lt;/li&gt;
&lt;li&gt;UBC checks re-resolved every permanent KVA and compared it with the direct-map view, looking for remapping or a disagreement consistent with a stale translation or cache-coherency problem;&lt;/li&gt;
&lt;li&gt;hooks watched direct copies, page zeroes, page copies, and foreign mappings;&lt;/li&gt;
&lt;li&gt;the SD/MMC path counted final DMA descriptors at or above 4 GiB;&lt;/li&gt;
&lt;li&gt;256 permanently owned pages near the top of RAM carried patterns derived from their physical addresses.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The canaries were intentionally boring. Nothing should write them after initialization. They also widened the search beyond UBC, because a second panic had already destroyed a pool page-header tree rather than a UBC array. Registers at that crash contained fragments of pkgsrc pathnames: &lt;code&gt;eneral/m&lt;/code&gt; and &lt;code&gt;text/doc&lt;/code&gt;. UBC was not uniquely cursed. It was simply a frequent victim near the top of memory.&lt;/p&gt;
&lt;p&gt;On &lt;code&gt;mars-virt&lt;/code&gt;, PGAUDIT booted with four CPUs, recorded 104 UBC pages, and completed repeated extraction cycles with every counter at zero. That proved the diagnostic could survive the workload. It said nothing about the board, which was exactly the boundary I needed it to keep.&lt;/p&gt;
&lt;h3&gt;Every counter stayed zero&lt;/h3&gt;
&lt;p&gt;The hardware result first looked like a disappointment. Across successive runs, the ownership and UBC-mapping checks remained clean. Later kernels added the direct-copy, foreign-mapping, canary, and final MMC-descriptor checks. At the decisive panic, every counter other than the canary check was still zero:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;physseg_overlaps = 0
double_alloc     = 0
double_free      = 0
untracked        = 0
direct_hits      = 0
map_hits         = 0
dma_high         = 0
104 KVAs, 0 remapped, 0 stale
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;One UBC page began, exactly on its boundary, with &lt;code&gt;/DESCR/1.1/Sun May  3 05:30:35&lt;/code&gt;, a CVS &lt;code&gt;Entries&lt;/code&gt; line present in the pkgsrc archive. The pool corruption showed that arbitrary kernel pages could be hit. Because the text appeared only after decompression, a disk read was not spraying compressed input into RAM. An ordinary CPU copy into a legitimate tmpfs page was somehow also changing kernel-owned memory.&lt;/p&gt;
&lt;p&gt;The decisive kernel was PGAUDIT build number ten. Its four-worker run reached 1,722,848 KiB in tmpfs at 60 seconds and faulted in &lt;code&gt;ubc_alloc()&lt;/code&gt;. A hash-bucket head had become the invalid pointer &lt;code&gt;0x100000000&lt;/code&gt;. From DDB, the canary check reported 64 damaged pages out of 256. Canary 25 held the &lt;code&gt;pkgsrc/print/tex-convbkmk-doc&lt;/code&gt; pathname. Its audit state still said allocated, and its original owner still resolved to &lt;code&gt;ubc_init()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;PGAUDIT still recorded the high page as owned by &lt;code&gt;ubc_init()&lt;/code&gt;, while the lower page had a separate descriptor and an allocation record from &lt;code&gt;uao_get()&lt;/code&gt;, exactly where a live tmpfs page should come from. The evidence now pointed to two separately tracked physical addresses rather than a recorded ownership transition. The lower address was four GiB below the damaged canary.&lt;/p&gt;
&lt;p&gt;That changed the question. I no longer needed to find a missing ownership transition. I needed to learn whether those two addresses were actually different once a memory access left the CPU caches.&lt;/p&gt;
&lt;h3&gt;Two addresses, one page&lt;/h3&gt;
&lt;p&gt;NetBSD's RISC-V direct map gives the kernel a simple virtual address for each physical address. In DDB, &lt;code&gt;pmap_extract()&lt;/code&gt; walked the real page tables and confirmed the mappings:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direct-map virtual address&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Physical address&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0xffffffe13e6d6000&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;&lt;code&gt;0x13e6d6000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0xffffffe23e6d6000&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;&lt;code&gt;0x23e6d6000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The virtual-memory machinery saw two legitimate mappings to two physical addresses. Both views initially contained the same pkgsrc pathname. That alone was suggestive, but caches made it ambiguous. Two independent physical cache lines can temporarily contain the same bytes even if the memory behind them differs. I needed to change one and force both back to backing memory.&lt;/p&gt;
&lt;p&gt;I wrote &lt;code&gt;0x11223344&lt;/code&gt; through the lower direct-map address. The lower view changed; the upper view still showed the old &lt;code&gt;pkgs&lt;/code&gt; bytes:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;low:  11223344 702f6372
high: 73676b70 702f6372
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That mismatch was useful. It showed that the two addresses had distinct cache-resident lines, so DDB was not truncating an address or displaying one location twice. I then called NetBSD's &lt;code&gt;fu540_ccache_cache_wbinv_range()&lt;/code&gt; for each 64-byte line. The documented U74 Flush64 operation back-probes L1 and writes back and invalidates the relevant L1/L2 line. Assuming the JH7110 cache block implements those semantics, reading both addresses again forced them to refill:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;low:  11223344 702f6372
high: 11223344 702f6372
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The marker had crossed a boundary that NetBSD, the page tables, and the cache tags all treated as real. The common point lay downstream of L2 under that Flush64 assumption. Under this firmware's 8 GiB configuration, the tested physical addresses four GiB apart reached the same CPU-visible DDR backing.&lt;/p&gt;
&lt;p&gt;This aliasing mechanism explains the whole strange history. UVM was free to allocate the lower and upper pages to unrelated owners because their physical addresses differed. Stores to the tmpfs page could then change the kernel page at the matching offset in the other four-GiB half. Damage began on page boundaries because file writes target pages. It became common only when enough memory was in use to reach the upper addresses. QEMU remained clean because its RAM backend really had separate storage for every advertised address.&lt;/p&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; prove that the board physically contains only four GiB. The unit may be a four-GiB SKU advertised as eight, an eight-GiB topology may have been programmed incorrectly, the controller may have a decode fault, or the hardware may be defective. What the experiment establishes is narrower and sufficient: this firmware exposed an eight-GiB range in which at least one tested pair, four GiB apart, shared backing memory. NetBSD could not safely use the upper half.&lt;/p&gt;
&lt;h3&gt;Put up a four-GiB fence&lt;/h3&gt;
&lt;p&gt;Before changing firmware, I tested that conclusion in the kernel. A temporary &lt;code&gt;MEMLIMIT&lt;/code&gt; stopped physical memory at the absolute exclusive address &lt;code&gt;0x140000000&lt;/code&gt;, preserving &lt;code&gt;[0x40000000, 0x140000000)&lt;/code&gt;. The word &lt;em&gt;absolute&lt;/em&gt; matters. Firmware reservations made NetBSD's runtime start &lt;code&gt;0x40060000&lt;/code&gt;; adding four GiB to that value would have admitted 384 KiB of the aliased upper aperture.&lt;/p&gt;
&lt;p&gt;The capped kernel reported 4,075 MB total and ran the same four-worker, 2,500 MiB tmpfs workload for 600 seconds. The uncapped control had failed after 60 seconds under less pressure. During the capped run, tmpfs repeatedly grew and emptied, memory pressure killed and restarted tar children, and all four worker loops were still alive when the script stopped them. Every PGAUDIT counter, all 256 canaries, and all 104 UBC mappings remained clean.&lt;/p&gt;
&lt;p&gt;Ten failure windows were enough to make the address limit a strong containment result. It was still a kernel workaround for a platform description that claimed unsafe memory. The next job was finding who had made that claim.&lt;/p&gt;
&lt;h3&gt;There were two device trees&lt;/h3&gt;
&lt;p&gt;The Mars device tree on the EFI partition describes four GiB and contains no reserved-memory entries. Yet the affected boot advertised an eight-GiB range from &lt;code&gt;0x40000000&lt;/code&gt; to &lt;code&gt;0x240000000&lt;/code&gt;. NetBSD's EFI loader rebuilt &lt;code&gt;/memory&lt;/code&gt; from the EFI memory map supplied by U-Boot, so the static node on disk did not determine the ranges NetBSD ultimately managed. The disk file was telling the truth and still losing the argument.&lt;/p&gt;
&lt;p&gt;The trap was that this boot chain uses device trees at two different times. The payload tree describes the finished board to NetBSD. That is the tree selected by &lt;code&gt;CONFIG_DEFAULT_FDT_FILE&lt;/code&gt;, the first one-line fix, and it repaired Ethernet. Much earlier, SPL needs its own built-in &lt;strong&gt;control device tree&lt;/strong&gt; to initialize DDR before full U-Boot or the operating-system payload exists.&lt;/p&gt;
&lt;p&gt;Mainline U-Boot uses the VisionFive 2 configuration for the Mars. In U-Boot 2024.04, &lt;code&gt;jh7110-starfive-visionfive-2.dtsi&lt;/code&gt; declares a bank at &lt;code&gt;0x40000000&lt;/code&gt; with size cells &lt;code&gt;&amp;lt;0x2 0x0&amp;gt;&lt;/code&gt;: eight GiB. SPL loads that value, then replaces it only if the board EEPROM reports a valid capacity of 2, 4, 8, or 16 GiB. This Mars has a blank EEPROM. No override occurs. The DDR driver selects &lt;code&gt;DDR_SIZE_8G&lt;/code&gt;, and the same size eventually reaches NetBSD.&lt;/p&gt;
&lt;p&gt;The blank EEPROM had now caused two independent failures. In full U-Boot it prevented selection of the Mars payload tree, which broke Ethernet. In SPL it left the VisionFive 2 control tree's eight-GiB default in place, which exposed the alias. Fixing &lt;code&gt;fdtfile&lt;/code&gt; could never change DDR geometry because DDR had already been initialized before that variable mattered.&lt;/p&gt;
&lt;p&gt;Milk-V's own Mars SDK supplied the missing clue. Its no-EEPROM path defaults to four GiB and keeps that value unless it reads a validated capacity. The 4G and 8G controller tables otherwise agreed with mainline. Rather than write a guessed capacity into the blank EEPROM, I changed the control tree's size by one cell:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;reg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mh"&gt;0x0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x40000000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x0&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;reg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mh"&gt;0x0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x40000000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x0&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That line does two things. It selects the four-GiB DDR geometry and tells later firmware to advertise one four-GiB bank. Because both change together, the test deliberately leaves the upper addresses unprobed.&lt;/p&gt;
&lt;h3&gt;Firmware had a second confession&lt;/h3&gt;
&lt;p&gt;There was another result hiding in the boot failures I had blamed on diagnostic kernels. U-Boot 2025.01 with OpenSBI 1.6 failed three of four measured hardware boots. Two kernels wedged silently during early autoconfiguration. One boot never reached NetBSD and instead printed repeated OpenSBI load-access faults from a secondary hart. The same failure points appeared with instrumented and untouched kernels.&lt;/p&gt;
&lt;p&gt;A U-Boot 2024.04/OpenSBI 1.4 pair had been staged for comparison since July. Once installed, every subsequent boot succeeded. Normal &lt;code&gt;/sbin/reboot&lt;/code&gt; began working too, turning a test loop that had required a person at the power cable into an autonomous 32-second reboot. The older firmware did not fix the memory problem by itself: its unmodified control tree still selected eight GiB, and the corruption remained. It separated boot stability from memory geometry, then gave me a stable base for the one-line four-GiB change.&lt;/p&gt;
&lt;p&gt;I rebuilt that pair from pinned source archives, preserving the known configuration byte for byte and changing only the control-tree line. The build verified that the SPL and U-Boot copies of the four-GiB control tree were embedded in their payloads.&lt;/p&gt;
&lt;h3&gt;Flashing the card from the card&lt;/h3&gt;
&lt;p&gt;Installing the corrected firmware did not require moving the microSD card to my laptop. NetBSD was already running from it, so I copied the two payloads over SSH, identified the live &lt;code&gt;spl&lt;/code&gt; and &lt;code&gt;uboot&lt;/code&gt; wedges by GPT label and type GUID, retained the baseline payloads for rollback, and wrote only those wedges. The EFI and FFS partitions were not written.&lt;/p&gt;
&lt;p&gt;Even that produced one last small trap. The SPL was 141,027 bytes. NetBSD's raw wedge accepted the first 275 complete sectors and rejected the final 227-byte fragment. The board was still running and had not rebooted into the incomplete write, so I fixed the flash script to pad only the final sector with &lt;code&gt;bs=512 conv=sync&lt;/code&gt;, rewrote both payloads, and verified hashes over their original lengths. The script now refuses to write unless the live disk mapping, labels, GUIDs, and expected payload hashes all agree.&lt;/p&gt;
&lt;p&gt;The final hardware A/B used the same &lt;strong&gt;uncapped&lt;/strong&gt; PGAUDIT kernel that had failed at 60 seconds under the eight-GiB firmware. With the corrected firmware, U-Boot printed &lt;code&gt;DRAM: 4 GiB&lt;/code&gt;, &lt;code&gt;bdinfo&lt;/code&gt; showed one bank ending at &lt;code&gt;0x13fffffff&lt;/code&gt;, and NetBSD received an end address of &lt;code&gt;0x140000000&lt;/code&gt;. It reported 4,075 MB without any kernel memory limit.&lt;/p&gt;
&lt;p&gt;Then the same stress test ran for 600 seconds. Zero double allocations. Zero double frees. Zero suspicious direct writes or foreign mappings. Zero high DMA descriptors. Zero damaged canaries. All 104 UBC mappings still agreed.&lt;/p&gt;
&lt;p&gt;The image builder has the same attitude. It defaults to the exact 2024.04/OpenSBI 1.4 firmware tested on this board, verifies both four-GiB control trees and their embedded copies, and ignores the old top-level eight-GiB files rather than falling back to them. &lt;code&gt;build-uboot.sh&lt;/code&gt; rebuilds the patched firmware from pinned archives and performs the same checks. A clean end-to-end image build passed without an SD card attached.&lt;/p&gt;
&lt;h3&gt;What “running” means now&lt;/h3&gt;
&lt;p&gt;The corrected firmware succeeded in both validation boots, while the underlying 2024.04/OpenSBI 1.4 pair had already made repeated boots and software resets reliable. NetBSD-current starts all four U74 cores, selects the Mars payload device tree, and receives a safe 4,075 MB physical-memory map. The exact uncapped PGAUDIT kernel that failed under the eight-GiB firmware completed the 600-second extraction test under the corrected firmware. Ethernet is stable, although NetBSD's &lt;code&gt;eqos&lt;/code&gt; transmit path still tops out around 2.45 Mbit/s on this board; receive performance is far better. There is more driver work available, but the reproducer no longer turns pathname text into kernel pointers.&lt;/p&gt;
&lt;div style="width: 100%; text-align: center;"&gt;
&lt;img src="https://tinycomputers.io/images/milkv-mars/netbsd-pgaudit-uname.png" alt="Terminal showing uname -a output from NetBSD 11.99.8 GENERIC64_PGAUDIT number 10 on the Milk-V Mars" loading="lazy" style="max-width: 100%; border-radius: 6px;"&gt;
&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The live Mars after the firmware correction, running the exact uncapped PGAUDIT #10 kernel used in the A/B.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The old &lt;code&gt;netbsd-mars-20260719.img.xz&lt;/code&gt; linked from my July article contains the affected U-Boot 2025.01/OpenSBI 1.6 eight-GiB firmware. &lt;strong&gt;Do not use that image for sustained writes or data you care about.&lt;/strong&gt; Current source builds from the repository use the corrected, hardware-tested firmware. The older artifact documents the original work; it is not a safe installation image.&lt;/p&gt;
&lt;p&gt;I still do not know how much DRAM is fitted to this particular board, or whether different topology programming could make all eight advertised GiB usable. The tested pair aliased under the eight-GiB configuration. Restricting the platform to the first four GiB contained the failure in both a NetBSD A/B and a firmware A/B. That is enough to run the machine safely and too little to advertise an eight-GiB fix.&lt;/p&gt;
&lt;p&gt;My first article ended with one line selecting the right device tree for the operating system. This one ends with one line correcting the private device tree SPL had already used before the operating system existed. The first made the Mars reachable. The second made its memory trustworthy. The lines were tiny. Proving which one told the hardware the wrong story was the project.&lt;/p&gt;</description><category>bsd</category><category>ddb</category><category>ddr</category><category>device tree</category><category>firmware</category><category>jh7110</category><category>kernel debugging</category><category>memory corruption</category><category>milk-v</category><category>milk-v mars</category><category>netbsd</category><category>opensbi</category><category>pmap</category><category>risc v</category><category>riscv64</category><category>serial console</category><category>single board computers</category><category>starfive</category><category>u-boot</category><category>uvm</category><category>virtual memory</category><guid>https://tinycomputers.io/posts/two-addresses-one-page-finding-the-firmware-bug-behind-netbsd-corruption-on-the-milk-v-mars.html</guid><pubDate>Sat, 05 Sep 2026 14:00:00 GMT</pubDate></item></channel></rss>