Persist flash writes to the backing file

The SPI NOR model read its image at realize and never wrote back, so the "flash"
was a g_malloc buffer: everything the firmware saved -- settings, edited
frequencies, channel data -- vanished when the QEMU process exited. That is the
"it behaves like RAM" the user reported, and the image on disk still had its
original mtime and was byte-identical to what make_flash.py produces.

Page-program and sector-erase now mark the image dirty, and it is written out when
chip select is released. Flushing there rather than per byte means one file write
per settings save instead of thousands, because the firmware's driver holds CS for
a whole erase-and-program sequence.

Written via a temporary file and rename: an interrupted flush must not leave a
truncated image, since that file is the only copy of the radio's state. A short
write keeps the previous image rather than replacing it with a partial one.

Also flushes from an exit notifier. Deselect covers normal operation, but QMP quit
-- which is what the web UI's power off sends -- can arrive with the chip still
selected, and the last write would be dropped.

tools/test_flash_persist.py covers it end to end on a copy of the image, so it
cannot disturb a running session. It asserts specific regions rather than just a
changed hash: flash 0x008100 (MR/VFO attributes) and 0x00A100 (settings), which are
the two sectors a boot demonstrably writes. Both were 0xFF before and non-0xFF
after, and sha256 moved from 933d6974 to 7cdff6ce.

Three approaches were tried and abandoned first, all for the same reason: driving
the guest from gdb. `call EEPROM_WriteBuffer` and `call SETTINGS_SaveSettings`
both hang, because the main loop is running and the called function waits on
hardware the debugger has frozen. Observing the file is simpler and closer to what
the user actually sees. An earlier version of the test also watched EEPROM offsets
instead of flash offsets and reported "same" for every region while persistence was
in fact working -- the two address spaces are related by the table in
App/driver/eeprom_compat.c, not equal.

keypad_test.py still passes, which matters because this file is where deleting
three fprintfs once silently removed the keypad.
This commit is contained in:
mckero committed 2026-08-28 12:52:06 +01:00
1 parent 3e743152c1
commit 23385d2eba
2 files changed
+294

No files matched your search

+82
View File
@@ -1008,8 +1008,24 @@ struct PY25Q16State {
uint32_t addr; uint32_t addr;
unsigned phase; /* bytes consumed since the command byte */ unsigned phase; /* bytes consumed since the command byte */
bool write_enabled; bool write_enabled;
/*
* Writes have to reach the backing file or nothing the firmware saves
* survives: settings, edited frequencies and channel data all live here, and
* on real hardware this is a physical part that keeps its contents with the
* power off.
*
* Flushing on every programmed byte would mean thousands of writes for one
* settings save, so a dirty flag is set here and the image is written out
* when the chip is deselected -- by which point the firmware's driver has
* finished the whole erase-and-program sequence.
*/
bool dirty;
Notifier exit_notifier;
}; };
static void py25q16_exit_notify(Notifier *n, void *data);
static uint8_t py25q16_xfer(void *opaque, uint8_t out) static uint8_t py25q16_xfer(void *opaque, uint8_t out)
{ {
PY25Q16State *s = opaque; PY25Q16State *s = opaque;
@@ -1049,6 +1065,7 @@ static uint8_t py25q16_xfer(void *opaque, uint8_t out)
if (s->write_enabled) { if (s->write_enabled) {
/* NOR can only clear bits without an erase. */ /* NOR can only clear bits without an erase. */
s->data[s->addr % PY25Q16_SIZE] &= out; s->data[s->addr % PY25Q16_SIZE] &= out;
s->dirty = true;
} }
s->addr++; s->addr++;
return 0xff; return 0xff;
@@ -1059,6 +1076,7 @@ static uint8_t py25q16_xfer(void *opaque, uint8_t out)
if (s->phase == 3 && s->write_enabled) { if (s->phase == 3 && s->write_enabled) {
const uint32_t sector = (s->addr / 0x1000) * 0x1000; const uint32_t sector = (s->addr / 0x1000) * 0x1000;
memset(s->data + (sector % PY25Q16_SIZE), 0xff, 0x1000); memset(s->data + (sector % PY25Q16_SIZE), 0xff, 0x1000);
s->dirty = true;
} }
} }
return 0xff; return 0xff;
@@ -1082,6 +1100,51 @@ static uint8_t py25q16_xfer(void *opaque, uint8_t out)
} }
} }
/*
* Write the image back to its file.
*
* Whole-file rather than a partial update: 2 MB is nothing on a host, and the
* alternative means tracking which sectors changed, which is more code and more to
* get wrong for no benefit here.
*
* Via a temporary file and rename so an interrupted flush cannot leave a truncated
* image behind -- the file is the only copy of the radio's settings, and losing it
* to a half-finished write would be worse than not persisting at all.
*/
static void py25q16_flush(PY25Q16State *s)
{
char *tmp_path;
FILE *fh;
if (!s->dirty || !s->image_path || !*s->image_path) {
return;
}
tmp_path = g_strdup_printf("%s.tmp", s->image_path);
fh = fopen(tmp_path, "wb");
if (!fh) {
warn_report("py25q16: cannot write %s, changes will be lost", tmp_path);
g_free(tmp_path);
return;
}
if (fwrite(s->data, 1, PY25Q16_SIZE, fh) != PY25Q16_SIZE) {
warn_report("py25q16: short write to %s, keeping the previous image",
tmp_path);
fclose(fh);
unlink(tmp_path);
g_free(tmp_path);
return;
}
fclose(fh);
if (rename(tmp_path, s->image_path) != 0) {
warn_report("py25q16: cannot replace %s", s->image_path);
unlink(tmp_path);
} else {
s->dirty = false;
}
g_free(tmp_path);
}
/* Chip select is active low. */ /* Chip select is active low. */
static void py25q16_set_cs(void *opaque, int line, int level) static void py25q16_set_cs(void *opaque, int line, int level)
{ {
@@ -1092,6 +1155,12 @@ static void py25q16_set_cs(void *opaque, int line, int level)
/* Deselect ends the command. */ /* Deselect ends the command. */
s->cmd = PY25Q16_CMD_NONE; s->cmd = PY25Q16_CMD_NONE;
s->phase = 0; s->phase = 0;
/*
* Flush here rather than per byte. The firmware's driver holds CS for a
* whole erase-and-program sequence, so this is once per settings save
* instead of once per programmed byte.
*/
py25q16_flush(s);
} }
s->selected = selected; s->selected = selected;
} }
@@ -1116,6 +1185,19 @@ static void py25q16_realize(DeviceState *dev, Error **errp)
} }
qdev_init_gpio_in_named(dev, py25q16_set_cs, "cs", 1); qdev_init_gpio_in_named(dev, py25q16_set_cs, "cs", 1);
/*
* Also flush at exit. Deselect covers the normal case, but QMP `quit` -- which
* is what the web UI's power off sends -- can arrive with the chip still
* selected, and the last write would be dropped.
*/
s->exit_notifier.notify = py25q16_exit_notify;
qemu_add_exit_notifier(&s->exit_notifier);
}
static void py25q16_exit_notify(Notifier *n, void *data)
{
py25q16_flush(container_of(n, PY25Q16State, exit_notifier));
} }
static Property py25q16_properties[] = { static Property py25q16_properties[] = {
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Flash writes must survive a power cycle.
The SPI NOR model keeps the image in a g_malloc buffer and only ever reads the
backing file, so everything the firmware saves -- settings, edited frequencies,
channel data -- disappears when the QEMU process exits. On real hardware that is a
physical part which holds its contents with the power off.
The check is deliberately blunt: boot, let the firmware run long enough to write
its settings, power off, and compare the image on disk. The firmware writes to
EEPROM during boot on its own (SETTINGS_InitEEPROM and the power-on save path), so
no UI navigation and no guest-side poking is needed.
Driving the guest from gdb was tried and abandoned: `call` into firmware functions
hangs, because the main loop is running and the called function waits on hardware
the debugger has effectively frozen. Observing the file is both simpler and closer
to the behaviour the user actually sees.
Boots its own emulator on a private socket and works on a copy of the image, so it
cannot disturb a running session or damage assets/flash.img.
Run: python3 tools/test_flash_persist.py
"""
import hashlib
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
SIM = os.path.dirname(HERE)
QEMU = os.path.expanduser("~/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm")
ELF = os.path.expanduser("~/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf")
SOURCE_IMAGE = os.path.join(SIM, "assets", "flash.img")
QMP = "/tmp/uvk5-persist-test.sock"
# Flash offsets the firmware demonstrably writes during a boot, measured rather than
# guessed. The mapping is in App/driver/eeprom_compat.c: these are *flash* addresses,
# not the EEPROM addresses the settings code uses, and an earlier version of this test
# watched EEPROM offsets by mistake and reported "same" for everything.
WATCH = [
("mr/vfo attrs", 0x008100, 0x20), # 1024 MR + 7 VFO attributes, 2 bytes each
("settings", 0x00A100, 0x20), # the settings block
]
class Qmp:
def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(25)
self.sock.connect(path)
self.buf = b""
self._readline()
self.command("qmp_capabilities")
def _readline(self):
while b"\n" not in self.buf:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("QMP closed")
self.buf += chunk
line, self.buf = self.buf.split(b"\n", 1)
return json.loads(line)
def command(self, name, **args):
msg = {"execute": name}
if args:
msg["arguments"] = args
self.sock.sendall(json.dumps(msg).encode() + b"\n")
while True:
reply = self._readline()
if "return" in reply:
return reply["return"]
if "error" in reply:
raise RuntimeError(reply["error"])
def close(self):
try:
self.sock.close()
except OSError:
pass
def boot(image):
if os.path.exists(QMP):
os.unlink(QMP)
proc = subprocess.Popen(
[QEMU, "-M", f"uv-k5-v3,flash-image={image}", "-nographic",
"-monitor", "none", "-qmp", f"unix:{QMP},server=on,wait=off",
"-kernel", ELF],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.time() + 25
while time.time() < deadline:
if os.path.exists(QMP):
return proc, Qmp(QMP)
time.sleep(0.1)
proc.kill()
raise RuntimeError("QMP socket never appeared")
def shutdown(proc, qmp):
"""Quit through QMP, which is exactly what the web UI's power off does."""
try:
qmp.command("quit")
except Exception:
pass # the socket usually drops first
qmp.close()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
if os.path.exists(QMP):
os.unlink(QMP)
def sha(path):
return hashlib.sha256(open(path, "rb").read()).hexdigest()
def snapshot(path):
data = open(path, "rb").read()
return {name: data[off:off + length] for name, off, length in WATCH}
def main():
for path, what in ((QEMU, "QEMU"), (ELF, "firmware"),
(SOURCE_IMAGE, "flash image")):
if not os.path.exists(path):
sys.exit(f"missing {what}: {path}")
workdir = tempfile.mkdtemp(prefix="uvk5-persist-")
image = os.path.join(workdir, "flash.img")
shutil.copy(SOURCE_IMAGE, image)
failures = []
try:
before_sha = sha(image)
before = snapshot(image)
print(f"image sha before boot: {before_sha[:16]}")
for name, _, _ in WATCH:
print(f" {name:9s} {before[name][:12].hex()}")
print("\nbooting, letting the firmware settle, then powering off")
proc, qmp = boot(image)
try:
time.sleep(20) # main loop plus a settings write
status = qmp.command("query-status")
print(f" guest: {status.get('status')}")
finally:
shutdown(proc, qmp)
after_sha = sha(image)
after = snapshot(image)
print(f"\nimage sha after power off: {after_sha[:16]}")
for name, _, _ in WATCH:
mark = "same" if after[name] == before[name] else "CHANGED"
print(f" {name:9s} {after[name][:12].hex()} {mark}")
if after_sha == before_sha:
failures.append(
"the image on disk is byte-identical after a boot and clean "
"power off, so nothing the firmware wrote to flash was saved")
else:
print("\nPASS the firmware's writes reached the file")
# Being specific matters: a changed hash alone could be almost anything.
# These are the regions holding channel and settings data.
unchanged = [n for n, _, _ in WATCH if after[n] == before[n]]
if unchanged:
failures.append(
f"the image changed but {', '.join(unchanged)} did not, so the "
"regions holding settings and channel data were not written")
else:
print("PASS the settings and channel regions were written")
# A second boot must see what the first one left behind.
print("\nbooting again from the same file")
proc, qmp = boot(image)
try:
time.sleep(20)
finally:
shutdown(proc, qmp)
third = snapshot(image)
if third != after:
changed = [n for n, _, _ in WATCH if third[n] != after[n]]
print(f" note: {', '.join(changed)} changed again on the second boot")
print(" (a stable image across reboots means state is being carried over)")
finally:
shutil.rmtree(workdir, ignore_errors=True)
print()
if failures:
for f in failures:
print("FAIL", f)
return 1
print("flash writes persist across a power cycle")
return 0
if __name__ == "__main__":
raise SystemExit(main())