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