case 09 · Personal · 2025
Bare-metal F446RE
No HAL, no CMSIS abstraction layer — register-level drivers for the STM32F446RE written from the reference manual, with my own linker scripts and startup file.
↗ github.com/Prateek2174/F446RE_bare_metalThe premise
Cube/HAL hides almost everything. The pinout configurator emits 200 lines of init code to toggle an LED. This project skips all of that — direct memory writes to the peripheral registers, a hand-written linker script, a startup file in assembly, and small driver modules that match the reference manual chapter-for-chapter.
What I wrote
base.h // peripheral base addresses + bit defs
gpio.h
usart.h
adc.h
Src/
gpio.c // MODER, OTYPER, PUPDR, ODR
usart.c // BRR math + framing
adc.c // channel select + conversion
main.c
Startup/
startup_stm32f446retx.s // vector table, reset handler
STM32F446RETX_FLASH.ld // memory layout
STM32F446RETX_RAM.ld // optional RAM-only image
Why it's worth doing
The HAL is fine until something doesn't work — and then it's a black box. Working at the register level once means that when a peripheral misbehaves later in a HAL-based project, you actually know what's wrong instead of guessing at which config field to flip.
The linker script and startup file are the parts most embedded courses skip.
Writing them once — what goes in flash, what gets copied to RAM, how the reset
handler sets up .data and .bss before main()
runs — demystifies a lot of the "magic" in any C program.
void gpio_init(void){
//Enable clock access to GPIO ports A, B and C
//Enable clock to GPIOA
RCC_AHB1ENR |= GPIOAEN;
//Enable clock to GPIOB
RCC_AHB1ENR |= GPIOBEN;
//Enable clock to GPIOC
RCC_AHB1ENR |= GPIOCEN;
//Set pin modes
//Red led, GPIOA8. output
GPIOA_MODER |= (1U << 16);
GPIOA_MODER &= ~(1U << 17);
//GPIOB5, blue led output
GPIOB_MODER |= (1U << 10);
GPIOB_MODER &= ~(1U << 11);
//input button, GPIOC13
GPIOC_MODER &= ~(3U << 26);
//Set GPIOA5 on board led to output
GPIOA_MODER |= (1U << 10);
GPIOA_MODER &= ~(1U << 11);
//Set PA9 and PA10 to alternative mode
GPIOA_MODER |= (1U << 19);
GPIOA_MODER &= ~(1U << 18);
GPIOA_MODER |= (1U << 21);
GPIOA_MODER &= ~(1U << 20);
//Set alternative functions for USART AF7 for PA9 and PA10
GPIOA_AFRH &= ~(0xF << 4); // clear PA9 AF
GPIOA_AFRH |= (7U << 4); // set AF7 for PA9
GPIOA_AFRH &= ~(0xF << 8); // clear PA10 AF
GPIOA_AFRH |= (7U << 8); // set AF7 for PA10
//Set GPIOA1 to analog for ADC
GPIOA_MODER |= (3U << 2);
}
- MCU
- STM32F446RE
- Peripherals
- GPIO, USART, ADC
- HAL
- None — direct register access
- Linker
- Custom (FLASH + RAM variants)
What's next
- SPI & I²C masters — the two most common sensor buses; writing them from the reference manual is the natural next step after USART
- DMA — offload peripheral transfers so the CPU isn't blocked during USART or ADC reads; requires understanding the DMA stream/channel arbitration
- Advanced timers — encoder interface mode for motor position, complementary PWM outputs with dead-time insertion for half-bridge drives
- FreeRTOS integration — layer a minimal RTOS on top of the bare-metal drivers to understand how task scheduling interacts with interrupt-driven peripherals
- Port to STM32H7 — apply the same register-level approach to a Cortex-M7 part; the core is faster, the peripheral register map shifts, and cache coherency becomes a real concern with DMA