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.
The project
Section titled “The project”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 hardwarestm32f407_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:
- The core reads the initial stack pointer from address
0x00000000and the address ofReset_Handlerfrom0x00000004. On the STM32F407, booting from flash, address 0 is an alias of flash at0x08000000. So our vector table must be the very first thing in flash. (startup.candlink.ld.) Reset_Handlerprepares memory so C works: initialized globals get their values, zero-initialized globals get zero. (startup.c.)mainenables 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: 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.
link.ld: where everything goes
Section titled “link.ld: where everything goes”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: 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-sectionstreats it as a root that must be kept.MEMORYdescribes the chip: 1 MB of flash at0x08000000and 128 KB of contiguous SRAM at0x20000000._estackis the address just past the end of RAM. The Cortex-M stack grows downward and the first push decrementsspbefore writing, so the initialsppoints one past the top.0x20020000is also 8-byte aligned, as the calling convention requires..isr_vectorcomes first in theFLASHregion, so the vector table lands at0x08000000.KEEPstops--gc-sectionsfrom discarding it. Nothing references the table from code, so withoutKEEPthe linker would conclude it is unused..textcollects code (.text*matches.text.main,.text.Reset_Handlerand so on, the per-function sections from-ffunction-sections) and read-only constants..datahas two addresses.> RAMgives the address the code uses at run time (the VMA).AT > FLASHsays the initial contents are stored in flash, right after.text(the LMA).LOADADDR(.data)is that flash address, which becomes_sidata. The labels_sdataand_edatamark the run-time range, which is exactly whatReset_Handlercopies to..bssis RAM only. It occupies no space in flash because its contents are all zero, whichReset_Handlertakes 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, version 1: a busy loop
Section titled “main.c, version 1: a busy loop”/* 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); }}- 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. - PD12’s mode field is bits 25:24 of
GPIOD_MODER. Clearing them and writing01makes it a general-purpose output. Every pin defaults to push-pull output type and low speed, which is fine for an LED. - XOR on
ODRflips bit 12. Onlymaintouches 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.
The Makefile
Section titled “The Makefile”# Makefile: bare-metal blinky for the STM32F407TARGET := firmware
CC := arm-none-eabi-gccOBJCOPY := arm-none-eabi-objcopySIZE := arm-none-eabi-size
CPU := -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16CFLAGS := $(CPU) -std=c11 -O2 -g -Wall -Wextra \ -ffunction-sections -fdata-sectionsLDFLAGS := $(CPU) -T link.ld -nostartfiles \ --specs=nano.specs --specs=nosys.specs \ -Wl,--gc-sections -Wl,-Map=$(TARGET).map
SRCS := startup.c main.cOBJS := $(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 cleanRecipe lines must start with a tab character, not spaces. The flags that matter:
| Flag | Why |
|---|---|
-mcpu=cortex-m4 -mthumb | Generate Thumb-2 code for the M4 |
-mfloat-abi=hard -mfpu=fpv4-sp-d16 | Use the M4F’s single-precision FPU and pass floats in FPU registers |
-ffunction-sections -fdata-sections | One section per function and per variable, so the linker can drop unused ones |
-T link.ld | Use our linker script instead of the toolchain default |
-nostartfiles | Do not link the toolchain’s own startup code (crt0); Reset_Handler replaces it |
--specs=nano.specs | Link newlib-nano, the size-optimized C library |
--specs=nosys.specs | Provide stub system calls (_sbrk, _write, …) so library functions link |
-Wl,--gc-sections | Discard sections nothing references |
-Wl,-Map=firmware.map | Write 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.
Build and read size
Section titled “Build and read size”makearm-none-eabi-gcc -mcpu=cortex-m4 -mthumb ... -c startup.c -o startup.oarm-none-eabi-gcc -mcpu=cortex-m4 -mthumb ... -c main.c -o main.oarm-none-eabi-gcc -mcpu=cortex-m4 -mthumb ... startup.o main.o -o firmware.elfarm-none-eabi-size firmware.elf text data bss dec hex filename 356 0 0 356 164 firmware.elfAbout 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.
Flash it to the board
Section titled “Flash it to the board”Connect the Discovery board’s ST-LINK USB port and run:
make flash** Programming Started **** Programming Finished **** Verify Started **** Verified OK **** Resetting Target **shutdown command invokedprogram 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.
Run it in Renode
Section titled “Run it in Renode”Without hardware, Renode simulates the Discovery board. Create blinky.resc:
using sysbusmach create "discovery"machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.replsysbus LoadELF @firmware.elfsysbus LogPeripheralAccess gpioPortDstartRun 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.
Debug it with gdb
Section titled “Debug it with gdb”Start OpenOCD in one terminal and leave it running; it becomes a gdb server on port 3333:
openocd -f board/stm32f4discovery.cfgIn a second terminal:
$ arm-none-eabi-gdb firmware.elf(gdb) target extended-remote :3333(gdb) monitor reset halt(gdb) load(gdb) break main(gdb) continueBreakpoint 1, main () at main.c:1515 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 = 0x1000000monitor 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.
main.c, version 2: SysTick
Section titled “main.c, version 2: SysTick”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: 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_Handleris defined here. It has exactly the name used in the vector table, so it overrides the weak alias instartup.c. No registration call, no attribute: the linker resolves the name. The prototype above it is just good hygiene.LOADis N minus 1. The counter counts fromLOADdown to 0 inclusive, so a period of 16,000 cycles needsLOAD = 15999. At the reset clock of 16 MHz, that is exactly 1 ms.g_ticksisvolatile. The handler changes it behindmain’s back. Withoutvolatile,maincould read it once and loop forever on a stale value.- The comparison is wrap-safe.
g_ticks - lastin unsigned arithmetic gives the elapsed time even wheng_tickswraps past0xFFFFFFFF(after 49.7 days). Clocks, reset, and timing builds a propermillis()anddelay_ms()on this idea. - The LED changes through
BSRR. Single writes, no read-modify-write ofODR, 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.
What if I remove this?
Section titled “What if I remove this?”The fastest way to understand each line is to break it on purpose. Try each change on the board, observe, then put it back.
| Change | What you see | Why |
|---|---|---|
Delete the RCC->AHB1ENR line | LED never lights; GPIOD->MODER reads 0 in gdb | Port D has no clock, so it ignores every write |
Remove volatile from the delay counter | LED looks permanently on, slightly dim | The empty loop is deleted, PD12 toggles millions of times per second |
Remove volatile from g_ticks | LED never toggles | main reads g_ticks once and never sees it change |
Misspell the handler as Systick_Handler | Program stops at the first tick, stuck in Default_Handler | The vector still points at the weak alias; the misspelled function is never called |
Remove KEEP around .isr_vector | Nothing 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 0x08004000 | Nothing runs | The table is no longer at 0x08000000; the core reads erased flash (0xFFFFFFFF) as SP and PC |
Set _estack beyond the end of RAM | HardFault or lockup on the first function call | The first push writes to an address with no memory behind it |
Delete the .data copy loop | Initialized globals hold random values | RAM is never loaded with the values stored in flash |
Use a float in main | HardFault, stuck in Default_Handler | The 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.
Common mistakes
Section titled “Common mistakes”- 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
RCCenable 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 showsTinstead ofW. - Linking without
-T link.ldand getting a default script that places code at address 0 or in the wrong memory. Fix: always pass the linker script and checknmforvector_tableat08000000. - Expecting
delay(N)to mean N microseconds or any fixed time. Fix: use SysTick or a hardware timer for anything time-related. - Flashing
firmware.binto the wrong address. A.binhas no addresses. Fix: flash the ELF, or always specify0x08000000for the.bin.
Exercises
Section titled “Exercises”- Build version 1 and run
arm-none-eabi-objdump -h firmware.elf. Confirm that.isr_vectorhas VMA08000000and size00000040(16 words), and that.textstarts right after it. - 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
MODERwrite (bits 31:24) and drive them only throughBSRR, 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. - Disassemble
delaywitharm-none-eabi-objdump -d -S firmware.elfand 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 fordelay(500000u)at 16 MHz. Measure the blink period with a stopwatch over 10 blinks and compare. - In version 2, set a breakpoint on
SysTick_Handlerin gdb. When it hits, runp/x $lrandx/8xw $sp. You should see0xfffffff9inlrand eight stacked words, the last two being a return address insidemainand anxPSRvalue with bit 24 set. Write down what you think each value is; The Cortex-M architecture explains them all. - 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.
Check yourself
Section titled “Check yourself”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?
A peripheral without its clock ignores writes and reads as zero. If the vector table were wrong, main would never run at all.
Question 2:Why does link.ld wrap the vector table in KEEP(*(.isr_vector))?
The hardware reads the table, but the linker only sees references from code. KEEP marks the section as a root for garbage collection.
Question 3:main.c defines void SysTick_Handler(void). How does it end up in the vector table?
Weak symbols are replaced by strong definitions with the same name. The vector table just holds the address of whatever SysTick_Handler resolves to.
Question 4:What does > RAM AT > FLASH on the .data section mean?
The section has a run-time address (VMA) in RAM and a load address (LMA) in flash. LOADADDR(.data) gives the flash copy, which is _sidata.
Question 5:With the CPU at 16 MHz, which SysTick->LOAD value gives exactly one exception per millisecond?
The counter runs from LOAD down to 0 inclusive, so the period is LOAD + 1 cycles. 16,000 cycles at 16 MHz is 1 ms.
Key takeaways
Section titled “Key takeaways”- A bare-metal Cortex-M program needs only four things of your own: a vector table, a reset handler that prepares
.dataand.bss, a linker script, andmain. - 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_Handlerlet any file override a handler just by defining a function with the right name. .datahas a load address in flash and a run-time address in RAM; startup code copies one to the other and zeroes.bssbeforemain.- 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.