The setup: a sealed Geehy APM32F103C8T6 ST-Link V2 clone, no SWD access, no case open — the only way in is the ST bootloader at 0x08000000 over USB. stlink-tool flashes Black Magic Probe to 0x08004000 and sends a GO command to start it. The itch was that BMP needed that command after every cold boot, while J-Link OB — flashed to the identical address on the identical bootloader — came up on its own with nothing attached at all.
The bootloader's own launch routine, disassembled directly, makes the puzzle sharper rather than easier: it loads a stack pointer and a reset vector from 0x08004000 and jumps. No marker check, no checksum, no encryption. It will launch anything sitting there, instantly, without complaint — which means it should treat BMP and J-Link identically. It doesn't. Working out why took six theories, two bricked devices, and a rewritten call-graph trace before landing on the real answer.
rcc_clock_setup_pll() unconditionally too; it just uses a struct-driven API that doesn’t leave bare addresses in the literal pool. The byte pattern was an artifact of which API was used, not a missing clock init.The page-9 technique is real — it's a documented way some ST-Link-class bootloaders get patched into auto-launching. The mistake wasn’t the technique. It was trusting that a description of someone else’s bootloader binary was a safe description of this one, without checking first.
Disassembling the real Bootloader_2.0.bin and cross-referencing every direct call into that page found eight separate call sites from across the bootloader’s core logic, landing on what reads as a shared block-copy routine — the kind of generic memcpy helper used throughout for buffer handling:
| caller address | calls into page 9 |
|---|---|
| 0x080003E8 | 0x08002540 |
| 0x080006AA | 0x08002518 |
| 0x08000796 | 0x08002518 |
| 0x08000EDC | 0x08002468 |
| 0x08000F76 | 0x08002412 |
| 0x08000F88 | 0x08002518 |
| 0x08001124 | 0x08002518 |
| 0x08001174 | 0x08002518 |
Erase that page and every one of those eight call sites breaks — not as an acceptable tradeoff in exchange for auto-boot, but as a bootloader that can no longer service its own command loop. That's a bricked bootloader, not a bricked feature. Whatever SEGGER's actual installer targets, on whatever bootloader build it's written against, it isn't this address on this image.
The fix for trusting another disassembly was a tracer built specifically to distinguish real code from data: walk forward from a known-good entry point, stop hard at the first genuine terminator, and treat anything that decodes to an instruction that doesn’t exist on Cortex-M3 — a coprocessor opcode showing up mid-function is the giveaway — as proof of having drifted into a literal pool, not a reason to keep going.
reset → init() → 0x8002198 → 0x08002210
That chain runs once, every single boot, with no dependency on a host being present. At the end of it sits the actual decision — the same snippet from the top of this post:
ldrb r0, [r4]
cmp r0, #7
bne #0x80021d4
bl #0x8000f2e
7 is dfuMANIFEST in the standard USB DFU state machine — the state a device reaches only after a host completes a download and explicitly signals it’s done. That’s what a GO command drives the bootloader into. Nothing about a cold boot with no host attached will ever produce that state on its own.
This check sits inside page 8 — a different page from the memcpy utility entirely. That made a surgical fix possible: read the real 1024 bytes of page 8, change exactly the two that matter, write the rest back identical.
D8 D1 → 00 BF /* bne → nop */ comparison result discarded; execution falls through into the launch call unconditionally
The replacement page is applied by the running application itself, once, on boot: read the live patch site first, confirm it’s the exact untouched original (not already patched, not something unexpected), confirm the page group isn’t hardware write-protected, then unlock, erase, and reprogram. Idempotent by construction — every boot after the first sees the patch already in place and returns immediately.
stlink-tool involvement at any point, comes up as Black Magic Probe on its own. Page 9’s memcpy utility, and every one of its eight callers, is never touched.lujji’s write-up on installing Black Magic via the ST-Link bootloader supplied two real, useful things: the observation that the bootloader leaves the USB peripheral already enumerated when it hands off to the app — which is why the app itself now resets the USB peripheral and pulls D+ low on boot, forcing the host to notice the handoff instead of assuming nothing changed — and the anecdote that SEGGER’s own firmware reportedly patches a bootloader page directly. That second detail is accurate as a description of what SEGGER does somewhere. It just isn’t a safe instruction for what to do to this binary, which is the distinction that cost two devices to learn properly.
Not perfectly consistent yet: occasionally it takes a couple of replugs rather than coming up first try. The evidence points at USB host-side power management — the XHCI controller dropping into runtime-suspend during longer idle periods — rather than anything left in the firmware. A bare unplug/replug with no flash step in between, confirmed with zero host tool involvement, is the cleanest reproduction, and it's reliable enough to stop thinking about day to day.
A documented technique names an address. It doesn’t promise your binary agrees with it.
The page-9 patch wasn’t wrong because it was guessed — it's a real, working mechanism, described accurately, from a credible source. It was wrong because the address it names was treated as a property of the technique rather than a property of one specific binary that happened to be different from this one. The actual fix only existed once the real bootloader image was disassembled, every caller into the target region was cross-referenced, and the patch site was picked based on what was demonstrably there — not on what a write-up about different hardware said should be there.