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.
This commit is contained in:
mckero committed 2026-08-27 14:59:21 +01:00
commit c0a09827ed
53 files changed
+5068

No files matched your search

+9
View File
@@ -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
+74
View File
@@ -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()
+76
View File
@@ -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.
+167
View File
@@ -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.
+34
View File
@@ -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.
Binary file not shown.
+48
View File
@@ -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 <string.h>
#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;
}
+37
View File
@@ -0,0 +1,37 @@
#include "sim_clock.h"
#include <stddef.h>
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();
}
}
+29
View File
@@ -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 <stdint.h>
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
+75
View File
@@ -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);
}
+27
View File
@@ -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 <stdbool.h>
#include <stdint.h>
// 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
+60
View File
@@ -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;
}
+45
View File
@@ -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 <stdbool.h>
#include <stdint.h>
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
+131
View File
@@ -0,0 +1,131 @@
#include "sim_record.h"
#include <stdio.h>
#include <string.h>
#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;
}
+50
View File
@@ -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 <stdbool.h>
#include <stdint.h>
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
+350
View File
@@ -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)
+61
View File
@@ -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
+1754
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -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
+11
View File
@@ -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
+99
View File
@@ -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 <stdint.h>
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
+11
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+83
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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;
}
+53
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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; }
+108
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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();
}
+66
View File
@@ -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 <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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.
+137
View File
@@ -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 <stdio.h>
#include <string.h>
#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;
}
+33
View File
@@ -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
+75
View File
@@ -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"
+42
View File
@@ -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"
}'
+127
View File
@@ -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 <optional symbol>:\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())
+29
View File
@@ -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"
+53
View File
@@ -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
+121
View File
@@ -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())
+59
View File
@@ -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
+66
View File
@@ -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"
+53
View File
@@ -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
+53
View File
@@ -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())
Executable
+29
View File
@@ -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
+56
View File
@@ -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
+54
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Read a QOM property from the running emulator.
Usage: qom_get.py <path> <property>
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())
+54
View File
@@ -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())
Executable
+26
View File
@@ -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
+62
View File
@@ -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"
+56
View File
@@ -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"
+176
View File
@@ -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())
+37
View File
@@ -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)"
Executable
+28
View File
@@ -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
+85
View File
@@ -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())