Back to Home

Bare-metal firmware STM32F103 without CubeMX

Guide to creating bare-metal firmware for STM32F103C8T6 without CubeMX and HAL. Describes linker script, startup code with Reset_Handler, GPIO PC13 and TIM2 initialization for 1 second delay. Blinking LED demonstrates full cycle from reset to main().

STM32 without CubeMX: from Reset_Handler to LED
Advertisement 728x90

Minimal Bare-Metal STM32F103 Firmware: From Linker Script to Blinking LED

After reset, the Cortex-M3 loads the stack pointer from 0x08000000, jumps to Reset_Handler from the vector table, and runs the startup code. In a bare-metal project without HAL, this process is fully transparent: manual memory setup, GPIO and TIM2 register configuration. This project showcases working with the STM32F103C8T6—blinking an LED on PC13 with a 1-second delay using a hardware timer.

The project structure is layered: inc/ for interfaces, src/ for implementations, startup/ for initialization, myLinker.ld for memory mapping.

Linker Script: Placing Sections in FLASH and RAM

The linker script defines the STM32F103C8T6 memory regions and allocates sections:

Google AdInline article slot
  • FLASH (0x08000000, 64 KB): .isr_vector, .text, .rodata
  • RAM (0x20000000, 20 KB): .data, .bss, stack

Key symbols for startup:

_estack = ORIGIN(RAM) + LENGTH(RAM);

MEMORY {
    FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 0x00010000
    RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 0x00005000
}

SECTIONS {
    .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
    .text : { *(.text*) } > FLASH
    .rodata : { *(.rodata*) } > FLASH
    .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT > FLASH
    _sidata = LOADADDR(.data);
    .bss : {
        _sbss = .;
        *(.bss*)
        _ebss = .;
    } > RAM
}

.data is copied from FLASH to RAM using addresses _sidata → _sdata.._edata. .bss is zeroed from _sbss to _ebss. _estack sets the initial stack pointer.

Startup Code: Reset_Handler and Vector Table

Startup runs before main(): copies .data, clears .bss, calls main(). Vector table in .isr_vector:

Google AdInline article slot
#include <stdint.h>

extern uint32_t _estack;
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;

int main(void);

void Reset_Handler(void);
void Default_Handler(void) {
    while (1);
}

void NMI_Handler(void)       __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));

__attribute__((used, section(".isr_vector")))
const void* vector_table[] = {
    &_estack,
    Reset_Handler,
    NMI_Handler,
    HardFault_Handler
};

void Reset_Handler(void) {
    uint32_t* src = &_sidata;
    uint32_t* dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }
    main();
    while (1) {}
}

weak alias routes unimplemented ISRs to Default_Handler.

Register Access: volatile and Direct Addressing

Bare-metal means writing to registers by address:

*(volatile uint32_t*)0x40021018 |= (1 << 4);  // RCC_APB2ENR, GPIOC

volatile prevents optimizations: the compiler generates every access. For TIM2, use a struct:

Google AdInline article slot
typedef struct {
    volatile uint32_t CR1, CR2, SMCR, DIER, SR, EGR;
    volatile uint32_t CCMR1, CCMR2, CCER, CNT;
    volatile uint32_t PSC, ARR, RCR;
    volatile uint32_t CCR1, CCR2, CCR3, CCR4;
    volatile uint32_t BDTR, DCR, DMAR;
} TIM_TypeDef;

#define TIM2 ((TIM_TypeDef*)0x40000000)

Access: TIM2->PSC = 7999;.

Peripheral Initialization: GPIO and TIM2

GPIO

Enable GPIOC clock: RCC->APB2ENR |= (1 << 4);. Configure PC13 as output:

  • GPIOC->CRH &= ~(0xF << 20);
  • GPIOC->CRH |= (0x2 << 20); (2.0 MODE, 00 CNF)

LED: GPIOC->ODR |= (1 << 13); to turn on, &= ~(1 << 13); to turn off.

TIM2

Set up for 1 Hz (72 MHz / 8000 / 9000 = 1 Hz):

  • RCC->APB1ENR |= (1 << 0); — enable TIM2 clock
  • TIM2->PSC = 7999; (prescaler)
  • TIM2->ARR = 8999; (auto-reload)
  • TIM2->CR1 |= (1 << 0); — start timer

delayOneSecond(): wait for TIM2->CNT >= 9000, reset CNT.

Main() Sequence

  • GPIO_init()
  • TIMERS_init()
  • Infinite loop: turnOnLED() → delayOneSecond() → turnOffLED() → delayOneSecond()

Key Takeaways

  • Linker symbols (_sdata, _edata, etc.) are essential for proper memory initialization.
  • volatile is critical for registers: without it, the compiler caches values.
  • Vector table in .isr_vector must be first in FLASH.
  • Register structs simplify access over raw pointers.
  • Bare-metal reveals the real flow: startup → main → peripherals.

— Editorial Team

Advertisement 728x90

Read Next