Programming Without STM32CubeProgrammer
STM32CubeProgrammer can leave the build and the bench with two small Rust tools:
tag-flashprograms, erases and reads a tag through a base. It is a command line overtagprobe, which Tag Programmer already uses for the same jobs, so the programming code exists and has been run on hardware.base-flashprograms a base through its MCU's ROM bootloader over USB DFU. It is new code, small, ondfu-coreanddfu-nusb(MIT OR Apache-2.0). Its one subtlety is ST's DfuSe dialect, below.
The CMake -download, -erase and -dfu targets then call these tools, with
STM32CubeProgrammer as the fallback while they are proven. Where cargo is
installed the build compiles them (the flash_tools target); without it they
are found on the path, as STM32_Programmer_CLI is, and failing that the
targets use STM32_Programmer_CLI.
What uses STM32CubeProgrammer today
| User | Call | Replaced by |
|---|---|---|
<Tag>-download (embedded/CMakeLists.txt) |
-d <elf> -g 0x08000000 through stm32_programmer_select.py |
tag-flash program |
<Tag>-erase |
-e all |
tag-flash erase |
<Base>-dfu |
-c port=usb1 -d <elf> |
base-flash |
embedded/tools/flash_release.py |
program, partial or mass erase, PEMPTY check | tag-flash program --erase ... (the script keeps its manifest and record work) |
embedded/tools/stm32_programmer_select.py |
chooses an ST-LINK by serial or index | --base on each tool; the script is retired |
embedded/tools/decode_scratchpad.py |
-u 0x2003E000 8192 |
tag-flash read |
embedded/tools/tag_capture_state.py |
register and memory reads | already replaced by tag-capture; retire |
embedded/loaders/ |
build .stldr external loaders |
nothing to replace: tag-xflash runs them itself. The format stays ST's, so they still work in CubeProgrammer. |
tag-flash
tag-flash [--base <serial>] info
tag-flash [--base <serial>] program <elf> [--erase image|all|pages:<first>-<last>|keep-calibration]
tag-flash [--base <serial>] erase
tag-flash [--base <serial>] read <address> <length> -o <file>
tag-flash list
Each command is one tagprobe call:
| Command | tagprobe |
Notes |
|---|---|---|
list |
list(), identify() |
Bases on USB, with serial and identity string. |
info |
connect_check() |
Device ID, part name, target voltage. |
program |
chip_for(), then program() |
Erase, program and verify in one session, then clear FLASH_SR.PEMPTY on an L4 and start the image from its vector table: the one-session rule flash_release.py relies on, and the -g 0x08000000 the download targets use. |
erase |
program() with Erase::All and no image, or a new erase() |
A mass erase leaves PEMPTY set; tag-flash erase says so and does not start anything. |
read |
read_memory() |
Under reset: post-mortem only, like every attach. |
- Base selection.
--basetakes the base's USB serial. With one base connected it may be omitted; with several it is required, and the error lists them. This replacesstm32_programmer_select.py's selectors, whose defaultpid:0483:3748does not match the bench bases anyway. - Driver.
Driver::for_identity(identify(..)), as Tag Programmer does, so field bases whose firmware predates the identify command use the FieldSafe driver. --erase keep-calibrationmovescalibration_keep_erase()fromflash_release.pyinto the tool: it reads__calibration_start__and__tag_flash_page_size__from the image and erases the pages below it, asErase::Range. Same refusals as the script (no pinned calibration, not a 2 KB-page image).- Exit status 0 on success, 1 on failure, 2 on bad options (clap), as the other Rust tools.
base-flash
base-flash [--serial <dfu serial>] [--wait <seconds>] <elf>
base-flash list
It writes the base image's flash contents to internal flash through the ROM bootloader and leaves DFU mode, so the base starts the new firmware. It is the equivalent of
dfu-util -a 0 -i 0 -s 0x08000000:leave -D base.bin
How STM32 DFU differs from plain DFU
The ROM bootloader on the base MCUs (STM32F042, STM32L432) speaks
DfuSe, ST's extension of DFU 1.1, announced as bcdDFU 0x011A. Plain DFU
writes a stream to an implied location; DfuSe adds commands carried in
DFU_DNLOAD block 0:
| Command | Byte | Use |
|---|---|---|
| Set Address Pointer | 0x21 |
Where the following blocks are written, and where leave jumps. |
| Erase | 0x41 |
Erase the page containing an address (or mass erase with no address). |
Data blocks start at wBlockNum 2 and land at pointer + (block - 2) x
transfer size. The memory map comes from the alternate setting's string,
for example @Internal Flash /0x08000000/32*001Kg: start address, then
page counts, sizes and attributes.
dfu-core handles this: given the DfuSe version it parses that string, erases
the pages the image covers (and no others), sets the address once, and writes
the blocks. Against the dfu-util line above:
dfu-util |
base-flash |
|---|---|
-i 0 |
Interface 0, the bootloader's only one. |
-a 0 |
Alternate setting 0, checked to be @Internal Flash at 0x08000000 before anything is written. Alternate setting 1 is option bytes, which this tool never writes. |
-s 0x08000000 |
override_address(0x08000000), taken from the ELF, not typed. |
:leave |
After the last block dfu-core sends the zero-length DFU_DNLOAD; the bootloader's GET_STATUS then manifests and jumps to the address pointer, which is still the start address because dfu-core set it once. The bootloader is not manifestation tolerant and sets willDetach, so the device leaves the bus itself. To be confirmed on each base MCU (below). |
-D base.bin |
The ELF's loadable segments in flash, flattened into one image from the lowest address with gaps filled with 0xFF (what objcopy -O binary makes). A segment outside the alternate setting's memory map is refused, not dropped. |
Selection, waiting and checking
- The bootloader enumerates as
0483:df11. Its serial number is not the base's ST-LINK serial, so--serialtakes the DFU serial thatbase-flash listprints. With one DFU device it may be omitted. - Entering DFU is done by hand: the base is plugged into USB with its
button held, which holds BOOT0 so the MCU starts its ROM bootloader. The base
firmware has no jump-to-bootloader command.
--waitpolls for the device so the command can be started first, and the tool says to plug the base in with the button held while it waits. - Checking the result.
dfu-corehas no upload (read-back). The check is that the base comes back as an ST-LINK (0483:3748) within a few seconds and that its identity string names the commit of the image just written: the base's own firmware reportsfw=<commit>;dirty=<0|1>. A DfuSe upload read-back can be added later in about fifty lines if that is not enough. - No async runtime.
dfu-nusboffers a synchronousDfuSync; only opening the interface is async, and a one-lineblock_oncovers it.
Where the code goes
tagprobe-rsgains adfumodule: listing DfuSe devices, the alternate setting check, the ELF flattening and the download. It is the project's programming library, and Tag Programmer could later offer Update base firmware from the same code.host/commandline-rsgains the two binaries. They are new tools with no C++ counterpart, so they do not change the decision that the shipped command-line tools stay C++.- Packaging.
cmake/RustFlashTools.cmakedefines one cargo build of the two tools (release profile,--locked) per build tree, theflash_toolstarget.host/commandline-rs/CMakeLists.txt, added with the Rust apps underBUILD_RUST_APPS, installs them with the host tools (tag_tools/bin; beside the other tools on Windows) and signs them on macOS, so a bench machine gets them from the release package. Developers can alsocargo install --path host/commandline-rs --bin tag-flash --bin base-flash. embedded/CMakeLists.txtuses the sameflash_toolsbuild, or without cargofind_program, and prefers the tools;EMBEDDED_PROGRAMMER(AUTO,TAG_FLASH,CUBEPROGRAMMER) can choose outright. A cache variableTAG_FLASH_BASEnames a base by serial, asSTM32_PROGRAMMER_PROBEdoes for the fallback.- Linux udev rule.
70-ultralight-tags.rulesnow grants0483:df11, sobase-flashneeds no root. BUILD_SOURCES.md,docs/build/toolchain-inventory.mdanddocs/bench/name the new tools where they now name STM32CubeProgrammer.
Progress
tag-flashran on 2026-10-30 against a BitTag V6 (UUID2039354D30315012005E0068, STM32L432KC, 3.22 V from the base) on thatbittag-base-v7, with the fw-v0.6 BitTag image the tag was already running (56e5e6a0, verified against its manifest):readof the image's 33,996 bytes was identical to the release.bin;program(image pages) took 2.5 s, and the tag then ran its image, IDLE, self-testsALL_PASSED;- 14 mass-erase cycles (
program --erase plan, which is a mass erase for this pre-layout image) and 4 separateerase-then-programcycles all left the tag running its image;FLASH_SRread 0 (PEMPTY clear). Bench check 2 is done for this BitTag. - A tool attaching the moment
tag-flashlet the base go could fail its first debug read (SWD status0x81): 3 of 5 back-to-backtag-inforuns did, and the tag answered at the next attach.tag-flashnow waits 1 s before exiting after any command that attaches; 4 of 4 then passed. - The connect check cannot read the L4's flash size register, because
system memory does not answer while the core is held in reset, so
tagprobe::flash_bytes()reads it in a probe-rs session (one more attach). It read 256 KB. base-flashis built, and on 2026-10-30 wrote abittag-base-v7(STM32F042, serial8009001A5556431520303537) its own image (be8f9062) over DFU: the bootloader's string parsed, the 19,548-byte image was written, leave started it without a replug, and the base came back as an ST-LINK reporting the image's identity. The same day it wrote atag-breakout-base-l432-u375-1v8(STM32L432, serial004D0059543050162030324E) main 5017632d (22,908 bytes), with the same result. Bench check 4 is done for both base MCUs. (tag-base-c071was an abandoned development base and is not a target.)tag-flashprogrammed the IMUTagNandBmp581 breakout (STM32U375RG, UUID00303143433650090059002E) on that base three times on 2026-10-30 with the image it already carried (main 7cef2ba9): 3.4 s each, the tag running its image (IDLE) after each, flash read back identical. Bench check 1 is done. The attempts are logged incaptures/2026-10-30-imutag-tag-flash/flash-log.md.- Steps 2 and 4 were done on 2026-10-30, with packaging: the CMake
-download,-eraseand-dfutargets runtag-flash program,tag-flash eraseandbase-flash program --wait 60;flash_release.pyprograms withtag-flash(--erase image|all, andplanfor--keep-calibrationafter the script's own checks) and falls back to STM32_Programmer_CLI;decode_scratchpad.py --readreads the scratchpad withtag-flash read. The targets were checked by configuring a build tree each way and buildingflash_tools(--locked); the host install was checked by installing and ad-hoc signing the two tools. The same day, on the IMUTagNandBmp581 breakout and itstag-breakout-base-l432-u375-1v8, each path programmed a board from a build at 33dfce16:IMUTagNandBmp581-download(11.6 s, tag IDLE on the new image),flash_release.py(manifest checked, JSON record written, tag IDLE), andtag-breakout-base-l432-u375-1v8-dfu(base found in DFU mode, written, confirmed reportingfw=33dfce16; the tag then answered through it). nusb's futures need a Tokio reactor when awaited (itstokiofeature is on throughdfu-nusb), sotagprobe::dfuopens the device on a single-threaded Tokio runtime. The download itself is synchronous.
Bench checks before CubeProgrammer is dropped
Following the flashing protocol in AGENTS.md (supply voltage, UUID, erase by
the firmware that wrote the log, flash, self-test):
- A U375 program cycle through
tag-flash program.tagprobehas only run Retest on the IMUTag; programming a U375 has not been run. - The PEMPTY rule on an L432 tag:
programaftereraseand after--erase all, several times each, the tag booting its image every time (the failure the download script was chosen to avoid boots the ROM bootloader 3 times in 4). --erase keep-calibrationon a CompassTag: calibration survives, configuration and log do not.base-flashon each base MCU (F042, L432): the image starts after leave without a power cycle, and the identity string names the new commit.- Windows and Linux for
base-flash(driver and udev, below).
Open
- Windows driver for
0483:df11.nusbneeds WinUSB bound to the bootloader. WinUSB itself ships with Windows 11; that it binds to the ST bootloader on a machine that has never had STM32CubeProgrammer installed is still to be seen on hardware (bench check 5). - Whether leave starts the image on every bootloader version. It does on
the F042 (above). Some ST bootloaders need the Set Address Pointer sent again before the zero-length
download, as
dfu-utildoes for:leave. If one does,base-flashsends it itself rather than relying ondfu-core's pointer. - Whether
flash_release.pyshould become atag-flash release <dir>command, folding in the manifest check and the CSV record that Tag Programmer already has in Rust. The first step keeps the script and only changes the program it calls.
Steps
tag-flashwithlist,info,program,erase,read, on the existingtagprobecalls; bench checks 1-3.- The CMake download and erase targets prefer
tag-flash;flash_release.pyanddecode_scratchpad.pycall it. tagprobe::dfuandbase-flash; the udev rule; bench checks 4-5.- The CMake
-dfutargets preferbase-flash. - Retire
stm32_programmer_select.pyandtag_capture_state.py; drop the STM32_Programmer_CLI fallback, leaving STM32CubeProgrammer only as Tag Programmer's Qt build's dependency until that build is removed.