All guides
TECHNICAL GUIDE STM32 DEBUG TOOLING 2026

Roll Your Own Flash & Debug Tools
Scripting OpenOCD, GDB, pyOCD, pylink & bootloaders

Build reproducible, CI-friendly flash + verify + reset automation for the STM32L4R5 from OpenOCD Tcl, GDB batch scripts, Python (pyOCD / pylink), and the on-chip USART/USB-DFU bootloader.

01 Why roll your own — L4R5 memory, boot & flash registers

A GUI programmer is fine on your desk. But automation — CI hardware-in-the-loop, board-bring-up scripts, field updates, batch programming — needs a deterministic, headless, exit-code-returning command. Every tool below reduces to the same three primitives: erase, program+verify, reset. To script them safely you must know the target's memory map and the FLASH controller it drives.

The STM32L4R5 is a Cortex-M4F (RM0432 reference manual, DS12023 datasheet) with up to 2 MB Flash (e.g. STM32L4R5ZI = 2 MB) and 640 KB SRAM. Flash is programmed as 72-bit words (a 64-bit double-word + 8 ECC bits), erased per page. Page size and bank layout depend on the DBANK option bit.

Memory map — the addresses your scripts pass

RegionBase addressSizeNotes
Main Flash (user code)0x0800 0000up to 2 MBProgramming/flash offset for every tool
System memory (ST bootloader)0x1FFF 000028 KBFactory USART/USB-DFU loader; entered via BOOT0
OTP area0x1FFF 70001 KBOne-time-programmable
Option bytes0x1FFF 7800RDP, BOOT config, DBANK, WRP
SRAM10x2000 0000192 KBDefault stack/heap; flash-loader RAM
SRAM20x1000 0000 (also aliased at 0x2003 0000)64 KBParity-protected, retained
SRAM30x2004 0000384 KBLarge buffer RAM
Peripherals0x4000 0000FLASH ctrl at 0x4002 2000

FLASH controller registers (base 0x4002 2000)

Every host tool that writes flash either (a) drives these registers directly over SWD, or (b) uploads a tiny flash-loader into SRAM that drives them. Knowing them lets you debug a stuck program cycle from the OpenOCD/pyOCD console.

RegisterOffsetPurpose / key fields
FLASH_ACR0x00LATENCY wait-states, prefetch, caches
FLASH_KEYR0x08Unlock CR: write 0x45670123 then 0xCDEF89AB
FLASH_OPTKEYR0x0CUnlock option bytes: 0x08192A3B then 0x4C5D6E7F
FLASH_SR0x10BSY(16), EOP(0), PROGERR(3), WRPERR(4), PGSERR(7), OPTVERR(15)
FLASH_CR0x14PG(0), PER(1), MER1(2), PNB[10:3], BKER(11), MER2(15), STRT(16), OPTSTRT(17), OBL_LAUNCH(27), OPTLOCK(30), LOCK(31)
FLASH_ECCR0x18ECC error address & flags
FLASH_OPTR0x20RDP[7:0], BFB2(20), DBANK(22), nBOOT1(23), nSWBOOT0(26), nBOOT0(27)

Boot selection — how your script forces the ROM bootloader

With the default option bytes (nSWBOOT0 = 1) the physical BOOT0 pin chooses the boot source at reset. This is the mechanism serial/DFU flashing relies on.

BOOT0 pinnBOOT0 (if nSWBOOT0=0)Boot source
0 (GND)1Main Flash @ 0x0800 0000 — your app
1 (VDD)0System memory @ 0x1FFF 0000 — ST bootloader (USART/USB-DFU)
NOTE

Empty-flash detection: if the first Flash word is 0xFFFF FFFF the ROM may fall through to the bootloader anyway. In dual-bank mode the BFB2 option bit and the bank-swap logic decide which bank maps to 0x0800 0000 — keep that in mind when a "successful" flash still boots the wrong image.

In this section

  • Flash offset for every tool is 0x0800 0000; ROM bootloader lives at 0x1FFF 0000.
  • Erase = per page; program = 64-bit double-words; FLASH_CR/SR/KEYR drive the cycle.
  • BOOT0 high + reset = enter the factory USART/USB-DFU loader.

02 OpenOCD Tcl scripting: procs for flash + verify + reset

OpenOCD is a full Tcl interpreter. Anything you can type at the telnet localhost 4444 prompt can live in a .cfg/.tcl file as a reusable proc. Start every project with one committed config file so the whole team (and CI) flashes identically.

A reusable target config

stm32l4r5.cfg — ST-Link over SWD
# --- probe: ST-Link v2/v3 (Nucleo/Discovery on-board or standalone) ---
source [find interface/stlink.cfg]
# stlink.cfg defaults to SWD via the high-level adapter (hla_swd).
# For a v3 in DAP mode use: source [find interface/stlink-dap.cfg]

# --- target: STM32L4 family (covers L4R5) ---
source [find target/stm32l4x.cfg]

adapter speed 1800          # kHz; drop to 480 for long/flaky wiring
reset_config srst_nogate    # use SRST for reliable reset-halt

Custom procs: erase → program → verify → run

The built-in program helper already does reset+verify+exit, but a hand-written proc gives you full control (mass-erase, option bytes, memory pokes). Append these to the config above.

stm32l4r5.cfg (continued) — helper procs
# One-shot: full-chip erase, program ELF, verify, run, then quit OpenOCD.
proc flash_run {image} {
    reset init                       # halt + init clocks for the flash loader
    stm32l4x mass_erase 0            # erase bank 0 (use 1 for bank 2)
    flash write_image erase "$image" 0 elf
    verify_image "$image"
    reset run
    shutdown                         # exit with code 0 on success
}

# Program a raw binary at a chosen offset (default main flash).
proc flash_bin {image {offset 0x08000000}} {
    program "$image" verify reset exit "$offset"
}

# Read a 32-bit word / poke memory from a script.
proc peek {addr}       { mem2array v 32 $addr 1; return $v(0) }
proc poke {addr val}   { mww $addr $val }

Invoking it headlessly (returns a shell exit code)

bash — batch flashing
# ELF: verify + reset + exit in one line, no separate GDB needed
openocd -f stm32l4r5.cfg -c "flash_run build/app.elf"

# Built-in helper (equivalent for most cases)
openocd -f interface/stlink.cfg -f target/stm32l4x.cfg \
        -c "program build/app.elf verify reset exit"

# Raw binary at the flash base
openocd -f stm32l4r5.cfg -c "flash_bin build/app.bin 0x08000000"

# Chain arbitrary commands with -c; each is one Tcl line
openocd -f stm32l4r5.cfg \
  -c "init" -c "reset halt" \
  -c "stm32l4x mass_erase 0" \
  -c "flash write_image erase build/app.bin 0x08000000 bin" \
  -c "reset run" -c "shutdown"

stm32l4x flash-driver command reference

CommandEffect
stm32l4x mass_erase 0Erase entire bank (0 = first flash bank object)
stm32l4x unlock 0Remove read/write protection (triggers RDP regression, wipes flash)
stm32l4x lock 0Re-enable protection
stm32l4x option_read 0 0x20Read option register at offset (0x20 = FLASH_OPTR)
stm32l4x option_write 0 0x20 val maskModify option bits (e.g. flip DBANK / nBOOT0)
stm32l4x option_load 0Reload option bytes (OBL_LAUNCH — forces a reset)
flash write_image erase F 0 elfErase-as-needed then write ELF file F
verify_image FRead-back compare against file F
dump_image F 0x08000000 0x1000Read 4 KB of flash to a file (backup)
WATCH OUT

OpenOCD default ports: gdb 3333, telnet 4444, tcl 6666. If a leftover openocd holds the ST-Link you get "LIBUSB_ERROR_BUSY". Always shutdown (or kill %1) at the end of a script.

03 GDB batch & command scripts (--batch -x / -ex)

When you need to flash and inspect — dump a variable, hit a breakpoint, compare-sections after load — script GDB itself. --batch runs a script and exits with a status code, making it CI-safe; -x file loads a command file; -ex "cmd" injects single commands inline.

A committed GDB command file

flash.gdb
# Assumes a gdbserver (OpenOCD/pyOCD) is already listening on :3333
target extended-remote :3333
monitor reset halt          # "monitor" forwards to OpenOCD/pyOCD
load                        # download the ELF sections into flash
compare-sections            # read-back verify every loadable section
monitor reset run
detach
quit

Running it — server + client, then all-inline

bash — GDB batch flashing
# 1) Start the gdbserver in the background
openocd -f stm32l4r5.cfg &
OCD_PID=$!

# 2) Run the command file in batch mode (exit code propagates)
arm-none-eabi-gdb --batch -x flash.gdb build/app.elf

# 3) Clean up the server
kill $OCD_PID

# --- or: everything inline, no .gdb file needed ---
arm-none-eabi-gdb --batch \
  -ex "target extended-remote :3333" \
  -ex "monitor reset halt" \
  -ex "load" \
  -ex "compare-sections" \
  -ex "monitor reset run" \
  -ex "detach" -ex "quit" \
  build/app.elf

Useful in-GDB commands for embedded scripting

CommandWhat it does
loadWrite all loadable ELF sections to their addresses (flash on M4)
compare-sectionsCRC-compare each section vs. target — the built-in verify
monitor reset haltPass reset halt to the underlying probe server
monitor flash write_image erase f 0x08000000Drive OpenOCD's flash command from GDB
x/4xw 0x08000000Examine 4 words at the flash base (check vector table)
p/x $pcPrint the program counter — sanity after reset-halt
set confirm offSuppress y/n prompts — mandatory in --batch
TIP

GDB reads .gdbinit from the CWD only if you opt in. In batch/CI use explicit -x/-ex and pass -nx to ignore stray init files, so the flash result is reproducible regardless of the developer's home config.

04 pyOCD: Python flashing, gdbserver & scripting API

pyOCD is a pure-Python programmer/debugger for Cortex-M over CMSIS-DAP, ST-Link and J-Link. It ships a CLI (flash, erase, reset, gdbserver, commander, pack, list) and a scriptable Python API — ideal for test rigs.

Install and add L4R5 device support (CMSIS-Pack)

The L4R5 isn't in the tiny built-in target set, so pull its device family pack. The exact target type string comes from the pack — don't guess it, list it.

bash — pyOCD setup
pip install pyocd

pyocd pack update                      # refresh the pack index
pyocd pack find stm32l4r5              # show matching devices
pyocd pack install stm32l4r5          # download + register the DFP

# Confirm the precise target type (e.g. stm32l4r5xg / stm32l4r5xi)
pyocd list --targets | grep -i l4r5
pyocd list --probes                    # show connected probes + unique IDs

CLI flashing

bash — pyocd flash / erase / gdbserver
TGT=stm32l4r5xi        # use the string printed by "pyocd list --targets"

# ELF/HEX carry their own addresses; program + verify are automatic
pyocd flash -t $TGT build/app.elf

# Raw .bin needs an explicit base address
pyocd flash -t $TGT --base-address 0x08000000 build/app.bin

pyocd erase -t $TGT --chip              # full mass erase (or --sector A B)
pyocd reset -t $TGT                      # hardware reset the target

# GDB server on :3333; --persist survives client disconnects (CI reuse)
pyocd gdbserver -t $TGT --persist --frequency 4000000

# Pick a specific probe when several are attached
pyocd flash -t $TGT -u 066EFF... build/app.elf

The Python API — full programmatic control

flash_l4r5.py — pyOCD scripting
#!/usr/bin/env python3
# pip install pyocd ; requires the L4R5 pack installed (see above)
from pyocd.core.helpers import ConnectHelper
from pyocd.flash.file_programmer import FileProgrammer

TARGET = "stm32l4r5xi"
FLASH_BASE = 0x08000000

# session_with_chosen_probe picks the only probe, or prompts / honours -u
with ConnectHelper.session_with_chosen_probe(
        target_override=TARGET,
        options={"frequency": 4_000_000}) as session:

    target = session.target

    # Program a file (chip_erase: "sector" | "chip" | "auto")
    prog = FileProgrammer(session, chip_erase="sector")
    prog.program("build/app.hex")          # .elf/.hex/.bin all supported
    # For .bin: prog.program("app.bin", base_address=FLASH_BASE, file_format="bin")

    # Inspect / poke memory directly over SWD
    target.reset_and_halt()
    sp    = target.read32(FLASH_BASE)      # initial stack pointer
    reset = target.read32(FLASH_BASE + 4)  # reset handler address
    print(f"SP=0x{sp:08x}  Reset=0x{reset:08x}")

    target.write32(0x20000000, 0xDEADBEEF) # scribble RAM for a test
    assert target.read32(0x20000000) == 0xDEADBEEF

    target.reset_and_halt()
    target.resume()                        # let the app run

pyOCD CLI subcommand reference

SubcommandUse
pyocd flashProgram + verify an image (elf/hex/bin)
pyocd erase--chip mass erase or --sector ranges
pyocd resetReset (and optionally halt) the target
pyocd gdbserverGDB remote on :3333 (add --persist)
pyocd commanderInteractive REPL: read32, write32, reg, halt
pyocd packfind / install / update CMSIS device packs
pyocd list--targets, --probes, --boards

05 pylink: driving a J-Link from Python

If your bench uses a SEGGER J-Link, pylink-square wraps the official J-Link DLL in Python. It's the go-to for test infrastructure: connect by serial number, flash, and poke memory with a clean object API. The J-Link DLL must be installed; the device name is SEGGER's string, e.g. STM32L4R5ZI.

bash — install
pip install pylink-square          # import name is still "pylink"
# Requires SEGGER J-Link Software & Documentation Pack (provides the DLL)
flash_jlink.py — pylink
#!/usr/bin/env python3
import pylink

DEVICE = "STM32L4R5ZI"             # SEGGER device string (case-insensitive)
FLASH_BASE = 0x08000000

jlink = pylink.JLink()
jlink.open()                       # or JLink().open(serial_no=504302020)
jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
jlink.connect(DEVICE, speed="auto", verbose=True)

print("Core ID: 0x%08x" % jlink.core_id())

# flash_file() erases the needed sectors, programs, and verifies
jlink.reset(halt=True)
jlink.flash_file("build/app.bin", FLASH_BASE)

# Direct memory access mirrors pyOCD
sp = jlink.memory_read32(FLASH_BASE, 1)[0]
print("Initial SP: 0x%08x" % sp)
jlink.memory_write32(0x20000000, [0xDEADBEEF])

jlink.reset(halt=False)            # reset and let the app run
jlink.close()

Key JLink methods

MethodPurpose
open(serial_no=None, ip_addr=None)Open USB or IP J-Link connection
set_tif(JLinkInterfaces.SWD)Select SWD (or .JTAG) transport
connect(chip_name, speed, verbose)Attach to the target; speed in kHz or "auto"/"adaptive"
flash_file(path, addr)Erase + program + verify a binary at addr
flash(data, addr)Program an in-memory byte list
erase()Mass-erase; returns bytes erased
reset(halt=False) / halt() / restart()Reset control; reset(halt=True) stops at the vector
memory_read32 / memory_write32(addr, ...)32-bit word memory access
WATCH OUT

If erase()/flash() throws an "unspecified error", the core wasn't halted. Set a reset strategy before connecting, e.g. jlink.set_reset_strategy(pylink.enums.JLinkResetStrategyCortexM3.RESETPIN), then reset(halt=True).

06 Serial & USB bootloader: stm32flash, DFU, pyserial/pyusb

No SWD probe? Every L4R5 ships a factory ROM bootloader at 0x1FFF 0000 reachable over USART (AN3155 protocol) and USB Full-Speed DFU (DfuSe). This is how field updates and gang programmers work. Force it by holding BOOT0 high at reset.

Bootloader interfaces & pins (STM32L4Rxxx, AN2606)

InterfacePinsHost tool
USART1PA9 (TX) / PA10 (RX)stm32flash
USART3PB10/PB11 or PC10/PC11stm32flash
USB FS DFUPA11 (DM) / PA12 (DP)dfu-util
I2C1/2/3, SPI1/2see AN2606 table for L4Rxxx/Sxxxcustom / STM32CubeProgrammer
NOTE

The USART bootloader runs at 8 data bits, even parity, 1 stop (8e1) and auto-detects baud from the initial 0x7F byte. The USB DFU device enumerates as VID:PID 0483:DF11, alternate setting 0 = "Internal Flash".

stm32flash — USART bootloader

bash — stm32flash
# Probe: reads chip PID + bootloader version (confirms wiring/BOOT0)
stm32flash /dev/ttyUSB0

# Write + verify + jump to the app at the flash base
stm32flash -w build/app.bin -v -g 0x08000000 /dev/ttyUSB0

# Faster link (bootloader auto-bauds), .hex also accepted
stm32flash -b 115200 -w build/app.hex -v /dev/ttyUSB0

stm32flash -o /dev/ttyUSB0        # mass erase only
stm32flash -r backup.bin -S 0x08000000:0x1000 /dev/ttyUSB0  # read back 4 KB

# Auto-toggle BOOT0/RESET via DTR/RTS, then reset out via RTS after write
stm32flash -R -i '-dtr,rts,:dtr,-rts' -w build/app.bin -v /dev/ttyUSB0

dfu-util — USB DFU (system memory)

bash — dfu-util
# With BOOT0 high + reset, the L4R5 appears as 0483:df11
dfu-util -l                        # list DFU devices + alt settings

# Program raw binary at the flash base, then leave DFU and run
dfu-util -a 0 -d 0483:df11 -s 0x08000000:leave -D build/app.bin

# DFU wants a .bin (or DfuSe .dfu): convert from ELF first
arm-none-eabi-objcopy -O binary build/app.elf build/app.bin

Roll your own: pyserial handshake (AN3155)

When you need a bespoke protocol (custom baud, encrypted payload, production log), talk the bootloader yourself. This minimal handshake confirms entry and reads the command set — the foundation for a full programmer.

boot_probe.py — pyserial + AN3155
#!/usr/bin/env python3
# pip install pyserial ; usage: boot_probe.py /dev/ttyUSB0
import serial, sys

INIT, ACK, NACK = 0x7F, 0x79, 0x1F

port = serial.Serial(sys.argv[1], 57600,
                     parity=serial.PARITY_EVEN,   # 8e1 — required
                     stopbits=serial.STOPBITS_ONE,
                     timeout=1)

# 1) Auto-baud + enter: single 0x7F, expect ACK
port.write(bytes([INIT]))
if port.read(1) != bytes([ACK]):
    sys.exit("no ACK - is BOOT0 high and the part in bootloader mode?")

# 2) Get command: 0x00 + its complement 0xFF (every cmd is checksummed)
port.write(bytes([0x00, 0xFF]))
if port.read(1) != bytes([ACK]):
    sys.exit("Get command rejected")

n    = port.read(1)[0]              # number of bytes that follow (minus 1)
body = port.read(n + 1)            # [bootloader_version, cmd0, cmd1, ...]
port.read(1)                        # trailing ACK

print("Bootloader version: 0x%02x" % body[0])
print("Supported cmds:", body[1:].hex())

pyusb: raw DFU enumeration

For USB DFU you can go under dfu-util with pyusb — useful to detect the device, read its interface string (the memory layout), or script a factory jig. For real transfers, dfu-util is still the pragmatic choice.

dfu_find.py — pyusb
#!/usr/bin/env python3
# pip install pyusb   (needs libusb backend)
import usb.core, usb.util

dev = usb.core.find(idVendor=0x0483, idProduct=0xDF11)
if dev is None:
    raise SystemExit("STM32 DFU device not found (BOOT0 high + reset?)")

print("Found STM32 bootloader:", usb.util.get_string(dev, dev.iProduct))
for cfg in dev:
    for intf in cfg:
        s = usb.util.get_string(dev, intf.iInterface)
        print(f"  alt {intf.bAlternateSetting}: {s}")   # e.g. "@Internal Flash /0x08000000/..."

07 CI flashing & a reusable flash.sh wrapper

Automation's payoff: a self-hosted CI runner with a probe attached that flashes and smoke-tests real hardware on every push. Wrap all methods behind one script so CI, bring-up, and production share the same command.

flash.sh — one wrapper, four backends

flash.sh — openocd / pyocd / dfu / serial
#!/usr/bin/env bash
# flash.sh <firmware.elf|.bin> [method]   method: openocd|pyocd|dfu|serial
set -euo pipefail

FW="${1:?usage: flash.sh <firmware> [openocd|pyocd|dfu|serial]}"
METHOD="${2:-${FLASH_METHOD:-openocd}}"
FLASH_ADDR="${FLASH_ADDR:-0x08000000}"
CFG="${OOCD_CFG:-stm32l4r5.cfg}"
PORT="${PORT:-/dev/ttyUSB0}"
TGT="${PYOCD_TARGET:-stm32l4r5xi}"

# Return a .bin path (objcopy if the input is an ELF)
to_bin() {
  case "$1" in
    *.bin) printf '%s' "$1" ;;
    *) arm-none-eabi-objcopy -O binary "$1" /tmp/fw.bin; printf '%s' /tmp/fw.bin ;;
  esac
}

echo "==> flashing $FW via $METHOD"
case "$METHOD" in
  openocd)
    openocd -f "$CFG" -c "program $FW verify reset exit" ;;
  pyocd)
    pyocd flash -t "$TGT" "$FW"
    pyocd reset -t "$TGT" ;;
  dfu)
    BIN="$(to_bin "$FW")"
    dfu-util -a 0 -d 0483:df11 -s "${FLASH_ADDR}:leave" -D "$BIN" ;;
  serial)
    BIN="$(to_bin "$FW")"
    stm32flash -w "$BIN" -v -g "$FLASH_ADDR" "$PORT" ;;
  *)
    echo "unknown method: $METHOD" >&2; exit 2 ;;
esac
echo "==> done"
bash — using the wrapper
chmod +x flash.sh
./flash.sh build/app.elf                 # default: openocd
./flash.sh build/app.elf pyocd
FLASH_METHOD=serial PORT=/dev/ttyUSB0 ./flash.sh build/app.bin
./flash.sh build/app.elf dfu             # auto-objcopy to .bin

GitHub Actions — hardware-in-the-loop on a self-hosted runner

Attach the ST-Link to a machine registered as a self-hosted runner (label it, e.g. stm32l4r5). The workflow builds, flashes with the wrapper, then runs a smoke test over the virtual COM port.

.github/workflows/flash.yml
name: hil-flash
on: [push]

jobs:
  flash:
    runs-on: [self-hosted, stm32l4r5]      # runner with a probe attached
    steps:
      - uses: actions/checkout@v4

      - name: Build firmware
        run: make -j$(nproc)

      - name: Flash + verify
        env:
          OOCD_CFG: ci/stm32l4r5.cfg
          PROBE_SERIAL: ${{ secrets.PROBE_SERIAL }}
        run: ./flash.sh build/app.elf openocd

      - name: Smoke test over UART
        run: ./scripts/smoke_test.py --port /dev/ttyACM0 --timeout 10

      - name: Save flash log
        if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: flash-log
          path: build/flash.log
{%- endraw -%}
TIP

Pin the probe by serial number in CI (pyocd -u SERIAL, openocd -c "adapter serial SERIAL", or hla_serial) so a runner with several boards flashes the right one. Gate merges on the smoke-test job's exit code — that's your hardware regression net.

Which tool for which job?

ToolProbe / linkgdbserverLanguageBest for
OpenOCDST-Link, CMSIS-DAP, J-Link, FTDI:3333TclUniversal, CI, custom procs
GDB batchvia any gdbserverclientgdb scriptFlash + inspect + assert
pyOCDCMSIS-DAP, ST-Link, J-Link:3333PythonPure-Python test rigs
pylinkSEGGER J-Link onlyvia JLinkGDBServerPythonJ-Link benches
dfu-utilUSB (no probe)C / CLIUSB field updates
stm32flashUSART (no probe)C / CLISerial / gang programming

08 Gotchas & common mistakes

The failure modes below account for most "it flashed but won't run" and "probe busy" tickets. Bake the fixes into your scripts.

Binary flashed at offset 0A raw .bin has no address. If you pass it without 0x08000000 (OpenOCD/pyOCD --base-address/DfuSe -s), it lands at address 0 (or is rejected). ELF/HEX carry addresses; prefer them.
Forgetting to verifyProgram without verify silently tolerates a flaky link. Always add verify (OpenOCD), compare-sections (GDB), or rely on flash_file's built-in read-back (pylink/pyOCD).
Probe left busyA lingering OpenOCD/pyocd gdbserver holds the ST-Link ("LIBUSB_ERROR_BUSY"). End scripts with shutdown / kill the PID; in CI use --persist deliberately or tear it down in a cleanup step.
Wrong or missing pyOCD targetThe L4R5 needs its CMSIS-Pack (pyocd pack install stm32l4r5). Don't hard-code a guessed type — read it from pyocd list --targets. A wrong type mis-sizes flash and erases the wrong pages.
BOOT0 not asserted for serial/DFUstm32flash/dfu-util fail with no ACK / no device unless BOOT0 is high at reset. Wire BOOT0 to a runner GPIO or use RTS/DTR (stm32flash -i) so CI can toggle it automatically.
USART parity wrongThe ROM USART loader is 8e1 (even parity). Opening the port as 8n1 in pyserial gives intermittent NACKs. Set parity=serial.PARITY_EVEN.
Read/RDP protectionIf RDP level 1 is set, reads return zeros and programming fails. stm32l4x unlock 0 regresses RDP but mass-erases the chip — never run it blindly in a "reflash" script.
Dual-bank / BFB2 surpriseIn dual-bank mode the bank mapped to 0x0800 0000 depends on BFB2 and bank-swap state. A "successful" flash to bank 1 can still boot bank 2's stale image. Fix the boot bank in your bring-up option-byte step.
GDB batch hangs on a promptConfirmation prompts stall --batch forever in CI. Add set confirm off and pass -nx to ignore developer .gdbinit files.
GitHub Actions ${{ }} in code blocksNot an MCU bug but a docs one: Jekyll/Liquid eats {% raw %}{{ }}. Wrap any Actions YAML you publish in {%- raw -%}{%- endraw -%} or the workflow renders blank.

Golden rules

  • Commit one config (stm32l4r5.cfg) + one wrapper (flash.sh); everyone flashes identically.
  • Always verify and always free the probe at the end.
  • ELF/HEX over raw BIN; when you must use BIN, always pass 0x08000000.
  • For serial/DFU: BOOT0 high, 8e1 parity, VID:PID 0483:DF11 — then reset to run.