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.

C Assembly STM32F446RE no-HAL linker
github.com/Prateek2174/F446RE_bare_metal

The 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

Inc/
  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.

Src/gpio.c C


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