A minimal, no-HAL, bare-metal game for STM32F401CCU6 that draws to a 128×64 SSD1306 OLED over I²C (bit-banged) and uses a single push button on PA0 to “flap” a 3×3 dot through moving pipes.
- Display: SSD1306 128×64, I²C address 0x3C (common)
- I²C pins (bit-banged): PA9 = SCL, PA10 = SDA
- Button: PA0 with internal pull-up (button shorts to GND when pressed)
- Clock: HSI 16 MHz (default)
- Timing: SysTick 1 ms tick for frame pacing (~30 fps target)
- STM32F401CCU6 board (a typical “Black Pill” style board)
- SSD1306 OLED (128×64) that supports I²C
| Signal | STM32F401 pin | OLED pin | Notes |
|---|---|---|---|
| SCL | PA9 | SCL | I²C clock (open-drain, needs pull-up) |
| SDA | PA10 | SDA | I²C data (open-drain, needs pull-up) |
| 3V3 | 3V3 | VCC | 3.3 V supply |
| GND | GND | GND | Common ground |
| Button | PA0 ↔ GND | — | Internal pull-up enabled in code |
I²C is a 2-wire, open-drain bus:
- SCL (clock) and SDA (data) are both open-drain outputs:
- Devices can pull the line low, but never drive it high.
- Pull-up resistors bring the lines high when nobody pulls low.
- Bus levels:
- Low (0): actively pulled down by a device
- High (1): released; the pull-up resistor lifts the line
Transfers:
- START: SDA falls while SCL is high
- Address + R/W (7-bit addr + 1 R/W bit)
- ACK/NACK: receiver pulls SDA low to ACK the byte
- Data bytes (each byte followed by ACK from receiver)
- STOP: SDA rises while SCL is high
In this project:
- We bit-bang SCL/SDA using PA9/PA10 in open-drain output mode.
i2c_start(): makes a START conditioni2c_write_byte(): shifts out 8 bits MSB-first and reads the ACK biti2c_stop(): makes a STOP condition- SSD1306 requires a control byte before commands/data:
0x00= commands0x40= data stream (display RAM)
SSD1306 addressing & pages:
- The 128×64 buffer is organized as 8 pages (each page = 8 vertical pixels × 128 columns).
- We keep a 1024-byte framebuffer in RAM and push it page-by-page.
- This is simple but means each frame writes 1024 bytes.
SysTick is a 24-bit core timer inside the ARM Cortex-M. You load a reload value; it counts down and interrupts periodically:
- We set reload to F_CPU/1000 - 1 so SysTick fires every 1 ms.
- Interrupt handler increments
ms_ticks. delay_ms(n)spins untilms_ticksadvances byn.- The main loop runs at a target period (≈33 ms → ~30 FPS), paced by
ms_ticks.
Why use it?
- Provides a stable timebase independent of code execution speed.
- Keeps physics & rendering smooth and consistent.
- RCC_AHB1ENR: we set bit 0 to enable the GPIOA clock.
- Without clocks, peripherals don’t work (their registers won’t respond).
- MODER: 2 bits per pin
00input,01output,10alternate function,11analog
- OTYPER: output type
0push-pull,1open-drain
- OSPEEDR: output slew rate (we set high so edges are sharp)
- PUPDR: internal pull-ups/pull-downs
- Button: PA0 uses pull-up (01) so it reads
1unpressed and0when pressed to GND
- Button: PA0 uses pull-up (01) so it reads
- IDR: input data register (we read PA0 here)
- BSRR: bit set/reset register
- Lower 16 bits “set high”, upper 16 bits “set low”
- With open-drain pins: “set” means release the line; “reset” means drive low
- SYST_RVR (reload value):
F_CPU/1000 - 1for 1 ms - SYST_CVR (current value): any write clears it
- SYST_CSR (control/status):
CLKSOURCE=1(CPU clock),TICKINT=1(IRQ),ENABLE=1(start)
MIT License