Add tooling + EFM8UB20 QFP48 pinout doc
Make the repo self-contained (no dependency on the parent firmware-tools/ checkout): copy in the patch/disasm/syx tools and the c2probe RP2040 C2 flash-reader/patcher firmware. - patch_usb1_to_cv.py : byte-level USB-1->CV patch (imported by roundtrip.py) - d8051.py : standalone 8051 disassembler - syx_extract.py : SysEx extractor - c2probe/ : RP2040 C2 flash reader + host scripts (c2probe.c, cdc/dump_flash/patch_c2/reflash_page/verify.py, CMake build) - RECOVERY.md : C2 flash recovery procedure - EFM8UB20_PINOUT.md : reverse-engineered QFP48 pinout + firmware pin usage - roundtrip.py : import local patch_usb1_to_cv (parent as fallback) - .gitignore : exclude c2probe/.venv, c2probe/build Verified: stock + patched round-trips still re-assemble byte-identical.
This commit is contained in:
+292
@@ -0,0 +1,292 @@
|
||||
# QuNexus unresponsive after flashing a modified firmware image — technical report
|
||||
|
||||
Prepared for KMI support (or anyone attempting recovery). Everything below is
|
||||
observed fact except where marked as hypothesis.
|
||||
|
||||
## Device
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Product | QuNexus (RED), USB VID `0x1F38` PID `0x0018`, bcdDevice `0x0200` |
|
||||
| Bootloader version | 1.1.0 (reported by identity request **before** the flash) |
|
||||
| Application version before flash | 2.2.1 |
|
||||
| Host | macOS (Apple silicon), CoreMIDI |
|
||||
|
||||
## What was flashed
|
||||
|
||||
A modified copy of `QuNexus_Firmware_v2.2.1-cs512.syx` (the image shipped in the
|
||||
qunexus-qt6 editor's Qt resources, `resources.qrc:83`).
|
||||
|
||||
Sent with `SendSysEx` v0.10.0 in **raw send mode** (`-n <port> -f <file>`) after
|
||||
`QunexusEnterBootloader.syx`, using the default transfer settings:
|
||||
`-cs 512 -cd 100 -pd 500`.
|
||||
|
||||
**Open question:** it is not known whether the transfer ran to completion. Per
|
||||
`SendSysEx --help`, for multi-message (chunked) files an identity-reply handshake
|
||||
is performed between every chunk, with `-cd` (default **100 ms**) as the timeout,
|
||||
"aborting the transfer otherwise". The console output was not retained. If the
|
||||
transfer aborted partway, the application region is **partially written**, which
|
||||
would be an additional and independent defect on top of the modification below.
|
||||
|
||||
## The modification
|
||||
|
||||
Two edits relative to the stock v2.2.1 image (which spans `0x2400`–`0xF806`):
|
||||
|
||||
1. `0xDF28`: `LCALL 0xA57B` → `LCALL 0xE8F2` (2 bytes changed, at `0xDF29`)
|
||||
2. `0xE8F2`: a new 23-byte routine:
|
||||
|
||||
```asm
|
||||
E8F2: 12 a5 7b LCALL 0xA57B ; original USB-MIDI cable routing
|
||||
E8F5: 90 0f 9b MOV DPTR,#0x0F9B ; the 4-byte USB-MIDI event buffer
|
||||
E8F8: e0 MOVX A,@DPTR
|
||||
E8F9: 54 f0 ANL A,#0F0h ; isolate the cable number
|
||||
E8FB: 70 0b JNZ 0xE908 ; not cable 0 -> done
|
||||
E8FD: e0 MOVX A,@DPTR
|
||||
E8FE: 44 20 ORL A,#020h ; retag cable 0 as cable 2
|
||||
E900: f0 MOVX @DPTR,A
|
||||
E901: 7e 0f MOV R6,#00Fh
|
||||
E903: 7f 9b MOV R7,#09Bh
|
||||
E905: 12 a5 7b LCALL 0xA57B ; route again, into the USB-3/CV ring
|
||||
E908: 22 RET
|
||||
```
|
||||
|
||||
Intent: have MIDI arriving on USB port 1 also reach the CV engine, since
|
||||
`CV_Out_Source` only offers Expander / USB 3.
|
||||
|
||||
**The mistake:** `0xE8F2` lies in a 270-byte address range (`0xE8F2`–`0xE9FF`)
|
||||
that **no hex record in the stock image covers**. A new record was added for it.
|
||||
Whether the bootloader erases a flash page it otherwise never writes was never
|
||||
verified.
|
||||
|
||||
## Symptoms after flashing
|
||||
|
||||
- Device enumerates normally and repeatedly: correct VID/PID, correct product
|
||||
string, correct MIDI port names ("QuNexus Control Surface" / "Expander" / "CV").
|
||||
So USB init, the descriptor tables, and the USB interrupt path are intact.
|
||||
- **No response to any MIDI input.** A universal identity request
|
||||
(`F0 7E 7F 06 01 F7`) gets no reply at an 8-second timeout.
|
||||
- `QunexusEnterBootloader.syx` has no effect, sent to any of the three ports.
|
||||
- **No CV output**, from USB or from the local keys.
|
||||
|
||||
## Diagnostics performed
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| USB presence / PID (`ioreg`) | present, PID `0x0018` = application |
|
||||
| Identity request, Control Surface port | no reply (8 s) |
|
||||
| Bootloader-entry SysEx to all 3 ports | no state change |
|
||||
| Power-on bootloader window | **none** — kernel log shows exactly one enumeration per attach, always PID `0018`; the bootloader never appears on the bus |
|
||||
| USB vendor control request path | none — the EP0 handler at `0x7873` dispatches only Class (`0x20`) and Standard requests |
|
||||
| DIN MIDI in | separate UART path exists (`0xA90D` reads `SBUF0` → `0xE2AA` → 24-byte ring at `0x0F31`), but no Expander hardware available to test |
|
||||
|
||||
## Most probable mechanism: a second page erase destroyed 236 bytes of code
|
||||
|
||||
`0xE8F2` lies in the 512-byte flash page `0xE800`–`0xE9FF`. The absence of a
|
||||
stock record for `0xE8F2`–`0xE9FF` meant only that the linker placed nothing
|
||||
there — **not** that the page was unused. In the stock image, `0xE800`–`0xE8F1`
|
||||
(the same page, just below the stub address) contains:
|
||||
|
||||
- 236 bytes of real code
|
||||
- 27 branch targets, 19 of them functions with live callers
|
||||
- 40+ call sites spread across the entire firmware (`0xE888` from 9 sites,
|
||||
`0xE893` from 5, `0xE8D5` from 4, `0xE834` from 4) — the profile of compiler
|
||||
runtime helpers
|
||||
|
||||
The added record was appended as a **new SysEx message immediately before the
|
||||
EOF record**, i.e. last in the transfer, long after the bootloader had already
|
||||
erased page `0xE800` and programmed those 236 bytes. To program into that page
|
||||
again the bootloader must erase all 512 bytes of it. That erase would have
|
||||
destroyed the 236 bytes of legitimate code, leaving only the 23 stub bytes.
|
||||
|
||||
This accounts for every observed symptom simultaneously: calls to any of those
|
||||
19 addresses now land in erased flash (`0xFF` = `MOV R7,A`) and run forward into
|
||||
unrelated code, so MIDI input never completes and the main loop derails, while
|
||||
interrupt-driven USB enumeration continues to work.
|
||||
|
||||
This has not been confirmed by reading the device's flash back, which is not
|
||||
possible without C2 access. A transfer aborted by the 100 ms inter-chunk
|
||||
handshake (see "Open question" above) would be an additional, independent cause
|
||||
of missing data.
|
||||
|
||||
**Implication for recovery:** a full reflash of `0x2400`–`0xF806` from the stock
|
||||
image restores everything; no permanent damage is expected.
|
||||
|
||||
## C2 flash read — confirmed actual state (2026-08-17)
|
||||
|
||||
The hypothesis above ("a second page erase destroyed 236 bytes of code") is
|
||||
**DISPROVEN by reading the device's flash back over C2**. An RP2040 was wired
|
||||
to the EFM8 (GP2→C2CK/pin 13, GP3→C2D/pin 14) and a read-only C2 flash reader
|
||||
was built (`firmware-tools/c2probe/`, Pico SDK, implements only FPDAT Block
|
||||
Read 0x06 — no erase/write path). DEVICEID=`0x28` (EFM8UB2) confirmed. The full
|
||||
64 KB was dumped to `firmware-tools/out/qunexus_device_dump.bin` and diffed
|
||||
against the stock image. Findings:
|
||||
|
||||
| Address range | Stock | Device | Verdict |
|
||||
|---|---|---|---|
|
||||
| `0xE800`–`0xE8EB` (236 B of code) | real code | **identical** to stock | **INTACT — page was never erased** |
|
||||
| `0xE8F2`–`0xE908` (23-B stub site) | `0xFF` (gap) | `0xFF` | **stub never programmed** |
|
||||
| `0xDF29`–`0xDF2A` (LCALL target) | `A5 7B` | `E8 F2` | retarget **was** written |
|
||||
| `0xFBFF` (lock byte) | — | `0xFF` | unlocked |
|
||||
| `0xFC00`–`0xFFFF` (top 1 KB) | — | read fails | reserved/lock page (not app) |
|
||||
|
||||
So the actual mechanism is the **transfer-abort** branch of the "Open question",
|
||||
not the page-erase branch: the `0xDF29` retarget sits in an existing record
|
||||
early in the transfer and was programmed; the appended `0xE8F2` record was
|
||||
*last* and was never sent before the 100 ms inter-chunk handshake aborted the
|
||||
transfer. Page `0xE800` was therefore never erased, the 236 bytes survived, and
|
||||
the stub was never written. The call at `0xDF28` now does `LCALL 0xE8F2`, which
|
||||
lands in `0xFF` flash (`MOV R7,A` then runs forward into `0xFF`...) and derails
|
||||
the USB-MIDI dispatcher `0xDF05` for every event on every USB port — matching
|
||||
"no response to any MIDI input" while interrupt-driven USB enumeration survives.
|
||||
|
||||
Other differences from the stock image are **not** damage:
|
||||
|
||||
- `0xF000`–`0xF806`: user configuration (MIDI routing / per-channel / CV
|
||||
settings). The stock `.syx` carries factory defaults; this unit was
|
||||
personalized. The per-channel blocks at `0xF200`/`0xF400`/`0xF600` carry
|
||||
channel-index bytes `01`/`02`/`03` and differ only in config values
|
||||
(`64 0F 14`→`64 01 0A`, etc.). Expected on a used device.
|
||||
- `0xEE00`–`0xEF5A`: ~348 bytes in a stock **gap** (no record covers it). The
|
||||
bootloader only erases pages it has records for, so data written here by an
|
||||
earlier image persists across reflashes. Harmless — stock code does not
|
||||
reference it.
|
||||
|
||||
**This revises the recovery outlook materially.** The "no software recovery
|
||||
path" conclusion below was premised on `0xE8C5` (UART MIDI byte processor) and
|
||||
`0xE8E6` (`LJMP 0x0000`, the bootloader-entry jump) being erased. **They are
|
||||
not erased — both are intact** (inside the surviving 236 bytes). The only thing
|
||||
broken is the single `LCALL` at `0xDF28`. Consequently:
|
||||
|
||||
- **Minimal C2 fix:** erase page `0xDF00`–`0xDFFF` (app region — **not** the
|
||||
bootloader) and reprogram it with the stock bytes, restoring `0xDF29`=`A5 7B`.
|
||||
That alone un-breaks the USB-MIDI receive path.
|
||||
- **Then normal recovery works:** with MIDI input restored, the SysEx
|
||||
bootloader-entry command reaches `0xB48D` → `0xE744` → `0xE8E6`
|
||||
(`LJMP 0x0000`) → bootloader, and the stock image can be reflashed over USB
|
||||
exactly as before the incident. No permanent damage; the bootloader region
|
||||
`0x0000`–`0x23FF` was never touched and must still not be erased.
|
||||
|
||||
Either way only `0x2400`–`0xF806` should be programmed; no mass erase.
|
||||
|
||||
## C2 flash write — recovery executed (2026-08-17)
|
||||
|
||||
The minimal C2 fix above was performed. The c2probe firmware was extended with
|
||||
guarded Page Erase (0x08) and Block Write (0x07) commands, hard-limited to the
|
||||
app region `0x2400`–`0xF9FF` (bootloader `0x0000`–`0x23FF` and lock/reserved
|
||||
`0xFA00`+ are refused; no Device Erase / mass-erase command exists at all). The
|
||||
host script `c2probe/reflash_page.py` ran the verified sequence:
|
||||
|
||||
1. `piinit` (halt core) + `wsetup` (AN127 Table 3.6 SFR setup: FLSCL=0x90,
|
||||
VDM0CN=0x80, CLKSEL=0x03, RSTSRC=0x02).
|
||||
2. Read page `0xDE00`–`0xDFFF` (the 512-byte flash page containing `0xDF28`;
|
||||
note the page base is `0xDE00`, not `0xDF00` — pages are 512-byte aligned).
|
||||
3. Page Erase page `0x6F` → verified all 512 bytes read back `0xFF`.
|
||||
4. Block Write the stock bytes for `0xDE00` and `0xDF00` (256 + 256).
|
||||
5. Read back and verify byte-identical to stock — **MATCH**.
|
||||
6. C2 reset → EFM8 reboots into the restored app.
|
||||
|
||||
Result: `0xDF28` = `12 A5 7B` (`LCALL 0xA57B`), the stock dispatcher call. The
|
||||
bad retarget to `0xE8F2` is gone. The bootloader region was never written or
|
||||
erased. The USB-MIDI receive path is intact again, so SysEx / bootloader entry
|
||||
should work; from here a full factory reflash of v2.2.1 over USB is possible but
|
||||
not required — only the one corrupted `LCALL` ever needed fixing.
|
||||
|
||||
Two firmware bugs found and fixed during the write: (a) Page Erase step 8 must
|
||||
poll OutReady until **set** (then read the `0x0D` page-number ack), not until
|
||||
clear — AN127's "poll until clear" wording is misleading; the EFM8UB2 presents
|
||||
the ack byte with OutReady set. (b) The CDC command line buffer was 160 bytes,
|
||||
truncating the 522-char `bw` command; enlarged to 1024.
|
||||
|
||||
## USB-1→CV patch applied via C2 (2026-08-17)
|
||||
|
||||
With the device recovered, the corrected patch (`patch_usb1_to_cv.py`,
|
||||
`STUB_ADDR=0x8126`) was applied surgically over C2 by `c2probe/patch_c2.py`:
|
||||
erase+reprogram page `0x8000` (23-byte stub into verified `0xFF` padding at
|
||||
`0x8126`) **first**, then page `0xDE00` (retarget `0xDF28`→`LCALL 0x8126`)
|
||||
**second** — stub-page-first ordering means a mid-failure never leaves a
|
||||
dangling retarget. Both pages read back byte-exact, and only the intended
|
||||
bytes changed. The device was reset and **functionally verified on an
|
||||
oscilloscope**: MIDI sent to USB port 1 (Control Surface) now drives the CV
|
||||
gate and 1 V/octave pitch outputs, which it never did before. User
|
||||
personalization at `0xF000`–`0xF806` was preserved (only two pages touched).
|
||||
The patch is fully reversible over C2 (`reflash_page.py` restores `0xDE00` to
|
||||
stock; the stub page can be restored the same way).
|
||||
|
||||
## The application has no alternative bootloader-entry path
|
||||
|
||||
Established by recursive-descent disassembly of the whole image:
|
||||
|
||||
- The only bootloader-entry trigger is the SysEx command. Handler at `0xB48D`
|
||||
checks category `0x11` (`SYX_SYSTEM`) and command `0x00` (`SYX_SYS_RESET`) at
|
||||
`0xB499`/`0xB49E`, then calls `0xE744`.
|
||||
- `0xE744` disables interrupts and falls through to `0xE8E6`, which is
|
||||
`LJMP 0x0000` — the only `LJMP 0x0000` in the entire 54 KB image.
|
||||
- `0xE744` has exactly one caller (`0xB4A6`); `0xE8E6` has exactly one referrer
|
||||
(`0xE751`, inside `0xE744`).
|
||||
- There is **no** software reset anywhere: every `RSTSRC` write is VDD-monitor
|
||||
init (`RSTSRC = 0x02` following `VDM0CN = 0x80`). No watchdog-forced reset
|
||||
path either.
|
||||
- No key/button code reaches `0xB48D`; its entire caller chain is MIDI message
|
||||
processing.
|
||||
|
||||
**Two of the destroyed functions are exactly the ones needed for recovery**, both
|
||||
inside the erased range `0xE800`–`0xE8F1`:
|
||||
|
||||
| Address | Function | Consequence |
|
||||
|---|---|---|
|
||||
| `0xE8C5` | UART/DIN MIDI byte processor, called from the main service loop at `0xE045` | Expander-port (`J2`) MIDI input is dead, so the enter-bootloader SysEx cannot be delivered over the UART either |
|
||||
| `0xE8E6` | `LJMP 0x0000` — the bootloader entry jump | bootloader entry is dead even if a command were received |
|
||||
|
||||
The UART is configured for MIDI (`SCON0 = 0x50`, Timer 1 mode 2, reload `0xC0`
|
||||
→ 31250 baud at 48 MHz), and the receive ISR at `0xA90D` reads `SBUF0` into a
|
||||
24-byte ring at `0x0F31` via `0xE2AA`; the ring is drained at `0xE042`/`0xE47E`
|
||||
and each byte handed to the now-missing `0xE8C5`.
|
||||
|
||||
Consequently **no software recovery path exists**: not USB MIDI, not DIN MIDI via
|
||||
the Expander port, not a key/button combination, not a USB vendor request, and
|
||||
not a power-on bootloader window (verified by kernel USB logs — exactly one
|
||||
enumeration per attach, always PID `0x0018`).
|
||||
|
||||
Note also that the application does `ACALL 0x21D4` at `0x265E`, i.e. it calls a
|
||||
routine inside the bootloader region, so the bootloader exposes an API to the
|
||||
application.
|
||||
|
||||
## What is needed
|
||||
|
||||
**The key question for KMI:** does the bootloader at `0x0000`–`0x23FF` check a
|
||||
button/key at power-on, or offer any entry path that does not require the
|
||||
application to be functional? That region is not present in any `.syx` file, so
|
||||
it could not be analysed here.
|
||||
|
||||
Failing that, either:
|
||||
|
||||
1. **EFM8 factory bootloader** (AN945). The MCU is an **EFM8UB20F64G in QFP48**.
|
||||
Per the EFM8UB2 data sheet (Rev 1.3) Table 3.3, the 48-pin package enters
|
||||
bootload mode by holding **`P3.7` (QFP48 pin 23)** low at reset; the
|
||||
bootloader lives in the last three pages of code flash and runs after *any*
|
||||
reset when the Bootloader Signature Byte (the byte before the Lock Byte) is
|
||||
`0xA5`. The application image ends at `0xF806` and so never overlaps that
|
||||
region — but whether KMI erased the factory bootloader in production is
|
||||
unknown. For reference, QFP48 pin 13 = `RST`/`C2CK`, pin 14 = `C2D`.
|
||||
2. **Direct reflash over the C2 debug interface.**
|
||||
|
||||
In either case only `0x2400`–`0xF806` should be programmed, and no mass erase
|
||||
should be performed: that would destroy KMI's bootloader and/or the factory
|
||||
bootloader, neither of which is recoverable from available files.
|
||||
|
||||
## Files available
|
||||
|
||||
In `firmware-tools/out/`:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `QuNexus_Firmware_v2.2.1.bin` | stock v2.2.1 application image, flat binary, base `0x2400`, 54 279 bytes |
|
||||
| `QuNexus_Firmware_v2.2.1.hex` | the same as standard ASCII Intel HEX |
|
||||
| `QuNexus_Firmware_v2.2.1-cs512-usb1cv-v2.syx` | corrected patch (stub relocated to `0x8126`, inside an existing stock record) — **not** the image that was flashed |
|
||||
|
||||
The image that was flashed is reproducible from the stock `.syx` with
|
||||
`patch_usb1_to_cv.py` by setting `STUB_ADDR = 0xE8F2` and using the
|
||||
add-a-new-record path.
|
||||
|
||||
Note that these images cover only `0x2400`–`0xF806`. The bootloader region
|
||||
`0x0000`–`0x23FF` is not present in any `.syx` and must not be erased.
|
||||
Reference in New Issue
Block a user