← Back to Blog SEC1.DK
Embedded / Hardware
ESP32-S3 SWD Black Magic Probe ARM Cortex-M Cardputer

Black Magic Probe on M5Stack Cardputer

Porting BMP to a pocket keyboard computer — full SWD probe with on-device REPL, scrollable display console, and USB output mirroring. From crashed SPI init to working standalone debugger.

Author Niel Nielsen Date Jul. 2026 Repo codeberg.org/nieldk/blackmagic-cardputer
00

Why

The M5Stack Cardputer is a compact ESP32-S3 device with a built-in 135×240 ST7789 display and a full QWERTY keyboard matrix. It has a Grove port, USB-C, and enough flash to run a real firmware image. The obvious question was whether it could run Black Magic Probe — a proper on-chip debugger with GDB remote protocol — and whether the keyboard and display could be wired up to give it a standalone command interface without needing a host at all.

The answer was yes, with a non-trivial amount of bring-up work. This documents what broke, what needed changing, and what ended up working.

01

Hardware

The Cardputer's ESP32-S3FN8 has no PSRAM. This matters — several things that seem reasonable (a 1MB UART RX buffer, a 63KB double framebuffer) become silent failures or reboot loops without it. The display is an ST7789 driven over SPI3. The keyboard is an 8×7 IO-matrix over a 74HC138 demux. The Grove port breaks out GPIO1 and GPIO2, which become SWDIO and SWCLK.

There is no spare Grove pin for NRST. The approach is monitor connect_rst enable in GDB. There is also a real bug in the upstream code: platform_nrst_get_val() passes NRST_PIN (-1) directly to gpio_get_level(), which has no argument validation and calls straight through to a HAL register read with -1 as an index. Fixed with a null-pin guard.

02

Build system

The base is litui/blackmagic-esp32s3. The first obstacle was ESP-IDF version sensitivity — TinyUSB's dcd_esp32sx.c (the ESP32-S3 USB-OTG device controller driver) depends on raw SoC register names that were reorganized in IDF 5.3.x. ESP-IDF v5.1.4 is required.

The second obstacle was sdkconfig persistence. The file lives at the repo root, not inside build/. Running rm -rf build leaves it untouched. Running idf.py set-target without the -D SDKCONFIG_DEFAULTS= flag writes a fresh sdkconfig from stock Kconfig defaults, silently overriding every Cardputer-specific value. Both commands need the flag:

rm -f sdkconfig && rm -rf build
idf.py -D SDKCONFIG_DEFAULTS=sdkconfig.defaults.cardputer set-target esp32s3
idf.py -D SDKCONFIG_DEFAULTS=sdkconfig.defaults.cardputer build

The third obstacle was a linker overflow. The original code allocates a 1MB UART RX buffer with EXT_RAM_ATTR, intended for the T-Display S3's 8MB PSRAM. On Cardputer that attribute silently falls back to internal DRAM, which is 312KB total. Fixed with a conditional — 1MB with EXT_RAM_ATTR when SPIRAM is present, 8KB otherwise.

03

Display bring-up

The ST7789 geometry took longer than expected. With MADCTL's MV bit set (SWAP_XY, needed for landscape), the controller's native column and row axes are transposed. The hagl_hal driver sets backend->width = MIPI_DISPLAY_WIDTH directly, which becomes hagl's internal clip boundary. Setting WIDTH=135 (the panel's portrait width) rather than 240 (the now-horizontal native row count) causes hagl to silently clip anything past pixel 134 on the X axis — exactly half the physical display. The correct values with SWAP_XY set are WIDTH=240, HEIGHT=135.

Rotation required MV+MX (SWAP_XY + MIRROR_X). MV alone, MV+MY, and MV+MX+MY were all tested on real hardware and produce wrong orientation. The FLIP_X and FLIP_Y Kconfig options map to bits 0 and 1 of the MADCTL register, which are reserved on the ST7789 — they are no-ops.

Double buffering is not an option without PSRAM. A ~63KB second framebuffer allocation fails silently and produces a ~1 second reboot loop with no debug output available on this hardware. The fix for flicker is a dirty flag — only call hagl_clear() and redraw when a key was actually pressed.

04

On-device REPL

The keyboard component scans an 8×7 IO-matrix over GPIO8, GPIO9, GPIO11 (74HC138 address lines) and rows on GPIO13, GPIO15, GPIO3–7. Edge detection runs every 25ms — a key is acted on only if it appears in the current scan but not the previous one.

Typed commands go through BMP's own command_process(). Output normally flows through gdb_out(), which wraps it as GDB O-packets. A capture flag is set around the call and an early-return path added to gdb_out() that routes to ui_capture_write() when active. This required an explicit #include "platform.h" in gdb_packet.c — the include chain in this fork does not reach platform.h through the normal general.h → platform_support.h path.

The scrollback buffer holds 50 lines (up from the original 10, which equalled the visible row count, meaning anything that scrolled off screen was already gone from memory). Fn+; scrolls back into history, Fn+. scrolls forward. Enter always snaps back to live view before executing a command.

All command output is simultaneously written to the second USB-CDC port as raw text. The CDC write is unconditional — checking tud_cdc_n_connected() would gate on DTR being asserted, and terminals with flow control disabled do not assert DTR. Opening that port also briefly pulses DTR on the GDB port due to Windows CDC enumeration behavior. A 200ms debounce on the GDB line-state callback filters this.

05

SWD wiring

Grove G1 (GPIO1) is SWDIO, Grove G2 (GPIO2) is SWCLK, GND is GND. Power the target separately. The SWDIO mode switching uses the fast gpio_ll register-level API rather than gpio_set_direction(), which matters for bit-bang timing.

Target support compiled in includes nRF51/nRF52 (including the nRF52 recovery AP path for APPROTECT-locked chips), STM32 F1/F4/G0/H5/H7/L0/L4, RP2040, SAMD/SAM series, LPC series, Kinetis, EFM32, RISC-V, and more.

06

Using it

The Cardputer enumerates as two USB-CDC ports. The first is the GDB remote protocol port, the second is the UART output mirror. Connect from a host:

arm-none-eabi-gdb your_firmware.elf
(gdb) target extended-remote /dev/ttyACM0
(gdb) monitor connect_rst enable
(gdb) monitor swd_scan
(gdb) attach 1
(gdb) load

Or skip the host entirely. Type swd_scan on the keyboard, read the result on screen, scroll through the output with Fn+; and Fn+.. The full command list is in help and fits in the 50-line scrollback buffer. To capture full untruncated output, open the second COM port in PuTTY with flow control set to None.

07

What's missing

JTAG is not wired — no spare Grove pins for TDI and TDO. WiFi, NV storage, and CMSIS-DAP were not brought in from the upstream repo. RTT and semihosting are compiled in but untested. Keyboard shift and Fn character layers are not decoded — the REPL only has lowercase letters, digits, and unshifted symbols for now. Actual SWD target scan against real hardware is pending — Grove wires in transit at time of writing.