The two "dead" instructions that made I2C work
MetalBird
In i2c_init there were two lines that re-read GPIOB->AFRH into R1, and R1 was overwritten three instructions later. Removing them made the firmware hang at the START-bit self-test.
I diffed the objdump output of both builds. Every instruction was identical; only the code after that point moved 6 bytes earlier. So the read itself didn't matter. Timing did.
STM32F4 flash is fetched in 16-byte lines with 2 wait states. In the working build, the instruction after the RCC_APB1ENR store started a fresh flash line and stalled for a few cycles. In the broken build it didn't.
Those few cycles were hiding the real bug: after enabling a peripheral clock, the store can still be in flight when the next instruction touches the peripheral. Writes to an unclocked APB peripheral are silently dropped, so I2C_CR2, CCR and TRISE never took effect. ST lists this in the errata ("Delay after an RCC peripheral clock enabling").
The correct fix is to read RCC_APB1ENR back (plus a DSB) before touching I2C2. That's correct by construction, not by where the code happens to land in flash.
Lesson: A fix you can't explain causally is a mask, not a fix.
Proving the IMU was dead, not my driver
MetalBird
Sensor readings were wrong, and with a hand-written I2C driver there were plenty of suspects. To take my own driver out of the picture, the diagnostic firmware bit-bangs I2C on the same pins.
It runs the factory self-test from the register map (±14 % pass limit, computed on the host), then streams the FIFO at exactly 1 kHz with a sequence counter, so a lost sample is a detected gap, not a guess.
Result, reproduced on 4 runs: accel Y stuck at 32767 (full scale) with zero noise and zero self-test response, accel X reading ≈ +1.96 g while flat, and gyro Z with a −43 °/s zero-rate offset (+271 % self-test). WHO_AM_I = 0x68, so it's a genuine part that's damaged.
Lesson: Build the tool that makes the bug impossible to argue with.
PenguOS vs. a read-only BIOS shadow
PenguOS
Stage 2 read the kernel-size sector into 0xFFE00, just below 1 MB. That's the BIOS ROM shadow region; on SeaBIOS it's read-only, so the size came back as BIOS code and the loader kept reading sectors until the ATA controller errored.
Moving the buffer to 0x90000 (free conventional memory) fixed it. The web build also pads the image to a whole number of sectors and picks the 1280×720×32 VBE mode the emulated card offers.
Lesson: Every emulator is a new board. Bring-up never really ends.