Skip to main content

Programming STM32 firmware over SPI using Binho Pulsar

August 31, 2026 · 5 min read
Share:
Programming STM32 firmware over SPI using Binho Pulsar

Every STM32 ships with a bootloader burned into ROM. You did not write it, you cannot erase it, and it will program flash for you with no debugger attached. The interfaces it offers vary by part; on the STM32H503 they are USART, I2C, SPI, I3C and USB. We have now driven three of them: I3C, I2C, and now SPI.

Of those three, SPI is the one whose host implementation is easiest to get subtly wrong. We know that because we got it wrong.

The busy byte is also a data byte

Here is the whole problem in one sentence. When the target has nothing to send, its SPI peripheral emits 0xA5. That is not a status the bootloader chose. It is the underrun pattern, the value that comes out when the transmit register is empty and the host keeps clocking.

Nothing distinguishes it from a byte of flash that happens to hold 0xA5.

The obvious implementation is wrong. You clock dummy bytes waiting for the acknowledgement, you see a run of 0xA5, you skip it as padding, and you take the first byte that is not 0xA5 as the start of the reply. That works. It works on Get, on Get Version, on Get ID, on every identification command you would reach for while bringing the interface up, because none of their replies begins with 0xA5.

Then you point it at flash and it returns the wrong bytes.

The bug that passes every test you would think to run

We had a working implementation. Session opened, device ID 0x474, ten commands listed, a full erase, program, verify and run cycle. Everything green.

What we did not have was a reason to trust the read path, because everything we had read back was either an identification reply or firmware that happened not to start a block with 0xA5.

We built an image designed to break it: 2048 bytes where every 256-byte block begins with 0xA5, and one block is 0xA5 all the way through. The first failure was a corrupted verify. The second, on the all-0xA5 block, was worse: the read never returned at all, because the host was still looking for a first byte that was not padding and there was never going to be one.

The fix is to stop searching. The framing that works is positional:

  1. Clock until the acknowledgement appears and stop there.
  2. Send 0x79 to release the target.
  3. Pause, so the target loads its first reply byte.
  4. Clock exactly the number of bytes asked for and filter nothing.

Step 3 is the one that makes it work, and it is safe for a reason worth internalizing: the target is an SPI slave, so it cannot send anything without being clocked. Pause and the reply sits in its transmit register waiting for you. Pause halfway through a reply and it queues the next byte and waits again. It never pads a reply it has already started.

The general lesson is not about SPI. It is that a protocol test built from identification commands tests almost nothing about a data path, because identification replies are short, structured, and never contain the awkward values. If you are implementing a wire protocol, build the hostile input on purpose. Ours took ten minutes to write and found two bugs.

You have to acknowledge the acknowledgement

The second thing that will stop you, earlier and more confusingly, is that the handshake runs both ways. After the target sends its ACK it waits for the host to send 0x79 back before it continues.

Miss that and the target blocks forever. There is no error, no NACK, no timeout on its side. The session simply stops answering, which looks exactly like bad wiring or a wrong clock mode, and sends you off measuring signals that are perfectly fine.

Related, and equally fiddly: exactly one dummy byte leads each reply, because the target's shift register still holds the busy pattern when the first real byte is queued behind it. AN4286 mentions this as sending a dummy byte before a read. What the wording does not make obvious is that it is once per reply, not once per transfer. We got that wrong in both directions before pinning it down, and each time the symptom was the reported command list shifted by one position.

Driving reset when you have no pins left

A practical one. Entering the bootloader needs BOOT0 held high while NRST is pulsed, which normally costs two GPIO pins. On this setup they were not available, but two spare chip selects were.

Chip selects look like the wrong tool, because no API sets one to a level. What they do offer is polarity, and an active-low select idles high and is driven low for exactly as long as a transfer lasts. That gives you both halves for free:

  • Select the line wired to NRST and clock a deliberately slow transfer. We use 200 bytes at 10 kHz, which holds NRST low for about 160 ms. That is a reset pulse of a known width.
  • The line wired to BOOT0 sits high the whole time, because it is not the selected one. That is exactly the level that puts the part into the bootloader.

It has one limit, and it is worth knowing before you wire it this way. It cannot select the application, because that needs BOOT0 low while NRST pulses, and only one line can be selected at a time. The bootloader's Go command covers that. The other consequence is that while the adapter is attached and idle, BOOT0 is high, so a power cycle drops the board back into the bootloader rather than into your firmware.

Which bus, and when

We would still not use any of these over SWD on a board that already has a debug header. The reason to reach for the ROM bootloader is that there is no header, or you cannot get to it once the product is assembled.

Between the three we have covered: SPI where the wiring is already there and you are mostly writing, keeping in mind that its read path is the fussiest of the three to implement. I2C for breadth, because it is on almost every STM32 you will meet and costs two wires. I3C when several targets share a bus, because dynamic addressing is the one thing the other two cannot do at all.

One more, aimed at anyone about to write a host for any of them: the pins are not the pins you expect. On the STM32H503 the bootloader's SPI1 is PA7, PA0, PA8 and PB8, while the board's Arduino SPI header carries PA5, PA6, PA7 and PC9. Only MOSI is common to both. That is the application mapping of the same peripheral, not the one the ROM configures, and no amount of correct protocol code helps if you are on the wrong four pins.

Get the details

AN0003 has the wiring, the full command set, the framing byte by byte, the measurements and a troubleshooting table. The assets archive has the utility, which now drives a Pulsar or a Supernova over I2C, I3C or SPI, plus the two verification images with source and a makefile.

Read AN0003 (PDF)
Download the utility and assets

Back to all posts
Share:

Ready to Ship Better Hardware, Faster?

Whether you need a tool, a sanity check, or a team to help you ship — we're ready when you are.