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:
- 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:
#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:
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 clockTIM2->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
No comments yet.