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

+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.