Open-source replacement firmware for the FNIRSI 2C53T handheld oscilloscope / multimeter / signal generator.
The FNIRSI 2C53T is a capable $75 handheld 3-in-1 instrument held back by buggy stock firmware. This project is a complete clean-room firmware rewrite built from reverse engineering the original binary.
Do not flash it to a 2C23T, a 2C53P, or any other FNIRSI model. The boards are close relatives but not interchangeable: different pin assignments, different FPGA transport, different application base address, and a different factory bootloader. Flashing this to the wrong model will not work and, if you follow First-Time Hardware Setup, it will overwrite that device's factory bootloader with one built for the 2C53T.
- FNIRSI 2C23T → use rosenrot00/OpenScope-2C23T, which is written for that hardware.
- Anything else → there is no open firmware for it yet. Please don't experiment with this one.
Check the model printed on the back of your unit before you start. If you have already flashed the wrong device, open an issue — the AT32's ROM DFU mode is unerasable mask ROM, so recovery is almost always possible.
🎉 The oscilloscope captures. As of 2026-08-13, the
make guest-coldtracebuild cold-boots, configures the Gowin GW1N-UV2 FPGA itself, arms the capture engine, and renders live, probe-responsive waveforms — no stock firmware, no warm handoff, no opening the case. The FPGA configuration problem that owned this project's critical path from April to August is solved. How to see it · the story · issue #18
The scope captures, and both axes now carry measured numbers — but it is validated on one physical unit. Timebase control reaches the hardware and 8 of 21 rate codes are bench-measured; the rest display
--rather than a guess. Vertical ranges 5/6/7 are measured and cross-validated four ways, 4/8/9 are provisional and marked~, and 0–3 rail and fall back to honest ADC counts. The measurement badges are real measurements, not placeholders. What is not settled: absolute vertical scale traces to a bench source never checked against a reference (the error is uniform and recoverable with one constant), the vertical graticule autoscales by default so a division does not mean the printed volts/div, CH2 has one usable attenuator tap, and the acquisition record carries stale data at its edges. If you need a scope you can trust unsupervised today, stay on stock.The multimeter works, but treat it as unverified on your unit. The decode is accurate within a few percent on our bench device, but the low-Ω calibration factor is per-device and currently hardcoded to that one unit — so absolute readings on your hardware have not been checked by anyone. Use it alongside a known-good meter, the way you would with any unfamiliar instrument, and don't trust it alone for anything that matters.
Flash it to help develop it, to explore the hardware, or because the reverse engineering interests you. PR #16 adds a dual-boot switcher so you can keep stock and switch between them rather than choosing.
Custom firmware runs on real hardware, and it captures. On 2026-08-13, bench unit #1 powered on into this firmware, configured the FPGA over SSPI (status 0x00039020 → 0x0003F460, DONE_FINAL set), armed the capture engine, and drew live traces from real ADC data on both channels — reproducibly across power cycles. Both axes now carry measured numbers: per-range volts/div on both channels (2026-08-18) and eight measured sample rates on the timebase ladder (2026-08-19), each cross-checked against an independent rig. Active development has moved to wiring the layer above acquisition. The measurement badges now read from real captures; the FFT, math channels and protocol decoders are still written, host-tested, and fed synthetic input.
Live capture lives in one specific build target. make and make guest do not configure the FPGA and will not capture — only guest-coldtrace runs the configuration path:
cd firmware && make guest-coldtrace
python3 ../scripts/iap_flash.py # MENU + tap Power → upgrade mode → detect → flashguest-coldtrace is a guest image: it links at 0x08007000 and runs under the FNIRSI stock IAP bootloader (the MENU + Power upgrade mode described below), rather than under our HID bootloader. It still needs the one-time 224KB SRAM option byte from First-Time Hardware Setup. Power-cycle the device, open scope mode, and probe something slow (a few Hz). The firmware ships a synthetic demo square wave as a fallback — when the demo trace disappears, you are looking at real samples.
Three caveats, stated plainly:
- The multimeter works in this image, but only on DC Voltage. Scope and meter run at the same time — bench-measured 2026-09-04 (EXP-23): a 1.61 V cell reads 1.6158 V while SPI3 acquisition keeps running. Selecting any other meter function still does nothing useful. The reason changed on 2026-09-07 and is worth stating precisely: the submode code now runs and the frontend really does move (EXP-24), but @Stlkv measured that our meter command frames carry the wrong two-byte header, so the meter never accepted any of them and has been sitting in its own power-on auto mode the whole time (issue #15). DC Voltage appeared to work because auto mode does DC Voltage. Fix in flight.
- It is validated on one physical unit. Nobody has run it on a second 2C53T.
- It is not the default
make guestboot path yet. Folding it in is on the roadmap.
The short version; see Feature maturity below for how far each one has actually been taken.
- Live oscilloscope capture from a cold boot (
guest-coldtraceonly) — MCU-driven FPGA configuration, engine arm, and per-channel0x04/0x05readout - Measured volts/div and time/div, with uncalibrated ranges labelled
--rather than guessed - 4 navigable UI modes: oscilloscope, multimeter, signal generator, settings
- 4 color themes, variable-width bitmap fonts at 4 sizes
- FreeRTOS with display + input tasks; 15/15 button matrix at 500 Hz
- Settings that survive a power cycle, to external flash
- Battery monitor, soft power management, watchdog and health monitoring
- USB HID bootloader for closed-case firmware updates
- FPGA USART communication (meter data flowing — but not in the capture build)
Every feature sits at one of five stages. The stages are cumulative, and the last two exist because this project has repeatedly shipped a number that was plausible, stable and wrong.
| Stage | What it means | |
|---|---|---|
| S0 | Written | Code and host tests exist. Nothing on the device reaches it — it is compiled and garbage-collected out of the image, or it draws from a synthetic source. |
| S1 | Wired | Reachable on the device and fed by real hardware data. |
| S2 | Measured | Checked on the bench against a known input, with the number and the method written up in docs/experiments/. |
| S3 | Guarded | A regression test would catch it breaking — ideally with a negative control, or data held out from whatever was tuned. |
| S4 | Polished | The UX has been considered: legible, responsive, and honest when it cannot answer. |
Nothing is at S4 yet. This table is the backward-looking half — where each
feature stands. The forward half — what would promote each one, and what
should exist that doesn't — lives in docs/specs/, one
reviewable promotion-ladder spec per feature.
| Feature | Stage | Where it actually stands |
|---|---|---|
| Cold-boot FPGA configuration | S2 | Bit-banged SSPI only. The same bytes through the SPI3 peripheral are still silently discarded. |
| Live capture, CH1 | S2 | Reproducible across power cycles on one unit. |
| Live capture, CH2 | S1 | One usable attenuator tap; every other code parks at a fixed level. Its vertical offset reference (TMR13 CH1 PWM on PA6) has never been programmed, which is the leading explanation. |
| Vertical scale (volts/div) | S3 | Ranges 5/6/7 measured and cross-validated four ways; 4/8/9 provisional and marked ~; 0–3 rail and return 0.0, with callers falling back to ADC counts. Absolute scale is unverified — every gain traces to an amplitude commanded from an unchecked source. One constant fixes it when a trusted source arrives. |
| Horizontal scale (time/div) | S3 | 8 of 21 timebase codes measured; the rest show -- rather than a guess. The UI button reaches the FPGA as of 2026-08-19 — before that it moved a label and nothing else. |
| Freq badge | S3 | Spectral, with a held-out fixture and a bin-stratified assertion. Refuses on torn records instead of guessing; answers ~87% of bench captures and has never been wrong on them. |
| Vpp / Vrms / Period badges | S2 | Bench-validated against a commanded sine, 3 ranges × 2 codes (EXP-19, 2026-08-20): Vrms within 3.8%, frequency-derived Period within 0.2%, Vpp within 7% with a small residual positive bias (peak detection reads high on a noisy record even after percentile trimming — Vrms is the number to trust). Same-source circularity means this is pipeline+linearity, not absolute volts. Duty passed its host battery but has not faced a commanded duty cycle yet. |
| Trigger level | S1 | Digital, SPI3 register 0x08, an ADC code. Re-armed at boot. |
| Settings persistence | S2 | Commissioned on hardware 2026-08-20: first record ever written to the W25Q, then restored — and pushed into the FPGA — across three consecutive power cycles. Until that day every write was refused by a build-time interlock (SETTINGS_PERSIST_WRITES=0) no bench build had ever enabled, so this row previously said "real" while zero records existed — the matrix's first overstatement. Documented gap (still true): a change carries only if a later button press or an orderly power-off follows it. |
| Multimeter | S1 | Works, and is accurate within a few percent on DCV and resistance — but not in guest-coldtrace, which holds USART2 dark. The two do not currently coexist. |
| Signal generator | S1 | Reachable; output has never been characterised against an instrument. |
| Screenshot capture (BMP) | S1 | Has a call site and writes to flash. |
| Rendering path | S3 | Flicker-free column compositor with a redraw gate. Display stability is bench-measured through the real render path (EXP-22, 2026-09-03): both channels driven, amplitude/frequency/phase varied, 11/11 scenarios lock to ≤1 px, with an on-hardware negative control that correctly fails. The scope trace autoscales to fill the band, so the vertical graticule does not currently mean the volts/div the status bar prints — that is the remaining S4 item. |
| FFT spectrum + waterfall | S0 | Fed a synthetic 1 kHz square wave generated on the spot. |
| Math channels | S0 | Fed a hardcoded sine LUT and square wave. |
| Bode plot | S0 | A generated demo response of a first-order low-pass. |
| Protocol decoders (UART/SPI/I2C/CAN/K-Line) | S0 | No call sites. |
Auto-measurements engine (measurement_compute) |
S0 | Still has no caller — superseded by scope_measure.c, which drives the badges above. Its one unique quantity (rise/fall time) is unwired; the rest is scheduled for deletion (see the spec). |
| XY / roll / trend / mask testing | S0 | No call sites. |
modules/ |
S0 | 17 guided-procedure files across four trades, with a provisional schema (modules/README.md) — but no loader: nothing in the firmware reads them. |
- By default the vertical graticule is not the volts/div label. The renderer autoscales every frame from the buffer's own min/max, a deliberate choice from when we had no measured gains and no offset control. The volts/div in the status bar is now genuinely measured, so the two disagree. An opt-in true-scale path (
fpga scope graticule true) draws at a fixed counts/division so one division means the printed volts/div on calibrated ranges — default off (it needs a centred baseline) and not yet eyeballed on the bench. - Absolute vertical scale is uncalibrated. All gains are relative to a bench source that has never been checked against a reference. Any error is uniform and recoverable with one constant.
- Only the bit-banged configuration path works. Stock configures the same part over hardware SPI, so this is an unexplained gap, not a property of the peripheral.
- The USB CDC debug shell does not enumerate on every build. It correlates exactly with which configuration path the image uses; the mechanism is unestablished. It is a diagnostic channel, not a user feature.
The story of how we got here — including the six weeks lost to a mis-clocked register read, and the several confident hypotheses that turned out to be wrong — is in the devlog.
| Component | Details |
|---|---|
| MCU | Artery AT32F403A — ARM Cortex-M4F @ 240MHz, 1MB flash, 224KB SRAM |
| Display | ST7789V 320x240 RGB565 via 16-bit parallel bus (EXMC) |
| FPGA | Gowin GW1N-UV2 — handles 250MS/s ADC sampling |
| ADC | Dual-channel, 8-bit, 250MS/s via FPGA SPI3 |
| Signal Gen | 2-channel 12-bit DAC |
| Flash | Winbond W25Q128JVSQ (16MB) — UI assets and calibration |
| Input | 15 buttons (4x3 scanned matrix + 3 passive) |
The MCU markings are sanded off. We identified it as AT32F403A through register probing — it's register-compatible with GD32/STM32F1 at the GPIO level.
Toolchain:
# macOS (Homebrew)
brew install --cask gcc-arm-embedded # ARM toolchain
brew install dfu-util # USB DFU flasher
# Linux (Debian/Ubuntu)
sudo apt install gcc-arm-none-eabi libnewlib-arm-none-eabi
sudo apt install dfu-util make
# Windows
# Install ARM GNU Toolchain from https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
# Install dfu-util from https://dfu-util.sourceforge.net/
# Build with Make (via MSYS2, WSL, or similar)Dependencies (all platforms):
The firmware depends on two libraries that aren't bundled in the repo. Clone them into the firmware/ directory:
cd firmware
git clone https://github.com/ArteryTek/AT32F403A_407_Firmware_Library.git at32f403a_lib
git clone https://github.com/FreeRTOS/FreeRTOS-Kernel.git FreeRTOSBuild once before flashing:
cd firmware && makeThis populates firmware/build/ with firmware.bin (the application) and option_bytes48.bin (a 48-byte blob used by the one-time option-byte DFU write below).
bootloader.binis built separately. Plainmakeonly builds the application. The USB HID bootloader is a separate target — runmake bootloaderto build it (output lands inbootloader/build/bootloader.bin). The first-timemake flash-allstep below builds it for you automatically, so you normally don't need to runmake bootloaderby hand.
The first flash requires opening the case to enter the AT32's ROM DFU mode — this is the only mode that can write option bytes. After the initial flash installs the USB HID bootloader, all future updates go over USB-C with the case closed.
Two bootloaders — don't confuse them. ROM DFU (entered via BOOT0 + pinhole reset, LCD dark,
2e3c:df11) is required for the one-time EOPB0 setup. The USB HID bootloader (Settings → Firmware Update, or POWER+PRM during reset, "BOOTLOADER MODE" on the LCD) handles every update after that but cannot write option bytes.
See the full walkthrough with photos: DFU Mode Guide
Windows users: community member @baraa1936 wrote a screenshot-by-screenshot walkthrough for flashing from Windows using the Artery ISP GUI tool (including the EOPB0 / 224KB SRAM option-byte step) — see issue #20.
The short version:
- Open the case (6 Phillips screws on back)
- Use a jumper wire to bridge 3.3V (from the SWD header near USB-C) to the BOOT0 pull-down resistor (MCU side, near the main chip)
- While holding 3.3V on BOOT0, press the pinhole reset button, then release both
- Verify ROM DFU:
dfu-util -lshould list2e3c:df11with alt interfaces 0 (Internal Flash) and 1 (Option Byte)
⚠ Step 6 is the irreversible-feeling one.
make flash-allwrites our bootloader over flash address0x08000000, replacing the FNIRSI factory IAP bootloader. Before you run it, confirm one last time that the device is a 2C53T — this is the step that removes a wrong-model device's way back. Everything before it is recoverable by simply not continuing. If you want to keep the stock bootloader entirely, use the MENU + Power channel with aguestbuild instead, which never touches0x08000000.
- Set EOPB0 = 0xFE → 224KB SRAM mode (one-time):
Expect
cd firmware dfu-util -a 1 -d 2e3c:df11 -s 0x1FFFF800 -D build/option_bytes48.binDownload done. / File downloaded successfully. TheInvalid DFU suffix signatureandError sending dfu abort requestwarnings are cosmetic. - Pinhole reset to stay in DFU, then flash the bootloader and application:
If the application write finishes but the device does not come up running the app, do not assume it booted. Remove the BOOT0 jumper, reset into the USB HID bootloader, and run
make flash-all
make flashto install the application through the bootloader. - Remove the BOOT0 jumper, pinhole reset, close the case — you won't need to open it again once the application boots
Once the USB HID bootloader is installed, updates are simple:
- On the device: Settings > Firmware Update (shows "BOOTLOADER MODE" screen)
- On your computer:
cd firmware && make flash
- The device auto-reboots into the updated firmware
If the app image is invalid or will not boot, reset while holding POWER+PRM to force the HID bootloader. POWER alone remains the normal battery power-on gesture.
The device's stock bootloader also accepts firmware over USB-C — handy for restoring the original FNIRSI firmware or flashing without the HID bootloader. Hold MENU + tap Power to enter upgrade mode (the LCD shows "firmware upgrade"); the device mounts a FAT12 volume named IAP.
macOS users: do not drag-drop the
.binin Finder — macOS's FAT driver corrupts the write (the volume uses 2048-byte sectors and Finder adds AppleDouble._junk the bootloader misreads as firmware). Use the bundled flasher, which writes the device correctly:
brew install mtools # one-time (Linux: sudo apt install mtools)
python3 scripts/iap_flash.py # detect device → pick firmware → flashIt auto-detects the device and available images, verifies the stock firmware by SHA-256, and shows a progress bar. Subcommands: status, list, flash <path>, doctor (prerequisite check), guide (full walkthrough). A bad flash is never a brick — re-enter upgrade mode and reflash any image.
Windows users can skip the tool — drag-drop the .bin onto the IAP drive (the official FNIRSI method; Windows' FAT driver handles the volume cleanly).
cd firmware
make # Build for hardware (AT32 @ 240MHz), for our HID bootloader — no FPGA config
make guest # Guest image at 0x08007000, stock IAP bootloader — no FPGA config
make guest-coldtrace # The one that captures: cold FPGA config + engine arm + live readout
make emu # Build for emulator (skips hardware init)guest-coldtrace is the only build that configures the FPGA and captures — see Seeing live waveforms today. make and make guest produce a working UI with a synthetic demo trace and no acquisition. The guest-* family is flashed with python3 scripts/iap_flash.py rather than make flash.
make renode # Run in Renode with LCD display
make renode-interactive # Run with keyboard inputRequires Renode (the Makefile looks for /Applications/Renode.app on macOS; set RENODE to override). An SDL3 native LCD viewer is also available (brew install sdl3 && cd emulator && make).
firmware/ Custom replacement firmware (C + FreeRTOS + Make)
src/main.c Entry point, FreeRTOS tasks, mode switching
src/drivers/ LCD, buttons, battery, watchdog, DFU boot
src/ui/ Scope, meter, siggen, settings, themes
src/dsp/ FFT, math channels, signal gen, Bode
src/decode/ Protocol decoders (UART, SPI, I2C, CAN, K-Line)
src/tasks/ Measurement engine, component tester, mask test
bootloader/ USB HID IAP bootloader (16KB)
reverse_engineering/ Hardware analysis and protocol documentation
ARCHITECTURE.md System overview (start here for RE)
HARDWARE_PINOUT.md Complete MCU pin assignments
FPGA_PROTOCOL_COMPLETE.md Full FPGA command/data specification
COVERAGE.md 309 functions mapped from stock firmware
analysis_v120/ Detailed V1.2.0 analysis artifacts
emulator/ Renode platform + SDL3 LCD viewer
docs/ Design docs, analysis, planning (see docs/README.md)
modules/ JSON procedure files (automotive, HVAC, ham radio)
scripts/ Font generation, flash tools, soak testing
Start with the Documentation Index. Key documents:
- Architecture Overview — How the hardware works
- FPGA Protocol — ADC sampling and command interface
- Hardware Pinout — Every MCU pin mapped
- Roadmap — What's done, what's next, future plans
- Devlog — Dated notes on what we tried, including the wrong turns
The stock firmware was reverse-engineered using Ghidra. We've identified and named 309 functions, mapped all ~40 FPGA commands, fully documented the ADC data format, and traced every hardware pin. About 98% of the stock firmware is now understood.
No FNIRSI source code is distributed in this repository. See reverse_engineering/README.md for methodology and legal basis.
The UI shell is built out and acquisition now works, but almost nothing in between is connected — and the next milestones need hardware captures and experimentation that a single bench unit can't provide. You don't need to write code to make a big contribution here (though wiring the DSP layer to real samples is a well-defined job for someone who does).
Capture obtained June 2026 thanks to @maksidze (issue #18), who patched the stock firmware's SPI prescaler to /64 and captured a full stock boot on a Saleae. That capture revealed our FPGA bitstream was extracted from the wrong file offset (we'd treated the flash address as a file offset, ignoring the 0x08007000 link base — the real bitstream is at file offset 0x4AD19). The corrected bitstream is byte-exact against the capture. Full decode: reverse_engineering/captures/.
It did not fix FPGA configuration on its own — but it was worth every minute: it eliminated the payload as a variable, which is what let us state the problem as config entry rather than config content, and maksidze's loader is the one that eventually broke the wall (see below).
For four months a GW1N-UV2 that had auto-booted its own resident design answered every SSPI query we sent and silently discarded every configuration command. On 2026-08-12, @Stlkv transplanted @maksidze's 2C23T loader — which bit-bangs the SSPI handshake on GPIO instead of using the SPI peripheral — onto the 2C53T's pins with our corrected payload, and the part configured on the first cold boot. We reproduced it on our bench the next day and reached cold-boot-to-live-trace. Issue #18 is the thread; the devlog entry is the story.
Still genuinely open, if Gowin internals are your thing: why the hardware-SPI path fails when the identical bytes bit-banged succeed — and when stock configures the same part over hardware SPI. We eliminated the obvious prelude difference (0x05 ERASE_SRAM) by direct test.
The old ask here said netlist analysis showed no sample-rate register in the FPGA's capture path, so the timebase had to be MCU-side pacing. That was wrong. The timebase is SPI3 register 0x01, and its low nibble selects a 1-2-5 rate ladder; eight codes are now measured (500 S/s to ~124 kS/s), cross-checked against an independent rig on a different unit that lands on the same ladder. Writeups: EXP-17, EXP-18.
What is still open here: codes 0x0A–0x0C (predicted 250k / 500k / 1.25M S/s) are beyond our bench source, and 0x06–0x09 return incoherent fits that all cluster near 1.25 kS/s — consistent with the rate field being narrower than the codes we write, or with those codes selecting roll mode. Untested.
Every vertical gain in this firmware traces to an amplitude commanded from a bench generator that has never been checked against a reference, and we found out the hard way that the same generator was delivering 0.825× its commanded frequency. The relative numbers are cross-validated and solid; the absolute scale is one unknown constant. If you have a 2C53T and a calibrated source, a handful of known amplitudes at known frequencies would close it — python3 scripts/verify_scope_cal.py exists to consume exactly that.
We've confirmed one board revision (V1.4) and one user has reported a different layout with no version marking. If your 2C53T looks different from our photos, photos of your PCB (top and bottom) are extremely valuable — especially near the FPGA, SPI flash, and analog frontend.
- Test on your hardware — different units reveal things a single bench unit can't
- Document what worked — first-flash walkthroughs for Linux or Windows are always welcome
- Contribute modules (
modules/*.json) for your domain (automotive, HVAC, ham radio, etc.) - Translate — we have users in Korea and Russia already; localization help is welcome
See CONTRIBUTING.md for the full guide. Bug reports and feature requests are always welcome via the issue tracker.
- pecostm32/FNIRSI-1013D-1014D-Hack — Schematics, datasheets, and FPGA docs for the 1013D/1014D
- pecostm32/FNIRSI_1013D_Firmware — Replacement firmware for the 1013D
- Atlan4/Fnirsi1013D — Most active FNIRSI firmware fork (471 commits)
- Gissio/radpro — Custom firmware for FNIRSI Geiger counters
