Skip to main content

Programming STM32 firmware over I2C using Binho Pulsar

August 31, 2026 · 6 min read
Share:
Programming STM32 firmware over I2C 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. Pull one pin high, reset the part, and it comes up listening instead of running your application.

We wrote about the I3C interface in a previous post, which is a recent addition and available for only a few STM32 families. This one is I2C, which AN2606 lists for most of them and which has been in the bootloader for years. The protocol is documented in AN4221.

A Pulsar wired to a NUCLEO-H503RB erased, programmed, verified and ran an image twenty nine times out of twenty nine attempts. Four parts of the protocol behave differently from what the documentation leads you to expect, and one of them cost us measurable lost time.

The pins are not the pins you expect

The bootloader answers on a fixed 7-bit address at a fixed pair of pins, and both are per part. On the STM32H503 that is address 0x67 on I2C2, at PB3 and PB4.

We got this wrong first time. A web search produced PB6, PB7 and address 0x5D, which are all plausible and all wrong for this part. AN2606 is the only authority, and it gives the address in an easily misread form: 0b1100111x, where x is the read/write bit. That is 0x67, not 0xCE.

The pin choice deserves a second look, because it is a trap. PB3 and PB4 come out of reset as JTDO/TRACESWO and NJTRST. The bootloader's I2C interface sits on the two pins your debugger wants. On a Nucleo that is fine because those functions are not driven by default, but on a custom board with a debug probe attached it is worth checking before you conclude the bootloader is broken.

Confirming it took thirty seconds once we stopped guessing: scan the bus with the target running normally, scan again with it held in the bootloader, and see what appears.

  target running its application : none
  target held in the bootloader  : 0x67

It never sends the busy byte

An erase or a program takes far longer than a bus transaction, so the host needs a way to know the target is still working. ST documents three status bytes: 0x79 for ACK, 0x1F for NACK, and 0x76 for BUSY.

On this device, 0x76 is never transmitted. Not once, in any sequence we ran.

What actually happens is that a busy target stops acknowledging its own address. The transaction is refused at the address phase, nothing is delivered, and the host retries until it is accepted again. That is the whole busy mechanism, and it is better than a status byte: a NACKed address means with certainty that nothing was delivered, so retrying is always safe.

The practical point is that host code written to poll for 0x76 will wait forever. Poll the address instead.

We had already learned this on the I3C side, where 0x76 is likewise defined and likewise never sent. Worth generalizing: ST's Open Bootloader middleware and the ROM bootloader are different programs, and where they disagree the silicon wins. The same thing bit us again on the command list. The reference implementation offers Readout Protect and Readout Unprotect; the ROM on this part reports fifteen commands over I2C and neither of those is among them. We nearly published the opposite.

The same chip disagrees with itself about erase

Extended Erase takes a count followed by a list of page numbers. Over I2C, the count field is the number of pages minus one. Erasing a single page sends a count of zero.

Over I3C, on the same silicon, the field carries the count itself.

We did not find this in a document. We found it by writing a distinct pattern into two adjacent pages, erasing the first one alone, and reading both back to see which had survived. Send the plain count over I2C and you erase one page more than you meant to, which is the kind of bug that destroys the page holding your calibration data and gives you nothing to look at afterwards.

If you implement this, verify the encoding on the bus you are actually using. Do not carry it across from an implementation written for the other one.

Reading a reply whose length you do not know

The Get command returns one frame of 2 + N bytes: a count, a version, then N opcodes. You cannot know N before you read it, and reading past the end of the frame times the bus out.

The way through is to ask twice. Read two bytes to learn N, take the trailing ACK, then reissue Get and read the whole frame. Reading short is harmless, because the target abandons the rest and moves on to its closing ACK. Reading long is not. Two round trips is a small price for not having to guess.

The bug: a bus timeout that was not a bus problem

Here is the one that cost real time.

The Supernova can drive a legacy I2C target from its I3C port, which is convenient when that is where your wiring already is. It worked, but only about three quarters of the time. Full cycles failed with BUS_TIMEOUT, always on a read, and never in the same place twice.

Our first instinct was to suspect the adapter. That instinct was wrong, and it is worth saying so plainly, because it is the expensive kind of wrong: it sends you looking for a spare unit instead of looking at your own code.

Instrumenting every transfer with the gap since the previous one made it obvious. Every failure was the status read after the erase page-list write, issued with no gap at all after the preceding transfer. Nothing else ever failed.

The explanation is a real difference between the two kinds of controller. Immediately after a command, and before it settles into refusing its address, the target holds SCL low for a moment. A conventional I2C controller rides that out, which is why the Pulsar never saw this. An I3C controller is not obliged to: I3C targets never stretch the clock, so an I3C peripheral driving a legacy bus can enforce a timeout and abort rather than wait. It is behaving reasonably. Our code had simply assumed I2C-controller semantics on a peripheral that does not owe them.

The fix is two milliseconds of patience before the status read, which skips the stretch window entirely and lets the target present a clean NACK that the ordinary busy-poll already handles.

StrategyCycles completedTimeouts absorbed
No settle9 of 12not applicable
2 ms settle before status reads12 of 120
2 ms settle plus retry on timeout12 of 120

The third row is the part we care about. If the settle were merely masking the problem, the retry would still be firing occasionally and absorbing timeouts. It absorbed zero. That is the difference between a fix and a delay that makes the symptom rarer, and it is worth designing the experiment that tells them apart. We got this wrong on the I3C side once already, where padding with delays changed the failure rate without removing the hazard and very nearly convinced us the problem was solved.

Proving the update actually took

Reading flash back and comparing byte for byte proves the bytes match. It does not prove the device is running them. A target left sitting in the bootloader, or one who's reset never released, passes that check happily.

So we ship two small firmware images with the note, 864 and 860 bytes, identical except that one reports IMAGE A and blinks at 1 Hz and the other reports IMAGE B and blinks at 5 Hz. Flash one, watch the serial output, flash the other, watch it change. The LED difference means you can tell them apart with no terminal at all.

They print a heartbeat once a second rather than only a banner at reset, which sounds trivial and is not: a banner printed at reset is gone by the time you open the terminal, and then you are staring at a blank window with no idea which image is running.

One detail from writing them that generalizes well beyond this. The images read HSIDIV at run time and compute the baud divisor from it, instead of assuming a frequency. On this part the reset default is a divider of 2, so the kernel clock is 32 MHz and not the 64 MHz the HSI itself runs at. Hardcode 64 MHz and your baud rate is out by a factor of two, and the only symptom is garbled output, which looks exactly like a wiring problem.

Is it worth it?

We would not use this over SWD on a board that already has a debug header. It is slower and it does more with less feedback. The reason to reach for it is that there is no header, or you cannot get to it once the product is assembled.

Between the two buses, we would pick I2C for breadth and I3C for multi-target. The bootloader address over I2C is fixed in ROM, which means two identical parts cannot sit in the bootloader on one bus at the same time. That is a real constraint for panel programming, and it is exactly the thing I3C's dynamic address assignment solves. If you are programming one board at a time on a bus that already exists, I2C is on almost every STM32 you will meet and I3C is on a handful.

And if you are driving a legacy I2C target from an I3C controller, whatever the vendor: assume it will not wait through clock stretching until you have proven otherwise. That one is not specific to our hardware.

Get the details

AN0002 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 drives a Pulsar or a Supernova over either bus, plus the two verification images with source and a makefile.

Read AN0002 (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.