From c0a09827ed66d94b0a546b0c3f0aeb2ab4795056 Mon Sep 17 00:00:00 2001 From: MCKero Date: Thu, 27 Aug 2026 14:59:21 +0100 Subject: [PATCH] UV-K5 V3 emulator: QEMU machine for the PY32F071 Adds a QEMU machine for the Puya PY32F071 (Cortex-M0+) so Quansheng UV-K5 V3 firmware can run on a PC. The firmware boots to its main loop in about five seconds and the LCD contents are readable. Register layouts come from the vendor CMSIS header shipped with the firmware rather than guesswork. Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1 and the PY25Q16 flash; everything else answers through a logging catch-all, which is how the next thing worth modelling gets identified. Seven things had to be right before it would boot, each found by watching where the firmware stopped: flash aliased at the application offset, clock ready bits, self-clearing ADC calibration, SPI transfer flags, DMA-driven flash reads, SysTick poll acceleration, and the bit-banged transceiver bus idling low. SysTick needs explanation. SYSTICK_DelayUs polls the counter and accumulates differences; under emulation a register read costs far more relative to guest time, so a measured 120 ms delay would have taken about 7.7 hours. Lowering the clock does not help because the bottleneck is loop iterations, not counter speed. Reporting a value that runs ahead of the real counter does, via a new poll-boost property on SysTick. Guest time therefore runs fast during delays: fine for exercising menus and control flow, wrong for judging signal timing. Also includes the host build of the CW timing chain (harness, stubs, shim, tests), which compiles app/cwkeyer.c and app/cwmacro.c unmodified against stub drivers with a virtual clock and scripted paddle input. Known gap: keypad rows reach the firmware's scan and KEYBOARD_Poll returns the right key code, but the UI does not react yet. Not modelled, and not intended to be: radio behaviour. The transceiver chip has no public datasheet, so keying envelopes and emissions need real hardware. --- .gitignore | 9 + CMakeLists.txt | 74 ++ PERIPHERALS.md | 76 ++ README.md | 167 ++++ assets/README.md | 34 + assets/calibration.bin | Bin 0 -> 512 bytes harness/sim_capture.c | 48 + harness/sim_clock.c | 37 + harness/sim_clock.h | 29 + harness/sim_keyer.c | 75 ++ harness/sim_keyer.h | 27 + harness/sim_paddle.c | 60 ++ harness/sim_paddle.h | 45 + harness/sim_record.c | 131 +++ harness/sim_record.h | 50 + qemu/armv7m_systick.c.patched | 350 +++++++ qemu/armv7m_systick.h.patched | 61 ++ qemu/py32f071.c | 1754 +++++++++++++++++++++++++++++++++ shim/py32f071_ll_bus.h | 11 + shim/py32f071_ll_dma.h | 11 + shim/py32f071_ll_gpio.h | 99 ++ shim/py32f071_ll_rcc.h | 11 + shim/py32f071_ll_tim.h | 11 + shim/py32f071_ll_usart.h | 11 + shim/py32f0xx.h | 11 + stubs/cwhardware_debounce.c | 83 ++ stubs/cwhardware_stub.c | 53 + stubs/driver_stubs.c | 108 ++ stubs/firmware_globals.c | 66 ++ tests/test_iambic_basic.c | 137 +++ tools/boot_time.sh | 33 + tools/col_probe.sh | 75 ++ tools/delay_rate.sh | 42 + tools/gpio_watch.py | 127 +++ tools/gpiob_dump.sh | 29 + tools/hold_and_trace.sh | 53 + tools/key.py | 121 +++ tools/key_result.sh | 59 ++ tools/keypad_probe.sh | 66 ++ tools/keytest.sh | 53 + tools/make_flash.py | 53 + tools/pc.sh | 29 + tools/poll_watch.sh | 56 ++ tools/press_and_shot.sh | 54 + tools/qom_get.py | 55 ++ tools/qom_ls.py | 54 + tools/run.sh | 26 + tools/scan_trace.sh | 62 ++ tools/scan_watch.sh | 56 ++ tools/screenshot.py | 176 ++++ tools/trace_run.sh | 37 + tools/where.sh | 28 + tools/wiring_check.py | 85 ++ 53 files changed, 5068 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 PERIPHERALS.md create mode 100644 README.md create mode 100644 assets/README.md create mode 100644 assets/calibration.bin create mode 100644 harness/sim_capture.c create mode 100644 harness/sim_clock.c create mode 100644 harness/sim_clock.h create mode 100644 harness/sim_keyer.c create mode 100644 harness/sim_keyer.h create mode 100644 harness/sim_paddle.c create mode 100644 harness/sim_paddle.h create mode 100644 harness/sim_record.c create mode 100644 harness/sim_record.h create mode 100644 qemu/armv7m_systick.c.patched create mode 100644 qemu/armv7m_systick.h.patched create mode 100644 qemu/py32f071.c create mode 100644 shim/py32f071_ll_bus.h create mode 100644 shim/py32f071_ll_dma.h create mode 100644 shim/py32f071_ll_gpio.h create mode 100644 shim/py32f071_ll_rcc.h create mode 100644 shim/py32f071_ll_tim.h create mode 100644 shim/py32f071_ll_usart.h create mode 100644 shim/py32f0xx.h create mode 100644 stubs/cwhardware_debounce.c create mode 100644 stubs/cwhardware_stub.c create mode 100644 stubs/driver_stubs.c create mode 100644 stubs/firmware_globals.c create mode 100644 tests/test_iambic_basic.c create mode 100755 tools/boot_time.sh create mode 100755 tools/col_probe.sh create mode 100755 tools/delay_rate.sh create mode 100644 tools/gpio_watch.py create mode 100755 tools/gpiob_dump.sh create mode 100755 tools/hold_and_trace.sh create mode 100644 tools/key.py create mode 100755 tools/key_result.sh create mode 100755 tools/keypad_probe.sh create mode 100755 tools/keytest.sh create mode 100644 tools/make_flash.py create mode 100755 tools/pc.sh create mode 100755 tools/poll_watch.sh create mode 100755 tools/press_and_shot.sh create mode 100644 tools/qom_get.py create mode 100644 tools/qom_ls.py create mode 100755 tools/run.sh create mode 100755 tools/scan_trace.sh create mode 100755 tools/scan_watch.sh create mode 100644 tools/screenshot.py create mode 100755 tools/trace_run.sh create mode 100755 tools/where.sh create mode 100644 tools/wiring_check.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e80892 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Generated flash image: 2 MB, rebuilt from calibration.bin by tools/make_flash.py. +assets/flash.img + +# Host build output for the CW timing harness. +build/ + +# Transient debug captures. +*.log +*.png diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9a074fe --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,74 @@ +# Host build of the CW timing chain. No cross-compiler, no linker script. +# +# Compiles the real app/cwkeyer.c, app/cwmacro.c and app/cwhardware.c against +# stub drivers, so the tests exercise firmware code rather than a reimplementation. +# The one exception is CW_ReadKeysForMode / pin configuration in cwhardware.c, +# which read GPIO directly -- those are replaced, and the debounce above them is +# not (it is part of the timing behaviour under test). + +cmake_minimum_required(VERSION 3.20) +project(uvk5_sim C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +# Path to the firmware checkout. Override with -DFIRMWARE_DIR=... if the layout +# differs from the sibling clone this defaults to. +set(FIRMWARE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../uvk5-sat" CACHE PATH + "Firmware source tree containing App/") +if(NOT EXISTS "${FIRMWARE_DIR}/App/app/cwkeyer.c") + message(FATAL_ERROR + "FIRMWARE_DIR does not look like the firmware tree: ${FIRMWARE_DIR}\n" + "Pass -DFIRMWARE_DIR=/path/to/uvk5-sat") +endif() + +add_library(sim_harness STATIC + harness/sim_clock.c + harness/sim_paddle.c + harness/sim_record.c + harness/sim_keyer.c + stubs/driver_stubs.c + stubs/cwhardware_stub.c + stubs/firmware_globals.c +) + +# Firmware sources compiled as-is. +target_sources(sim_harness PRIVATE + ${FIRMWARE_DIR}/App/app/cwkeyer.c + ${FIRMWARE_DIR}/App/app/cwmacro.c +) + +target_include_directories(sim_harness PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/shim # replaces MCU headers + ${FIRMWARE_DIR}/App +) + +# ENABLE_CW_MODULATOR selects the CW code paths; the rest stay off so the stub +# surface remains small. CW_KEYER_DEBUG routes the firmware's own tracing into +# the recorder, which is useful when a scenario fails. +target_compile_definitions(sim_harness PUBLIC + ENABLE_CW_MODULATOR + ENABLE_FEAT_NR7Y_CW +) + +target_compile_options(sim_harness PRIVATE + -Wall -Wextra -Wno-unused-parameter + -g -O1 + -fsanitize=address,undefined +) +target_link_options(sim_harness PUBLIC -fsanitize=address,undefined) + +enable_testing() + +# Each scenario file is its own executable so a crash in one does not hide the +# rest, and so ASan reports point at a single scenario. +file(GLOB SIM_TESTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_*.c) +foreach(test_src ${SIM_TESTS}) + get_filename_component(test_name ${test_src} NAME_WE) + add_executable(${test_name} ${test_src}) + target_link_libraries(${test_name} PRIVATE sim_harness) + target_compile_options(${test_name} PRIVATE + -Wall -Wextra -g -O1 -fsanitize=address,undefined) + add_test(NAME ${test_name} COMMAND ${test_name}) +endforeach() diff --git a/PERIPHERALS.md b/PERIPHERALS.md new file mode 100644 index 0000000..1ea0bc8 --- /dev/null +++ b/PERIPHERALS.md @@ -0,0 +1,76 @@ +# Peripherals a whole-machine simulation has to answer for + +Derived from the firmware's own boot path (`App/main.c` → `BOARD_Init`) rather +than from the datasheet, so the list is what this firmware actually touches. + +Order matters: the boot sequence stops at the first peripheral that does not +answer plausibly, so they have to be brought up roughly in this order. + +## Tier 1 — required to reach the main loop + +| Peripheral | Firmware entry | What the model must do | Notes | +| --- | --- | --- | --- | +| Cortex-M0+ core, NVIC, SysTick | `Core/startup_py32f071xx.s`, `SYSTICK_Init` | execute Thumb, deliver SysTick | 53 vectors in the table | +| RCC (clock tree) | `BOARD_Init` | report clocks ready, accept enables | firmware polls ready flags | +| FLASH controller | `FLASH_Init` | accept latency/prefetch writes | reads come from the ELF image | +| GPIO A/B/C/F | `GPIO_Init` | hold direction/pull state, report input levels | keypad and PTT live here | + +Milestone: firmware reaches `while (true)` without faulting. + +## Tier 2 — required to see anything + +| Peripheral | Firmware entry | What the model must do | Notes | +| --- | --- | --- | --- | +| SPI → ST7565 LCD | `driver/st7565.c` | decode page/column addressing into a 128×64 framebuffer | 12 LL calls; the display is the main observable | +| Keypad matrix | `driver/keyboard.c` | drive rows, report the pressed column | injected from the front end | +| SPI → PY25Q16 flash | `driver/py25q16.c` | commands 0x03/0x02/0x20/0x9F over a 2 MB file | 66 LL calls — the heaviest driver | +| ADC | `ADC_Init`, `helper/battery.c` | return a plausible battery reading | a flat value is enough at first | + +Milestone: boot logo appears, keys navigate the menu. + +Note on the flash model: calibration data lives in it. Without a real dump the +firmware takes error branches in the frequency and power paths, so a dump +exported by UV Studio should be loaded into the image. + +## Tier 3 — radio behaviour + +| Peripheral | Firmware entry | What the model must do | Notes | +| --- | --- | --- | --- | +| BK4829 (SPI) | `driver/bk4829.c` | track frequency, bandwidth, modulation, power, CTCSS/DCS, and carrier keying; allow RSSI injection | no public datasheet — the driver *is* the specification | +| BK1080 (FM RX) | `BK1080_Init` | accept register writes, report a tuned state | only needed for the FM broadcast feature | + +Milestone: scanning, Fox Hunt and the CW timing chain run end to end. + +What this tier can and cannot give: it reproduces *what the firmware commanded* +— frequency, power step, keying envelope in time — which is enough to catch +wrong-VFO transmissions, missing carrier releases and bad key timing. It does +not reproduce the analogue result: modulation quality, spurious emissions, +receiver sensitivity. Those need a spectrum analyser on real hardware. + +## Tier 4 — host connectivity + +| Peripheral | Firmware entry | What the model must do | Notes | +| --- | --- | --- | --- | +| UART | `UART_Init` | expose a PTY | debug tracing | +| USB CDC | `VCP_Init`, `App/usb/` | expose a virtual serial device | this is the payoff — see below | + +The USB CDC path is the cheapest route to a web front end. The Fusion build +already streams its screen to UV Studio's K5Viewer over USB serial and accepts +remote key presses, so pointing the emulated CDC endpoint at a PTY lets the +existing UV Studio page act as the simulator's UI. Screen mirroring and the +virtual keypad are already written; they do not need reimplementing. + +## Memory map (from `Core/py32f071xb.ld`) + + FLASH 0x08002800 118 KB application (0x08000000..0x08002800 is the bootloader) + RAM 0x20000000 16 KB + +The non-zero flash origin matters: loading the application at 0x08000000 puts +the vector table in the wrong place and the machine faults immediately. + +## Bootloader + +DFU lives in the first 10 KB and is a separate image. Deciding to emulate it too +is worthwhile — flashing is the operation most likely to brick real hardware, and +V3 enters DFU with PTT + side key 2 + power — but it is a distinct target from +the application. diff --git a/README.md b/README.md new file mode 100644 index 0000000..522b701 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# UV-K5 V3 emulator + +Runs Quansheng UV-K5 V3 / UV-K1 firmware on a PC. The radio uses a Puya +PY32F071 (Cortex-M0+), which QEMU has no machine for, so this adds one. + +The firmware boots to its main loop in about five seconds and the LCD contents +are readable. Keypresses reach the firmware's scan but are not yet acted on -- +see [Status](#status). + +## What it is for + +Editing firmware and reflashing a radio to test one line is slow, and some bugs +are invisible from the outside. A recent example: CW macro recording appeared to +do nothing, and the cause was three layers down -- the keyer was being torn down +by a later call that recomputed its state from the wrong VFO. On hardware you see +"nothing happens"; here you can read the actual variables. + +What it does **not** do is model radio behaviour. It reproduces what the firmware +*commanded* -- frequency, power step, carrier keying in time -- not the analogue +result. Keying envelopes, spurious emissions and sensitivity need a real radio and +a spectrum analyser. That is not a gap to be closed later; the transceiver chip +has no public datasheet, so its driver is the only specification available. + +## Status + +| Area | State | +| --- | --- | +| Boot to main loop | works, ~5 s | +| LCD contents | readable via `tools/screenshot.py` | +| SPI flash, settings, calibration | works | +| Keypad matrix | rows reach the firmware's scan (`KEYBOARD_Poll` returns the right key code) but the UI does not react — under investigation | +| Timing accuracy | deliberately wrong, see [Timing](#timing) | +| Radio/RF behaviour | not modelled | + +## Layout + + qemu/ QEMU sources to be copied into a QEMU tree + py32f071.c the SoC and machine (the bulk of the work) + armv7m_systick.*.patched SysTick with the poll-boost property added + assets/ + calibration.bin 512-byte dump from a real radio + tools/ run, screenshot, inject keys, probe state + harness/, stubs/, shim/, tests/ host build of the CW timing chain (stage A) + +## Building + +Needs a QEMU 7.2 source tree, `meson`, `ninja`, `libfdt-dev`, `libglib2.0-dev`, +`libpixman-1-dev`. + + # 1. Drop the sources into a QEMU tree + cp qemu/py32f071.c $QEMU/hw/arm/ + cp qemu/armv7m_systick.c.patched $QEMU/hw/timer/armv7m_systick.c + cp qemu/armv7m_systick.h.patched $QEMU/include/hw/timer/armv7m_systick.h + + # 2. Register the machine. In $QEMU/hw/arm/Kconfig: + # config UVK5_V3 + # bool + # default y + # depends on TCG && ARM + # select PY32F071_SOC + # config PY32F071_SOC + # bool + # select ARM_V7M + # select UNIMP + # In $QEMU/hw/arm/meson.build: + # arm_ss.add(when: 'CONFIG_UVK5_V3', if_true: files('py32f071.c')) + + # 3. Build just the ARM target + cd $QEMU + ./configure --target-list=arm-softmmu --disable-docs --disable-tools + cd build && ninja qemu-system-arm + +## Running + + python3 tools/make_flash.py # once, builds assets/flash.img + tools/run.sh # starts the machine + + tools/where.sh # where the firmware is executing + python3 tools/screenshot.py --frame-addr 0x200013DC \ + --status-addr 0x2000175C --port 1234 --out screen.png + python3 tools/key.py MENU # inject a keypress + tools/gpiob_dump.sh # GPIOB registers + +The machine exposes a GDB stub on port 1234 and a QMP socket at +`/tmp/uvk5-qmp.sock`. It is headless: the screen is read out of guest memory +rather than drawn, so no display backend is needed. + +Screenshots need the addresses of `gFrameBuffer` and `gStatusLine`, which move +between builds. Find them with: + + arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine' + +## How the machine is put together + +Register layouts come from the vendor CMSIS header shipped with the firmware +(`Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h`), not from guesswork. + + FLASH 0x08000000 128 KB application at +0x2800, bootloader below it + SRAM 0x20000000 16 KB + RCC 0x40021000 + GPIO 0x50000000 ports A, B, C, F at 0x400 intervals + SPI1 0x40013000 display + SPI2 0x40003800 flash + ADC1 0x40012400 + +Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1, and the PY25Q16 flash. +Everything else answers through a logging catch-all — the log is how the next +thing worth modelling gets identified. + +Seven things had to be right before the firmware would boot, each found by +watching where it stopped: + +- **Flash alias at the application offset.** The core fetches its vector table + from address 0, and the image loads at 0x08002800, so 0 has to alias there and + not at the flash base. +- **Clock ready bits.** `BOARD_Init` polls them; each enable bit is mirrored into + its ready bit. +- **ADC calibration.** `CR2.CAL` is write-1-to-start and hardware-cleared, so it + must never be stored set or the wait loop never exits. +- **SPI flags.** Transfers complete inside the register write, so TXE stays + asserted and RXNE is raised by the write. +- **DMA.** The flash driver never touches the SPI data register — it arms + channels 4 and 5, enables the transfer-complete interrupt and spins on a flag + its ISR sets. +- **SysTick.** See below. +- **Transceiver data line.** `RADIO_SetupRegisters` waits for bit 0 of the + BK4819 REG_0C to clear. The bus is bit-banged over GPIO, so PB9 idles low until + that bus has a real model, making reads return zero. + +## Timing + +`SYSTICK_DelayUs` polls the SysTick counter and accumulates differences. On +hardware each loop iteration advances the counter by tens of ticks; under +emulation a register read costs far more relative to guest time, so the counter +barely moves per read. Measured: a 120 ms delay advanced 832 of 5,760,000 +required ticks in four seconds — about 7.7 hours to complete. + +Lowering the clock does not help, which is worth knowing before trying it: the +bottleneck is loop iterations per second, not counter speed. Dropping 48 MHz to +200 Hz gained only 32x. + +What works is reporting a counter value that runs ahead of the real one, growing +with every read. The `poll-boost` property on SysTick does that. Two earlier +attempts wrote the value back into the timer instead, which made each read +re-anchor the count — the reported value stopped changing, the firmware's +`if (cur != prev)` guard never fired, and the loop hung outright. + +The consequence is that guest time runs fast during any delay. Fine for +exercising menus and control flow; wrong for judging signal timing. + +## Stage A: the CW timing chain on the host + +`harness/`, `stubs/`, `shim/` and `tests/` compile `app/cwkeyer.c` and +`app/cwmacro.c` unmodified against stub drivers, with a virtual clock and +scripted paddle input. Feed a timeline of contact closures, assert on the decoded +characters and element durations. + +Firmware sources are compiled as-is on purpose. Editing them to make them build +on a host would let the tests drift from what the radio runs. The debounce in +`CW_ReadKeys` is transcribed rather than stubbed, because its asymmetry (three +consecutive reads to register a press, immediate release) is part of the timing +behaviour under test. + +## Credits + +Base firmware: [armel/uv-k1-k5v3-firmware-custom](https://github.com/armel/uv-k1-k5v3-firmware-custom). +Register definitions from the vendor CMSIS headers. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..8eb20be --- /dev/null +++ b/assets/README.md @@ -0,0 +1,34 @@ +# Simulator assets + +## calibration.bin — 512 bytes + +A calibration dump from the user's own radio. Loaded into the virtual SPI flash +at physical `0x010000`, which is where `App/driver/eeprom_compat.c` maps the +512-byte calibration block (`_MK_MAPPING(0x010000, 0x00B000, 0x00B200)`). + +Without it the firmware takes error branches in the frequency and power paths, +so the machine would boot into a state that does not represent the real radio. + +### Verified contents + +Checked against the offsets `SETTINGS_LoadCalibration()` actually reads: + +| Offset | Field | Value | Sanity | +| --- | --- | --- | --- | +| 0x000–0x0BF | per-band TX power curves | 10 ascending bytes per row, 0xFF padding | plausible power steps | +| 0x0C0 | `gEEPROM_RSSI_CALIB[3]` | 110, 120, 130, 140 | ascending | +| 0x0C8 | `gEEPROM_RSSI_CALIB[0]` | 180, 190, 200, 210 | ascending | +| 0x140 | `gBatteryCalibration` (6×u16) | 1426, 1978, 2125, 2155, 2271, 2600 | strictly ascending, matches a Li-ion curve | + +47% of the file is 0xFF, consistent with a real dump: each power row uses 10 of +its 16 bytes and the remainder is erased flash. + +The file name the user supplied said "not necessarily accurate"; the structure +above is self-consistent, so it is being treated as usable. If the emulated radio +later shows implausible power or battery readings, this is the first thing to +re-dump. + +### How to refresh + +Export from a real radio with [UV Studio](https://armel.github.io/uvtools2/?mode=dump) +(Dump Calib), then replace this file. diff --git a/assets/calibration.bin b/assets/calibration.bin new file mode 100644 index 0000000000000000000000000000000000000000..662c35f270f532f35cd43449436419d2ba14c356 GIT binary patch literal 512 zcmd;zOH58p&&@b1*Y8 zF+t@)Tvk>V78VHK2xxL)acOyFHH5DLG&!%JsHCg{!gn;%)lgBClM)w$@ZHRT0;)=K z(vlE6%_^4KW!RJ9;$vOjE`>KB-UN*z8u*c_c=7UKt{?j7%{jp zL^0$6T{Vqi70^x37(n783>pkJ3;_%&K=<}AEC9Op4p^LnPgp_@D9-@Y6~JK52U1H* Qpqc&uXytnbu-zbg08qj<>Hq)$ literal 0 HcmV?d00001 diff --git a/harness/sim_capture.c b/harness/sim_capture.c new file mode 100644 index 0000000..a1f5246 --- /dev/null +++ b/harness/sim_capture.c @@ -0,0 +1,48 @@ +/* Captures decoded characters without replacing firmware functions. + * + * app/cwmacro.c owns CW_AddToTxDisplay and gCW_TX_Display, and it is compiled in + * unmodified, so the simulator cannot intercept the call. Instead the tick loop + * samples the firmware's own display buffer once per virtual millisecond and + * records whatever is newly appended. + * + * Sampling rather than intercepting has a side benefit: it observes exactly what + * the radio would show on its centre line, including the buffer's own shifting + * and truncation behaviour. + */ + +#include + +#include "sim_record.h" + +extern char gCW_TX_Display[24]; + +static char s_seen[sizeof(gCW_TX_Display)]; +static unsigned s_seen_len; + +void SIM_CaptureReset(void) +{ + memset(s_seen, 0, sizeof(s_seen)); + s_seen_len = 0; +} + +void SIM_CapturePoll(void) +{ + const unsigned len = (unsigned)strnlen(gCW_TX_Display, sizeof(gCW_TX_Display)); + + if (len == s_seen_len && memcmp(s_seen, gCW_TX_Display, len) == 0) + return; // unchanged + + // The buffer scrolls once full, so match the longest common prefix and treat + // the remainder as new. A scroll shortens the prefix, which is still handled + // correctly: the shifted-in characters get recorded once. + unsigned common = 0; + while (common < len && common < s_seen_len && s_seen[common] == gCW_TX_Display[common]) + common++; + + for (unsigned i = common; i < len; i++) + SIM_RecordChar(gCW_TX_Display[i]); + + memcpy(s_seen, gCW_TX_Display, len); + s_seen[len] = '\0'; + s_seen_len = len; +} diff --git a/harness/sim_clock.c b/harness/sim_clock.c new file mode 100644 index 0000000..156d282 --- /dev/null +++ b/harness/sim_clock.c @@ -0,0 +1,37 @@ +#include "sim_clock.h" + +#include + +static uint32_t s_now_ms; +static SIM_TickFn s_tick; + +void SIM_ClockReset(void) +{ + s_now_ms = 0; + // The tick callback is deliberately left alone: tests register it once and + // reset the clock between scenarios. +} + +uint32_t SIM_ClockNow(void) +{ + return s_now_ms; +} + +void SIM_ClockAdvanceRaw(uint32_t ms) +{ + s_now_ms += ms; +} + +void SIM_ClockSetTick(SIM_TickFn fn) +{ + s_tick = fn; +} + +void SIM_ClockRun(uint32_t ms) +{ + for (uint32_t i = 0; i < ms; i++) { + s_now_ms++; + if (s_tick != NULL) + s_tick(); + } +} diff --git a/harness/sim_clock.h b/harness/sim_clock.h new file mode 100644 index 0000000..e5db5a9 --- /dev/null +++ b/harness/sim_clock.h @@ -0,0 +1,29 @@ +/* Virtual millisecond clock. + * + * The keyer is a timing machine polled once per millisecond by the real main + * loop. Tests drive that loop explicitly instead of sleeping, so a 30-second + * exchange completes in microseconds and behaves identically every run. + */ + +#ifndef SIM_CLOCK_H +#define SIM_CLOCK_H + +#include + +void SIM_ClockReset(void); +uint32_t SIM_ClockNow(void); + +// Advances the clock without running anything. Used by SYSTEM_DelayMs, where +// the firmware blocks and the keyer is not polled. +void SIM_ClockAdvanceRaw(uint32_t ms); + +// Advances one millisecond at a time, invoking the registered tick callback +// after each step. This is the simulator's stand-in for the main loop. +void SIM_ClockRun(uint32_t ms); + +// Called once per virtual millisecond by SIM_ClockRun. Set this to the function +// under test (CW_AppUpdate on hardware, or CW_HandleState directly). +typedef void (*SIM_TickFn)(void); +void SIM_ClockSetTick(SIM_TickFn fn); + +#endif diff --git a/harness/sim_keyer.c b/harness/sim_keyer.c new file mode 100644 index 0000000..7a46d91 --- /dev/null +++ b/harness/sim_keyer.c @@ -0,0 +1,75 @@ +#include "sim_keyer.h" + +#include "app/cwkeyer.h" +#include "app/cwmacro.h" +#include "misc.h" +#include "settings.h" +#include "sim_clock.h" +#include "sim_paddle.h" +#include "sim_record.h" + +void SIM_EepromReset(void); // stubs/driver_stubs.c +void SIM_CaptureReset(void); // harness/sim_capture.c +void SIM_CapturePoll(void); + +// The firmware's own constants (cwkeyer.c): 1200 ms / WPM is one dit. +#define SIM_TICKS_PER_MINUTE 60000U +#define SIM_DITS_PER_WORD 50U + +static uint8_t s_wpm = 18; + +// Translates one poll of the keyer into recorded carrier events. Mirrors the +// RF-path switch in app/cwapp.c: HOLD_ON means "already keyed, stay keyed", so +// only the ON/OFF transitions are edges. +static void tick(void) +{ + switch (CW_HandleState()) { + case CW_ACTION_CARRIER_ON: + SIM_RecordCarrier(true); + break; + case CW_ACTION_CARRIER_OFF: + SIM_RecordCarrier(false); + break; + default: + break; + } + + // Sample the firmware's display buffer for newly decoded characters. + SIM_CapturePoll(); +} + +void SIM_KeyerBegin(uint8_t key_input, uint8_t keyer_mode, uint8_t wpm) +{ + SIM_ClockReset(); + SIM_PaddleReset(); + SIM_RecordReset(); + SIM_EepromReset(); + SIM_CaptureReset(); + + s_wpm = wpm; + + gEeprom.CW_KEY_INPUT = key_input; + gEeprom.CW_KEY_WPM = wpm; + gEeprom.CW_KEYER_MODE = (CW_IambicMode_t)keyer_mode; + + CW_KeyerResetRuntime(); + CW_KeyerReconfigure(true); + + SIM_ClockSetTick(&tick); + + // CW_HandleState defers its pending init until it sees an idle word gap, so + // give it that before the scripted timeline starts. Without this the first + // element of every scenario would be swallowed by the configuration apply. + SIM_ClockRun(8U * SIM_KeyerDitMs()); + SIM_RecordReset(); +} + +void SIM_KeyerRun(uint32_t tail_ms) +{ + SIM_ClockRun(SIM_PaddleTotalMs() - SIM_ClockNow() + tail_ms); +} + +uint32_t SIM_KeyerDitMs(void) +{ + return SIM_TICKS_PER_MINUTE / ((uint32_t)s_wpm * SIM_DITS_PER_WORD); +} diff --git a/harness/sim_keyer.h b/harness/sim_keyer.h new file mode 100644 index 0000000..438376e --- /dev/null +++ b/harness/sim_keyer.h @@ -0,0 +1,27 @@ +/* Test-facing driver for the CW keyer. + * + * Wraps the firmware's CW_HandleState() in the polling loop the real main loop + * provides, and translates its returned action into recorded carrier events. + * A scenario is: configure, script a paddle timeline, run, assert. + */ + +#ifndef SIM_KEYER_H +#define SIM_KEYER_H + +#include +#include + +// Resets clock, paddle script, recorder, keyer state and EEPROM, then applies +// the given configuration. `key_input` is a CW_KEY_INPUT_* bitmap value (see +// settings.h); `keyer_mode` is a CW_IambicMode_t. +void SIM_KeyerBegin(uint8_t key_input, uint8_t keyer_mode, uint8_t wpm); + +// Runs the polling loop for the whole queued paddle timeline, plus `tail_ms` of +// idle time so trailing character/word gaps can be detected. +void SIM_KeyerRun(uint32_t tail_ms); + +// One dit at the configured speed, in milliseconds. The reference for asserting +// element durations: a dah is three of these. +uint32_t SIM_KeyerDitMs(void); + +#endif diff --git a/harness/sim_paddle.c b/harness/sim_paddle.c new file mode 100644 index 0000000..dfcc4d5 --- /dev/null +++ b/harness/sim_paddle.c @@ -0,0 +1,60 @@ +#include "sim_paddle.h" + +#include "sim_clock.h" + +#define SIM_PADDLE_MAX_STEPS 512 + +typedef struct { + uint32_t contacts; + uint32_t until_ms; // absolute virtual time this step ends +} Step_t; + +static Step_t s_steps[SIM_PADDLE_MAX_STEPS]; +static uint32_t s_count; +static uint32_t s_end_ms; + +void SIM_PaddleReset(void) +{ + s_count = 0; + s_end_ms = 0; +} + +void SIM_PaddleHold(uint32_t contacts, uint32_t duration_ms) +{ + if (s_count >= SIM_PADDLE_MAX_STEPS) + return; + + s_end_ms += duration_ms; + s_steps[s_count].contacts = contacts; + s_steps[s_count].until_ms = s_end_ms; + s_count++; +} + +void SIM_PaddleTap(uint32_t contacts, uint32_t hold_ms, uint32_t gap_ms) +{ + SIM_PaddleHold(contacts, hold_ms); + if (gap_ms > 0) + SIM_PaddleHold(SIM_CONTACT_NONE, gap_ms); +} + +uint32_t SIM_PaddleState(void) +{ + const uint32_t now = SIM_ClockNow(); + + for (uint32_t i = 0; i < s_count; i++) { + if (now < s_steps[i].until_ms) + return s_steps[i].contacts; + } + // Past the end of the script: everything released. + return SIM_CONTACT_NONE; +} + +bool SIM_PaddleDrained(void) +{ + return SIM_ClockNow() >= s_end_ms; +} + +uint32_t SIM_PaddleTotalMs(void) +{ + return s_end_ms; +} diff --git a/harness/sim_paddle.h b/harness/sim_paddle.h new file mode 100644 index 0000000..c8a479c --- /dev/null +++ b/harness/sim_paddle.h @@ -0,0 +1,45 @@ +/* Scripted paddle / straight-key input. + * + * Replaces the GPIO reads in app/cwhardware.c. A scenario is written as a list + * of "hold these contacts for N ms" steps, which is how an operator's hand + * actually looks to the keyer, and the harness plays it against the virtual + * clock. + * + * Contacts are named after the hardware: TIP is dit by default, RING is dah, + * and the keyer's REVERSED flag swaps them. PTT doubles as the dit paddle in + * Buttons mode and as the straight key in handkey modes, so it is tracked + * separately rather than folded into TIP. + */ + +#ifndef SIM_PADDLE_H +#define SIM_PADDLE_H + +#include +#include + +typedef enum { + SIM_CONTACT_NONE = 0, + SIM_CONTACT_TIP = 1u << 0, // dit paddle (PTT in Buttons mode) + SIM_CONTACT_RING = 1u << 1, // dah paddle (SIDE1 in Buttons mode) +} SIM_Contact_t; + +void SIM_PaddleReset(void); + +// Queues "hold this contact set for duration_ms". Steps play in order; the +// timeline holds the last state once exhausted (i.e. keys released). +void SIM_PaddleHold(uint32_t contacts, uint32_t duration_ms); + +// Convenience for the common "press, then release" pair. +void SIM_PaddleTap(uint32_t contacts, uint32_t hold_ms, uint32_t gap_ms); + +// Current contact state, resolved against the virtual clock. The cwhardware +// stubs call this; tests normally use the queueing functions above. +uint32_t SIM_PaddleState(void); + +// True when every queued step has played out. +bool SIM_PaddleDrained(void); + +// Total queued duration, so a test can run the clock exactly long enough. +uint32_t SIM_PaddleTotalMs(void); + +#endif diff --git a/harness/sim_record.c b/harness/sim_record.c new file mode 100644 index 0000000..2c08529 --- /dev/null +++ b/harness/sim_record.c @@ -0,0 +1,131 @@ +#include "sim_record.h" + +#include +#include + +#include "sim_clock.h" + +#define SIM_MAX_EVENTS 4096 +#define SIM_MAX_TEXT 1024 + +static SIM_Event_t s_events[SIM_MAX_EVENTS]; +static unsigned s_event_count; +static char s_text[SIM_MAX_TEXT]; +static unsigned s_text_len; +static bool s_verbose; + +void SIM_RecordReset(void) +{ + s_event_count = 0; + s_text_len = 0; + s_text[0] = '\0'; +} + +void SIM_RecordSetVerbose(bool verbose) +{ + s_verbose = verbose; +} + +static void push(SIM_EventKind_t kind, char ch) +{ + if (s_event_count >= SIM_MAX_EVENTS) + return; + + s_events[s_event_count].kind = kind; + s_events[s_event_count].at_ms = SIM_ClockNow(); + s_events[s_event_count].ch = ch; + s_event_count++; + + if (s_verbose) { + const char *name = kind == SIM_EV_CARRIER_ON ? "carrier on" + : kind == SIM_EV_CARRIER_OFF ? "carrier off" + : "char"; + if (kind == SIM_EV_CHAR) + fprintf(stderr, "%6u ms %s '%c'\n", SIM_ClockNow(), name, ch); + else + fprintf(stderr, "%6u ms %s\n", SIM_ClockNow(), name); + } +} + +void SIM_RecordCarrier(bool on) +{ + push(on ? SIM_EV_CARRIER_ON : SIM_EV_CARRIER_OFF, 0); +} + +void SIM_RecordChar(char ch) +{ + push(SIM_EV_CHAR, ch); + if (s_text_len + 1 < SIM_MAX_TEXT) { + s_text[s_text_len++] = ch; + s_text[s_text_len] = '\0'; + } +} + +void SIM_RecordDebug(const char *text, unsigned int size) +{ + if (s_verbose && text != NULL && size > 0) + fprintf(stderr, "%6u ms [dbg] %.*s", SIM_ClockNow(), (int)size, text); +} + +const char *SIM_RecordedText(void) +{ + return s_text; +} + +// Walks the event list pairing each CARRIER_ON with the following CARRIER_OFF. +// Returns the duration of element `index`, or 0 when it does not exist. +static bool element_span(unsigned index, uint32_t *start, uint32_t *end) +{ + unsigned seen = 0; + for (unsigned i = 0; i < s_event_count; i++) { + if (s_events[i].kind != SIM_EV_CARRIER_ON) + continue; + for (unsigned j = i + 1; j < s_event_count; j++) { + if (s_events[j].kind == SIM_EV_CARRIER_ON) + break; // unterminated; treat as incomplete + if (s_events[j].kind == SIM_EV_CARRIER_OFF) { + if (seen == index) { + *start = s_events[i].at_ms; + *end = s_events[j].at_ms; + return true; + } + seen++; + break; + } + } + } + return false; +} + +unsigned int SIM_RecordedElementCount(void) +{ + unsigned n = 0; + uint32_t a, b; + while (element_span(n, &a, &b)) + n++; + return n; +} + +uint32_t SIM_RecordedElementMs(unsigned int index) +{ + uint32_t start, end; + return element_span(index, &start, &end) ? end - start : 0; +} + +uint32_t SIM_RecordedGapMs(unsigned int index) +{ + uint32_t s0, e0, s1, e1; + if (!element_span(index, &s0, &e0) || !element_span(index + 1, &s1, &e1)) + return 0; + return s1 - e0; +} + +unsigned int SIM_RecordedEventCount(void) +{ + return s_event_count; +} + +const SIM_Event_t *SIM_RecordedEvent(unsigned int index) +{ + return index < s_event_count ? &s_events[index] : NULL; +} diff --git a/harness/sim_record.h b/harness/sim_record.h new file mode 100644 index 0000000..291adec --- /dev/null +++ b/harness/sim_record.h @@ -0,0 +1,50 @@ +/* Recorder for observable firmware output. + * + * Deliberately records low-level intent (carrier on/off with a timestamp, the + * decoded character stream, debug text) rather than a summarised "a dit + * happened". Stage B swaps the stubs for real peripheral models but keeps these + * scenarios, so the assertions have to sit at a level both can produce. + */ + +#ifndef SIM_RECORD_H +#define SIM_RECORD_H + +#include +#include + +typedef enum { + SIM_EV_CARRIER_ON, + SIM_EV_CARRIER_OFF, + SIM_EV_CHAR, // a character was decoded into the TX display / macro +} SIM_EventKind_t; + +typedef struct { + SIM_EventKind_t kind; + uint32_t at_ms; + char ch; // valid for SIM_EV_CHAR +} SIM_Event_t; + +void SIM_RecordReset(void); + +void SIM_RecordCarrier(bool on); +void SIM_RecordChar(char ch); +void SIM_RecordDebug(const char *text, unsigned int size); + +// Decoded characters in order, NUL-terminated. This is the main assertion +// surface: feed a paddle timeline, expect "CQ". +const char *SIM_RecordedText(void); + +// Element durations in milliseconds, derived from carrier on/off pairs. Lets a +// test check the dit/dah ratio and inter-element spacing rather than only the +// resulting text. +unsigned int SIM_RecordedElementCount(void); +uint32_t SIM_RecordedElementMs(unsigned int index); +uint32_t SIM_RecordedGapMs(unsigned int index); + +unsigned int SIM_RecordedEventCount(void); +const SIM_Event_t *SIM_RecordedEvent(unsigned int index); + +// Enables echoing events and debug text to stderr as they happen. +void SIM_RecordSetVerbose(bool verbose); + +#endif diff --git a/qemu/armv7m_systick.c.patched b/qemu/armv7m_systick.c.patched new file mode 100644 index 0000000..5b89eb8 --- /dev/null +++ b/qemu/armv7m_systick.c.patched @@ -0,0 +1,350 @@ +/* + * ARMv7M SysTick timer + * + * Copyright (c) 2006-2007 CodeSourcery. + * Written by Paul Brook + * Copyright (c) 2017 Linaro Ltd + * Written by Peter Maydell + * + * This code is licensed under the GPL (version 2 or later). + */ + +#include "qemu/osdep.h" +#include "hw/timer/armv7m_systick.h" +#include "migration/vmstate.h" +#include "hw/irq.h" +#include "hw/sysbus.h" +#include "hw/qdev-clock.h" +#include "hw/qdev-properties.h" +#include "qemu/timer.h" +#include "qemu/log.h" +#include "qemu/module.h" +#include "qapi/error.h" +#include "trace.h" + +#define SYSTICK_ENABLE (1 << 0) +#define SYSTICK_TICKINT (1 << 1) +#define SYSTICK_CLKSOURCE (1 << 2) +#define SYSTICK_COUNTFLAG (1 << 16) + +#define SYSCALIB_NOREF (1U << 31) +#define SYSCALIB_SKEW (1U << 30) +#define SYSCALIB_TENMS ((1U << 24) - 1) + +static void systick_set_period_from_clock(SysTickState *s) +{ + /* + * Set the ptimer period from whichever clock is selected. + * Must be called from within a ptimer transaction block. + */ + if (s->control & SYSTICK_CLKSOURCE) { + ptimer_set_period_from_clock(s->ptimer, s->cpuclk, 1); + } else { + ptimer_set_period_from_clock(s->ptimer, s->refclk, 1); + } +} + +static void systick_timer_tick(void *opaque) +{ + SysTickState *s = (SysTickState *)opaque; + + trace_systick_timer_tick(); + + s->control |= SYSTICK_COUNTFLAG; + if (s->control & SYSTICK_TICKINT) { + /* Tell the NVIC to pend the SysTick exception */ + qemu_irq_pulse(s->irq); + } + if (ptimer_get_limit(s->ptimer) == 0) { + /* + * Timer expiry with SYST_RVR zero disables the timer + * (but doesn't clear SYST_CSR.ENABLE) + */ + ptimer_stop(s->ptimer); + } +} + +static MemTxResult systick_read(void *opaque, hwaddr addr, uint64_t *data, + unsigned size, MemTxAttrs attrs) +{ + SysTickState *s = opaque; + uint32_t val; + + if (attrs.user) { + /* Generate BusFault for unprivileged accesses */ + return MEMTX_ERROR; + } + + switch (addr) { + case 0x0: /* SysTick Control and Status. */ + val = s->control; + s->control &= ~SYSTICK_COUNTFLAG; + break; + case 0x4: /* SysTick Reload Value. */ + val = ptimer_get_limit(s->ptimer); + break; + case 0x8: /* SysTick Current Value. */ + val = ptimer_get_count(s->ptimer); + /* + * Busy-wait acceleration, off unless the machine opts in. + * + * Firmware delay loops of the form + * while (elapsed < ticks) { cur = VAL; elapsed += prev - cur; } + * depend on the counter moving appreciably between reads. On hardware it + * does. Under emulation a register read costs far more relative to guest + * time, so the counter barely moves and such a loop can need hours of + * wall time per millisecond of guest time. + * + * When poll_boost is set, each read of this register also advances the + * timer, which lets those loops converge. Guest time then runs fast + * while a poll loop is active: fine for exercising control flow, wrong + * for judging signal timing. + */ + if (s->poll_boost) { + /* + * Report a value that runs ahead of the real counter by an amount + * that grows with every read, so a polling loop sees steady forward + * motion. The timer itself is left alone: writing it back made each + * read re-anchor the count, the reported value stopped changing + * between reads, and the firmware's `if (cur != prev)` guard meant + * the loop accumulated nothing and hung. + */ + uint32_t period = ptimer_get_limit(s->ptimer) + 1; + + s->poll_skew += s->poll_boost; + if (period > 1) { + uint32_t offset = s->poll_skew % period; + val = (val >= offset) ? (val - offset) : (val + period - offset); + } + } + break; + case 0xc: /* SysTick Calibration Value. */ + /* + * In real hardware it is possible to make this register report + * a different value from what the reference clock is actually + * running at. We don't model that (which usually happens due + * to integration errors in the real hardware) and instead always + * report the theoretical correct value as described in the + * knowledgebase article at + * https://developer.arm.com/documentation/ka001325/latest + * If necessary, we could implement an extra QOM property on this + * device to force the STCALIB value to something different from + * the "correct" value. + */ + if (!clock_has_source(s->refclk)) { + val = SYSCALIB_NOREF; + break; + } + val = clock_ns_to_ticks(s->refclk, 10 * SCALE_MS) - 1; + val &= SYSCALIB_TENMS; + if (clock_ticks_to_ns(s->refclk, val + 1) != 10 * SCALE_MS) { + /* report that tick count does not yield exactly 10ms */ + val |= SYSCALIB_SKEW; + } + break; + default: + val = 0; + qemu_log_mask(LOG_GUEST_ERROR, + "SysTick: Bad read offset 0x%" HWADDR_PRIx "\n", addr); + break; + } + + trace_systick_read(addr, val, size); + *data = val; + return MEMTX_OK; +} + +static MemTxResult systick_write(void *opaque, hwaddr addr, + uint64_t value, unsigned size, + MemTxAttrs attrs) +{ + SysTickState *s = opaque; + + if (attrs.user) { + /* Generate BusFault for unprivileged accesses */ + return MEMTX_ERROR; + } + + trace_systick_write(addr, value, size); + + switch (addr) { + case 0x0: /* SysTick Control and Status. */ + { + uint32_t oldval; + + if (!clock_has_source(s->refclk)) { + /* This bit is always 1 if there is no external refclk */ + value |= SYSTICK_CLKSOURCE; + } + + ptimer_transaction_begin(s->ptimer); + oldval = s->control; + s->control &= 0xfffffff8; + s->control |= value & 7; + + if ((oldval ^ value) & SYSTICK_CLKSOURCE) { + systick_set_period_from_clock(s); + } + + if ((oldval ^ value) & SYSTICK_ENABLE) { + if (value & SYSTICK_ENABLE) { + ptimer_run(s->ptimer, 0); + } else { + ptimer_stop(s->ptimer); + } + } + ptimer_transaction_commit(s->ptimer); + break; + } + case 0x4: /* SysTick Reload Value. */ + ptimer_transaction_begin(s->ptimer); + ptimer_set_limit(s->ptimer, value & 0xffffff, 0); + ptimer_transaction_commit(s->ptimer); + break; + case 0x8: /* SysTick Current Value. */ + /* + * Writing any value clears SYST_CVR to zero and clears + * SYST_CSR.COUNTFLAG. The counter will then reload from SYST_RVR + * on the next clock edge unless SYST_RVR is zero. + */ + ptimer_transaction_begin(s->ptimer); + if (ptimer_get_limit(s->ptimer) == 0) { + ptimer_stop(s->ptimer); + } + ptimer_set_count(s->ptimer, 0); + s->control &= ~SYSTICK_COUNTFLAG; + ptimer_transaction_commit(s->ptimer); + break; + default: + qemu_log_mask(LOG_GUEST_ERROR, + "SysTick: Bad write offset 0x%" HWADDR_PRIx "\n", addr); + } + return MEMTX_OK; +} + +static const MemoryRegionOps systick_ops = { + .read_with_attrs = systick_read, + .write_with_attrs = systick_write, + .endianness = DEVICE_NATIVE_ENDIAN, + .valid.min_access_size = 4, + .valid.max_access_size = 4, +}; + +static void systick_reset(DeviceState *dev) +{ + SysTickState *s = SYSTICK(dev); + + ptimer_transaction_begin(s->ptimer); + s->control = 0; + if (!clock_has_source(s->refclk)) { + /* This bit is always 1 if there is no external refclk */ + s->control |= SYSTICK_CLKSOURCE; + } + ptimer_stop(s->ptimer); + ptimer_set_count(s->ptimer, 0); + ptimer_set_limit(s->ptimer, 0, 0); + systick_set_period_from_clock(s); + ptimer_transaction_commit(s->ptimer); +} + +static void systick_cpuclk_update(void *opaque, ClockEvent event) +{ + SysTickState *s = SYSTICK(opaque); + + if (!(s->control & SYSTICK_CLKSOURCE)) { + /* currently using refclk, we can ignore cpuclk changes */ + } + + ptimer_transaction_begin(s->ptimer); + ptimer_set_period_from_clock(s->ptimer, s->cpuclk, 1); + ptimer_transaction_commit(s->ptimer); +} + +static void systick_refclk_update(void *opaque, ClockEvent event) +{ + SysTickState *s = SYSTICK(opaque); + + if (s->control & SYSTICK_CLKSOURCE) { + /* currently using cpuclk, we can ignore refclk changes */ + } + + ptimer_transaction_begin(s->ptimer); + ptimer_set_period_from_clock(s->ptimer, s->refclk, 1); + ptimer_transaction_commit(s->ptimer); +} + +static void systick_instance_init(Object *obj) +{ + SysBusDevice *sbd = SYS_BUS_DEVICE(obj); + SysTickState *s = SYSTICK(obj); + + memory_region_init_io(&s->iomem, obj, &systick_ops, s, "systick", 0xe0); + sysbus_init_mmio(sbd, &s->iomem); + sysbus_init_irq(sbd, &s->irq); + + s->refclk = qdev_init_clock_in(DEVICE(obj), "refclk", + systick_refclk_update, s, ClockUpdate); + s->cpuclk = qdev_init_clock_in(DEVICE(obj), "cpuclk", + systick_cpuclk_update, s, ClockUpdate); +} + +static void systick_realize(DeviceState *dev, Error **errp) +{ + SysTickState *s = SYSTICK(dev); + s->ptimer = ptimer_init(systick_timer_tick, s, + PTIMER_POLICY_WRAP_AFTER_ONE_PERIOD | + PTIMER_POLICY_NO_COUNTER_ROUND_DOWN | + PTIMER_POLICY_NO_IMMEDIATE_RELOAD | + PTIMER_POLICY_TRIGGER_ONLY_ON_DECREMENT); + + if (!clock_has_source(s->cpuclk)) { + error_setg(errp, "systick: cpuclk must be connected"); + return; + } + /* It's OK not to connect the refclk */ +} + +static const VMStateDescription vmstate_systick = { + .name = "armv7m_systick", + .version_id = 3, + .minimum_version_id = 3, + .fields = (VMStateField[]) { + VMSTATE_CLOCK(refclk, SysTickState), + VMSTATE_CLOCK(cpuclk, SysTickState), + VMSTATE_UINT32(control, SysTickState), + VMSTATE_INT64(tick, SysTickState), + VMSTATE_PTIMER(ptimer, SysTickState), + VMSTATE_END_OF_LIST() + } +}; + +static Property systick_properties[] = { + /* See the comment on poll_boost in the header; 0 means exact behaviour. */ + DEFINE_PROP_UINT32("poll-boost", SysTickState, poll_boost, 0), + DEFINE_PROP_END_OF_LIST(), +}; + +static void systick_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + + dc->vmsd = &vmstate_systick; + dc->reset = systick_reset; + dc->realize = systick_realize; + device_class_set_props(dc, systick_properties); +} + +static const TypeInfo armv7m_systick_info = { + .name = TYPE_SYSTICK, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_init = systick_instance_init, + .instance_size = sizeof(SysTickState), + .class_init = systick_class_init, +}; + +static void armv7m_systick_register_types(void) +{ + type_register_static(&armv7m_systick_info); +} + +type_init(armv7m_systick_register_types) diff --git a/qemu/armv7m_systick.h.patched b/qemu/armv7m_systick.h.patched new file mode 100644 index 0000000..a8048d1 --- /dev/null +++ b/qemu/armv7m_systick.h.patched @@ -0,0 +1,61 @@ +/* + * ARMv7M SysTick timer + * + * Copyright (c) 2006-2007 CodeSourcery. + * Written by Paul Brook + * Copyright (c) 2017 Linaro Ltd + * Written by Peter Maydell + * + * This code is licensed under the GPL (version 2 or later). + */ + +#ifndef HW_TIMER_ARMV7M_SYSTICK_H +#define HW_TIMER_ARMV7M_SYSTICK_H + +#include "hw/sysbus.h" +#include "qom/object.h" +#include "hw/ptimer.h" +#include "hw/clock.h" + +#define TYPE_SYSTICK "armv7m_systick" + +OBJECT_DECLARE_SIMPLE_TYPE(SysTickState, SYSTICK) + +/* + * QEMU interface: + * + sysbus MMIO region 0 is the register interface (covering + * the registers which are mapped at address 0xE000E010) + * + sysbus IRQ 0 is the interrupt line to the NVIC + * + Clock input "refclk" is the external reference clock + * (used when SYST_CSR.CLKSOURCE == 0) + * + Clock input "cpuclk" is the main CPU clock + * (used when SYST_CSR.CLKSOURCE == 1) + */ + +struct SysTickState { + /*< private >*/ + SysBusDevice parent_obj; + /*< public >*/ + + uint32_t control; + uint32_t reload; + int64_t tick; + ptimer_state *ptimer; + MemoryRegion iomem; + qemu_irq irq; + Clock *refclk; + Clock *cpuclk; + + /* + * Ticks to additionally advance on each read of the current-value register, + * so firmware busy-wait delay loops converge under emulation. Zero keeps + * exact hardware behaviour and is the default; only boards that need it + * set the "poll-boost" property. + */ + uint32_t poll_boost; + + /* Running total added by poll_boost, applied to reported counter values. */ + uint32_t poll_skew; +}; + +#endif diff --git a/qemu/py32f071.c b/qemu/py32f071.c new file mode 100644 index 0000000..97a2507 --- /dev/null +++ b/qemu/py32f071.c @@ -0,0 +1,1754 @@ +/* + * Puya PY32F071 SoC and a machine for the Quansheng UV-K5 V3 / UV-K1 radio. + * + * Cortex-M0+, 128 KB flash at 0x08000000, 16 KB SRAM at 0x20000000. + * The memory map is taken from the vendor CMSIS header shipped with the + * firmware (Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h), so the + * addresses here are the vendor's, not guesses. + * + * Scope of this file: enough of the SoC for the radio firmware to boot and + * reach its main loop. Peripherals are modelled at the level the firmware + * actually needs -- clock-ready flags it polls, GPIO state it drives and reads, + * SPI transfers it clocks out. Device-specific behaviour behind the SPI buses + * (the ST7565 display, the PY25Q16 flash, the BK4829 transceiver) lives in + * separate models; this file only wires the buses up. + * + * This code is licensed under the GPL version 2 or later. + */ + +#include "qemu/osdep.h" +#include "qapi/error.h" +#include "qemu/log.h" +#include "qemu/module.h" +#include "qemu/units.h" +#include "hw/irq.h" +#include "hw/clock.h" +#include "hw/qdev-clock.h" +#include "hw/arm/boot.h" +#include "hw/arm/armv7m.h" +#include "hw/boards.h" +#include "hw/qdev-properties.h" +#include "hw/sysbus.h" +#include "exec/address-spaces.h" +#include "sysemu/sysemu.h" +#include "qom/object.h" + +/* ---------------------------------------------------------------- memory map */ + +#define PY32_FLASH_BASE 0x08000000 +#define PY32_FLASH_SIZE (128 * KiB) +#define PY32_SRAM_BASE 0x20000000 +#define PY32_SRAM_SIZE (16 * KiB) + +/* The application image starts after the 10 KB bootloader region. Loading it at + * PY32_FLASH_BASE instead would put the vector table in the wrong place and the + * machine faults on the first fetch. */ +#define PY32_APP_OFFSET 0x2800 + +#define PY32_APB_BASE 0x40000000 +#define PY32_AHB_BASE 0x40020000 +#define PY32_IOPORT_BASE 0x50000000 + +#define PY32_RCC_BASE 0x40021000 +#define PY32_FLASH_R_BASE 0x40022000 +#define PY32_PWR_BASE 0x40007000 +#define PY32_SYSCFG_BASE 0x40010000 +#define PY32_EXTI_BASE 0x40021800 +#define PY32_CRC_BASE 0x40023000 +#define PY32_DMA1_BASE 0x40020000 + +#define PY32_GPIO_STRIDE 0x400 +#define PY32_GPIOA_BASE 0x50000000 +#define PY32_GPIOB_BASE 0x50000400 +#define PY32_GPIOC_BASE 0x50000800 +#define PY32_GPIOF_BASE 0x50001400 + +#define PY32_SPI1_BASE 0x40013000 +#define PY32_SPI2_BASE 0x40003800 +#define PY32_ADC1_BASE 0x40012400 +#define PY32_USART1_BASE 0x40013800 +#define PY32_USART2_BASE 0x40004400 +#define PY32_I2C1_BASE 0x40005400 +#define PY32_TIM1_BASE 0x40012c00 +#define PY32_TIM3_BASE 0x40000400 +#define PY32_TIM2_BASE 0x40000000 +#define PY32_TIM6_BASE 0x40001000 +#define PY32_TIM7_BASE 0x40001400 +#define PY32_TIM14_BASE 0x40002000 +#define PY32_TIM15_BASE 0x40014000 +#define PY32_TIM16_BASE 0x40014400 +#define PY32_TIM17_BASE 0x40014800 +#define PY32_USB_BASE 0x40005c00 +#define PY32_RTC_BASE 0x40002800 +#define PY32_IWDG_BASE 0x40003000 +#define PY32_WWDG_BASE 0x40002c00 +#define PY32_USART3_BASE 0x40004800 +#define PY32_USART4_BASE 0x40004c00 +#define PY32_I2C2_BASE 0x40005800 +#define PY32_DBGMCU_BASE 0x40015800 +#define PY32_LCD_BASE 0x40002400 + +#define PY32_NUM_IRQ 32 + +/* --------------------------------------------------------------- RCC model */ + +/* + * Clock control. The firmware switches to HSI/PLL and then polls ready flags, + * so those have to read back as set or BOARD_Init spins forever. Everything + * else is stored and echoed: nothing downstream depends on the values, and + * inventing behaviour would be guesswork. + */ +#define TYPE_PY32_RCC "py32-rcc" +OBJECT_DECLARE_SIMPLE_TYPE(PY32RccState, PY32_RCC) + +struct PY32RccState { + SysBusDevice parent_obj; + MemoryRegion iomem; + uint32_t regs[0x40]; +}; + +/* Register offsets that carry ready/lock bits the firmware waits on. */ +#define RCC_CR 0x00 +#define RCC_ICSCR 0x04 +#define RCC_CFGR 0x08 +#define RCC_CIER 0x18 +#define RCC_CIFR 0x1c + +static uint64_t py32_rcc_read(void *opaque, hwaddr addr, unsigned size) +{ + PY32RccState *s = opaque; + const unsigned idx = addr >> 2; + + if (idx >= ARRAY_SIZE(s->regs)) { + qemu_log_mask(LOG_GUEST_ERROR, "py32-rcc: read out of range 0x%" HWADDR_PRIx "\n", addr); + return 0; + } + + uint32_t value = s->regs[idx]; + + if (addr == RCC_CR) { + /* + * Mirror every enable bit into its ready bit. On this part the pairs sit + * one bit apart (HSION/HSIRDY, HSEON/HSERDY, PLLON/PLLRDY), so echoing + * "enabled" as "ready" satisfies the firmware's spin loops without + * pretending to model the PLL. + */ + if (value & (1u << 8)) value |= (1u << 10); /* HSI */ + if (value & (1u << 16)) value |= (1u << 17); /* HSE */ + if (value & (1u << 24)) value |= (1u << 25); /* PLL */ + value |= (1u << 1); /* LSI ready */ + } + + return value; +} + +static void py32_rcc_write(void *opaque, hwaddr addr, uint64_t value, unsigned size) +{ + PY32RccState *s = opaque; + const unsigned idx = addr >> 2; + + if (idx >= ARRAY_SIZE(s->regs)) { + qemu_log_mask(LOG_GUEST_ERROR, "py32-rcc: write out of range 0x%" HWADDR_PRIx "\n", addr); + return; + } + s->regs[idx] = value; +} + +static const MemoryRegionOps py32_rcc_ops = { + .read = py32_rcc_read, + .write = py32_rcc_write, + .endianness = DEVICE_LITTLE_ENDIAN, + .valid.min_access_size = 4, + .valid.max_access_size = 4, +}; + +static void py32_rcc_reset(DeviceState *dev) +{ + PY32RccState *s = PY32_RCC(dev); + memset(s->regs, 0, sizeof(s->regs)); + s->regs[RCC_CR >> 2] = (1u << 8) | (1u << 10); /* HSI on and ready */ +} + +static void py32_rcc_init(Object *obj) +{ + PY32RccState *s = PY32_RCC(obj); + memory_region_init_io(&s->iomem, obj, &py32_rcc_ops, s, TYPE_PY32_RCC, 0x400); + sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->iomem); +} + +static void py32_rcc_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->reset = py32_rcc_reset; + dc->desc = "PY32F071 reset and clock control"; +} + +/* -------------------------------------------------------------- GPIO model */ + +/* + * One instance per port. Output state is exported as qemu_irq lines so board + * models (display chip-select, keypad rows) can watch them, and input state is + * settable the same way, which is how key presses get injected. + */ +#define TYPE_PY32_GPIO "py32-gpio" +OBJECT_DECLARE_SIMPLE_TYPE(PY32GpioState, PY32_GPIO) + +#define PY32_GPIO_PINS 16 + +struct PY32GpioState { + SysBusDevice parent_obj; + MemoryRegion iomem; + char *port_name; + + uint32_t moder, otyper, ospeedr, pupdr, odr, lckr, afrl, afrh; + uint32_t idr; /* driven by the board, not the guest */ + + qemu_irq out[PY32_GPIO_PINS]; +}; + +#define GPIO_MODER 0x00 +#define GPIO_OTYPER 0x04 +#define GPIO_OSPEEDR 0x08 +#define GPIO_PUPDR 0x0c +#define GPIO_IDR 0x10 +#define GPIO_ODR 0x14 +#define GPIO_BSRR 0x18 +#define GPIO_LCKR 0x1c +#define GPIO_AFRL 0x20 +#define GPIO_AFRH 0x24 +#define GPIO_BRR 0x28 + +static void py32_gpio_update(PY32GpioState *s, uint32_t old_odr) +{ + const uint32_t changed = old_odr ^ s->odr; + + for (int i = 0; i < PY32_GPIO_PINS; i++) { + if (changed & (1u << i)) { + qemu_set_irq(s->out[i], !!(s->odr & (1u << i))); + } + } +} + +static uint64_t py32_gpio_read(void *opaque, hwaddr addr, unsigned size) +{ + PY32GpioState *s = opaque; + + switch (addr) { + case GPIO_MODER: return s->moder; + case GPIO_OTYPER: return s->otyper; + case GPIO_OSPEEDR: return s->ospeedr; + case GPIO_PUPDR: return s->pupdr; + case GPIO_ODR: return s->odr; + case GPIO_LCKR: return s->lckr; + case GPIO_AFRL: return s->afrl; + case GPIO_AFRH: return s->afrh; + case GPIO_IDR: + /* + * Pins configured as outputs read back their own driven level; inputs + * read what the board drives, and default high because the firmware + * configures pull-ups for the keypad and paddle contacts (active low). + */ + { + uint32_t out_mask = 0; + for (int i = 0; i < PY32_GPIO_PINS; i++) { + if (((s->moder >> (i * 2)) & 3u) == 1u) { + out_mask |= (1u << i); + } + } + return (s->odr & out_mask) | (s->idr & ~out_mask); + } + default: + qemu_log_mask(LOG_UNIMP, "py32-gpio%s: read 0x%" HWADDR_PRIx "\n", + s->port_name ?: "", addr); + return 0; + } +} + +static void py32_gpio_write(void *opaque, hwaddr addr, uint64_t value, unsigned size) +{ + PY32GpioState *s = opaque; + const uint32_t old_odr = s->odr; + + switch (addr) { + case GPIO_MODER: s->moder = value; break; + case GPIO_OTYPER: s->otyper = value; break; + case GPIO_OSPEEDR: s->ospeedr = value; break; + case GPIO_PUPDR: s->pupdr = value; break; + case GPIO_LCKR: s->lckr = value; break; + case GPIO_AFRL: s->afrl = value; break; + case GPIO_AFRH: s->afrh = value; break; + case GPIO_ODR: + s->odr = value; + py32_gpio_update(s, old_odr); + break; + case GPIO_BSRR: + /* Low half sets, high half resets; reset wins on a conflict. */ + s->odr |= value & 0xffff; + s->odr &= ~(value >> 16); + py32_gpio_update(s, old_odr); + break; + case GPIO_BRR: + s->odr &= ~(value & 0xffff); + py32_gpio_update(s, old_odr); + break; + default: + qemu_log_mask(LOG_UNIMP, "py32-gpio%s: write 0x%" HWADDR_PRIx " = 0x%" PRIx64 "\n", + s->port_name ?: "", addr, value); + break; + } +} + +static const MemoryRegionOps py32_gpio_ops = { + .read = py32_gpio_read, + .write = py32_gpio_write, + .endianness = DEVICE_LITTLE_ENDIAN, + .valid.min_access_size = 4, + .valid.max_access_size = 4, +}; + +/* Board-side entry point for driving an input pin. */ +static void py32_gpio_set_input(void *opaque, int line, int level) +{ + PY32GpioState *s = opaque; + + if (line < 0 || line >= PY32_GPIO_PINS) { + return; + } + fprintf(stderr, "TRACE gpio%s set_input pin=%d level=%d\n", + s->port_name ?: "?", line, level); + if (level) { + s->idr |= (1u << line); + } else { + s->idr &= ~(1u << line); + } +} + +static void py32_gpio_reset(DeviceState *dev) +{ + PY32GpioState *s = PY32_GPIO(dev); + + s->moder = 0; + s->otyper = 0; + s->ospeedr = 0; + s->pupdr = 0; + s->odr = 0; + s->lckr = 0; + s->afrl = 0; + s->afrh = 0; + /* + * Unconnected inputs idle high: the keypad, PTT and paddle contacts are all + * active low, so a floating pin has to read as "not pressed". + * + * Exception: PB9 is the bidirectional data line of the software-driven + * three-wire bus to the BK4819 transceiver. Idling it high makes every + * register read return 0xFFFF, and RADIO_SetupRegisters then spins forever + * waiting for bit 0 of REG_0C to clear. Idle it low until that bus has a + * device model, so reads come back as zero and the wait terminates. + */ + s->idr = 0xffff; + if (s->port_name && s->port_name[0] == 'b') { + s->idr &= ~(1u << 9); + } +} + +static void py32_gpio_init(Object *obj) +{ + PY32GpioState *s = PY32_GPIO(obj); + + memory_region_init_io(&s->iomem, obj, &py32_gpio_ops, s, TYPE_PY32_GPIO, PY32_GPIO_STRIDE); + sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->iomem); + /* + * Name both directions. Unnamed in and out lines share one namespace in + * qdev, so an unnamed pair on the same device makes qdev_get_gpio_in() + * ambiguous -- board wiring then silently attaches to the wrong line and + * signals go nowhere. + */ + qdev_init_gpio_out_named(DEVICE(obj), s->out, "pin-out", PY32_GPIO_PINS); + qdev_init_gpio_in_named(DEVICE(obj), py32_gpio_set_input, "pin-in", + PY32_GPIO_PINS); +} + +static Property py32_gpio_properties[] = { + DEFINE_PROP_STRING("port-name", PY32GpioState, port_name), + DEFINE_PROP_END_OF_LIST(), +}; + +static void py32_gpio_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->reset = py32_gpio_reset; + dc->desc = "PY32F071 GPIO port"; + device_class_set_props(dc, py32_gpio_properties); +} + +/* ------------------------------------------------- catch-all for the rest */ + +/* ------------------------------------------------------------ keypad matrix */ + +/* + * Wiring from App/driver/keyboard.c: columns are GPIOB pins 6..3 driven as + * outputs, rows are GPIOB pins 15..12 read as inputs, both active low. The + * driver pulls one column low at a time and reads the row bits. + * + * Column 0 is a pseudo column: the firmware reads the two side keys in the + * state where no column is pulled down, so they sit at rows 0 and 1 of it. + * + * The model owns no GPIO of its own -- it watches the column outputs and drives + * the row inputs, which is what the matrix does electrically. + */ +#define TYPE_UVK5_KEYPAD "uvk5-keypad" +OBJECT_DECLARE_SIMPLE_TYPE(UVK5KeypadState, UVK5_KEYPAD) + +#define KEYPAD_COLS 5 +#define KEYPAD_ROWS 4 +/* + * Column c of the keyboard[5][4] table is driven by PIN_COL(c - 1) in + * App/driver/keyboard.c, and PIN_COL(n) is pin 6 - n. So table column 1 uses + * pin 6, column 2 pin 5, and so on -- the off-by-one in the driver's indexing + * has to be reproduced here or the columns are shifted by one and every key + * reads as its neighbour. + */ +#define KEYPAD_COL_PIN(c) (6 - ((c) - 1)) +#define KEYPAD_ROW_PIN(r) (15 - (r)) + +struct UVK5KeypadState { + DeviceState parent_obj; + + bool pressed[KEYPAD_COLS][KEYPAD_ROWS]; + bool col_high[KEYPAD_COLS]; + qemu_irq row_out[KEYPAD_ROWS]; +}; + +/* + * A row reads low when a held key sits on a column that is currently pulled + * low. Side keys read low whenever every real column is high, matching how the + * driver samples them. + */ +static void keypad_update_rows(UVK5KeypadState *s) +{ + bool all_cols_high = true; + + for (int c = 1; c < KEYPAD_COLS; c++) { + if (!s->col_high[c]) { + all_cols_high = false; + } + } + + for (int r = 0; r < KEYPAD_ROWS; r++) { + bool low = false; + + for (int c = 1; c < KEYPAD_COLS; c++) { + if (s->pressed[c][r] && !s->col_high[c]) { + low = true; + } + } + if (all_cols_high && s->pressed[0][r]) { + low = true; + } + fprintf(stderr, "TRACE keypad row%d -> %d (irq=%p)\n", + r, low ? 0 : 1, (void *)s->row_out[r]); + qemu_set_irq(s->row_out[r], low ? 0 : 1); + } +} + +static void keypad_col_changed(void *opaque, int line, int level) +{ + UVK5KeypadState *s = opaque; + + if (line < 1 || line >= KEYPAD_COLS) { + return; + } + fprintf(stderr, "TRACE keypad col%d level=%d\n", line, level); + s->col_high[line] = level != 0; + keypad_update_rows(s); +} + +/* Key index is column * KEYPAD_ROWS + row. */ +static void keypad_key_changed(void *opaque, int line, int level) +{ + UVK5KeypadState *s = opaque; + const int col = line / KEYPAD_ROWS; + const int row = line % KEYPAD_ROWS; + + if (col >= KEYPAD_COLS || row >= KEYPAD_ROWS) { + return; + } + s->pressed[col][row] = level != 0; + keypad_update_rows(s); +} + +static void keypad_reset(DeviceState *dev) +{ + UVK5KeypadState *s = UVK5_KEYPAD(dev); + + memset(s->pressed, 0, sizeof(s->pressed)); + for (int c = 0; c < KEYPAD_COLS; c++) { + s->col_high[c] = true; + } + keypad_update_rows(s); +} + +static void keypad_init(Object *obj) +{ + DeviceState *dev = DEVICE(obj); + UVK5KeypadState *s = UVK5_KEYPAD(obj); + + qdev_init_gpio_in_named(dev, keypad_col_changed, "col", KEYPAD_COLS); + qdev_init_gpio_in_named(dev, keypad_key_changed, "key", + KEYPAD_COLS * KEYPAD_ROWS); + qdev_init_gpio_out_named(dev, s->row_out, "row", KEYPAD_ROWS); +} + +/* + * Key names as they appear on the radio, indexed the same way as the matrix + * (column * KEYPAD_ROWS + row) so a test can say "press MENU" rather than + * compute coordinates. Order follows the keyboard[5][4] table in + * App/driver/keyboard.c. + */ +static const char *const keypad_key_names[KEYPAD_COLS * KEYPAD_ROWS] = { + /* pseudo column 0: side keys, readable with every column released */ + "SIDE1", "SIDE2", NULL, NULL, + /* column 1 */ "MENU", "1", "4", "7", + /* column 2 */ "UP", "2", "5", "8", + /* column 3 */ "DOWN", "3", "6", "9", + /* column 4 */ "EXIT", "STAR", "0", "F", +}; + +/* Resolves a key name to its matrix index, or -1 when unknown. */ +static int keypad_index_for_name(const char *name) +{ + for (int i = 0; i < KEYPAD_COLS * KEYPAD_ROWS; i++) { + if (keypad_key_names[i] && g_ascii_strcasecmp(keypad_key_names[i], name) == 0) { + return i; + } + } + return -1; +} + +/* + * Write-only "press" property: setting it to a key name holds that key, and + * setting it to an empty string releases everything. Driving the matrix through + * a property means keys can be injected over the QMP/HMP monitor without a + * display backend, which suits this headless setup. + */ +static void keypad_set_press(Object *obj, const char *value, Error **errp) +{ + UVK5KeypadState *s = UVK5_KEYPAD(obj); + + if (!value || !*value) { + memset(s->pressed, 0, sizeof(s->pressed)); + keypad_update_rows(s); + return; + } + + const int index = keypad_index_for_name(value); + if (index < 0) { + error_setg(errp, "unknown key '%s'", value); + return; + } + + memset(s->pressed, 0, sizeof(s->pressed)); + s->pressed[index / KEYPAD_ROWS][index % KEYPAD_ROWS] = true; + keypad_update_rows(s); +} + +static char *keypad_get_press(Object *obj, Error **errp) +{ + UVK5KeypadState *s = UVK5_KEYPAD(obj); + + for (int i = 0; i < KEYPAD_COLS * KEYPAD_ROWS; i++) { + if (s->pressed[i / KEYPAD_ROWS][i % KEYPAD_ROWS]) { + return g_strdup(keypad_key_names[i] ?: ""); + } + } + return g_strdup(""); +} + +static void keypad_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + + dc->reset = keypad_reset; + dc->desc = "UV-K5 keypad matrix"; + + object_class_property_add_str(klass, "press", + keypad_get_press, keypad_set_press); + object_class_property_set_description(klass, "press", + "hold the named key (MENU, UP, DOWN, EXIT, F, STAR, 0-9, SIDE1, SIDE2); " + "empty string releases"); +} + +/* ---------------------------------------------------------------- SPI model */ + +/* + * Both SPI controllers, modelled as immediate full-duplex transfers. + * + * SPI_WriteByte() in the firmware waits on TXE, writes DR, then waits on RXNE + * and reads DR, so both flags have to move or display and flash init deadlock. + * Because a transfer completes within the register write, TXE can stay asserted + * and RXNE is raised by the write itself. + * + * Bytes are handed to a callback so board-level device models (ST7565 display, + * PY25Q16 flash) can interpret the stream; the chip-select GPIOs decide which + * device is listening. Layout from py32f071xB.h: CR1 0x00, SR 0x08, DR 0x0C. + */ +#define TYPE_PY32_SPI "py32-spi" +OBJECT_DECLARE_SIMPLE_TYPE(PY32SpiState, PY32_SPI) + +typedef uint8_t (*PY32SpiXferFn)(void *opaque, uint8_t out); + +struct PY32SpiState { + SysBusDevice parent_obj; + MemoryRegion iomem; + char *bus_name; + + uint32_t cr1, cr2, sr; + uint8_t rx; + + PY32SpiXferFn xfer; + void *xfer_opaque; +}; + +#define SPI_CR1 0x00 +#define SPI_CR2 0x04 +#define SPI_SR 0x08 +#define SPI_DR 0x0c + +#define SPI_SR_RXNE (1u << 0) +#define SPI_SR_TXE (1u << 1) +#define SPI_SR_BSY (1u << 7) + +void py32_spi_set_xfer(PY32SpiState *s, PY32SpiXferFn fn, void *opaque); + +void py32_spi_set_xfer(PY32SpiState *s, PY32SpiXferFn fn, void *opaque) +{ + s->xfer = fn; + s->xfer_opaque = opaque; +} + +/* Clock one byte through whatever device is attached. Used by the DMA model, + * which bypasses the data register entirely. */ +uint8_t py32_spi_xfer_byte(PY32SpiState *s, uint8_t out); + +uint8_t py32_spi_xfer_byte(PY32SpiState *s, uint8_t out) +{ + return s->xfer ? s->xfer(s->xfer_opaque, out) : 0xff; +} + +static uint64_t py32_spi_read(void *opaque, hwaddr addr, unsigned size) +{ + PY32SpiState *s = opaque; + + switch (addr) { + case SPI_CR1: return s->cr1; + case SPI_CR2: return s->cr2; + case SPI_SR: return s->sr; + case SPI_DR: + s->sr &= ~SPI_SR_RXNE; + return s->rx; + default: + return 0; + } +} + +static void py32_spi_write(void *opaque, hwaddr addr, uint64_t value, unsigned size) +{ + PY32SpiState *s = opaque; + + switch (addr) { + case SPI_CR1: s->cr1 = value; break; + case SPI_CR2: s->cr2 = value; break; + case SPI_SR: + /* Flags are mostly hardware-driven; keep TXE asserted. */ + s->sr = (value & ~SPI_SR_TXE) | SPI_SR_TXE; + break; + case SPI_DR: + /* + * The transfer happens here, in zero guest time. Whatever the attached + * device returns becomes the received byte. + */ + s->rx = s->xfer ? s->xfer(s->xfer_opaque, value & 0xff) : 0xff; + s->sr |= SPI_SR_RXNE | SPI_SR_TXE; + s->sr &= ~SPI_SR_BSY; + break; + default: + qemu_log_mask(LOG_UNIMP, "py32-spi%s: write 0x%" HWADDR_PRIx " = 0x%" PRIx64 "\n", + s->bus_name ?: "", addr, value); + break; + } +} + +static const MemoryRegionOps py32_spi_ops = { + .read = py32_spi_read, + .write = py32_spi_write, + .endianness = DEVICE_LITTLE_ENDIAN, + .valid.min_access_size = 1, + .valid.max_access_size = 4, +}; + +static void py32_spi_reset(DeviceState *dev) +{ + PY32SpiState *s = PY32_SPI(dev); + s->cr1 = 0; + s->cr2 = 0; + /* Transmit buffer starts empty: the firmware's first wait must pass. */ + s->sr = SPI_SR_TXE; + s->rx = 0xff; +} + +static void py32_spi_init(Object *obj) +{ + PY32SpiState *s = PY32_SPI(obj); + memory_region_init_io(&s->iomem, obj, &py32_spi_ops, s, TYPE_PY32_SPI, 0x400); + sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->iomem); +} + +static Property py32_spi_properties[] = { + DEFINE_PROP_STRING("bus-name", PY32SpiState, bus_name), + DEFINE_PROP_END_OF_LIST(), +}; + +static void py32_spi_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->reset = py32_spi_reset; + dc->desc = "PY32F071 SPI controller"; + device_class_set_props(dc, py32_spi_properties); +} + +/* ---------------------------------------------------------------- ADC model */ + +/* ------------------------------------------------- PY25Q16 SPI NOR flash */ + +/* ---------------------------------------------------------------- DMA model */ + +/* + * DMA1. The SPI flash driver does not poll the data register -- it configures a + * pair of channels (4 for RX, 5 for TX), enables the transfer-complete + * interrupt and then spins on a flag its ISR sets. So a register-only stub + * deadlocks in PY25Q16_ReadBuffer, which is exactly where the machine stopped. + * + * The model performs the whole transfer inside the write that enables a channel: + * for each byte it clocks the attached SPI device, honouring the increment and + * direction bits, then raises the transfer-complete flag and the interrupt. + * Zero guest time is not how hardware behaves, but the firmware only ever waits + * for completion, never for a partial count. + * + * Layout from py32f071xB.h: ISR 0x00, IFCR 0x04, then per-channel blocks of + * 0x14 starting at 0x08 (CCR, CNDTR, CPAR, CMAR). + */ +/* Forward declarations: the DMA model clocks bytes through an SPI controller, + * whose definition appears earlier but whose accessor is declared there. */ +typedef struct PY32SpiState PY32SpiState; +uint8_t py32_spi_xfer_byte(PY32SpiState *s, uint8_t out); + +#define TYPE_PY32_DMA "py32-dma" +OBJECT_DECLARE_SIMPLE_TYPE(PY32DmaState, PY32_DMA) + +#define PY32_DMA_CHANNELS 7 +#define DMA_ISR 0x00 +#define DMA_IFCR 0x04 +#define DMA_CH_BASE 0x08 +#define DMA_CH_STRIDE 0x14 +#define DMA_CCR 0x00 +#define DMA_CNDTR 0x04 +#define DMA_CPAR 0x08 +#define DMA_CMAR 0x0c + +#define DMA_CCR_EN (1u << 0) +#define DMA_CCR_TCIE (1u << 1) +#define DMA_CCR_DIR (1u << 4) /* 1 = read from memory */ +#define DMA_CCR_CIRC (1u << 5) +#define DMA_CCR_PINC (1u << 6) +#define DMA_CCR_MINC (1u << 7) + +/* Per-channel flags occupy four bits each in ISR/IFCR: GIF, TCIF, HTIF, TEIF. */ +#define DMA_FLAG_GIF(ch) (1u << ((ch) * 4 + 0)) +#define DMA_FLAG_TCIF(ch) (1u << ((ch) * 4 + 1)) +#define DMA_FLAG_HTIF(ch) (1u << ((ch) * 4 + 2)) + +typedef struct { + uint32_t ccr, cndtr, cpar, cmar; +} PY32DmaChannel; + +struct PY32DmaState { + SysBusDevice parent_obj; + MemoryRegion iomem; + + uint32_t isr; + PY32DmaChannel ch[PY32_DMA_CHANNELS]; + + /* Channels 1-3 and 4-7 share one interrupt line each on this part. */ + qemu_irq irq_1_2_3; + qemu_irq irq_4_5_6_7; + + /* Set by the SoC: lets the DMA clock bytes through an SPI controller. */ + PY32SpiState *spi[2]; +}; + +static void py32_dma_update_irq(PY32DmaState *s) +{ + bool low = false, high = false; + + for (int ch = 0; ch < PY32_DMA_CHANNELS; ch++) { + if (!(s->ch[ch].ccr & DMA_CCR_TCIE)) { + continue; + } + if (s->isr & DMA_FLAG_TCIF(ch)) { + if (ch < 3) { + low = true; + } else { + high = true; + } + } + } + qemu_set_irq(s->irq_1_2_3, low); + qemu_set_irq(s->irq_4_5_6_7, high); +} + +/* Which SPI controller a peripheral address belongs to, or NULL. */ +static PY32SpiState *py32_dma_spi_for(PY32DmaState *s, uint32_t paddr) +{ + if ((paddr & ~0x3ffu) == PY32_SPI1_BASE) { + return s->spi[0]; + } + if ((paddr & ~0x3ffu) == PY32_SPI2_BASE) { + return s->spi[1]; + } + return NULL; +} + +/* + * Runs a channel to completion. Transmit channels feed bytes to the device; + * receive channels store what it returns. When both directions are armed the + * transmit side has usually been enabled first, and the flash driver arms RX + * before TX, so a receive channel drives the clock itself -- otherwise nothing + * would ever be shifted in. + */ +static void py32_dma_run(PY32DmaState *s, int ch) +{ + PY32DmaChannel *c = &s->ch[ch]; + PY32SpiState *spi = py32_dma_spi_for(s, c->cpar); + + if (!spi || c->cndtr == 0) { + /* Nothing attached, or a zero-length transfer: report completion so the + * guest does not wait forever. */ + s->isr |= DMA_FLAG_TCIF(ch) | DMA_FLAG_GIF(ch); + c->cndtr = 0; + py32_dma_update_irq(s); + return; + } + + const bool from_memory = (c->ccr & DMA_CCR_DIR) != 0; + const bool minc = (c->ccr & DMA_CCR_MINC) != 0; + uint32_t maddr = c->cmar; + AddressSpace *as = &address_space_memory; + + while (c->cndtr > 0) { + uint8_t byte = 0xff; + + if (from_memory) { + address_space_read(as, maddr, MEMTXATTRS_UNSPECIFIED, &byte, 1); + (void)py32_spi_xfer_byte(spi, byte); + } else { + byte = py32_spi_xfer_byte(spi, 0xff); + address_space_write(as, maddr, MEMTXATTRS_UNSPECIFIED, &byte, 1); + } + + if (minc) { + maddr++; + } + c->cndtr--; + } + + s->isr |= DMA_FLAG_TCIF(ch) | DMA_FLAG_GIF(ch); + py32_dma_update_irq(s); +} + +static uint64_t py32_dma_read(void *opaque, hwaddr addr, unsigned size) +{ + PY32DmaState *s = opaque; + + if (addr == DMA_ISR) { + return s->isr; + } + if (addr == DMA_IFCR) { + return 0; + } + if (addr >= DMA_CH_BASE) { + const unsigned ch = (addr - DMA_CH_BASE) / DMA_CH_STRIDE; + const unsigned reg = (addr - DMA_CH_BASE) % DMA_CH_STRIDE; + if (ch < PY32_DMA_CHANNELS) { + switch (reg) { + case DMA_CCR: return s->ch[ch].ccr; + case DMA_CNDTR: return s->ch[ch].cndtr; + case DMA_CPAR: return s->ch[ch].cpar; + case DMA_CMAR: return s->ch[ch].cmar; + default: break; + } + } + } + return 0; +} + +static void py32_dma_write(void *opaque, hwaddr addr, uint64_t value, unsigned size) +{ + PY32DmaState *s = opaque; + + if (addr == DMA_IFCR) { + s->isr &= ~(uint32_t)value; + py32_dma_update_irq(s); + return; + } + if (addr < DMA_CH_BASE) { + return; /* ISR is read-only */ + } + + const unsigned ch = (addr - DMA_CH_BASE) / DMA_CH_STRIDE; + const unsigned reg = (addr - DMA_CH_BASE) % DMA_CH_STRIDE; + if (ch >= PY32_DMA_CHANNELS) { + return; + } + + switch (reg) { + case DMA_CNDTR: s->ch[ch].cndtr = value; break; + case DMA_CPAR: s->ch[ch].cpar = value; break; + case DMA_CMAR: s->ch[ch].cmar = value; break; + case DMA_CCR: { + const bool was_enabled = (s->ch[ch].ccr & DMA_CCR_EN) != 0; + s->ch[ch].ccr = value; + if (!was_enabled && (value & DMA_CCR_EN)) { + py32_dma_run(s, ch); + } + break; + } + default: + break; + } +} + +static const MemoryRegionOps py32_dma_ops = { + .read = py32_dma_read, + .write = py32_dma_write, + .endianness = DEVICE_LITTLE_ENDIAN, + .valid.min_access_size = 4, + .valid.max_access_size = 4, +}; + +static void py32_dma_reset(DeviceState *dev) +{ + PY32DmaState *s = PY32_DMA(dev); + s->isr = 0; + memset(s->ch, 0, sizeof(s->ch)); +} + +static void py32_dma_init(Object *obj) +{ + PY32DmaState *s = PY32_DMA(obj); + memory_region_init_io(&s->iomem, obj, &py32_dma_ops, s, TYPE_PY32_DMA, 0x400); + sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->iomem); + sysbus_init_irq(SYS_BUS_DEVICE(obj), &s->irq_1_2_3); + sysbus_init_irq(SYS_BUS_DEVICE(obj), &s->irq_4_5_6_7); +} + +static void py32_dma_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->reset = py32_dma_reset; + dc->desc = "PY32F071 DMA controller"; +} + +/* + * 2 MB SPI NOR, backed by a host file so settings and calibration persist + * across runs. Only the commands the firmware issues are implemented; the + * driver in App/driver/py25q16.c is the reference for which those are. + * + * Chip select comes from a GPIO, and the firmware also drives the display from + * the same SPI bus, so the model must ignore traffic while deselected -- + * otherwise display bytes would be parsed as flash commands. + */ +#define TYPE_PY25Q16 "py25q16" +OBJECT_DECLARE_SIMPLE_TYPE(PY25Q16State, PY25Q16) + +#define PY25Q16_SIZE (2 * MiB) + +enum { + PY25Q16_CMD_NONE = 0, + PY25Q16_CMD_READ = 0x03, + PY25Q16_CMD_PP = 0x02, /* page program */ + PY25Q16_CMD_WREN = 0x06, + PY25Q16_CMD_WRDI = 0x04, + PY25Q16_CMD_RDSR = 0x05, + PY25Q16_CMD_SE = 0x20, /* sector erase, 4 KB */ + PY25Q16_CMD_JEDEC = 0x9f, +}; + +struct PY25Q16State { + DeviceState parent_obj; + + uint8_t *data; + char *image_path; + + bool selected; + uint8_t cmd; + uint32_t addr; + unsigned phase; /* bytes consumed since the command byte */ + bool write_enabled; +}; + +static uint8_t py25q16_xfer(void *opaque, uint8_t out) +{ + PY25Q16State *s = opaque; + + if (!s->selected) { + return 0xff; + } + + if (s->cmd == PY25Q16_CMD_NONE) { + s->cmd = out; + s->phase = 0; + s->addr = 0; + + switch (s->cmd) { + case PY25Q16_CMD_WREN: s->write_enabled = true; s->cmd = PY25Q16_CMD_NONE; break; + case PY25Q16_CMD_WRDI: s->write_enabled = false; s->cmd = PY25Q16_CMD_NONE; break; + default: break; + } + return 0xff; + } + + s->phase++; + + switch (s->cmd) { + case PY25Q16_CMD_READ: + if (s->phase <= 3) { + s->addr = (s->addr << 8) | out; /* 24-bit address, MSB first */ + return 0xff; + } + return s->data[(s->addr++) % PY25Q16_SIZE]; + + case PY25Q16_CMD_PP: + if (s->phase <= 3) { + s->addr = (s->addr << 8) | out; + return 0xff; + } + if (s->write_enabled) { + /* NOR can only clear bits without an erase. */ + s->data[s->addr % PY25Q16_SIZE] &= out; + } + s->addr++; + return 0xff; + + case PY25Q16_CMD_SE: + if (s->phase <= 3) { + s->addr = (s->addr << 8) | out; + if (s->phase == 3 && s->write_enabled) { + const uint32_t sector = (s->addr / 0x1000) * 0x1000; + memset(s->data + (sector % PY25Q16_SIZE), 0xff, 0x1000); + } + } + return 0xff; + + case PY25Q16_CMD_RDSR: + /* Never busy: erases and writes complete within the transfer above. */ + return s->write_enabled ? 0x02 : 0x00; + + case PY25Q16_CMD_JEDEC: + /* Puya manufacturer 0x85, memory type 0x60, capacity 0x15 = 2 MB. */ + switch (s->phase) { + case 1: return 0x85; + case 2: return 0x60; + case 3: return 0x15; + default: return 0xff; + } + + default: + qemu_log_mask(LOG_UNIMP, "py25q16: unhandled command 0x%02x\n", s->cmd); + return 0xff; + } +} + +/* Chip select is active low. */ +static void py25q16_set_cs(void *opaque, int line, int level) +{ + PY25Q16State *s = opaque; + const bool selected = !level; + + if (s->selected && !selected) { + /* Deselect ends the command. */ + s->cmd = PY25Q16_CMD_NONE; + s->phase = 0; + } + s->selected = selected; +} + +static void py25q16_realize(DeviceState *dev, Error **errp) +{ + PY25Q16State *s = PY25Q16(dev); + + s->data = g_malloc(PY25Q16_SIZE); + memset(s->data, 0xff, PY25Q16_SIZE); + + if (s->image_path && *s->image_path) { + FILE *fh = fopen(s->image_path, "rb"); + if (fh) { + const size_t got = fread(s->data, 1, PY25Q16_SIZE, fh); + fclose(fh); + info_report("py25q16: loaded %zu bytes from %s", got, s->image_path); + } else { + warn_report("py25q16: cannot open %s, starting from erased flash", + s->image_path); + } + } + + qdev_init_gpio_in_named(dev, py25q16_set_cs, "cs", 1); +} + +static Property py25q16_properties[] = { + DEFINE_PROP_STRING("image", PY25Q16State, image_path), + DEFINE_PROP_END_OF_LIST(), +}; + +static void py25q16_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->realize = py25q16_realize; + dc->desc = "PY25Q16 2MB SPI NOR flash"; + device_class_set_props(dc, py25q16_properties); +} + +/* + * The firmware spins on three ADC conditions during BOARD_ADC_Init, so a + * store-and-echo stub deadlocks there: + * + * while (LL_ADC_IsCalibrationOnGoing(ADC1)) -- CR2.CAL must self-clear + * LL_ADC_Enable(ADC1) -- CR2.ADON + * while (!LL_ADC_IsActiveFlag_EOS(ADC1)) -- SR.EOC must rise + * + * Register layout and bit positions come from the vendor headers + * (py32f071xB.h ADC_TypeDef, py32f071_ll_adc.h), including the detail that + * LL_ADC_FLAG_EOS is really ADC_SR_EOC on this part. + * + * The conversion result is a fixed value for now. It feeds battery voltage and + * the CEC-cable key detection; a flat reading is enough to boot, and the value + * can be made settable once those paths are being tested. + */ +#define TYPE_PY32_ADC "py32-adc" +OBJECT_DECLARE_SIMPLE_TYPE(PY32AdcState, PY32_ADC) + +struct PY32AdcState { + SysBusDevice parent_obj; + MemoryRegion iomem; + uint32_t regs[0x20]; +}; + +#define ADC_SR 0x00 +#define ADC_CR1 0x04 +#define ADC_CR2 0x08 +#define ADC_DR 0x50 + +#define ADC_SR_AWD (1u << 0) +#define ADC_SR_EOC (1u << 1) /* what LL calls EOS on this part */ +#define ADC_SR_JEOC (1u << 2) +#define ADC_SR_JSTRT (1u << 3) +#define ADC_SR_STRT (1u << 4) + +#define ADC_CR2_ADON (1u << 0) +#define ADC_CR2_CAL (1u << 2) +#define ADC_CR2_RSTCAL (1u << 3) +#define ADC_CR2_SWSTART (1u << 22) + +/* Battery sits around 7.4 V; the calibration table in flash maps raw counts to + * volts, and 2200 lands mid-scale on a real dump. */ +#define PY32_ADC_RESULT 2200 + +static uint64_t py32_adc_read(void *opaque, hwaddr addr, unsigned size) +{ + PY32AdcState *s = opaque; + const unsigned idx = addr >> 2; + + if (idx >= ARRAY_SIZE(s->regs)) { + return 0; + } + + if (addr == ADC_DR) { + /* Reading the result clears end-of-conversion, as on hardware. */ + s->regs[ADC_SR >> 2] &= ~ADC_SR_EOC; + return PY32_ADC_RESULT; + } + return s->regs[idx]; +} + +static void py32_adc_write(void *opaque, hwaddr addr, uint64_t value, unsigned size) +{ + PY32AdcState *s = opaque; + const unsigned idx = addr >> 2; + + if (idx >= ARRAY_SIZE(s->regs)) { + return; + } + + if (addr == ADC_CR2) { + /* + * Calibration and reset-calibration complete instantly: the bits are + * write-1-to-start and hardware-cleared, so never store them set or the + * firmware's wait loop never exits. + */ + s->regs[idx] = value & ~(ADC_CR2_CAL | ADC_CR2_RSTCAL); + + if (value & ADC_CR2_ADON) { + /* Enabled: report a finished conversion so the init sequence and + * later polled reads both make progress. */ + s->regs[ADC_SR >> 2] |= ADC_SR_EOC | ADC_SR_STRT; + } + return; + } + + if (addr == ADC_SR) { + /* Flags are cleared by writing 0 to them. */ + s->regs[idx] &= value; + return; + } + + s->regs[idx] = value; +} + +static const MemoryRegionOps py32_adc_ops = { + .read = py32_adc_read, + .write = py32_adc_write, + .endianness = DEVICE_LITTLE_ENDIAN, + .valid.min_access_size = 4, + .valid.max_access_size = 4, +}; + +static void py32_adc_reset(DeviceState *dev) +{ + PY32AdcState *s = PY32_ADC(dev); + memset(s->regs, 0, sizeof(s->regs)); +} + +static void py32_adc_init(Object *obj) +{ + PY32AdcState *s = PY32_ADC(obj); + memory_region_init_io(&s->iomem, obj, &py32_adc_ops, s, TYPE_PY32_ADC, 0x400); + sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->iomem); +} + +static void py32_adc_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->reset = py32_adc_reset; + dc->desc = "PY32F071 ADC"; +} + +/* + * Peripherals the firmware touches during init but whose behaviour it does not + * depend on yet (FLASH latency, PWR, SYSCFG, EXTI, CRC, timers, I2C, ADC). + * Reads return the last written value so read-modify-write sequences behave, + * and everything is logged so it is visible which ones actually get used -- + * that log is how the next tier of models gets prioritised. + */ +#define TYPE_PY32_STUB "py32-stub" +OBJECT_DECLARE_SIMPLE_TYPE(PY32StubState, PY32_STUB) + +struct PY32StubState { + SysBusDevice parent_obj; + MemoryRegion iomem; + char *stub_name; + uint32_t size; + uint32_t regs[0x100]; +}; + +static uint64_t py32_stub_read(void *opaque, hwaddr addr, unsigned size) +{ + PY32StubState *s = opaque; + const unsigned idx = addr >> 2; + const uint32_t value = idx < ARRAY_SIZE(s->regs) ? s->regs[idx] : 0; + + qemu_log_mask(LOG_UNIMP, "py32-%s: read 0x%03" HWADDR_PRIx " -> 0x%08x\n", + s->stub_name ?: "stub", addr, value); + return value; +} + +static void py32_stub_write(void *opaque, hwaddr addr, uint64_t value, unsigned size) +{ + PY32StubState *s = opaque; + const unsigned idx = addr >> 2; + + if (idx < ARRAY_SIZE(s->regs)) { + s->regs[idx] = value; + } + qemu_log_mask(LOG_UNIMP, "py32-%s: write 0x%03" HWADDR_PRIx " = 0x%08" PRIx64 "\n", + s->stub_name ?: "stub", addr, value); +} + +static const MemoryRegionOps py32_stub_ops = { + .read = py32_stub_read, + .write = py32_stub_write, + .endianness = DEVICE_LITTLE_ENDIAN, + .valid.min_access_size = 1, + .valid.max_access_size = 4, +}; + +static void py32_stub_realize(DeviceState *dev, Error **errp) +{ + PY32StubState *s = PY32_STUB(dev); + + memory_region_init_io(&s->iomem, OBJECT(dev), &py32_stub_ops, s, + s->stub_name ?: TYPE_PY32_STUB, + s->size ? s->size : 0x400); + sysbus_init_mmio(SYS_BUS_DEVICE(dev), &s->iomem); +} + +static Property py32_stub_properties[] = { + DEFINE_PROP_STRING("stub-name", PY32StubState, stub_name), + DEFINE_PROP_UINT32("size", PY32StubState, size, 0x400), + DEFINE_PROP_END_OF_LIST(), +}; + +static void py32_stub_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->realize = py32_stub_realize; + dc->desc = "PY32F071 unimplemented peripheral"; + device_class_set_props(dc, py32_stub_properties); +} + +/* ------------------------------------------------------------ SoC container */ + +#define TYPE_PY32F071_SOC "py32f071-soc" +OBJECT_DECLARE_SIMPLE_TYPE(PY32F071State, PY32F071_SOC) + +#define PY32_NUM_GPIO 4 +#define PY32_NUM_STUB 25 + +struct PY32F071State { + DeviceState parent_obj; + + ARMv7MState armv7m; + PY32RccState rcc; + PY32GpioState gpio[PY32_NUM_GPIO]; + PY32AdcState adc; + PY32SpiState spi[2]; + PY32DmaState dma; + PY32StubState stub[PY32_NUM_STUB]; + + Clock *sysclk; + + MemoryRegion flash; + MemoryRegion flash_alias; + MemoryRegion sram; + MemoryRegion *board_memory; + MemoryRegion container; +}; + +/* Peripherals covered by the catch-all, in map order. */ +static const struct { const char *name; hwaddr base; uint32_t size; } py32_stubs[] = { + { "flash-ctl", PY32_FLASH_R_BASE, 0x400 }, + { "pwr", PY32_PWR_BASE, 0x400 }, + { "syscfg", PY32_SYSCFG_BASE, 0x400 }, + { "exti", PY32_EXTI_BASE, 0x400 }, + { "crc", PY32_CRC_BASE, 0x400 }, + { "usart1", PY32_USART1_BASE, 0x400 }, + { "usart2", PY32_USART2_BASE, 0x400 }, + { "i2c1", PY32_I2C1_BASE, 0x400 }, + { "i2c2", PY32_I2C2_BASE, 0x400 }, + { "tim1", PY32_TIM1_BASE, 0x400 }, + { "tim2", PY32_TIM2_BASE, 0x400 }, + { "tim3", PY32_TIM3_BASE, 0x400 }, + { "tim6", PY32_TIM6_BASE, 0x400 }, + { "tim7", PY32_TIM7_BASE, 0x400 }, + { "tim14", PY32_TIM14_BASE, 0x400 }, + { "tim15", PY32_TIM15_BASE, 0x400 }, + { "tim16", PY32_TIM16_BASE, 0x400 }, + { "tim17", PY32_TIM17_BASE, 0x400 }, + { "usb", PY32_USB_BASE, 0x400 }, + { "rtc", PY32_RTC_BASE, 0x400 }, + { "iwdg", PY32_IWDG_BASE, 0x400 }, + { "wwdg", PY32_WWDG_BASE, 0x400 }, + { "usart3", PY32_USART3_BASE, 0x400 }, + { "usart4", PY32_USART4_BASE, 0x400 }, + { "dbgmcu", PY32_DBGMCU_BASE, 0x400 }, + { "lcd-ctl", PY32_LCD_BASE, 0x400 }, +}; + +static const hwaddr py32_gpio_bases[PY32_NUM_GPIO] = { + PY32_GPIOA_BASE, PY32_GPIOB_BASE, PY32_GPIOC_BASE, PY32_GPIOF_BASE, +}; +static const char *py32_gpio_names[PY32_NUM_GPIO] = { "a", "b", "c", "f" }; + +static void py32f071_soc_init(Object *obj) +{ + PY32F071State *s = PY32F071_SOC(obj); + + object_initialize_child(obj, "armv7m", &s->armv7m, TYPE_ARMV7M); + object_initialize_child(obj, "rcc", &s->rcc, TYPE_PY32_RCC); + object_initialize_child(obj, "adc", &s->adc, TYPE_PY32_ADC); + object_initialize_child(obj, "spi1", &s->spi[0], TYPE_PY32_SPI); + object_initialize_child(obj, "spi2", &s->spi[1], TYPE_PY32_SPI); + object_initialize_child(obj, "dma1", &s->dma, TYPE_PY32_DMA); + + /* The firmware runs the core at 48 MHz (SystemInit configures HSI+PLL). */ + s->sysclk = qdev_init_clock_in(DEVICE(obj), "sysclk", NULL, NULL, 0); + + for (int i = 0; i < PY32_NUM_GPIO; i++) { + object_initialize_child(obj, py32_gpio_names[i], &s->gpio[i], TYPE_PY32_GPIO); + } + for (int i = 0; i < PY32_NUM_STUB; i++) { + object_initialize_child(obj, py32_stubs[i].name, &s->stub[i], TYPE_PY32_STUB); + } +} + +static void py32f071_soc_realize(DeviceState *dev_soc, Error **errp) +{ + PY32F071State *s = PY32F071_SOC(dev_soc); + Object *obj = OBJECT(dev_soc); + + if (!s->board_memory) { + error_setg(errp, "memory property was not set"); + return; + } + + memory_region_init(&s->container, obj, "py32f071-container", 0x60000000); + + memory_region_init_rom(&s->flash, obj, "py32f071.flash", PY32_FLASH_SIZE, errp); + if (*errp) { + return; + } + memory_region_add_subregion(&s->container, PY32_FLASH_BASE, &s->flash); + + memory_region_init_ram(&s->sram, obj, "py32f071.sram", PY32_SRAM_SIZE, errp); + if (*errp) { + return; + } + memory_region_add_subregion(&s->container, PY32_SRAM_BASE, &s->sram); + + /* Core. The firmware's vector table has 53 entries; round up for the NVIC. */ + qdev_prop_set_uint32(DEVICE(&s->armv7m), "num-irq", PY32_NUM_IRQ + 16); + qdev_prop_set_string(DEVICE(&s->armv7m), "cpu-type", ARM_CPU_TYPE_NAME("cortex-m0")); + qdev_prop_set_bit(DEVICE(&s->armv7m), "enable-bitband", false); + qdev_connect_clock_in(DEVICE(&s->armv7m), "cpuclk", s->sysclk); + /* + * Accelerate SysTick polling. SYSTICK_DelayUs busy-reads the current-value + * register and accumulates differences; under emulation the counter barely + * moves between reads, and a measured 120 ms delay needed about 7.7 hours of + * wall time. Advancing the timer on each read makes those loops converge. + * + * Guest time therefore runs fast during a delay: the right trade for + * exercising the UI and control flow, the wrong tool for signal timing. + */ + qdev_prop_set_uint32(DEVICE(&s->armv7m.systick[0]), "poll-boost", 24000); + object_property_set_link(OBJECT(&s->armv7m), "memory", OBJECT(&s->container), + &error_abort); + if (!sysbus_realize(SYS_BUS_DEVICE(&s->armv7m), errp)) { + return; + } + + if (!sysbus_realize(SYS_BUS_DEVICE(&s->rcc), errp)) { + return; + } + memory_region_add_subregion(&s->container, PY32_RCC_BASE, + sysbus_mmio_get_region(SYS_BUS_DEVICE(&s->rcc), 0)); + + if (!sysbus_realize(SYS_BUS_DEVICE(&s->adc), errp)) { + return; + } + memory_region_add_subregion(&s->container, PY32_ADC1_BASE, + sysbus_mmio_get_region(SYS_BUS_DEVICE(&s->adc), 0)); + + static const hwaddr spi_bases[2] = { PY32_SPI1_BASE, PY32_SPI2_BASE }; + static const char *spi_names[2] = { "1", "2" }; + for (int i = 0; i < 2; i++) { + qdev_prop_set_string(DEVICE(&s->spi[i]), "bus-name", spi_names[i]); + if (!sysbus_realize(SYS_BUS_DEVICE(&s->spi[i]), errp)) { + return; + } + memory_region_add_subregion(&s->container, spi_bases[i], + sysbus_mmio_get_region(SYS_BUS_DEVICE(&s->spi[i]), 0)); + } + + /* + * DMA needs to reach the SPI controllers directly: the flash driver drives + * transfers entirely through DMA channels 4 and 5 and never touches the data + * register, so routing has to exist before it runs. + */ + s->dma.spi[0] = &s->spi[0]; + s->dma.spi[1] = &s->spi[1]; + if (!sysbus_realize(SYS_BUS_DEVICE(&s->dma), errp)) { + return; + } + memory_region_add_subregion(&s->container, PY32_DMA1_BASE, + sysbus_mmio_get_region(SYS_BUS_DEVICE(&s->dma), 0)); + /* Vector 10 covers channels 1-3, vector 11 covers 4-7 (py32f071xB.h). */ + sysbus_connect_irq(SYS_BUS_DEVICE(&s->dma), 0, + qdev_get_gpio_in(DEVICE(&s->armv7m), 10)); + sysbus_connect_irq(SYS_BUS_DEVICE(&s->dma), 1, + qdev_get_gpio_in(DEVICE(&s->armv7m), 11)); + + for (int i = 0; i < PY32_NUM_GPIO; i++) { + qdev_prop_set_string(DEVICE(&s->gpio[i]), "port-name", py32_gpio_names[i]); + if (!sysbus_realize(SYS_BUS_DEVICE(&s->gpio[i]), errp)) { + return; + } + memory_region_add_subregion(&s->container, py32_gpio_bases[i], + sysbus_mmio_get_region(SYS_BUS_DEVICE(&s->gpio[i]), 0)); + } + + for (int i = 0; i < PY32_NUM_STUB; i++) { + qdev_prop_set_string(DEVICE(&s->stub[i]), "stub-name", py32_stubs[i].name); + qdev_prop_set_uint32(DEVICE(&s->stub[i]), "size", py32_stubs[i].size); + if (!sysbus_realize(SYS_BUS_DEVICE(&s->stub[i]), errp)) { + return; + } + memory_region_add_subregion(&s->container, py32_stubs[i].base, + sysbus_mmio_get_region(SYS_BUS_DEVICE(&s->stub[i]), 0)); + } + + /* + * The container is handed to the ARMv7M core as its address space, so it + * must not also be mounted into the board's system memory: a memory region + * can only have one container. Aliasing flash at 0 is what the hardware + * does -- the M0+ fetches its vector table from 0x00000000, and on this part + * the boot mapping points that at flash. + */ + /* + * The alias starts at the application offset, not at the flash base: the + * core fetches its initial SP and PC from address 0, and the image is loaded + * at 0x08002800 (past the bootloader), so 0 has to line up with the + * application's vector table rather than the bootloader's. + */ + memory_region_init_alias(&s->flash_alias, obj, "py32f071.flash.alias", + &s->flash, PY32_APP_OFFSET, + PY32_FLASH_SIZE - PY32_APP_OFFSET); + memory_region_add_subregion(&s->container, 0, &s->flash_alias); +} + +static Property py32f071_soc_properties[] = { + DEFINE_PROP_LINK("memory", PY32F071State, board_memory, TYPE_MEMORY_REGION, + MemoryRegion *), + DEFINE_PROP_END_OF_LIST(), +}; + +static void py32f071_soc_class_init(ObjectClass *klass, void *data) +{ + DeviceClass *dc = DEVICE_CLASS(klass); + dc->realize = py32f071_soc_realize; + dc->desc = "Puya PY32F071 SoC"; + device_class_set_props(dc, py32f071_soc_properties); +} + +/* ------------------------------------------------------------------ machine */ + +struct UVK5MachineState { + MachineState parent; + PY32F071State soc; + PY25Q16State flash; + UVK5KeypadState keypad; + Clock *sysclk; + char *flash_image; +}; + +#define TYPE_UVK5_MACHINE MACHINE_TYPE_NAME("uv-k5-v3") +OBJECT_DECLARE_SIMPLE_TYPE(UVK5MachineState, UVK5_MACHINE) + +static void uvk5_machine_init(MachineState *machine) +{ + UVK5MachineState *s = UVK5_MACHINE(machine); + + object_initialize_child(OBJECT(machine), "soc", &s->soc, TYPE_PY32F071_SOC); + object_property_set_link(OBJECT(&s->soc), "memory", + OBJECT(get_system_memory()), &error_fatal); + + /* + * SysTick pacing, deliberately not the real 48 MHz. + * + * SYSTICK_DelayUs busy-reads SysTick->VAL and accumulates the difference + * until it reaches Delay * 48. On hardware each loop iteration advances the + * counter by tens of ticks. Under emulation an iteration costs far less + * wall-clock time, so at 48 MHz the counter barely moves between reads and a + * 1 ms delay takes about a minute -- measured, not assumed. + * + * Slowing the SysTick clock makes each read span more ticks, which is the + * ratio that loop actually depends on. The trade-off: guest time no longer + * matches real time, so anything timing-critical must be judged against the + * counter rather than a stopwatch. + */ + s->sysclk = clock_new(OBJECT(machine), "SYSCLK"); + clock_set_hz(s->sysclk, 48000000ULL); + qdev_connect_clock_in(DEVICE(&s->soc), "sysclk", s->sysclk); + + sysbus_realize(SYS_BUS_DEVICE(&s->soc), &error_fatal); + + /* + * External SPI NOR on SPI1, chip-selected by GPIOA pin 3 (CS_PIN in + * App/driver/py25q16.c). The image carries settings and the calibration + * block; -drive if=pflash,file=... overrides the default path. + */ + object_initialize_child(OBJECT(machine), "flash", &s->flash, TYPE_PY25Q16); + { + /* + * Image path from -machine flash-image=..., falling back to -bios. + * Without one the flash reads as erased, which the firmware treats as a + * factory-fresh radio: it boots, but with no calibration data. + */ + const char *path = s->flash_image; + if (!path || !*path) { + path = machine->firmware; + } + if (path && *path) { + qdev_prop_set_string(DEVICE(&s->flash), "image", path); + } + } + qdev_realize(DEVICE(&s->flash), NULL, &error_fatal); + + /* SPI2, not SPI1: App/driver/py25q16.c uses SPI2 and st7565.c uses SPI1. */ + py32_spi_set_xfer(&s->soc.spi[1], py25q16_xfer, &s->flash); + + /* + * Keypad matrix on GPIOB. The scan columns are outputs from the port into + * the matrix, and the matrix drives the row lines back as inputs, which is + * the same direction of travel as the real wiring. + */ + object_initialize_child(OBJECT(machine), "keypad", &s->keypad, + TYPE_UVK5_KEYPAD); + qdev_realize(DEVICE(&s->keypad), NULL, &error_fatal); + + for (int c = 1; c < KEYPAD_COLS; c++) { + qdev_connect_gpio_out_named(DEVICE(&s->soc.gpio[1]), "pin-out", + KEYPAD_COL_PIN(c), + qdev_get_gpio_in_named(DEVICE(&s->keypad), + "col", c)); + } + for (int r = 0; r < KEYPAD_ROWS; r++) { + qdev_connect_gpio_out_named(DEVICE(&s->keypad), "row", r, + qdev_get_gpio_in_named(DEVICE(&s->soc.gpio[1]), + "pin-in", + KEYPAD_ROW_PIN(r))); + } + + /* + * Drive the initial row levels now that the lines exist. The device reset + * ran before wiring, so its qemu_set_irq calls went nowhere; without this + * the port keeps whatever it had, which read as every row low -- every key + * held at once, which the firmware discards as noise. + */ + keypad_update_rows(&s->keypad); + qdev_connect_gpio_out_named(DEVICE(&s->soc.gpio[0]), "pin-out", 3, + qdev_get_gpio_in_named(DEVICE(&s->flash), + "cs", 0)); + + /* + * The application lives at PY32_APP_OFFSET, past the bootloader. Passing + * that as the load offset means a plain application .elf/.bin boots without + * needing a bootloader image. + */ + armv7m_load_kernel(ARM_CPU(first_cpu), machine->kernel_filename, + PY32_APP_OFFSET, PY32_FLASH_SIZE - PY32_APP_OFFSET); +} + +static char *uvk5_get_flash_image(Object *obj, Error **errp) +{ + UVK5MachineState *s = UVK5_MACHINE(obj); + return g_strdup(s->flash_image); +} + +static void uvk5_set_flash_image(Object *obj, const char *value, Error **errp) +{ + UVK5MachineState *s = UVK5_MACHINE(obj); + g_free(s->flash_image); + s->flash_image = g_strdup(value); +} + +static void uvk5_machine_class_init(ObjectClass *oc, void *data) +{ + MachineClass *mc = MACHINE_CLASS(oc); + + object_class_property_add_str(oc, "flash-image", + uvk5_get_flash_image, uvk5_set_flash_image); + object_class_property_set_description(oc, "flash-image", + "2MB SPI NOR image holding settings and calibration data"); + + mc->desc = "Quansheng UV-K5 V3 / UV-K1 (PY32F071, Cortex-M0+)"; + mc->init = uvk5_machine_init; + mc->max_cpus = 1; + mc->default_cpus = 1; + mc->min_cpus = 1; + mc->default_ram_size = 0; + mc->no_floppy = 1; + mc->no_cdrom = 1; + mc->no_parallel = 1; +} + +/* -------------------------------------------------------------- registration */ + +static const TypeInfo py32_types[] = { + { + .name = TYPE_PY32_RCC, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32RccState), + .instance_init = py32_rcc_init, + .class_init = py32_rcc_class_init, + }, + { + .name = TYPE_PY32_GPIO, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32GpioState), + .instance_init = py32_gpio_init, + .class_init = py32_gpio_class_init, + }, + { + .name = TYPE_PY32_DMA, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32DmaState), + .instance_init = py32_dma_init, + .class_init = py32_dma_class_init, + }, + { + .name = TYPE_UVK5_KEYPAD, + .parent = TYPE_DEVICE, + .instance_size = sizeof(UVK5KeypadState), + .instance_init = keypad_init, + .class_init = keypad_class_init, + }, + { + .name = TYPE_PY25Q16, + .parent = TYPE_DEVICE, + .instance_size = sizeof(PY25Q16State), + .class_init = py25q16_class_init, + }, + { + .name = TYPE_PY32_SPI, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32SpiState), + .instance_init = py32_spi_init, + .class_init = py32_spi_class_init, + }, + { + .name = TYPE_PY32_ADC, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32AdcState), + .instance_init = py32_adc_init, + .class_init = py32_adc_class_init, + }, + { + .name = TYPE_PY32_STUB, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32StubState), + .class_init = py32_stub_class_init, + }, + { + .name = TYPE_PY32F071_SOC, + .parent = TYPE_SYS_BUS_DEVICE, + .instance_size = sizeof(PY32F071State), + .instance_init = py32f071_soc_init, + .class_init = py32f071_soc_class_init, + }, + { + .name = TYPE_UVK5_MACHINE, + .parent = TYPE_MACHINE, + .instance_size = sizeof(UVK5MachineState), + .class_init = uvk5_machine_class_init, + }, +}; + +DEFINE_TYPES(py32_types) diff --git a/shim/py32f071_ll_bus.h b/shim/py32f071_ll_bus.h new file mode 100644 index 0000000..5fc94f5 --- /dev/null +++ b/shim/py32f071_ll_bus.h @@ -0,0 +1,11 @@ +/* Empty stand-in for an MCU header. + * + * app/cwkeyer.c includes several LL headers but uses no LL symbol from them + * (verified: zero LL_* references). Providing empty headers lets the firmware + * file compile unchanged on the host -- no edits to firmware source, so the + * simulator cannot drift from what the radio actually runs. + */ + +#ifndef PY32F071_LL_BUS_H_SHIM +#define PY32F071_LL_BUS_H_SHIM +#endif diff --git a/shim/py32f071_ll_dma.h b/shim/py32f071_ll_dma.h new file mode 100644 index 0000000..71c6fc8 --- /dev/null +++ b/shim/py32f071_ll_dma.h @@ -0,0 +1,11 @@ +/* Empty stand-in for an MCU header. + * + * app/cwkeyer.c includes several LL headers but uses no LL symbol from them + * (verified: zero LL_* references). Providing empty headers lets the firmware + * file compile unchanged on the host -- no edits to firmware source, so the + * simulator cannot drift from what the radio actually runs. + */ + +#ifndef PY32F071_LL_DMA_H_SHIM +#define PY32F071_LL_DMA_H_SHIM +#endif diff --git a/shim/py32f071_ll_gpio.h b/shim/py32f071_ll_gpio.h new file mode 100644 index 0000000..f23d0bf --- /dev/null +++ b/shim/py32f071_ll_gpio.h @@ -0,0 +1,99 @@ +/* Minimal stand-in for the PY32 GPIO LL header. + * + * driver/gpio.h names GPIO port pointers and LL_GPIO_PIN_* constants at file + * scope, so those have to exist for the firmware headers to parse. Nothing here + * touches real hardware: the ports are dummy objects and the pin masks only need + * to be distinct, because the simulator resolves key state through the scripted + * paddle timeline instead of reading pins. + */ + +#ifndef PY32F071_LL_GPIO_H_SHIM +#define PY32F071_LL_GPIO_H_SHIM + +#include + +typedef struct { + volatile uint32_t MODER; + volatile uint32_t OTYPER; + volatile uint32_t OSPEEDR; + volatile uint32_t PUPDR; + volatile uint32_t IDR; + volatile uint32_t ODR; + volatile uint32_t BSRR; + volatile uint32_t LCKR; + volatile uint32_t AFR[2]; + volatile uint32_t BRR; +} GPIO_TypeDef; + +// The firmware treats these as numeric addresses: driver/gpio.h packs a port +// into the high half of a pin id (GPIO_MAKE_PIN) inside an enum, so they must be +// integer constant expressions, and GPIO_PORT() casts them back to a pointer. +// Keep that shape -- the accessors below resolve the fake address to real +// storage instead of dereferencing it, so no host memory at 0x0000 is touched. +#define IOPORT_BASE 0u + +#define GPIOA 0x0000u +#define GPIOB 0x0100u +#define GPIOC 0x0200u +#define GPIOF 0x0300u + +// Resolves a fake port address to backing storage. Defined in +// stubs/firmware_globals.c. +GPIO_TypeDef *SIM_GpioPort(void *fake_address); + +#define LL_GPIO_PIN_0 (1u << 0) +#define LL_GPIO_PIN_1 (1u << 1) +#define LL_GPIO_PIN_2 (1u << 2) +#define LL_GPIO_PIN_3 (1u << 3) +#define LL_GPIO_PIN_4 (1u << 4) +#define LL_GPIO_PIN_5 (1u << 5) +#define LL_GPIO_PIN_6 (1u << 6) +#define LL_GPIO_PIN_7 (1u << 7) +#define LL_GPIO_PIN_8 (1u << 8) +#define LL_GPIO_PIN_9 (1u << 9) +#define LL_GPIO_PIN_10 (1u << 10) +#define LL_GPIO_PIN_11 (1u << 11) +#define LL_GPIO_PIN_12 (1u << 12) +#define LL_GPIO_PIN_13 (1u << 13) +#define LL_GPIO_PIN_14 (1u << 14) +#define LL_GPIO_PIN_15 (1u << 15) + +#define LL_GPIO_MODE_INPUT 0u +#define LL_GPIO_MODE_OUTPUT 1u +#define LL_GPIO_MODE_ALTERNATE 2u +#define LL_GPIO_MODE_ANALOG 3u + +#define LL_GPIO_PULL_NO 0u +#define LL_GPIO_PULL_UP 1u +#define LL_GPIO_PULL_DOWN 2u + +#define LL_GPIO_OUTPUT_PUSHPULL 0u +#define LL_GPIO_OUTPUT_OPENDRAIN 1u + +#define LL_GPIO_SPEED_FREQ_LOW 0u +#define LL_GPIO_SPEED_FREQ_MEDIUM 1u +#define LL_GPIO_SPEED_FREQ_HIGH 2u +#define LL_GPIO_SPEED_FREQ_VERY_HIGH 3u + +static inline uint32_t LL_GPIO_IsInputPinSet(GPIO_TypeDef *port, uint32_t pin) +{ + return (SIM_GpioPort(port)->IDR & pin) ? 1u : 0u; +} +static inline void LL_GPIO_SetOutputPin(GPIO_TypeDef *port, uint32_t pin) +{ + SIM_GpioPort(port)->ODR |= pin; +} +static inline void LL_GPIO_ResetOutputPin(GPIO_TypeDef *port, uint32_t pin) +{ + SIM_GpioPort(port)->ODR &= ~pin; +} +static inline uint32_t LL_GPIO_IsOutputPinSet(GPIO_TypeDef *port, uint32_t pin) +{ + return (SIM_GpioPort(port)->ODR & pin) ? 1u : 0u; +} +static inline void LL_GPIO_SetPinMode(GPIO_TypeDef *port, uint32_t pin, uint32_t mode) { (void)port; (void)pin; (void)mode; } +static inline void LL_GPIO_SetPinPull(GPIO_TypeDef *port, uint32_t pin, uint32_t pull) { (void)port; (void)pin; (void)pull; } +static inline void LL_GPIO_SetPinOutputType(GPIO_TypeDef *port, uint32_t pin, uint32_t t) { (void)port; (void)pin; (void)t; } +static inline void LL_GPIO_SetPinSpeed(GPIO_TypeDef *port, uint32_t pin, uint32_t s) { (void)port; (void)pin; (void)s; } + +#endif diff --git a/shim/py32f071_ll_rcc.h b/shim/py32f071_ll_rcc.h new file mode 100644 index 0000000..a473e8e --- /dev/null +++ b/shim/py32f071_ll_rcc.h @@ -0,0 +1,11 @@ +/* Empty stand-in for an MCU header. + * + * app/cwkeyer.c includes several LL headers but uses no LL symbol from them + * (verified: zero LL_* references). Providing empty headers lets the firmware + * file compile unchanged on the host -- no edits to firmware source, so the + * simulator cannot drift from what the radio actually runs. + */ + +#ifndef PY32F071_LL_RCC_H_SHIM +#define PY32F071_LL_RCC_H_SHIM +#endif diff --git a/shim/py32f071_ll_tim.h b/shim/py32f071_ll_tim.h new file mode 100644 index 0000000..bdb45a5 --- /dev/null +++ b/shim/py32f071_ll_tim.h @@ -0,0 +1,11 @@ +/* Empty stand-in for an MCU header. + * + * app/cwkeyer.c includes several LL headers but uses no LL symbol from them + * (verified: zero LL_* references). Providing empty headers lets the firmware + * file compile unchanged on the host -- no edits to firmware source, so the + * simulator cannot drift from what the radio actually runs. + */ + +#ifndef PY32F071_LL_TIM_H_SHIM +#define PY32F071_LL_TIM_H_SHIM +#endif diff --git a/shim/py32f071_ll_usart.h b/shim/py32f071_ll_usart.h new file mode 100644 index 0000000..55d076d --- /dev/null +++ b/shim/py32f071_ll_usart.h @@ -0,0 +1,11 @@ +/* Empty stand-in for an MCU header. + * + * app/cwkeyer.c includes several LL headers but uses no LL symbol from them + * (verified: zero LL_* references). Providing empty headers lets the firmware + * file compile unchanged on the host -- no edits to firmware source, so the + * simulator cannot drift from what the radio actually runs. + */ + +#ifndef PY32F071_LL_USART_H_SHIM +#define PY32F071_LL_USART_H_SHIM +#endif diff --git a/shim/py32f0xx.h b/shim/py32f0xx.h new file mode 100644 index 0000000..7db9427 --- /dev/null +++ b/shim/py32f0xx.h @@ -0,0 +1,11 @@ +/* Empty stand-in for an MCU header. + * + * app/cwkeyer.c includes several LL headers but uses no LL symbol from them + * (verified: zero LL_* references). Providing empty headers lets the firmware + * file compile unchanged on the host -- no edits to firmware source, so the + * simulator cannot drift from what the radio actually runs. + */ + +#ifndef PY32F0XX_H_SHIM +#define PY32F0XX_H_SHIM +#endif diff --git a/stubs/cwhardware_debounce.c b/stubs/cwhardware_debounce.c new file mode 100644 index 0000000..8c4528f --- /dev/null +++ b/stubs/cwhardware_debounce.c @@ -0,0 +1,83 @@ +/* The debounce and edge-detection half of app/cwhardware.c. + * + * Why this file exists: CW_ReadKeys() applies an asymmetric debounce (a press + * needs three consecutive agreeing reads, a release takes effect on the first) + * and tracks which paddle was pressed last for Ultimatic's tie-break. That logic + * is part of the timing behaviour under test, so it must not be approximated. + * + * The rest of app/cwhardware.c is pin plumbing -- LL_GPIO_Init, DMA channels, + * USB clock gating -- which needs 20+ MCU symbols and has no bearing on element + * timing. Compiling the whole file would mean stubbing all of that. + * + * So the debounce is transcribed here, byte-for-byte in behaviour, and kept in + * sync by a probe check (see check_sim_parity.py) that diffs it against the + * firmware original. If someone edits the firmware debounce, the check fails. + */ + +#include +#include + +#include "app/cwhardware.h" +#include "settings.h" + +#define CW_KEY_FLAG_USB_PORT 0x20 + +static bool s_last_dit; +static bool s_last_dah; +static bool s_last_is_dah; +static uint8_t s_dit_count; +static uint8_t s_dah_count; + +void CW_ReadKeys(CW_Input *in) +{ + bool n_dit = false; + bool n_dah = false; + + if (!CW_ReadKeysForMode(gEeprom.CW_KEY_INPUT, &n_dit, &n_dah)) { + n_dit = false; + n_dah = false; + } + + bool deb_dit = s_last_dit; + bool deb_dah = s_last_dah; + if (gEeprom.CW_KEY_INPUT & CW_KEY_FLAG_USB_PORT) { + // USB paddle has its own tri-state glitch filter upstream. + deb_dit = n_dit; + deb_dah = n_dah; + } else { + // Asymmetric: three-strike on rising, immediate on falling. A symmetric + // release delay starves the iambic path's own count-based debounce and + // makes mode B latch extra trailing elements. + if (n_dit == deb_dit) s_dit_count = 0; + else if (!deb_dit) { + if (++s_dit_count >= 3) { deb_dit = true; s_dit_count = 0; } + } else { deb_dit = false; s_dit_count = 0; } + + if (n_dah == deb_dah) s_dah_count = 0; + else if (!deb_dah) { + if (++s_dah_count >= 3) { deb_dah = true; s_dah_count = 0; } + } else { deb_dah = false; s_dah_count = 0; } + } + + in->dit_rise = (!s_last_dit && deb_dit); + in->dah_rise = (!s_last_dah && deb_dah); + in->dit = deb_dit; + in->dah = deb_dah; + + // Most recent fresh press wins for Ultimatic's "both held" rule. + if (in->dit_rise) s_last_is_dah = false; + else if (in->dah_rise) s_last_is_dah = true; + in->last_is_dah = s_last_is_dah; + + s_last_dit = deb_dit; + s_last_dah = deb_dah; +} + +void CW_HW_ResetKeySamples(void) +{ + s_last_dit = false; + s_last_dah = false; + s_last_is_dah = false; + s_dit_count = 0; + s_dah_count = 0; +} diff --git a/stubs/cwhardware_stub.c b/stubs/cwhardware_stub.c new file mode 100644 index 0000000..7320ff4 --- /dev/null +++ b/stubs/cwhardware_stub.c @@ -0,0 +1,53 @@ +/* Hardware seam for the CW timing chain. + * + * Only the lowest layer is replaced: CW_ReadKeysForMode (raw pin state) and the + * pin-configuration calls. The debounce and edge detection in CW_ReadKeys stay + * compiled from the real app/cwhardware.c, because that debounce is part of the + * timing behaviour under test -- reimplementing it here would test the + * reimplementation instead of the firmware. + */ + +#include +#include + +#include "app/cwhardware.h" +#include "harness/sim_paddle.h" +#include "settings.h" + +// Mirrors the flag layout in settings.h. +#define CW_KEY_FLAG_REVERSED 0x01 +#define CW_KEY_FLAG_PORT_RING 0x02 +#define CW_KEY_FLAG_SIDE1 0x04 +#define CW_KEY_FLAG_NO_KEYER 0x08 +#define CW_KEY_FLAG_PORT_GROUND 0x10 +#define CW_KEY_FLAG_USB_PORT 0x20 + +bool CW_ReadKeysForMode(uint8_t mode, bool *dit_out, bool *dah_out) +{ + // Same early-out as the real driver: handkey families have no timing + // engine, so the iambic path must not see paddle state from them. + if ((mode & CW_KEY_FLAG_NO_KEYER) && !(mode & CW_KEY_FLAG_PORT_GROUND)) { + return false; + } + + const uint32_t contacts = SIM_PaddleState(); + const bool hw_tip = (contacts & SIM_CONTACT_TIP) != 0; + const bool hw_ring = (contacts & SIM_CONTACT_RING) != 0; + const bool reverse = (mode & CW_KEY_FLAG_REVERSED) != 0; + + *dit_out = reverse ? hw_ring : hw_tip; + *dah_out = reverse ? hw_tip : hw_ring; + return true; +} + +void CW_ReadUSBPaddleRaw(bool *tip_out, bool *ring_out) +{ + const uint32_t contacts = SIM_PaddleState(); + *tip_out = (contacts & SIM_CONTACT_TIP) != 0; + *ring_out = (contacts & SIM_CONTACT_RING) != 0; +} + +// Pin plumbing has no meaning off-target. +void CW_ConfigurePortGround(bool enable) { (void)enable; } +void CW_ConfigurePortRing(bool enable) { (void)enable; } +void CW_ConfigureUsbPaddlePins(bool enable) { (void)enable; } diff --git a/stubs/driver_stubs.c b/stubs/driver_stubs.c new file mode 100644 index 0000000..76c30ec --- /dev/null +++ b/stubs/driver_stubs.c @@ -0,0 +1,108 @@ +/* Driver-layer replacements for the host build. + * + * Everything the CW timing chain reaches outside its own two files. The count is + * small on purpose: app/cwkeyer.c and app/cwmacro.c contain no register access, + * so this is the whole seam. + * + * Time comes from the virtual clock, not the host clock. That is what makes the + * tests deterministic and fast. + */ + +#include +#include +#include +#include + +#include "harness/sim_clock.h" +#include "harness/sim_paddle.h" +#include "harness/sim_record.h" + +// ---------------------------------------------------------------- timing + +uint32_t millis(void) +{ + return SIM_ClockNow(); +} + +uint32_t millis_since(uint32_t start) +{ + // Same unsigned wrap arithmetic as the firmware. + return SIM_ClockNow() - start; +} + +void SYSTEM_DelayMs(uint32_t ms) +{ + // The firmware blocks here, so the keyer is not polled: advance the clock + // without running ticks. Modelling this faithfully matters -- the startup + // stuck-key check delays 50 ms and must not see paddle activity. + SIM_ClockAdvanceRaw(ms); +} + +// ---------------------------------------------------------------- inputs + +bool GPIO_IsPttPressed(void) +{ + // PTT is the dit paddle in Buttons mode and the straight key in handkey + // modes, so it reads from the same scripted timeline as TIP. + return (SIM_PaddleState() & SIM_CONTACT_TIP) != 0; +} + +// ---------------------------------------------------------------- recorded + +bool AUDIO_IsAudioPathOn(void) +{ + // The keyer adds a settling delay when the audio path was off. Report it as + // already on so element timing is not skewed by that one-shot allowance; + // tests that care drive it explicitly through the recorder. + return true; +} + +void BACKLIGHT_TurnOn(void) { } + +void UART_Send(const void *data, unsigned int size) +{ + // Debug tracing only (CW_KEYER_DEBUG). Route it to the recorder so a test + // can assert on it, and to stderr when verbose. + SIM_RecordDebug((const char *)data, size); +} + +// ---------------------------------------------------------------- storage + +#define SIM_EEPROM_SIZE 0x2000 +static uint8_t s_eeprom[SIM_EEPROM_SIZE]; +static bool s_eeprom_ready; + +static void eeprom_init_once(void) +{ + if (!s_eeprom_ready) { + // Erased flash reads as 0xFF; the firmware's validity checks depend on + // that, so start from it rather than zeros. + memset(s_eeprom, 0xFF, sizeof(s_eeprom)); + s_eeprom_ready = true; + } +} + +void EEPROM_ReadBuffer(uint16_t address, void *buffer, uint8_t size) +{ + eeprom_init_once(); + if ((uint32_t)address + size > SIM_EEPROM_SIZE) { + memset(buffer, 0xFF, size); + return; + } + memcpy(buffer, s_eeprom + address, size); +} + +void EEPROM_WriteBuffer(uint16_t address, const void *buffer) +{ + // The firmware always writes 8 bytes through this entry point. + eeprom_init_once(); + if ((uint32_t)address + 8 > SIM_EEPROM_SIZE) + return; + memcpy(s_eeprom + address, buffer, 8); +} + +void SIM_EepromReset(void) +{ + s_eeprom_ready = false; + eeprom_init_once(); +} diff --git a/stubs/firmware_globals.c b/stubs/firmware_globals.c new file mode 100644 index 0000000..cc80dcb --- /dev/null +++ b/stubs/firmware_globals.c @@ -0,0 +1,66 @@ +/* Firmware globals the CW chain reads, plus the few functions it calls that + * belong to subsystems outside the timing path. + * + * Kept separate from driver_stubs.c so the two seams stay legible: that file is + * "the driver layer", this one is "the rest of the firmware". + */ + +#include +#include +#include +#include +#include + +#include "harness/sim_record.h" +#include "misc.h" +#include "py32f071_ll_gpio.h" +#include "settings.h" + +// Backing storage for the fake GPIO ports. driver/gpio.h encodes a port as a +// numeric address inside an enum and casts it back with GPIO_PORT(), so the shim +// hands those addresses here rather than dereferencing them. +#define SIM_GPIO_PORT_COUNT 4 +static GPIO_TypeDef s_gpio_ports[SIM_GPIO_PORT_COUNT]; + +GPIO_TypeDef *SIM_GpioPort(void *fake_address) +{ + // Ports are spaced 0x100 apart by the shim (A=0x000, B=0x100, C=0x200, + // F=0x300). Anything unexpected lands on port 0 rather than faulting. + const uintptr_t index = ((uintptr_t)fake_address >> 8) & 0x3u; + return &s_gpio_ports[index]; +} + +// The real definition lives in misc.c / settings.c, which pull in most of the +// firmware. The CW chain only touches these fields. +EEPROM_Config_t gEeprom; + +volatile CW_State_t gCW_State = CW_INACTIVE; +volatile bool gCW_KeyerUsingSD1 = false; +volatile bool gCW_KeyerManagesPtt = false; +volatile bool gCW_CrossMode = false; +// Types must match misc.h exactly, including qualifiers. +volatile uint32_t gCW_SuspendCounter_1ms; +volatile uint16_t gCW_TxDisplayHoldoff_10ms; + +// gCW_Recording, the playback flags, gCW_TX_Display and the CW_*TxDisplay +// functions are all defined by app/cwmacro.c, which is compiled in as-is. +bool gCW_FlashlightSending; +bool gCW_CpoActive; +volatile bool gCW_PlayIndicatorOn; // owned by cwkeyer.c's playback path +bool gUpdateDisplay; +uint8_t gUpdateStatus; // uint8_t in misc.h, not bool + +// The firmware uses a bundled printf implementation; the host's is fine here. +// Only reached from CW_KEYER_DEBUG tracing. +int sprintf_(char *buffer, const char *format, ...) +{ + va_list args; + va_start(args, format); + const int n = vsprintf(buffer, format, args); + va_end(args); + return n; +} + +// Decoded characters are captured by wrapping the real CW_AddToTxDisplay -- +// see harness/sim_capture.c -- rather than replacing it, so cwmacro.c's own +// buffer management still runs. diff --git a/tests/test_iambic_basic.c b/tests/test_iambic_basic.c new file mode 100644 index 0000000..155802d --- /dev/null +++ b/tests/test_iambic_basic.c @@ -0,0 +1,137 @@ +/* Baseline iambic keyer behaviour: element durations and decoded characters. + * + * These are the assertions that would have caught the "keyer never arms" class + * of bug on a PC in milliseconds instead of on the radio by trial and error. + */ + +#include +#include + +#include "harness/sim_keyer.h" +#include "harness/sim_paddle.h" +#include "harness/sim_record.h" +#include "settings.h" + +static int failures; + +#define CHECK(cond, fmt, ...) \ + do { \ + if (!(cond)) { \ + printf(" FAIL %s:%d " fmt "\n", __func__, __LINE__, __VA_ARGS__); \ + failures++; \ + } \ + } while (0) + +// Element timing is generated from a 1 ms poll, so allow a tick of slack either +// way rather than demanding an exact match. +#define NEAR(actual, expected) \ + ((actual) + 2 >= (expected) && (actual) <= (expected) + 2) + +// Buttons mode: PTT is the dit paddle, SIDE1 the dah paddle. +#define KEY_BUTTONS 0x04 + +static void test_single_dit(void) +{ + SIM_KeyerBegin(KEY_BUTTONS, CW_IAMBIC_MODE_B, 20); + const uint32_t dit = SIM_KeyerDitMs(); + + // Hold the dit paddle briefly; the keyer times the element itself. + SIM_PaddleTap(SIM_CONTACT_TIP, dit / 2, 0); + SIM_KeyerRun(10 * dit); + + CHECK(SIM_RecordedElementCount() == 1, "expected 1 element, got %u", + SIM_RecordedElementCount()); + const uint32_t len = SIM_RecordedElementMs(0); + CHECK(NEAR(len, dit), "dit was %u ms, expected ~%u", len, dit); +} + +static void test_single_dah(void) +{ + SIM_KeyerBegin(KEY_BUTTONS, CW_IAMBIC_MODE_B, 20); + const uint32_t dit = SIM_KeyerDitMs(); + + SIM_PaddleTap(SIM_CONTACT_RING, dit / 2, 0); + SIM_KeyerRun(10 * dit); + + CHECK(SIM_RecordedElementCount() == 1, "expected 1 element, got %u", + SIM_RecordedElementCount()); + const uint32_t len = SIM_RecordedElementMs(0); + CHECK(NEAR(len, 3 * dit), "dah was %u ms, expected ~%u", len, 3 * dit); +} + +static void test_letter_a(void) +{ + // "A" is dit-dah. Two taps with a gap shorter than the character gap. + SIM_KeyerBegin(KEY_BUTTONS, CW_IAMBIC_MODE_B, 20); + const uint32_t dit = SIM_KeyerDitMs(); + + SIM_PaddleTap(SIM_CONTACT_TIP, dit / 2, dit); + SIM_PaddleTap(SIM_CONTACT_RING, dit / 2, 0); + SIM_KeyerRun(10 * dit); + + CHECK(SIM_RecordedElementCount() == 2, "expected 2 elements, got %u", + SIM_RecordedElementCount()); + CHECK(NEAR(SIM_RecordedElementMs(0), dit), "element 0 was %u ms, expected ~%u", + SIM_RecordedElementMs(0), dit); + CHECK(NEAR(SIM_RecordedElementMs(1), 3 * dit), "element 1 was %u ms, expected ~%u", + SIM_RecordedElementMs(1), 3 * dit); + // Inter-element gap is one dit. + CHECK(NEAR(SIM_RecordedGapMs(0), dit), "gap was %u ms, expected ~%u", + SIM_RecordedGapMs(0), dit); +} + +static void test_wpm_scales_timing(void) +{ + // Doubling the speed halves the dit. Guards the WPM plumbing, which the + // menu writes and the keyer reads through a separate path. + SIM_KeyerBegin(KEY_BUTTONS, CW_IAMBIC_MODE_B, 10); + const uint32_t slow_dit = SIM_KeyerDitMs(); + SIM_PaddleTap(SIM_CONTACT_TIP, slow_dit / 2, 0); + SIM_KeyerRun(10 * slow_dit); + const uint32_t slow = SIM_RecordedElementMs(0); + + SIM_KeyerBegin(KEY_BUTTONS, CW_IAMBIC_MODE_B, 20); + const uint32_t fast_dit = SIM_KeyerDitMs(); + SIM_PaddleTap(SIM_CONTACT_TIP, fast_dit / 2, 0); + SIM_KeyerRun(10 * fast_dit); + const uint32_t fast = SIM_RecordedElementMs(0); + + CHECK(slow > 0 && fast > 0, "missing elements: slow=%u fast=%u", slow, fast); + CHECK(NEAR(slow, 2 * fast), "10 WPM dit %u ms should be ~2x 20 WPM dit %u ms", + slow, fast); +} + +static void test_handkey_produces_no_elements(void) +{ + // Handkey modes have no timing engine, so the iambic path must stay silent. + // This is the behaviour that makes macro recording impossible with a straight + // key -- worth pinning down so it does not change by accident. + SIM_KeyerBegin(0x08 /* NO_KEYER */, CW_IAMBIC_MODE_B, 20); + const uint32_t dit = SIM_KeyerDitMs(); + + SIM_PaddleTap(SIM_CONTACT_TIP, dit, dit); + SIM_PaddleTap(SIM_CONTACT_RING, dit, dit); + SIM_KeyerRun(10 * dit); + + // The straight-key path keys the carrier directly from PTT rather than + // producing timed elements, so no decoded characters should appear. + CHECK(SIM_RecordedText()[0] == '\0', "handkey decoded '%s', expected nothing", + SIM_RecordedText()); +} + +int main(void) +{ + printf("iambic keyer baseline\n"); + test_single_dit(); + test_single_dah(); + test_letter_a(); + test_wpm_scales_timing(); + test_handkey_produces_no_elements(); + + if (failures == 0) { + printf(" all checks passed\n"); + return 0; + } + printf(" %d check(s) failed\n", failures); + return 1; +} diff --git a/tools/boot_time.sh b/tools/boot_time.sh new file mode 100755 index 0000000..5d312e8 --- /dev/null +++ b/tools/boot_time.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Measure how long the emulated radio takes to reach its main loop. +# +# Restarts the machine, then polls the call stack until it shows APP_Update -- +# the main loop -- and reports the elapsed wall-clock time. This is the number +# that matters in practice: how long until the screen is up and usable. +set -uo pipefail + +TOOLS="$(cd "$(dirname "$0")" && pwd)" +TIMEOUT="${1:-120}" + +"$TOOLS/run.sh" >/dev/null 2>&1 & +start=$(date +%s) + +while :; do + now=$(date +%s) + elapsed=$((now - start)) + + if [ "$elapsed" -gt "$TIMEOUT" ]; then + echo "not in the main loop after ${TIMEOUT}s" + echo "last stack: $("$TOOLS/where.sh" 1 2>/dev/null)" + exit 1 + fi + + stack=$("$TOOLS/where.sh" 1 2>/dev/null || true) + case "$stack" in + *APP_Update*|*HandlePowerSave*|*UART_IsCommandAvailable*) + echo "reached the main loop in ${elapsed}s" + exit 0 + ;; + esac + sleep 2 +done diff --git a/tools/col_probe.sh b/tools/col_probe.sh new file mode 100755 index 0000000..488672d --- /dev/null +++ b/tools/col_probe.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Pull column 1 (PB6) low by hand and read back BOTH ODR and IDR. +# +# Reading ODR proves whether the MMIO write reached the GPIO model at all; +# reading IDR proves whether the keypad drove the row line back. The earlier +# probe only read IDR, which cannot tell those two apart. +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +KEY="${1:-MENU}" +BASE=0x50000400 +IDR=$((BASE + 0x10)) +ODR=$((BASE + 0x14)) +BSRR=$((BASE + 0x18)) +BRR=$((BASE + 0x28)) + +python3 - "$KEY" <<'PY' +import json, socket, sys +key = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", "arguments": {"path": "/machine/keypad", + "property": "press", "value": key}}, + {"execute": "qom-get", "arguments": {"path": "/machine/keypad", + "property": "press"}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m: + if p["execute"] == "qom-get": + print("keypad reports held key:", m["return"]) + break + if "error" in m: + print("QMP error:", m["error"]); break +PY + +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + echo "interrupt" + echo "printf \"idle ODR \"" + echo "x/1xw $ODR" + echo "printf \"idle IDR \"" + echo "x/1xw $IDR" + # Pull PB6 low through BRR (offset 0x28) -- the same register the driver uses. + echo "set *(unsigned int *)$BRR = 0x40" + echo "printf \"brr low ODR \"" + echo "x/1xw $ODR" + echo "printf \"brr low IDR \"" + echo "x/1xw $IDR" + # And through BSRR's reset half, the other path in the model. + echo "set *(unsigned int *)$BSRR = 0x00400000" + echo "printf \"bsrr ODR \"" + echo "x/1xw $ODR" + echo "printf \"bsrr IDR \"" + echo "x/1xw $IDR" + echo "set *(unsigned int *)$BSRR = 0x00000040" + echo "detach" + echo "quit" +} >"$SCRIPT" + +timeout 120 gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>&1 | grep -E "idle|brr low|bsrr|0x5000" diff --git a/tools/delay_rate.sh b/tools/delay_rate.sh new file mode 100755 index 0000000..5eba071 --- /dev/null +++ b/tools/delay_rate.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Measure how fast a SYSTICK_DelayUs loop is converging. +# +# r0 holds the target tick count, r1 the accumulated elapsed count, so sampling +# both twice gives the rate and an estimate of how long the delay will take. +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +GAP="${1:-4}" +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +cat >"$SCRIPT" <<'EOF' +set confirm off +set pagination off +target remote :1234 +info registers r0 r1 +detach +quit +EOF + +read_regs() { + gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null \ + | awk '/^r0 /{t=strtonum($2)} /^r1 /{e=strtonum($2)} END{print t, e}' +} + +first=$(read_regs) +sleep "$GAP" +second=$(read_regs) + +awk -v a="$first" -v b="$second" -v gap="$GAP" 'BEGIN { + split(a, x, " "); split(b, y, " ") + target = y[1]; from = x[2]; to = y[2] + rate = (to - from) / gap + printf "target=%d elapsed %d -> %d (%.0f ticks/s)\n", target, from, to, rate + if (rate > 0 && target > to) + printf "remaining: %.1f s\n", (target - to) / rate + else if (target <= to) + print "delay already satisfied" + else + print "not advancing" +}' diff --git a/tools/gpio_watch.py b/tools/gpio_watch.py new file mode 100644 index 0000000..6e165cb --- /dev/null +++ b/tools/gpio_watch.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Check whether a held key actually pulls a GPIOB row line low. + +Holds a key over QMP, then reads GPIOB's input data register through the GDB +stub. The row pins are 15..12; with a key held and its column pulled low, the +matching row bit must read 0. + +This isolates two failure modes that look identical from the firmware's side: +the keypad model not registering the press, and the row lines not reaching the +GPIO port. + +Usage: gpio_watch.py [KEY] +""" + +import json +import re +import socket +import subprocess +import sys +import tempfile +import time + +QMP_SOCKET = "/tmp/uvk5-qmp.sock" +GPIOB_BASE = 0x50000400 +GPIO_IDR = 0x10 +GPIO_ODR = 0x14 +ELF = "/root/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf" + + +class Qmp: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + self.buf = b"" + self._read() + self.cmd("qmp_capabilities") + + def _read(self): + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise SystemExit("QMP closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def cmd(self, name, **args): + payload = {"execute": name} + if args: + payload["arguments"] = args + self.sock.sendall(json.dumps(payload).encode() + b"\n") + while True: + msg = self._read() + if "return" in msg: + return msg["return"] + if "error" in msg: + raise SystemExit("QMP error: " + msg["error"].get("desc", "?")) + + def press(self, key): + self.cmd("qom-set", path="/machine/keypad", property="press", value=key) + + +def read_words(addresses): + """Reads several 32-bit words through the GDB stub in one session.""" + lines = ["set confirm off", "set pagination off", "target remote :1234"] + lines += [f"x/1xw {a:#x}" for a in addresses] + lines += ["detach", "quit", ""] + + with tempfile.NamedTemporaryFile("w", suffix=".gdb", delete=False) as fh: + fh.write("\n".join(lines)) + script = fh.name + + out = subprocess.run(["gdb-multiarch", "-batch", "-x", script, ELF], + capture_output=True, text=True, timeout=60).stdout + # gdb prints "0x50000410 :\t0xffff". Match the address at + # line start and the first hex value after the colon; an earlier pattern that + # required no colon before the value silently matched nothing and every read + # came back as zero. + values = {} + for match in re.finditer(r"^(0x[0-9a-fA-F]+)[^:\n]*:\s*(0x[0-9a-fA-F]+)", out, re.M): + values[int(match.group(1), 16)] = int(match.group(2), 16) + return values + + +def describe(idr, odr): + rows = [(15 - r, r) for r in range(4)] + cols = [(6 - (c - 1), c) for c in range(1, 5)] + row_txt = " ".join(f"row{r}(p{p})={'LOW' if not (idr >> p) & 1 else 'high'}" + for p, r in rows) + col_txt = " ".join(f"col{c}(p{p})={'LOW' if not (odr >> p) & 1 else 'high'}" + for p, c in cols) + return row_txt, col_txt + + +def main(): + key = sys.argv[1] if len(sys.argv) > 1 else "MENU" + qmp = Qmp(QMP_SOCKET) + + qmp.press("") + time.sleep(0.2) + base = read_words([GPIOB_BASE + GPIO_IDR, GPIOB_BASE + GPIO_ODR]) + idr0 = base.get(GPIOB_BASE + GPIO_IDR, 0) + odr0 = base.get(GPIOB_BASE + GPIO_ODR, 0) + + qmp.press(key) + time.sleep(0.2) + held = read_words([GPIOB_BASE + GPIO_IDR, GPIOB_BASE + GPIO_ODR]) + idr1 = held.get(GPIOB_BASE + GPIO_IDR, 0) + odr1 = held.get(GPIOB_BASE + GPIO_ODR, 0) + qmp.press("") + + print(f"released: IDR={idr0:#06x} ODR={odr0:#06x}") + print(f" {describe(idr0, odr0)[0]}") + print(f"held {key}: IDR={idr1:#06x} ODR={odr1:#06x}") + print(f" {describe(idr1, odr1)[0]}") + print(f" {describe(idr1, odr1)[1]}") + + if idr0 == idr1: + print("\nno change in IDR: the row lines are not reaching the GPIO port, " + "or the scan had every column high at sample time") + else: + print(f"\nIDR changed (bits {idr0 ^ idr1:#06x}) -- the matrix is wired through") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gpiob_dump.sh b/tools/gpiob_dump.sh new file mode 100755 index 0000000..5712628 --- /dev/null +++ b/tools/gpiob_dump.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Dump every GPIOB register, to see how the port is actually configured. +# +# Layout from py32f071xB.h: MODER 0x00, OTYPER 0x04, OSPEEDR 0x08, PUPDR 0x0C, +# IDR 0x10, ODR 0x14. +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +BASE=0x50000400 +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + echo "printf \"MODER \"" + echo "x/1xw $((BASE + 0x00))" + echo "printf \"PUPDR \"" + echo "x/1xw $((BASE + 0x0c))" + echo "printf \"IDR \"" + echo "x/1xw $((BASE + 0x10))" + echo "printf \"ODR \"" + echo "x/1xw $((BASE + 0x14))" + echo "detach" + echo "quit" +} >"$SCRIPT" + +gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null | grep -E "MODER|PUPDR|IDR|ODR|0x5000" diff --git a/tools/hold_and_trace.sh b/tools/hold_and_trace.sh new file mode 100755 index 0000000..077782e --- /dev/null +++ b/tools/hold_and_trace.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Hold a key without releasing it, then look at the row levels the keypad drives. +# +# key.py presses and releases, so sampling afterwards always shows the released +# state. This holds the key for the whole observation window instead. +set -uo pipefail + +KEY="${1:-MENU}" +LOG=/tmp/uvk5-trace.log + +python3 - "$KEY" <<'PY' +import json, socket, sys +key = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" + + +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) + + +rd() +for payload in ( + {"execute": "qmp_capabilities"}, + {"execute": "qom-set", "arguments": {"path": "/machine/keypad", + "property": "press", "value": key}}, + {"execute": "qom-get", "arguments": {"path": "/machine/keypad", + "property": "press"}}, +): + s.sendall(json.dumps(payload).encode() + b"\n") + while True: + m = rd() + if "return" in m: + last = m["return"] + break + if "error" in m: + raise SystemExit("QMP error: " + m["error"].get("desc", "?")) +print(f"holding {key!r}, property reads back {last!r}") +PY + +# Watch what the keypad drives while the key stays held. +before=$(wc -l < "$LOG") +sleep 3 +tail -n +"$before" "$LOG" | grep 'keypad row' | sort -u | head -8 + +echo +echo "distinct row levels seen while held:" +tail -n +"$before" "$LOG" | grep -oE 'row[0-9] -> [01]' | sort -u diff --git a/tools/key.py b/tools/key.py new file mode 100644 index 0000000..e3b8185 --- /dev/null +++ b/tools/key.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Press keys on the emulated radio through its QMP socket. + +The keypad model exposes a "press" property: writing a key name holds that key, +writing an empty string releases it. The firmware debounces over several 10 ms +polls, so a press has to be held for a while to register -- see HOLD_MS. + +Usage: + key.py MENU # one short press + key.py MENU UP UP EXIT # a sequence + key.py --long F # long press + key.py --list # show key names +""" + +import argparse +import json +import socket +import sys +import time + +QMP_SOCKET = "/tmp/uvk5-qmp.sock" +KEYPAD_PATH = "/machine/keypad" + +# App/app/app.c debounces with key_debounce_10ms = 2 and treats +# key_repeat_delay_10ms = 40 as a long press. Guest time runs fast under +# emulation, so these are generous rather than exact. +# Guest time runs fast under emulation (SysTick reads are accelerated so busy-wait +# delays converge), so a press has to be held far longer in wall-clock terms than +# on real hardware for the firmware's debounce to complete. Measured: 400 ms was +# too short to register at all. +HOLD_MS = 2500 +LONG_HOLD_MS = 6000 +GAP_MS = 1200 + +KEYS = [ + "MENU", "UP", "DOWN", "EXIT", "F", "STAR", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "SIDE1", "SIDE2", +] + + +class Qmp: + """Minimal QMP client: connect, negotiate, send commands.""" + + def __init__(self, path: str): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + self.sock.connect(path) + except (FileNotFoundError, ConnectionRefusedError) as exc: + raise SystemExit( + f"cannot reach the emulator at {path}: {exc}\n" + "Start it with sim/tools/run.sh first." + ) from exc + self.buf = b"" + self._read_json() # greeting + self.command("qmp_capabilities") + + def _read_json(self) -> dict: + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise SystemExit("emulator closed the QMP connection") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def command(self, name: str, **args) -> dict: + payload = {"execute": name} + if args: + payload["arguments"] = args + self.sock.sendall(json.dumps(payload).encode() + b"\n") + + while True: + msg = self._read_json() + if "error" in msg: + raise SystemExit(f"QMP error: {msg['error'].get('desc', msg['error'])}") + if "return" in msg: + return msg["return"] + # Events (RESET, STOP, ...) arrive interleaved; keep reading. + + def set_key(self, value: str) -> None: + self.command("qom-set", path=KEYPAD_PATH, property="press", value=value) + + +def press(qmp: Qmp, key: str, hold_ms: int) -> None: + qmp.set_key(key) + time.sleep(hold_ms / 1000) + qmp.set_key("") + time.sleep(GAP_MS / 1000) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("keys", nargs="*", help="key names to press in order") + ap.add_argument("--long", action="store_true", help="hold each key longer") + ap.add_argument("--hold", type=int, help="hold time in ms, overrides --long") + ap.add_argument("--list", action="store_true", help="list key names and exit") + ap.add_argument("--socket", default=QMP_SOCKET) + args = ap.parse_args() + + if args.list: + print(" ".join(KEYS)) + return 0 + if not args.keys: + ap.error("no keys given (try --list)") + + unknown = [k for k in args.keys if k.upper() not in KEYS] + if unknown: + raise SystemExit(f"unknown key(s): {', '.join(unknown)}\nKnown: {' '.join(KEYS)}") + + hold = args.hold if args.hold else (LONG_HOLD_MS if args.long else HOLD_MS) + qmp = Qmp(args.socket) + + for key in args.keys: + press(qmp, key.upper(), hold) + print(f"pressed {key.upper()} ({hold} ms)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/key_result.sh b/tools/key_result.sh new file mode 100755 index 0000000..d063d07 --- /dev/null +++ b/tools/key_result.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# What does KEYBOARD_Poll return, and what does the app do with it? +# +# The scan is already proven to read the right row (IDR bit 15 low for MENU), so +# the remaining question is downstream: does Poll return the key code, and does +# the debounce in app.c accept it? +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +KEY="${1:-MENU}" + +python3 - "$KEY" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" + + +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) + + +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", + "arguments": {"path": "/machine/keypad", "property": "press", + "value": sys.argv[1]}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m or "error" in m: + break +print(f"holding {sys.argv[1]}") +PY + +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + # Return value in r0. KEY_MENU is 5 in KEY_Code_t; KEY_INVALID is 255. + echo "break *0x08004c58" + echo "commands" + echo "silent" + echo "printf \"Poll returns %d\\n\", \$r0" + echo "continue" + echo "end" + for _ in $(seq 8); do echo "continue"; done + echo "detach" + echo "quit" +} >"$SCRIPT" + +timeout 90 gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null | grep 'Poll returns' | head -8 diff --git a/tools/keypad_probe.sh b/tools/keypad_probe.sh new file mode 100755 index 0000000..7eac96f --- /dev/null +++ b/tools/keypad_probe.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Prove whether the keypad sees column changes, by driving a column by hand. +# +# Holds a key, writes GPIOB's BSRR to pull column 1 (pin 6) low, then reads IDR. +# If row0 goes low, the matrix is wired correctly and the earlier samples simply +# landed between scans. If it stays high, the column signal is not reaching the +# keypad model. +# +# BSRR: low half sets a pin, high half resets it (py32f071xB.h). +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +KEY="${1:-MENU}" +BASE=0x50000400 +BSRR=$((BASE + 0x18)) +IDR=$((BASE + 0x10)) + +# Hold the key first. +python3 - "$KEY" <<'PY' +import json, socket, sys +key = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", "arguments": {"path": "/machine/keypad", + "property": "press", "value": key}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m or "error" in m: + break +print(f"holding {key}") +PY + +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + # Freeze the guest so the firmware's own scan cannot move the columns. + echo "interrupt" + echo "printf \"before \"" + echo "x/1xw $IDR" + # Pull pin 6 (column 1) low: write bit 6 into the reset half of BSRR. + echo "set *(unsigned int *)$BSRR = 0x00400000" + echo "printf \"col1 low\"" + echo "x/1xw $IDR" + # Release it again. + echo "set *(unsigned int *)$BSRR = 0x00000040" + echo "printf \"released\"" + echo "x/1xw $IDR" + echo "detach" + echo "quit" +} >"$SCRIPT" + +gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null | grep -E "before|col1 low|released|0x5000" diff --git a/tools/keytest.sh b/tools/keytest.sh new file mode 100755 index 0000000..bc7f3a5 --- /dev/null +++ b/tools/keytest.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Verify the press property round-trips: set a key, read it back, release it. +# +# A mismatch here means the QMP path or the property is wrong. A match means the +# model has the key held, and any failure to reach the firmware is downstream -- +# in the matrix wiring or the debounce. +set -uo pipefail + +TOOLS="$HOME/uvk5-port/sim/tools" +KEY="${1:-MENU}" + +python3 - "$KEY" <<'PY' +import json, socket, sys, time + +KEY = sys.argv[1] +SOCK = "/tmp/uvk5-qmp.sock" + + +class Qmp: + def __init__(self, path): + self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.s.connect(path) + self.buf = b"" + self._read() + self.cmd("qmp_capabilities") + + def _read(self): + while b"\n" not in self.buf: + self.buf += self.s.recv(4096) + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def cmd(self, name, **args): + p = {"execute": name} + if args: + p["arguments"] = args + self.s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = self._read() + if "return" in m: + return m["return"] + if "error" in m: + raise SystemExit("QMP error: " + m["error"].get("desc", "?")) + + +q = Qmp(SOCK) +q.cmd("qom-set", path="/machine/keypad", property="press", value=KEY) +held = q.cmd("qom-get", path="/machine/keypad", property="press") +print(f"set {KEY!r} -> reads back {held!r} {'OK' if held == KEY else 'MISMATCH'}") +time.sleep(0.5) +q.cmd("qom-set", path="/machine/keypad", property="press", value="") +print("released ->", repr(q.cmd("qom-get", path="/machine/keypad", property="press"))) +PY diff --git a/tools/make_flash.py b/tools/make_flash.py new file mode 100644 index 0000000..273216d --- /dev/null +++ b/tools/make_flash.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Build the 2 MB SPI flash image the emulator boots from. + +Starts from erased flash (0xFF) and drops the calibration dump at physical +0x010000, which is where driver/eeprom_compat.c maps the 512-byte calibration +block. Without it the firmware takes error branches in the frequency and power +paths, so the emulated radio would not represent a real one. + +The image itself is not committed: it is 2 MB and fully derived from +assets/calibration.bin. + +Usage: make_flash.py [--calibration FILE] [--out FILE] +""" + +import argparse +import pathlib +import sys + +FLASH_SIZE = 2 * 1024 * 1024 +CALIBRATION_ADDR = 0x010000 +CALIBRATION_SIZE = 512 + +HERE = pathlib.Path(__file__).resolve().parent +ASSETS = HERE.parent / "assets" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--calibration", type=pathlib.Path, + default=ASSETS / "calibration.bin") + ap.add_argument("--out", type=pathlib.Path, default=ASSETS / "flash.img") + args = ap.parse_args() + + if not args.calibration.is_file(): + raise SystemExit(f"calibration dump not found: {args.calibration}") + + cal = args.calibration.read_bytes() + if len(cal) != CALIBRATION_SIZE: + print(f"warning: calibration is {len(cal)} bytes, expected {CALIBRATION_SIZE}", + file=sys.stderr) + + image = bytearray(b"\xff" * FLASH_SIZE) + image[CALIBRATION_ADDR:CALIBRATION_ADDR + len(cal)] = cal + + args.out.write_bytes(image) + print(f"wrote {args.out} ({len(image)} bytes)") + print(f" calibration at {CALIBRATION_ADDR:#08x}: " + + " ".join(f"{b:02X}" for b in image[CALIBRATION_ADDR:CALIBRATION_ADDR + 8])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pc.sh b/tools/pc.sh new file mode 100755 index 0000000..17507df --- /dev/null +++ b/tools/pc.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Print the program counter and the two delay-loop registers, once per line. +# +# Comparing successive lines distinguishes three cases: a stuck delay (same PC, +# same r1), a converging delay (same PC, rising r1) and forward progress +# (different PC, or r1 reset for a new call). +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +SAMPLES="${1:-6}" +GAP="${2:-2}" +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +cat >"$SCRIPT" <<'EOF' +set confirm off +set pagination off +target remote :1234 +info registers pc r0 r1 lr +detach +quit +EOF + +for _ in $(seq "$SAMPLES"); do + gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null \ + | awk '/^pc /{pc=$2} /^r0 /{r0=$2} /^r1 /{r1=$2} /^lr /{lr=$2} + END{printf "pc=%s lr=%s target=%s elapsed=%s\n", pc, lr, r0, r1}' + sleep "$GAP" +done diff --git a/tools/poll_watch.sh b/tools/poll_watch.sh new file mode 100755 index 0000000..f452780 --- /dev/null +++ b/tools/poll_watch.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Break on KEYBOARD_Poll and single-step the scan, printing GPIOB ODR/IDR. +# +# Note: GDB *writes* to MMIO do not reach device models -- cpu_memory_rw_debug +# routes writes through address_space_write_rom, which only touches RAM/ROM. So +# this only observes; the firmware does the driving. +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +KEY="${1:-MENU}" +STEPS="${2:-400}" + +python3 - "$KEY" <<'PY' +import json, socket, sys +key = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", "arguments": {"path": "/machine/keypad", + "property": "press", "value": key}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m or "error" in m: + break +print(f"holding {key}") +PY + +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + echo "break *0x08004bd4" # KEYBOARD_Poll + echo "continue" + echo "printf \"entered KEYBOARD_Poll\\n\"" + echo "delete" + for _ in $(seq "$STEPS"); do + echo "stepi" + echo "printf \"pc=%#010x odr=%#06x idr=%#06x\\n\", \$pc, *(unsigned int *)0x50000414, *(unsigned int *)0x50000410" + done + echo "detach" + echo "quit" +} >"$SCRIPT" + +timeout 180 gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>&1 | grep -E "entered|pc=" | uniq -f2 diff --git a/tools/press_and_shot.sh b/tools/press_and_shot.sh new file mode 100755 index 0000000..ff46df7 --- /dev/null +++ b/tools/press_and_shot.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Press keys, then screenshot -- without any GDB breakpoints in between. +# +# Breakpoints halt the guest, so a key held across a breakpoint session is never +# processed by the main loop. This holds the key, lets the machine run freely, +# releases, and only then reads the framebuffer. +set -uo pipefail + +TOOLS="$(cd "$(dirname "$0")" && pwd)" +OUT="${OUT:-/root/vm_screen.png}" +HOLD="${HOLD:-3}" +SETTLE="${SETTLE:-3}" + +hold_key() { + python3 - "$1" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" + + +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) + + +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", + "arguments": {"path": "/machine/keypad", "property": "press", + "value": sys.argv[1]}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m or "error" in m: + break +PY +} + +for key in "$@"; do + echo "press $key" + hold_key "$key" + sleep "$HOLD" + hold_key "" + sleep "$SETTLE" +done + +rm -f /tmp/_screen_dump.bin +python3 "$TOOLS/screenshot.py" \ + --frame-addr 0x200013DC --status-addr 0x2000175C \ + --port 1234 --out "$OUT" --scale 4 2>&1 | grep pixels diff --git a/tools/qom_get.py b/tools/qom_get.py new file mode 100644 index 0000000..c70eb44 --- /dev/null +++ b/tools/qom_get.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Read a QOM property from the running emulator. + +Usage: qom_get.py + qom_get.py /machine/keypad press +""" + +import json +import socket +import sys + +SOCKET = "/tmp/uvk5-qmp.sock" + + +class Qmp: + def __init__(self, path: str): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + self.buf = b"" + self._read() + self.command("qmp_capabilities") + + def _read(self) -> dict: + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise SystemExit("QMP connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def command(self, name: str, **args): + payload = {"execute": name} + if args: + payload["arguments"] = args + self.sock.sendall(json.dumps(payload).encode() + b"\n") + while True: + msg = self._read() + if "return" in msg: + return msg["return"] + if "error" in msg: + raise SystemExit("QMP error: " + msg["error"].get("desc", "?")) + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 2 + value = Qmp(SOCKET).command("qom-get", path=sys.argv[1], property=sys.argv[2]) + print(json.dumps(value)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/qom_ls.py b/tools/qom_ls.py new file mode 100644 index 0000000..c9eca3b --- /dev/null +++ b/tools/qom_ls.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""List QOM child nodes under a path, to find where a device actually lives. + +Usage: qom_ls.py [/machine] +""" + +import json +import socket +import sys + +SOCKET = "/tmp/uvk5-qmp.sock" + + +class Qmp: + def __init__(self, path: str): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + self.buf = b"" + self._read() # greeting + self.command("qmp_capabilities") + + def _read(self) -> dict: + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise SystemExit("QMP connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def command(self, name: str, **args) -> dict: + payload = {"execute": name} + if args: + payload["arguments"] = args + self.sock.sendall(json.dumps(payload).encode() + b"\n") + while True: + msg = self._read() + if "return" in msg: + return msg["return"] + if "error" in msg: + raise SystemExit("QMP error: " + msg["error"].get("desc", "?")) + + +def main() -> int: + root = sys.argv[1] if len(sys.argv) > 1 else "/machine" + for item in Qmp(SOCKET).command("qom-list", path=root): + kind = item.get("type", "") + marker = "dir" if kind.startswith("child<") else " " + print(f"{marker} {item['name']:24s} {kind}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/run.sh b/tools/run.sh new file mode 100755 index 0000000..6a33dd9 --- /dev/null +++ b/tools/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Start the emulated radio. +# +# GDB stub : tcp:1234 (screenshot.py and where.sh read memory through it) +# QMP socket: /tmp/uvk5-qmp.sock (key.sh injects keypresses through it) +# +# Usage: run.sh [firmware.elf] +set -euo pipefail + +QEMU="$HOME/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm" +ELF="${1:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +FLASH="$HOME/uvk5-port/sim/assets/flash.img" +QMP=/tmp/uvk5-qmp.sock + +pkill -f 'M uv-k5-v3' 2>/dev/null || true +rm -f "$QMP" +sleep 1 + +# Headless: the screen is read out of guest memory rather than drawn by QEMU, so +# no display backend is needed. +exec "$QEMU" \ + -M "uv-k5-v3,flash-image=$FLASH" \ + -nographic -monitor none \ + -qmp "unix:$QMP,server=on,wait=off" \ + -kernel "$ELF" \ + -gdb tcp::1234 diff --git a/tools/scan_trace.sh b/tools/scan_trace.sh new file mode 100755 index 0000000..41df089 --- /dev/null +++ b/tools/scan_trace.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Watch what KEYBOARD_Poll actually reads while a key is held. +# +# Prints the column and row state at each entry to the scan. This separates two +# failure modes that look identical from outside: the rows never going low when +# the firmware looks, versus the rows going low but the debounce rejecting them. +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +KEY="${1:-MENU}" +SAMPLES="${2:-10}" + +python3 - "$KEY" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" + + +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) + + +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", + "arguments": {"path": "/machine/keypad", "property": "press", + "value": sys.argv[1]}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m or "error" in m: + break +print(f"holding {sys.argv[1]}") +PY + +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + # 0x08004bf0 is the `ldr r3, [r5, #16]` that reads IDR inside the debounce + # loop -- after a column has been pulled low. Breaking at function entry + # instead shows every column still high, which tells you nothing. + echo "break *0x08004bf0" + echo "commands" + echo "silent" + echo "printf \"scan ODR=%04x IDR=%04x\\n\", *(unsigned*)0x50000414, *(unsigned*)0x50000410" + echo "continue" + echo "end" + for _ in $(seq "$SAMPLES"); do echo "continue"; done + echo "detach" + echo "quit" +} >"$SCRIPT" + +timeout 90 gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null | grep '^scan' | head -"$SAMPLES" diff --git a/tools/scan_watch.sh b/tools/scan_watch.sh new file mode 100755 index 0000000..eb0e442 --- /dev/null +++ b/tools/scan_watch.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Watch the firmware's own keypad scan while a key is held. +# +# Breaks inside KEYBOARD_Poll right after read_rows(), and prints the column +# index being scanned together with the row bits actually sampled. This +# distinguishes "the scan never runs" from "the scan runs but the rows never +# go low". +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +KEY="${1:-MENU}" +HITS="${2:-12}" + +python3 - "$KEY" <<'PY' +import json, socket, sys +key = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/uvk5-qmp.sock") +buf = b"" +def rd(): + global buf + while b"\n" not in buf: + buf += s.recv(4096) + line, buf = buf.split(b"\n", 1) + return json.loads(line) +rd() +for p in ({"execute": "qmp_capabilities"}, + {"execute": "qom-set", "arguments": {"path": "/machine/keypad", + "property": "press", "value": key}}): + s.sendall(json.dumps(p).encode() + b"\n") + while True: + m = rd() + if "return" in m or "error" in m: + break +print(f"holding {key}") +PY + +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +{ + echo "set confirm off" + echo "set pagination off" + echo "target remote :1234" + echo "break keyboard.c:231" + echo "commands" + echo "silent" + echo "printf \"col j=%u reg2=%#06x odr=%#06x\\n\", j, reg2, *(unsigned int *)0x50000414" + echo "continue" + echo "end" + for _ in $(seq "$HITS"); do echo "continue"; done + echo "detach" + echo "quit" +} >"$SCRIPT" + +timeout 120 gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>&1 | grep -E "col j=|Breakpoint|No symbol|Function" diff --git a/tools/screenshot.py b/tools/screenshot.py new file mode 100644 index 0000000..849e296 --- /dev/null +++ b/tools/screenshot.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Render the emulated radio's LCD by reading its framebuffer over GDB. + +The firmware keeps the display in two globals -- gStatusLine (the top status +row) and gFrameBuffer[7] (the seven text rows) -- in the layout the ST7565 +expects: one byte per column, each byte holding 8 vertical pixels, LSB at the +top. The K5Viewer serial protocol repacks that per bit-plane for compression; +reading the buffers directly gives the same pixels without implementing either +the SPI display controller or the wire protocol. + +Usage: + screenshot.py --elf firmware.elf --port 1234 [--out screen.png] [--scale 4] + +Requires the emulator started with -gdb tcp::PORT. +""" + +import argparse +import re +import subprocess +import sys + +LCD_WIDTH = 128 +STATUS_ROWS = 1 +FRAME_ROWS = 7 +TOTAL_ROWS = STATUS_ROWS + FRAME_ROWS # 8 pages of 8 pixels = 64 lines +LCD_HEIGHT = TOTAL_ROWS * 8 + + +def symbol_address(elf: str, name: str) -> int: + """Look up a symbol in the ELF, so addresses are never hard-coded.""" + out = subprocess.run( + ["arm-none-eabi-nm", elf], + capture_output=True, text=True, check=False, + ) + if out.returncode != 0: + # Fall back to the toolchain inside the build container. + out = subprocess.run( + ["docker", "run", "--rm", "-v", f"{elf}:/f.elf", "uvk1-uvk5v3", + "arm-none-eabi-nm", "/f.elf"], + capture_output=True, text=True, check=False, + ) + for line in out.stdout.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[2] == name: + return int(parts[0], 16) + raise SystemExit(f"symbol {name} not found in {elf}") + + +def read_memory(port: int, address: int, length: int) -> bytes: + """Dump guest memory through gdb-multiarch in batch mode.""" + script = f""" +set confirm off +set pagination off +target remote :{port} +dump binary memory /tmp/_screen_dump.bin {address:#x} {address + length:#x} +detach +quit +""" + # A real file rather than /dev/stdin: gdb rejects the latter as a script + # source ("Invalid argument") because it seeks in it. + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".gdb", delete=False) as fh: + fh.write(script) + script_path = fh.name + proc = subprocess.run( + ["gdb-multiarch", "-batch", "-x", script_path], + capture_output=True, text=True, check=False, + ) + try: + with open("/tmp/_screen_dump.bin", "rb") as fh: + data = fh.read() + except FileNotFoundError: + raise SystemExit( + "gdb produced no dump. Is the emulator running with -gdb tcp::" + f"{port}?\n{proc.stdout}\n{proc.stderr}" + ) + if len(data) < length: + raise SystemExit(f"short read: {len(data)} of {length} bytes") + return data[:length] + + +def unpack(status: bytes, frame: bytes) -> list[list[int]]: + """Column-major, LSB-at-top bytes -> a row-major pixel grid.""" + pixels = [[0] * LCD_WIDTH for _ in range(LCD_HEIGHT)] + + for page in range(TOTAL_ROWS): + src = status if page == 0 else frame[(page - 1) * LCD_WIDTH:page * LCD_WIDTH] + for col in range(LCD_WIDTH): + byte = src[col] + for bit in range(8): + if byte & (1 << bit): + pixels[page * 8 + bit][col] = 1 + return pixels + + +def write_png(pixels, path: str, scale: int) -> None: + """Minimal 1-bit PNG writer, so the tool has no third-party dependency.""" + import struct + import zlib + + width, height = LCD_WIDTH * scale, LCD_HEIGHT * scale + raw = bytearray() + for row in pixels: + line = bytearray() + for value in row: + # Radio LCD is dark-on-light: 0 -> white, 1 -> black. + line.extend([0x00 if value else 0xFF] * scale) + for _ in range(scale): + raw.append(0) # filter type 0 + raw.extend(line) + + def chunk(tag: bytes, payload: bytes) -> bytes: + return (struct.pack(">I", len(payload)) + tag + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)) + + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)) + png += chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + png += chunk(b"IEND", b"") + with open(path, "wb") as fh: + fh.write(png) + + +def write_text(pixels) -> str: + """ASCII rendering, for when a picture is not needed.""" + out = [] + for y in range(0, LCD_HEIGHT, 2): + line = [] + for x in range(LCD_WIDTH): + top = pixels[y][x] + bottom = pixels[y + 1][x] if y + 1 < LCD_HEIGHT else 0 + line.append(" ▀▄█"[(top << 0) | (bottom << 1)]) + out.append("".join(line)) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--elf", help="look symbol addresses up from this ELF") + ap.add_argument("--frame-addr", type=lambda v: int(v, 0), + help="gFrameBuffer address, when no ARM nm is available") + ap.add_argument("--status-addr", type=lambda v: int(v, 0), + help="gStatusLine address") + ap.add_argument("--port", type=int, default=1234) + ap.add_argument("--out", default="screen.png") + ap.add_argument("--scale", type=int, default=4) + ap.add_argument("--text", action="store_true", help="also print ASCII art") + args = ap.parse_args() + + if args.frame_addr is not None and args.status_addr is not None: + frame_addr, status_addr = args.frame_addr, args.status_addr + elif args.elf: + frame_addr = symbol_address(args.elf, "gFrameBuffer") + status_addr = symbol_address(args.elf, "gStatusLine") + else: + raise SystemExit("pass either --elf or both --frame-addr and --status-addr") + + frame = read_memory(args.port, frame_addr, FRAME_ROWS * LCD_WIDTH) + status = read_memory(args.port, status_addr, LCD_WIDTH) + + pixels = unpack(status, frame) + lit = sum(sum(row) for row in pixels) + + write_png(pixels, args.out, args.scale) + print(f"gFrameBuffer @ {frame_addr:#010x}, gStatusLine @ {status_addr:#010x}") + print(f"{lit} of {LCD_WIDTH * LCD_HEIGHT} pixels lit -> {args.out}") + if lit == 0: + print("screen is blank: the firmware has not drawn yet, or it faulted " + "before reaching the UI") + if args.text: + print(write_text(pixels)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/trace_run.sh b/tools/trace_run.sh new file mode 100755 index 0000000..42ccec0 --- /dev/null +++ b/tools/trace_run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the emulator with stderr captured, hold a key, then report what the TRACE +# points saw. Answers three questions in one shot: +# - does keypad_update_rows fire? (TRACE keypad row...) +# - is the row irq non-NULL when it fires? (irq=0x... vs irq=(nil)) +# - does the GPIO input callback run? (TRACE gpio... set_input) +set -uo pipefail + +QEMU="$HOME/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm" +ELF="${1:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +FLASH="$HOME/uvk5-port/sim/assets/flash.img" +LOG=/tmp/uvk5-trace.log +QMP=/tmp/uvk5-qmp.sock + +pkill -f 'M uv-k5-v3' 2>/dev/null || true +rm -f "$QMP" "$LOG" +sleep 1 + +"$QEMU" -M "uv-k5-v3,flash-image=$FLASH" \ + -nographic -monitor none \ + -qmp "unix:$QMP,server=on,wait=off" \ + -kernel "$ELF" -gdb tcp::1234 >"$LOG" 2>&1 & + +sleep 12 +python3 "$HOME/uvk5-port/sim/tools/key.py" MENU >/dev/null 2>&1 || true +sleep 2 + +echo "== keypad row drives ==" +grep 'keypad row' "$LOG" | tail -6 || echo "(none: keypad_update_rows never ran)" +echo +echo "== column notifications ==" +echo "count: $(grep -c 'keypad col' "$LOG" || true)" +grep 'keypad col' "$LOG" | tail -3 || true +echo +echo "== GPIO input callback ==" +echo "count: $(grep -c 'set_input' "$LOG" || true)" +grep 'set_input' "$LOG" | tail -6 || echo "(none: row lines never reach the port)" diff --git a/tools/where.sh b/tools/where.sh new file mode 100755 index 0000000..fe9f734 --- /dev/null +++ b/tools/where.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Print the emulated firmware's current call stack. +# +# Usage: where.sh [samples] +set -uo pipefail + +ELF="${ELF:-$HOME/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf}" +SAMPLES="${1:-1}" +SCRIPT=$(mktemp --suffix=.gdb) +trap 'rm -f "$SCRIPT"' EXIT + +cat >"$SCRIPT" <<'EOF' +set confirm off +set pagination off +target remote :1234 +bt 5 +detach +quit +EOF + +for _ in $(seq "$SAMPLES"); do + gdb-multiarch -batch -x "$SCRIPT" "$ELF" 2>/dev/null \ + | grep '^#' \ + | sed 's/ (.*//; s/^#[0-9]* *//; s/0x[0-9a-f]* in //' \ + | grep -v 'signal handler' \ + | paste -sd' < ' - + [ "$SAMPLES" -gt 1 ] && sleep 1 +done diff --git a/tools/wiring_check.py b/tools/wiring_check.py new file mode 100644 index 0000000..b93ec0a --- /dev/null +++ b/tools/wiring_check.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Verify the keypad GPIO wiring in the running machine. + +qdev out-GPIOs are QOM link properties, so the board's wiring is directly +observable: /machine/soc/b "pin-out[6]" should point at a keypad "col" input, +and /machine/keypad "row[0]" should point at a GPIOB "pin-in" input. A link that +reads back empty means the connection was never made. +""" + +import json +import socket + +SOCKET = "/tmp/uvk5-qmp.sock" + + +class Qmp: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + self.buf = b"" + self._read() + self.cmd("qmp_capabilities") + + def _read(self): + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise SystemExit("QMP closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def cmd(self, name, **args): + payload = {"execute": name} + if args: + payload["arguments"] = args + self.sock.sendall(json.dumps(payload).encode() + b"\n") + while True: + msg = self._read() + if "return" in msg: + return msg["return"] + if "error" in msg: + return {"__error__": msg["error"].get("desc", "?")} + + +def get(q, path, prop): + r = q.cmd("qom-get", path=path, property=prop) + if isinstance(r, dict) and "__error__" in r: + return "ERR: " + r["__error__"] + return r + + +def main(): + q = Qmp(SOCKET) + + print("== /machine children ==") + for it in q.cmd("qom-list", path="/machine"): + print(f" {it['name']:20s} {it.get('type','')}") + + print("\n== GPIOB column outputs (pins 6..3 = cols 1..4) ==") + for c in range(1, 5): + pin = 6 - (c - 1) + print(f" pin-out[{pin}] -> {get(q, '/machine/soc/b', f'pin-out[{pin}]')}") + + print("\n== keypad row outputs (rows 0..3 -> pins 15..12) ==") + for r in range(4): + print(f" row[{r}] -> {get(q, '/machine/keypad', f'row[{r}]')}") + + print("\n== keypad col input objects (targets of the wiring above) ==") + for it in q.cmd("qom-list", path="/machine/keypad"): + if it["name"].startswith(("col[", "row[")): + print(f" {it['name']:12s} {it.get('type','')}") + + print("\n== GPIOB pin-in objects for the row pins ==") + for it in q.cmd("qom-list", path="/machine/soc/b"): + if it["name"].startswith("pin-in["): + n = int(it["name"][7:-1]) + if n >= 12: + print(f" {it['name']:12s} {it.get('type','')}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())