diff --git a/qemu/py32f071.c b/qemu/py32f071.c index 3e82640..eac95fb 100644 --- a/qemu/py32f071.c +++ b/qemu/py32f071.c @@ -684,28 +684,30 @@ static void py32_spi_write(void *opaque, hwaddr addr, uint64_t value, unsigned s PY32SpiState *s = opaque; switch (addr) { + /* + * A DMA-driven transfer starts only once SPE and TXDMAEN are both set. + * + * TXDMAEN specifically, not "either direction": TX is what clocks the bus, so + * it is the gate. Both driver paths set it last: + * + * arm RX, arm TX, RXDMAEN, SPE, TXDMAEN + * + * Starting at SPE, when only RXDMAEN was set, ran the whole transfer while the + * TX channel was armed but not yet requesting. On the sector write-back that + * meant sending 4096 bytes read from BlackHole (0x200003D4, four zero bytes, + * no address increment) instead of SectorCache (0x200003D8), so the sector was + * programmed with zeros -- wiping the per-band VFO frequencies at 0x9000 and + * with them any frequency the user typed. + */ case SPI_CR1: s->cr1 = value; - /* - * SPE can be the last thing enabled. The flash driver's read path arms both - * channels, sets RXDMAEN, enables SPI, then sets TXDMAEN -- but the write - * path enables SPI last, so both orders have to work. - */ - if ((value & SPI_CR1_SPE) && (s->cr2 & (SPI_CR2_TXDMAEN | SPI_CR2_RXDMAEN)) - && s->dma_kick) { + if ((value & SPI_CR1_SPE) && (s->cr2 & SPI_CR2_TXDMAEN) && s->dma_kick) { s->dma_kick(s->dma, s); } break; case SPI_CR2: s->cr2 = value; - /* - * A DMA request is what actually starts the transfer on hardware. Kick the - * armed channels here rather than when they were enabled: at arm time the - * read command has not been clocked out yet, so the reply would be read - * before the device had anything to say. - */ - if ((value & (SPI_CR2_TXDMAEN | SPI_CR2_RXDMAEN)) - && (s->cr1 & SPI_CR1_SPE) && s->dma_kick) { + if ((value & SPI_CR2_TXDMAEN) && (s->cr1 & SPI_CR1_SPE) && s->dma_kick) { s->dma_kick(s->dma, s); } break; @@ -833,6 +835,25 @@ struct PY32DmaState { /* Set by the SoC: lets the DMA clock bytes through an SPI controller. */ PY32SpiState *spi[2]; + + /* + * The address space DMA transfers move bytes through. + * + * Must be the CPU's, not address_space_memory. This SoC builds its own + * container region and hands that to the ARMv7M core, and never registers it + * with the global system memory, so address_space_memory cannot decode SRAM at + * all: reads returned MEMTX_DECODE_ERROR with all-zero data and writes went + * nowhere. + * + * That single mistake accounted for every "flash forgets things" symptom. + * PY25Q16_WriteBuffer reads a 4 KB sector into SectorCache, patches it, and + * programs the whole sector back. The read appeared to work -- the model + * returned real 0xFF bytes -- but DMA dropped them on the floor, so the + * write-back sourced 4096 zeros and cleared the sector, VFO frequencies at + * 0x9000 included. Hence a typed frequency reverting to 18 MHz, which is + * simply BX4819_band1_lower after RADIO_ConfigureChannel read a zero. + */ + AddressSpace *as; }; static void py32_dma_update_irq(PY32DmaState *s) @@ -882,9 +903,15 @@ static PY32SpiState *py32_dma_spi_for(PY32DmaState *s, uint32_t paddr) */ static void py32_dma_run_for_spi(PY32DmaState *s, PY32SpiState *spi) { - AddressSpace *as = &address_space_memory; + AddressSpace *as = s->as; int tx = -1, rx = -1; + if (!as) { + /* Fail loudly rather than silently transferring zeros. */ + qemu_log_mask(LOG_GUEST_ERROR, "py32-dma: no address space configured\n"); + return; + } + for (int ch = 0; ch < PY32_DMA_CHANNELS; ch++) { PY32DmaChannel *c = &s->ch[ch]; if (!(c->ccr & DMA_CCR_EN) || c->cndtr == 0) { @@ -1596,6 +1623,8 @@ struct PY32F071State { MemoryRegion sram; MemoryRegion *board_memory; MemoryRegion container; + /* An address space over `container`, so DMA sees the same map as the CPU. */ + AddressSpace dma_as; }; /* Peripherals covered by the catch-all, in map order. */ @@ -1736,6 +1765,15 @@ static void py32f071_soc_realize(DeviceState *dev_soc, Error **errp) s->spi[i].dma = &s->dma; s->spi[i].dma_kick = py32_dma_kick; } + + /* + * DMA must move bytes through the CPU's address space. The container above is + * this SoC's whole memory map and is given only to the core, so the global + * address_space_memory cannot see SRAM -- reads through it fail with + * MEMTX_DECODE_ERROR and yield zeros. + */ + s->dma.as = &s->dma_as; + address_space_init(&s->dma_as, &s->container, "py32f071-dma"); if (!sysbus_realize(SYS_BUS_DEVICE(&s->dma), errp)) { return; } diff --git a/tools/test_freq_entry.py b/tools/test_freq_entry.py new file mode 100755 index 0000000..ff373a0 --- /dev/null +++ b/tools/test_freq_entry.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""A typed frequency must take effect and survive a power cycle. + +This is the user-visible bug that took four separate model faults to explain: + + 1. Page-program did not wrap within its 256-byte page, so a 512-byte burst at + 0x008F00 spilled into the VFO frequency area at 0x009000. + 2. DMA transfers started when a channel was enabled rather than when the + peripheral requested one, so a read ran before the command had been clocked. + 3. Each DMA channel ran to completion independently, so on a duplex transfer the + TX side finished before RX ever sampled the bus. + 4. DMA used address_space_memory, which cannot decode this SoC's SRAM at all -- + the container region is handed only to the CPU. Reads returned + MEMTX_DECODE_ERROR with zeros and writes went nowhere. + +Any one of them zeroed the sector that holds per-band frequencies, and +RADIO_ConfigureChannel substitutes the band's lower limit only for 0xFFFFFFFF, so a +stored zero was taken literally and clamped to BX4819_band1_lower -- 18 MHz. Hence +"the frequency will not change" and "it forgets after power off" were one bug. + +Drives the emulator over QMP with no debugger attached: the frequency input box +times out in about 2.5 s and a gdb attach takes longer, which silently clears the +box and invalidates the run. +""" +import gzip +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = 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") +PRISTINE = os.path.join(ROOT, "assets", "pristine", "flash-pristine.img.gz") + +BOOT_SECONDS = 20 + +# Flash layout, from App/driver/eeprom_compat.c: 14 VFO slots of 16 bytes at 0x9000, +# two slots per band. The frequency is the first word, in units of 10 Hz. +VFO_BASE = 0x9000 +BAND_STRIDE = 32 +WANT_MHZ = 435.0 +WANT_RAW = 43_500_000 +WANT_BAND = 5 # 400-470 MHz contains 435 + + +class Qmp: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(30) + self.sock.connect(path) + self.buf = b"" + self._read() # greeting + self.cmd("qmp_capabilities") + + def _read(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 cmd(self, name, **args): + msg = {"execute": name} + if args: + msg["arguments"] = args + self.sock.sendall(json.dumps(msg).encode() + b"\n") + while True: + reply = self._read() + if "return" in reply or "error" in reply: + return reply + + def press(self, key, hold=0.08): + self.cmd("qom-set", path="/machine/keypad", property="press", value=key) + time.sleep(hold) + self.cmd("qom-set", path="/machine/keypad", property="press", value="") + time.sleep(0.12) + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +def boot(image, sock_path): + if os.path.exists(sock_path): + os.unlink(sock_path) + proc = subprocess.Popen( + [QEMU, "-M", f"uv-k5-v3,flash-image={image}", "-nographic", + "-monitor", "none", "-qmp", f"unix:{sock_path},server=on,wait=off", + "-kernel", ELF], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if os.path.exists(sock_path): + break + time.sleep(0.1) + else: + proc.kill() + raise RuntimeError("QMP socket never appeared") + time.sleep(BOOT_SECONDS) + return proc + + +def shutdown(proc, qmp): + try: + qmp.cmd("quit") + except Exception: + pass + qmp.close() + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + proc.kill() + + +def stored_frequency(image, band, vfo=0): + with open(image, "rb") as fh: + data = fh.read() + off = VFO_BASE + band * BAND_STRIDE + vfo * 16 + return int.from_bytes(data[off:off + 4], "little") + + +def main(): + for path, what in ((QEMU, "QEMU"), (ELF, "firmware"), (PRISTINE, "pristine image")): + if not os.path.exists(path): + sys.exit(f"missing {what}: {path}") + + workdir = tempfile.mkdtemp(prefix="uvk5-freq-") + image = os.path.join(workdir, "flash.img") + sock_path = os.path.join(workdir, "qmp.sock") + with gzip.open(PRISTINE, "rb") as src, open(image, "wb") as dst: + shutil.copyfileobj(src, dst) + + failures = [] + try: + proc = boot(image, sock_path) + qmp = Qmp(sock_path) + + qmp.press("EXIT") + time.sleep(0.8) + + # F+1 leaves memory mode. It only does that while the VFO is on a memory + # channel; in frequency mode the same shortcut cycles the band instead. + qmp.press("F") + time.sleep(0.5) + qmp.press("1") + time.sleep(1.5) + + # Six digits back to back. The gap between them must stay well under + # key_input_timeout_500ms / 3, roughly 2.5 s, or the box clears. + start = time.time() + for digit in "435000": + qmp.press(digit) + elapsed = time.time() - start + print(f"typed 435000 in {elapsed:.2f}s") + if elapsed > 2.0: + failures.append(f"digits took {elapsed:.2f}s, close to the input timeout") + + time.sleep(5) # let the save reach flash + shutdown(proc, qmp) + + raw = stored_frequency(image, WANT_BAND) + print(f"stored in band{WANT_BAND}: " + f"{'blank' if raw == 0xFFFFFFFF else f'{raw / 100000:.5f} MHz'}") + if raw != WANT_RAW: + failures.append( + f"band{WANT_BAND} holds {raw:#x}, expected {WANT_RAW:#x} " + f"({WANT_MHZ} MHz)") + else: + print(f"PASS {WANT_MHZ} MHz was stored") + + # Other bands must be untouched, which is what a spilling write breaks. + clobbered = [b for b in range(7) + if b != WANT_BAND and stored_frequency(image, b) == 0] + if clobbered: + failures.append( + f"bands {clobbered} were zeroed -- a write spilled across sectors") + else: + print("PASS no other band was zeroed") + + # And it has to still be there after a power cycle. + proc = boot(image, sock_path) + qmp = Qmp(sock_path) + time.sleep(2) + shutdown(proc, qmp) + + raw_after = stored_frequency(image, WANT_BAND) + if raw_after != WANT_RAW: + failures.append( + f"after a power cycle band{WANT_BAND} holds {raw_after:#x}, " + f"expected {WANT_RAW:#x}") + else: + print(f"PASS {WANT_MHZ} MHz survived a power cycle") + + finally: + shutil.rmtree(workdir, ignore_errors=True) + + if failures: + print() + for f in failures: + print(f"FAIL {f}") + sys.exit(1) + print("\na typed frequency takes effect and persists") + + +if __name__ == "__main__": + main()