Skip to content

Part 2 · C Meets the Metal25 min read

Blinky from Nothing

Every embedded engineer’s first program blinks an LED. Most do it by clicking “new project” in a vendor IDE, which generates thousands of lines of startup code, linker scripts, and HAL calls that nobody reads. In this lesson you write all of it yourself: five small files, about 150 lines in total, and not one line from ST. When the green LED on the Discovery board blinks, you will know exactly what every byte in flash is for. The startup file and linker script here are deliberately minimal; Startup code and the vector table and Linker scripts take each one apart in depth.

Create a directory with these files:

blinky/
Makefile build rules: compile, link, convert, flash
link.ld linker script: where code and data go in memory
startup.c vector table and Reset_Handler
main.c the application
stm32f407_regs.h register definitions (from the previous lesson)
blinky.resc Renode script, for running without hardware

stm32f407_regs.h is exactly the file you wrote in Memory-mapped registers in C. Copy it in unchanged. This project uses RCC, GPIOD, SysTick, and their bit macros from it.

What has to happen between power-on and a blinking LED:

  1. The core reads the initial stack pointer from address 0x00000000 and the address of Reset_Handler from 0x00000004. On the STM32F407, booting from flash, address 0 is an alias of flash at 0x08000000. So our vector table must be the very first thing in flash. (startup.c and link.ld.)
  2. Reset_Handler prepares memory so C works: initialized globals get their values, zero-initialized globals get zero. (startup.c.)
  3. main enables the clock to GPIO port D, makes PD12 an output, and toggles it forever. (main.c.)

startup.c: the vector table and reset handler

Section titled “startup.c: the vector table and reset handler”
startup.c
/* startup.c: minimal vector table and reset handler for the STM32F407. */
#include <stdint.h>
/* Symbols created by link.ld. They have no storage; only their addresses
* mean something. */
extern uint32_t _estack[];
extern uint32_t _sidata[], _sdata[], _edata[], _sbss[], _ebss[];
int main(void);
void Reset_Handler(void);
/* Any exception without a handler of its own ends up here. */
void Default_Handler(void)
{
for (;;) {
}
}
/* Weak aliases: defining a function with the same name anywhere else in
* the program replaces the alias. */
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void MemManage_Handler(void) __attribute__((weak, alias("Default_Handler")));
void BusFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void UsageFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SVC_Handler(void) __attribute__((weak, alias("Default_Handler")));
void DebugMon_Handler(void) __attribute__((weak, alias("Default_Handler")));
void PendSV_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));
typedef void (*isr_t)(void);
typedef struct {
uint32_t *initial_sp; /* 0x00: loaded into SP at reset */
isr_t exceptions[15]; /* 0x04-0x3C: exception numbers 1 to 15 */
} vector_table_t;
__attribute__((section(".isr_vector"), used))
const vector_table_t vector_table = {
.initial_sp = _estack,
.exceptions = {
Reset_Handler, /* 1 0x04 */
NMI_Handler, /* 2 0x08 */
HardFault_Handler, /* 3 0x0C */
MemManage_Handler, /* 4 0x10 */
BusFault_Handler, /* 5 0x14 */
UsageFault_Handler, /* 6 0x18 */
0, 0, 0, 0, /* 7-10 reserved */
SVC_Handler, /* 11 0x2C */
DebugMon_Handler, /* 12 0x30 */
0, /* 13 reserved */
PendSV_Handler, /* 14 0x38 */
SysTick_Handler, /* 15 0x3C */
},
};
void Reset_Handler(void)
{
/* Copy initial values of .data from flash to RAM. */
const uint32_t *src = _sidata;
for (uint32_t *dst = _sdata; dst < _edata;) {
*dst++ = *src++;
}
/* Zero .bss. */
for (uint32_t *dst = _sbss; dst < _ebss;) {
*dst++ = 0u;
}
(void)main();
for (;;) { /* main should never return; if it does, stay here */
}
}

Walking through it:

The linker symbols. _estack, _sidata, and the others are not variables. The linker script assigns them addresses: _sdata is where .data starts in RAM, _sidata is where its initial values sit in flash, and so on. Declaring them as arrays of unknown size, extern uint32_t _sdata[];, lets C use the name as an address without implying there is a single uint32_t object there.

Default_Handler and weak aliases. The vector table needs an address for every exception. __attribute__((weak, alias("Default_Handler"))) declares NMI_Handler and the rest as extra names for Default_Handler, marked weak. If main.c defines a real SysTick_Handler, the linker uses that one and ignores the weak alias. If nothing defines it, the vector points at Default_Handler, an infinite loop. An unexpected exception halts the program somewhere obvious instead of jumping to a random address.

The vector table. It is a const struct: first a pointer (the initial stack pointer), then fifteen function pointers for exception numbers 1 to 15. Using a struct instead of an array of function pointers keeps the types honest; the first word really is a data address, not a function. Reserved slots are 0. The table ends after SysTick_Handler because this project uses no device interrupts; the full STM32F407 table with all 82 interrupt vectors is in Startup code and the vector table.

The attributes. section(".isr_vector") puts the table in a section of its own, so the linker script can place it first in flash. used tells the compiler to emit the object even though no C code references it.

Reset_Handler. Two loops, then main. The first copies .data: a global like uint32_t mode = 3; lives in RAM at run time, but RAM is undefined at power-on, so the value 3 is stored in flash and copied over before any code reads mode. The second zeroes .bss, so static uint32_t count; really starts at 0 as the C standard promises. Then main is called. Firmware main never returns, but if it did, there is no operating system to return to, so we trap.

Notice what is not here: no stack setup. On Cortex-M the hardware loads the stack pointer from the vector table before executing the first instruction, so Reset_Handler can be ordinary C from its first line.

The compiler produces object files whose code and data have no addresses yet. The linker script tells the linker what memory exists and which sections go where.

link.ld
/* link.ld: minimal linker script for the STM32F407VG */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 0x20020000: top of RAM */
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
.text :
{
. = ALIGN(4);
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > FLASH
_sidata = LOADADDR(.data);
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT > FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
  • ENTRY(Reset_Handler) records the entry point in the ELF header. The hardware ignores it (it uses the vector table), but debuggers use it and --gc-sections treats it as a root that must be kept.
  • MEMORY describes the chip: 1 MB of flash at 0x08000000 and 128 KB of contiguous SRAM at 0x20000000.
  • _estack is the address just past the end of RAM. The Cortex-M stack grows downward and the first push decrements sp before writing, so the initial sp points one past the top. 0x20020000 is also 8-byte aligned, as the calling convention requires.
  • .isr_vector comes first in the FLASH region, so the vector table lands at 0x08000000. KEEP stops --gc-sections from discarding it. Nothing references the table from code, so without KEEP the linker would conclude it is unused.
  • .text collects code (.text* matches .text.main, .text.Reset_Handler and so on, the per-function sections from -ffunction-sections) and read-only constants.
  • .data has two addresses. > RAM gives the address the code uses at run time (the VMA). AT > FLASH says the initial contents are stored in flash, right after .text (the LMA). LOADADDR(.data) is that flash address, which becomes _sidata. The labels _sdata and _edata mark the run-time range, which is exactly what Reset_Handler copies to.
  • .bss is RAM only. It occupies no space in flash because its contents are all zero, which Reset_Handler takes care of.

The stack is not a section here. It is the space from the end of .bss up to _estack, about 128 KB minus your globals. Linker scripts adds a reserved stack size and a link-time check that it fits.

main.c
/* main.c: blink the green LED (PD12) with a busy-wait delay. */
#include <stdint.h>
#include "stm32f407_regs.h"
#define LED_GREEN 12u
static void delay(uint32_t count)
{
for (volatile uint32_t i = 0; i < count; i++) {
}
}
int main(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN; /* 1. clock GPIO port D */
(void)RCC->AHB1ENR; /* read back, let it settle */
GPIOD->MODER = (GPIOD->MODER & ~GPIO_MODER_Msk(LED_GREEN))
| (GPIO_MODE_OUTPUT << GPIO_MODER_Pos(LED_GREEN)); /* 2. */
for (;;) {
GPIOD->ODR ^= 1u << LED_GREEN; /* 3. toggle PD12 */
delay(500000u);
}
}
  1. After reset, every GPIO port’s clock is off and a port with no clock ignores writes. Bit 3 of RCC_AHB1ENR (0x40023830) gates port D. The read-back makes sure the enable has taken effect before the next access to GPIOD; Clocks, reset, and timing explains why that matters.
  2. PD12’s mode field is bits 25:24 of GPIOD_MODER. Clearing them and writing 01 makes it a general-purpose output. Every pin defaults to push-pull output type and low speed, which is fine for an LED.
  3. XOR on ODR flips bit 12. Only main touches GPIOD, so the read-modify-write is safe here.

The volatile on the loop counter is what makes delay take time. Without it, the loop has no observable effect and the compiler deletes it. Each iteration is a stack load, add, store, load, compare, and branch, roughly ten cycles at 16 MHz with zero flash wait states, so 500,000 iterations take a few hundred milliseconds. The exact time depends on the compiler version, the optimization level, and the clock, which is the first of many reasons busy loops are not how production firmware keeps time.

Makefile
# Makefile: bare-metal blinky for the STM32F407
TARGET := firmware
CC := arm-none-eabi-gcc
OBJCOPY := arm-none-eabi-objcopy
SIZE := arm-none-eabi-size
CPU := -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16
CFLAGS := $(CPU) -std=c11 -O2 -g -Wall -Wextra \
-ffunction-sections -fdata-sections
LDFLAGS := $(CPU) -T link.ld -nostartfiles \
--specs=nano.specs --specs=nosys.specs \
-Wl,--gc-sections -Wl,-Map=$(TARGET).map
SRCS := startup.c main.c
OBJS := $(SRCS:.c=.o)
all: $(TARGET).elf $(TARGET).bin $(TARGET).hex
$(TARGET).elf: $(OBJS) link.ld
$(CC) $(LDFLAGS) $(OBJS) -o $@
$(SIZE) $@
%.o: %.c stm32f407_regs.h
$(CC) $(CFLAGS) -c $< -o $@
%.bin: %.elf
$(OBJCOPY) -O binary $< $@
%.hex: %.elf
$(OBJCOPY) -O ihex $< $@
flash: $(TARGET).elf
openocd -f board/stm32f4discovery.cfg -c "program $< verify reset exit"
clean:
rm -f $(OBJS) $(TARGET).elf $(TARGET).bin $(TARGET).hex $(TARGET).map
.PHONY: all flash clean

Recipe lines must start with a tab character, not spaces. The flags that matter:

FlagWhy
-mcpu=cortex-m4 -mthumbGenerate Thumb-2 code for the M4
-mfloat-abi=hard -mfpu=fpv4-sp-d16Use the M4F’s single-precision FPU and pass floats in FPU registers
-ffunction-sections -fdata-sectionsOne section per function and per variable, so the linker can drop unused ones
-T link.ldUse our linker script instead of the toolchain default
-nostartfilesDo not link the toolchain’s own startup code (crt0); Reset_Handler replaces it
--specs=nano.specsLink newlib-nano, the size-optimized C library
--specs=nosys.specsProvide stub system calls (_sbrk, _write, …) so library functions link
-Wl,--gc-sectionsDiscard sections nothing references
-Wl,-Map=firmware.mapWrite a map file showing where everything went

Why not -nostdlib? That drops the C library and libgcc, the compiler’s helper library. The compiler is allowed to emit calls to memcpy, memset, and helpers such as __aeabi_uldivmod (64-bit division) even when your source never mentions them, and then you would have to supply them yourself. Linking newlib-nano with stub syscalls costs nothing for functions you do not use, because the linker only pulls in what is referenced. The C standard library on a microcontroller covers the library side in detail.

objcopy turns the ELF into raw formats some flashing tools want. firmware.bin is a flat image of flash starting at 0x08000000, with no addresses inside it; firmware.hex is Intel HEX, which carries addresses. OpenOCD and gdb flash the ELF directly, which is the most reliable option because the ELF knows where every section goes.

Terminal window
make
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb ... -c startup.c -o startup.o
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb ... -c main.c -o main.o
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb ... startup.o main.o -o firmware.elf
arm-none-eabi-size firmware.elf
text data bss dec hex filename
356 0 0 356 164 firmware.elf

About 350 bytes of flash, no RAM except the stack. text is the 64-byte vector table plus code; data and bss are zero because version 1 has no global variables. Your numbers will differ by a few dozen bytes between GCC versions. Run arm-none-eabi-nm -n firmware.elf and you will see vector_table at 08000000, followed by the functions.

Connect the Discovery board’s ST-LINK USB port and run:

Terminal window
make flash
** Programming Started **
** Programming Finished **
** Verify Started **
** Verified OK **
** Resetting Target **
shutdown command invoked

program firmware.elf verify reset exit writes every loadable section of the ELF to flash, reads it back to verify, resets the chip so it starts from the new vector table, and exits OpenOCD. The green LED next to the user button should now blink.

Without hardware, Renode simulates the Discovery board. Create blinky.resc:

blinky.resc
using sysbus
mach create "discovery"
machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl
sysbus LoadELF @firmware.elf
sysbus LogPeripheralAccess gpioPortD
start

Run renode blinky.resc from the project directory. The script creates a machine from Renode’s STM32F4 Discovery description, loads the ELF (which also sets the initial sp and pc from the vector table, as real hardware would), turns on access logging for GPIO port D, and starts the CPU. The monitor window then prints a line for every read and write to GPIOD, and you should see the ODR writes alternate between 0x1000 (PD12 high) and 0x0. Type peripherals in the monitor to see what the board model contains; current Renode versions model the Discovery LEDs as well, and you can query an LED’s state by name.

Start OpenOCD in one terminal and leave it running; it becomes a gdb server on port 3333:

Terminal window
openocd -f board/stm32f4discovery.cfg

In a second terminal:

$ arm-none-eabi-gdb firmware.elf
(gdb) target extended-remote :3333
(gdb) monitor reset halt
(gdb) load
(gdb) break main
(gdb) continue
Breakpoint 1, main () at main.c:15
15 RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN; /* 1. clock GPIO port D */
(gdb) p/x *(uint32_t *)0x40023830
$1 = 0x100000
(gdb) next
(gdb) p/x *(uint32_t *)0x40023830
$2 = 0x100008
(gdb) next
(gdb) next
(gdb) p/x ((GPIO_TypeDef *)0x40020C00)->MODER
$3 = 0x1000000

monitor reset halt passes a command to OpenOCD: reset the chip and stop before the first instruction. load writes the ELF to flash. After the first next, RCC_AHB1ENR gains bit 3 (bit 20, already set at reset, enables the CCM RAM clock). After the MODER line executes, bits 25:24 read 01. gdb does not know the C macros GPIOD or RCC (unless you compile with -g3), but it does know the struct types from the debug information, so casting the base address works. For Renode, add machine StartGdbServer 3333 to the script before start and use target remote :3333 in gdb.

The busy loop wastes the CPU and its timing is a guess. SysTick is a 24-bit down-counter built into every Cortex-M core. It reloads from LOAD when it reaches zero and, if enabled, raises the SysTick exception. Configure it to wrap every millisecond and count milliseconds in the handler:

main.c
/* main.c: blink the green LED (PD12) at 1 Hz using SysTick. */
#include <stdint.h>
#include "stm32f407_regs.h"
#define LED_GREEN 12u
#define CPU_HZ 16000000u /* HSI, the clock after reset */
#define TICK_HZ 1000u /* 1 ms tick */
void SysTick_Handler(void);
static volatile uint32_t g_ticks; /* milliseconds since start */
void SysTick_Handler(void)
{
g_ticks++;
}
int main(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN;
(void)RCC->AHB1ENR;
GPIOD->MODER = (GPIOD->MODER & ~GPIO_MODER_Msk(LED_GREEN))
| (GPIO_MODE_OUTPUT << GPIO_MODER_Pos(LED_GREEN));
SysTick->LOAD = CPU_HZ / TICK_HZ - 1u; /* 15999: 16000 counts = 1 ms */
SysTick->VAL = 0u; /* clear the current count */
SysTick->CTRL = SYSTICK_CTRL_CLKSOURCE /* count the CPU clock */
| SYSTICK_CTRL_TICKINT /* exception on wrap */
| SYSTICK_CTRL_ENABLE;
uint32_t last = g_ticks;
for (;;) {
if ((uint32_t)(g_ticks - last) >= 500u) {
last += 500u;
if (GPIOD->ODR & (1u << LED_GREEN)) {
GPIOD->BSRR = 1u << (LED_GREEN + 16u); /* off */
} else {
GPIOD->BSRR = 1u << LED_GREEN; /* on */
}
}
}
}

What changed:

  • SysTick_Handler is defined here. It has exactly the name used in the vector table, so it overrides the weak alias in startup.c. No registration call, no attribute: the linker resolves the name. The prototype above it is just good hygiene.
  • LOAD is N minus 1. The counter counts from LOAD down to 0 inclusive, so a period of 16,000 cycles needs LOAD = 15999. At the reset clock of 16 MHz, that is exactly 1 ms.
  • g_ticks is volatile. The handler changes it behind main’s back. Without volatile, main could read it once and loop forever on a stale value.
  • The comparison is wrap-safe. g_ticks - last in unsigned arithmetic gives the elapsed time even when g_ticks wraps past 0xFFFFFFFF (after 49.7 days). Clocks, reset, and timing builds a proper millis() and delay_ms() on this idea.
  • The LED changes through BSRR. Single writes, no read-modify-write of ODR, the habit from the previous lesson.

Build again and size shows bss of 4 bytes: g_ticks. Now open firmware.map and search for memset and memcpy. Depending on your GCC version you may find both, pulled in from libc_nano.a, even though your code never calls them: the compiler recognised the two loops in Reset_Handler as a copy and a fill and replaced them with library calls. That is legal and harmless here, and Startup code and the vector table explains when it is not.

The fastest way to understand each line is to break it on purpose. Try each change on the board, observe, then put it back.

ChangeWhat you seeWhy
Delete the RCC->AHB1ENR lineLED never lights; GPIOD->MODER reads 0 in gdbPort D has no clock, so it ignores every write
Remove volatile from the delay counterLED looks permanently on, slightly dimThe empty loop is deleted, PD12 toggles millions of times per second
Remove volatile from g_ticksLED never togglesmain reads g_ticks once and never sees it change
Misspell the handler as Systick_HandlerProgram stops at the first tick, stuck in Default_HandlerThe vector still points at the weak alias; the misspelled function is never called
Remove KEEP around .isr_vectorNothing runs; the chip faults immediately or locks up--gc-sections discards the unreferenced table; flash starts with code, which the core reads as SP and PC
Change FLASH ORIGIN to 0x08004000Nothing runsThe table is no longer at 0x08000000; the core reads erased flash (0xFFFFFFFF) as SP and PC
Set _estack beyond the end of RAMHardFault or lockup on the first function callThe first push writes to an address with no memory behind it
Delete the .data copy loopInitialized globals hold random valuesRAM is never loaded with the values stored in flash
Use a float in mainHardFault, stuck in Default_HandlerThe FPU is still disabled

When the core cannot even run its fault handler (for example, because the stack pointer is invalid while stacking a HardFault), it enters lockup: it stops executing, and the debugger shows a strange PC such as 0xEFFFFFFE. A reset or a fixed image is the only way out.

  • Spaces instead of a tab at the start of Makefile recipe lines, giving missing separator. Fix: configure your editor to keep tabs in Makefiles.
  • Forgetting the peripheral clock enable. Fix: the first line of every peripheral init is its RCC enable bit.
  • A typo in an exception handler name, silently leaving the weak default in place. Fix: check with arm-none-eabi-nm firmware.elf | grep Handler; a correctly overridden handler shows T instead of W.
  • Linking without -T link.ld and getting a default script that places code at address 0 or in the wrong memory. Fix: always pass the linker script and check nm for vector_table at 08000000.
  • Expecting delay(N) to mean N microseconds or any fixed time. Fix: use SysTick or a hardware timer for anything time-related.
  • Flashing firmware.bin to the wrong address. A .bin has no addresses. Fix: flash the ELF, or always specify 0x08000000 for the .bin.
  1. Build version 1 and run arm-none-eabi-objdump -h firmware.elf. Confirm that .isr_vector has VMA 08000000 and size 00000040 (16 words), and that .text starts right after it.
  2. Using the SysTick version, light the four LEDs (PD12 green, PD13 orange, PD14 red, PD15 blue) one at a time for 250 ms each, in a loop. Configure all four pins with a single masked MODER write (bits 31:24) and drive them only through BSRR, turning the previous LED off and the next one on with one write. Expected: a light that rotates around the four LEDs once per second.
  3. Disassemble delay with arm-none-eabi-objdump -d -S firmware.elf and count the instructions in its loop. Look up each one’s cycle count in the Cortex-M4 Technical Reference Manual (loads typically take 2 cycles, taken branches 2 to 4) and estimate the real delay for delay(500000u) at 16 MHz. Measure the blink period with a stopwatch over 10 blinks and compare.
  4. In version 2, set a breakpoint on SysTick_Handler in gdb. When it hits, run p/x $lr and x/8xw $sp. You should see 0xfffffff9 in lr and eight stacked words, the last two being a return address inside main and an xPSR value with bit 24 set. Write down what you think each value is; The Cortex-M architecture explains them all.
  5. Read user button PA0 (active high) and make it select between a 1 Hz and a 5 Hz blink. You need to enable the GPIOA clock and leave PA0 as an input (its reset state). Change only the PA0 mode bits, never PA13 or PA14. Expected: holding the button blinks the LED fast.

Question 1:The project builds and flashes, but the LED never lights and gdb shows GPIOD->MODER reading 0 right after it was written. What is the most likely cause?

Question 2:Why does link.ld wrap the vector table in KEEP(*(.isr_vector))?

Question 3:main.c defines void SysTick_Handler(void). How does it end up in the vector table?

Question 4:What does > RAM AT > FLASH on the .data section mean?

Question 5:With the CPU at 16 MHz, which SysTick->LOAD value gives exactly one exception per millisecond?

  • A bare-metal Cortex-M program needs only four things of your own: a vector table, a reset handler that prepares .data and .bss, a linker script, and main.
  • The vector table must be the first thing in flash: word 0 is the initial stack pointer, word 1 is the address of Reset_Handler.
  • Weak aliases to a Default_Handler let any file override a handler just by defining a function with the right name.
  • .data has a load address in flash and a run-time address in RAM; startup code copies one to the other and zeroes .bss before main.
  • Every peripheral needs its RCC clock enabled before it responds to anything.
  • Flash the ELF with OpenOCD’s program ... verify reset exit, debug through OpenOCD’s gdb server on port 3333, and use Renode as a quick check without hardware.
  • Break things on purpose: knowing what each line prevents is how you debug a board that does nothing.

Next: Startup code and the vector table, which takes startup.c apart and rebuilds it for real projects.