I have a sealed Geehy APM32F103C8T6 ST-Link V2 clone — the cheap aluminium-tube kind — that I wanted running Black Magic Probe firmware. No case access, so no SWD, so the only way in is the ST bootloader already sitting at 0x08000000 that answers over USB.
That part worked. stlink-tool flashes BMP into the application region at 0x08004000, the bootloader hands over, BMP enumerates. The catch is that it only hands over when stlink-tool tells it to — every plug-in leaves you in the bootloader until something issues the jump. A udev rule papers over it, but it nagged at me, because J-Link OB doesn’t need that. Convert a clone with SEGGER’s tool and it just comes up as a J-Link, every time, no host-side nudge.
So what does SEGGER’s firmware have that BMP’s doesn’t? That question became a hunt for a marker — a flag, an option byte, a magic word — that makes the bootloader auto-launch one image and not the other. What follows is the log of that hunt, wrong turns included, because the wrong turns are the point.
Before I had the bootloader binary I reasoned about the application images — and reasoning produced one plausible mechanism after another. Each survived until a byte contradicted it. Keeping the casualties visible is more honest than a tidy story, and more useful: it shows which evidence retired which idea.
The lesson isn’t any single theory. It’s that I produced five confident mechanisms faster than I could verify one — and stopped only when I read the code instead of the tea leaves.
The first real data came from dumping the first two words of each application image — initial stack pointer, then reset vector. If the bootloader gated on the vector table, the launchable image and the stuck one would differ here.
| image | size | SP (word 0) | reset (word 1) |
|---|---|---|---|
| ST-Link (genuine app) | 25 872 | 0x20003548 | 0x0800a209 |
| J-Link OB | 44 636 | 0x200018b8 | 0x0800eb85 |
| Black Magic Probe | ~17 KB | 0x20001000 | 0x08016539 |
Three firmwares, three vendors, one structure: every stack pointer lands in SRAM, every reset vector is a valid Thumb address in flash, all three base at 0x08004000. To any sane boot check they are identical. Whatever separated them was not in the image — which, after H1 through H4, finally pointed at the one binary I hadn’t looked at: the bootloader itself.
The full-flash recovery image — STLinkV2.J16.S4.bin, 64 KB based at 0x08000000 — contains the bootloader. That is the code that decides whether to jump. Everything before this was inference about images that get jumped to; this is the thing doing the jumping.
Across the entire 16 KB bootloader, the app base 0x08004000 is referenced exactly once. It’s loaded inside a single short routine:
0x08000f2e: push {r4, lr}
0x08000f30: ldr r0, [pc, #0x290] ; r0 = 0x08004000 (app base)
0x08000f32: ldr r1, [r0, #4] ; r1 = app reset vector
0x08000f3a: ldr r0, [r0] ; r0 = app initial SP
0x08000f3c: subs r0, #8
0x08000f3e: bl 0x80023c0 ; install SP / VTOR
0x08000f42: ldr r0, [r4, #4] ; reload reset vector
0x08000f48: bx r0 ; jump — unconditionally
Look at what isn’t there. It reads the stack pointer and the reset vector, installs them, and branches. No range test on the SP. No comparison against 0xFFFFFFFF. No signature. No marker. This routine runs whatever sits at 0x08004000, no questions asked. H1 and H5 die right here: the bootloader doesn’t validate the app, so it can’t be refusing BMP for failing validation.
Then who calls it?
If the jump is unconditional, the decision lives in the caller. There is exactly one, and it sits inside the USB request loop — the same state machine that handles DFU commands:
0x0800221c: ldrb r0, [r4] ; received command byte 0x0800221e: cmp r0, #7 ; the GO command 0x08002220: bne 0x80021d4 ; anything else -> keep looping 0x08002222: bl 0x8000f2e ; command 7 -> jump to app
And the reset handler? It calls init, then drops straight into this USB loop and stays there. It never touches the app region, never peeks at 0x08004000, never attempts a jump. I confirmed the negative directly: the app base appears in precisely one place in the whole bootloader, and that place is the routine above.
stlink-tool with no arguments sends exactly that.If the bootloader can’t auto-run anything, then “J-Link OB launches on its own” has to mean something other than what it looks like. It does. The J-Link software stack is always resident on the host, and it issues the GO command on enumeration — automatically, invisibly. SEGGER didn’t make their firmware more launchable. They made the host send command 7 for you.
Which is, mechanically, identical to a udev rule firing stlink-tool when the clone appears. BMP and J-Link OB are in exactly the same position: both are inert app images that this bootloader will only enter on command. One vendor hides the command in a driver; the other leaves it to you. There was never a firmware difference to find — the asymmetry was entirely host-side automation.
Every “convert ST-Link to X” tool is the same trick wearing different coats: flash an app, then send command 7.
This closes a project I’d half-started: an in-application updater to replace the ST bootloader so BMP could own reset and truly auto-run. The disassembly says that was the only path to genuine plug-in auto-run — because the stock bootloader is command-gated by design, the alternatives are exactly two:
| goal | requirement | on a sealed unit |
|---|---|---|
| True auto-run on plug-in | Replace the bootloader (own reset) | needs SWD — out of reach |
| Effective auto-run | Automate command 7 host-side | udev rule — already done |
So the working setup — BMP in the app region, launched by a udev-triggered GO command — isn’t a workaround for a better solution waiting to be discovered. It is the solution, and it’s the same architecture SEGGER ships, minus the proprietary daemon. The udev rule is the auto-run.
The honest takeaway isn’t about ST-Link at all. Five times I produced a clean, confident explanation from indirect evidence — application headers, embedded constants, register-address scans — and five times it was wrong in a way only a direct read could expose. The hunt didn’t end when I got smarter about the apps. It ended when I stopped reasoning about the apps and disassembled the binary that actually makes the decision. When the cost of a wrong answer is a brick you can’t open, “probably” is not a finding. The cmp r0, #7 is.