From 5310df329d3c6d39543dda635153ed08fefe04bb Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 15 Oct 2026 14:02:28 -0700 Subject: [PATCH 1/5] supervisor: don't lose a write that lands during a flush On zephyr-cp, USB MSC writes run in their own thread while the idle flush runs in the VM thread. supervisor_flash_flush() cleared filesystem_dirty and stopped the tick after flushing, and flash_write_blocks() marked dirty before writing. A block cached in between stayed in RAM with no tick to write it out. Today the flush on every MSC write hides this. Clear the flag before flushing and mark dirty after writing. This also changes ports whose write path calls supervisor_flash_flush() on a page change: external_flash.c, nordic, stm, silabs, mimxrt and cxd56. That flush cleared the flag and stopped the tick, so the newly cached page waited for the next write, sync or reload. Now the 1 s flush stays armed for it. raspberrypi and espressif flush the old page with port_internal_flash_flush() and are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- supervisor/shared/flash.c | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/supervisor/shared/flash.c b/supervisor/shared/flash.c index 61a6f03c444..ef7c32aadd9 100644 --- a/supervisor/shared/flash.c +++ b/supervisor/shared/flash.c @@ -178,27 +178,31 @@ static mp_uint_t flash_write_blocks(mp_obj_t self_in, const uint8_t *src, uint32 // can't write MBR, but pretend we did return 0; } else { - supervisor_flash_mark_dirty(); block_num -= PART1_START_BLOCK; #if CIRCUITPY_SAVES_PARTITION_SIZE > 0 mp_vfs_blockdev_t *self = (mp_vfs_blockdev_t *)self_in; block_num += self->offset / self->block_size; #endif - return supervisor_flash_write_blocks(src, block_num, num_blocks); + mp_uint_t result = supervisor_flash_write_blocks(src, block_num, num_blocks); + // Mark dirty after the write, so a flush running in another thread + // can't clear the flag between marking and caching the block. + supervisor_flash_mark_dirty(); + return result; } } void PLACE_IN_ITCM(supervisor_flash_flush)(void) { + // Clear the flag before flushing. A write that lands during the flush + // marks the filesystem dirty again and turns the tick back on. + if (filesystem_dirty) { + filesystem_dirty = false; + supervisor_disable_tick(); + } #if INTERNAL_FLASH_FILESYSTEM port_internal_flash_flush(); #else supervisor_external_flash_flush(); #endif - // Turn off ticks now that our filesystem has been flushed. - if (filesystem_dirty) { - supervisor_disable_tick(); - } - filesystem_dirty = false; } static mp_obj_t supervisor_flash_obj_readblocks(mp_obj_t self, mp_obj_t block_num, mp_obj_t buf) { From 89654009c01c7642a10e1c3063d0cff07c677020 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 15 Oct 2026 14:11:00 -0700 Subject: [PATCH 2/5] zephyr-cp: keep the idle flush armed after a page change When a write moved to a different flash page, _flash_write_block() flushed the old page with supervisor_flash_flush(). That also clears filesystem_dirty and stops the supervisor tick. littlefs marks the filesystem dirty before writing, so its newly cached page was never written by the background flush. Called from the USB MSC thread, it could also race the VM thread's background flush, with both seeing the flag set and both disabling the tick. Flush only the old page, as ports/raspberrypi does with port_internal_flash_flush(). Co-Authored-By: Claude Opus 5.5 (1M context) --- ports/zephyr-cp/supervisor/flash.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ports/zephyr-cp/supervisor/flash.c b/ports/zephyr-cp/supervisor/flash.c index 5acae94b177..d01e91f2c1c 100644 --- a/ports/zephyr-cp/supervisor/flash.c +++ b/ports/zephyr-cp/supervisor/flash.c @@ -552,7 +552,9 @@ static bool _flash_write_block(const uint8_t *data, uint32_t block) { return write_flash(address, data, FILESYSTEM_BLOCK_SIZE); } if (_current_page_address != NO_PAGE_LOADED) { - supervisor_flash_flush(); + // Flush only the old page. supervisor_flash_flush() would also + // clear the dirty flag, which littlefs sets before writing. + _flush_keep_cache(true); } if (flash_cache_table == NULL && !allocate_ram_cache()) { erase_page(_scratch_page_address); From 9e8694046dd69e7ddfde85cd5cf37bdffc316539 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 15 Oct 2026 14:11:00 -0700 Subject: [PATCH 3/5] zephyr-cp: don't flush flash on every USB write Zephyr's USB MSC class issues DISK_IOCTL_CTRL_SYNC at the end of every WRITE10, and _zephyr_disk_ioctl() turned that into a full flash flush. The host waited for a page erase and reprogram after each command, and a page written by consecutive commands, such as the FAT, was erased once per command. Leave the cached page for the next page change, the background flush and autoreload, as the TinyUSB ports do. Unlike them, eject doesn't flush: Zephyr's MSC class doesn't pass START STOP UNIT to the disk. Co-Authored-By: Claude Opus 5.5 (1M context) --- ports/zephyr-cp/supervisor/usb.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ports/zephyr-cp/supervisor/usb.c b/ports/zephyr-cp/supervisor/usb.c index b6e7d3c9d07..becbdee41b6 100644 --- a/ports/zephyr-cp/supervisor/usb.c +++ b/ports/zephyr-cp/supervisor/usb.c @@ -199,7 +199,8 @@ int _zephyr_disk_ioctl(struct disk_info *disk, uint8_t cmd, void *buff) { disk_ioctl(root, GET_SECTOR_SIZE, buff); return 0; case DISK_IOCTL_CTRL_SYNC: - disk_ioctl(root, CTRL_SYNC, buff); + // Zephyr's MSC class sends this after every WRITE10. Don't flush + // here; the background flush writes the cached page. autoreload_resume(AUTORELOAD_SUSPEND_USB); // This write is complete; initiate an autoreload if this was a file data or metadata write, From 053b92db0b9ac4829f4c6ab06932d3c368d2978f Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 15 Oct 2026 14:42:38 -0700 Subject: [PATCH 4/5] zephyr-cp: test the background flush after a page change Overwrite a sector, then one three pages later, and never flush or close the file. Only the background flush can write the last sector before the simulator is stopped. Fails on main, where the page change stopped the tick. Do the same on littlefs, which marks the filesystem dirty before writing. Fill and free the drive first so new blocks aren't erased, since writes to erased flash skip the page cache. Fails without the previous commit to ports/zephyr-cp/supervisor/flash.c. Co-Authored-By: Claude Opus 5.5 (1M context) --- ports/zephyr-cp/tests/test_flash.py | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/ports/zephyr-cp/tests/test_flash.py b/ports/zephyr-cp/tests/test_flash.py index 9af4c95ab14..2e806891506 100644 --- a/ports/zephyr-cp/tests/test_flash.py +++ b/ports/zephyr-cp/tests/test_flash.py @@ -192,3 +192,93 @@ def test_flash_256k_overwrite(circuitpython, board): circuitpython.flash_file, "overwrite.txt", board, erase_page_size=262144 ) assert content == "second version" + + +PAGE_CHANGE_CODE = """\ +import board +import storage +import time +# The display's auto refresh keeps the supervisor tick running, which would +# flush the cache whether or not the filesystem is marked dirty. +board.DISPLAY.auto_refresh = False +storage.remount("/", readonly=False) +# Program the sectors first. Writes to erased flash skip the page cache. +with open("/data.bin", "wb") as f: + f.write(bytes(16384)) +# Overwrite one sector, then one three pages later, so the last write moves +# the flash cache to a new page. Don't flush or close the file. +f = open("/data.bin", "r+b") +f.write(b"A" * 512) +f.seek(12288) +f.write(b"Z" * 512) +# The background flush runs after 1000 supervisor ticks. native_sim's 100 Hz +# kernel tick makes that about 10 seconds. +time.sleep(15) +print("slept") +while True: + time.sleep(1) +""" + + +@pytest.mark.circuitpy_drive({"code.py": PAGE_CHANGE_CODE}) +@pytest.mark.fat_filesystem_only +def test_flash_background_flush_after_page_change(circuitpython): + """Test that the background flush writes the page cached by a page change. + + The file is never flushed or closed, so only the background flush can + write the last sector before the simulator is stopped. + """ + circuitpython.serial.wait_for("slept") + circuitpython.shutdown() + + assert b"Z" * 512 in circuitpython.flash_file.read_bytes() + + +LFS_PAGE_CHANGE_CODE = """\ +import board +import os +import storage +import time +# The display's auto refresh keeps the supervisor tick running, which would +# flush the cache whether or not the filesystem is marked dirty. +board.DISPLAY.auto_refresh = False +storage.remount("/", readonly=False) +# littlefs writes new data to unused blocks, and writes to erased flash skip +# the page cache. Fill the free space and free it again so they don't. +f = open("/burn.bin", "wb") +chunk = bytes(8192) +try: + while True: + f.write(chunk) +except OSError: + pass +f.close() +os.remove("/burn.bin") +# Creating the file syncs, so the cache holds the metadata page. The first +# block of the file is on another page, so writing it changes pages. Don't +# flush or close the file. +f = open("/z.bin", "wb") +f.write(b"Z" * 512) +# The background flush runs after 1000 supervisor ticks. native_sim's 100 Hz +# kernel tick makes that about 10 seconds. +time.sleep(15) +print("slept") +while True: + time.sleep(1) +""" + + +@pytest.mark.circuitpy_drive({"code.py": LFS_PAGE_CHANGE_CODE}) +@pytest.mark.parametrize("board", ["native_native_sim_lfs"], indirect=True) +def test_flash_background_flush_after_page_change_lfs(circuitpython): + """Test that the background flush writes the page cached by a littlefs write. + + littlefs marks the filesystem dirty before writing, so the page change + must not clear the dirty flag. The file is never flushed or closed, so + only the background flush can write the block before the simulator is + stopped. + """ + circuitpython.serial.wait_for("slept") + circuitpython.shutdown() + + assert b"Z" * 512 in circuitpython.flash_file.read_bytes() From e55266ff5becf1b001fd6c3b691ca34c81d67cc2 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 15 Oct 2026 13:10:28 -0700 Subject: [PATCH 5/5] zephyr-cp: use a 4 KB USB MSC buffer With the default 512 byte SCSI buffer every sector of a write is its own round trip through the MSC thread and the disk layer, about 4 ms per sector on RP2040. A 4 KB buffer moves eight sectors at a time. It costs 7 KB of RAM with double buffering. Co-Authored-By: Claude Opus 5.5 (1M context) --- ports/zephyr-cp/prj.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index be3c56ef755..39b4206ff88 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -33,6 +33,7 @@ CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=n CONFIG_USBD_MSC_CLASS=y CONFIG_USBD_MSC_LUNS_PER_INSTANCE=1 +CONFIG_USBD_MSC_SCSI_BUFFER_SIZE=4096 CONFIG_HWINFO=y CONFIG_REBOOT=y