
A vendor IDE will get a blinking LED running in five minutes and then spend the next five weeks hiding what happens between the build button and the first instruction your chip executes. Building the same firmware by hand is not nostalgia. It gives you a directory you can read from top to bottom: a toolchain, a linker script, a startup file, and a command that writes bytes into flash. Once you own those four pieces, moving between an STM32, an nRF52 and a Cortex-M0+ sensor board stops being a relearning exercise.
Pick the toolchain first
Install arm-none-eabi-gcc and its friends: binutils, gdb, and newlib. On Debian-derived systems this is usually a package called something like gcc-arm-none-eabi plus binutils-arm-none-eabi; the exact names drift between distributions and releases, so check before you commit to a Dockerfile. The ARM-published GNU toolchain tarballs are another reliable route. LLVM works too if you prefer clang --target=arm-none-eabi -mcpu=cortex-m4, but GCC has the least friction with vendor startup oddities.
You also need device headers. You do not need a pack manager. Pull the CMSIS Core headers plus your vendor's single device header into an include/ folder, or write your own register definitions — a GPIO block is often a dozen lines of structs and bit masks. Verify the install with arm-none-eabi-gcc --version and arm-none-eabi-objdump --version before you touch the board.
The linker script is the real map
The linker script tells the toolchain where flash and RAM live and how sections are arranged. Start with a MEMORY block:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }
Then a SECTIONS block. The vector table must land at the very start of flash, so keep it with KEEP(*(.isr_vector)) — without the KEEP, a linker with garbage collection enabled will happily delete it and your board will fault on reset. Code and read-only data go to FLASH. The .data section is the interesting one: it has a load address in flash and a run address in RAM, expressed with an AT(...) clause. .bss gets RAM space and no content at all.
Export symbols the startup code can use — _sidata, _sdata, _edata, _sbss, _ebss — and set _estack to the top of RAM. If you want the linker to catch stack overflows at build time, reserve a dummy section at the end of RAM and check the map file. Writing a script from scratch is fine; editing a known-good one from your vendor's SDK is faster and teaches you the same thing.
Startup code: what must happen before main
A short assembly file handles reset. The C compiler is not ready for you yet, so the sequence is strict:
- Emit the vector table: initial stack pointer, reset handler, then the exception and IRQ entries. Declare it with __attribute__((section(".isr_vector"))) and give every handler a weak alias to a default infinite loop, so an accidental interrupt does not jump into nothing.
- In the reset handler, copy .data from flash to RAM using the linker symbols, then zero .bss.
- Enable the FPU by writing CPACR if you compile with hard float, and set SCB->VTOR when a bootloader has relocated your image.
- Call the vendor's SystemInit if you use one for clock setup.
- Call __libc_init_array, otherwise C++ constructors and static initialisers silently never run.
- Jump to main, and loop forever after it returns.
Compile with -nostartfiles rather than -nostdlib unless you truly want no libc. If you want printf, add -specs=nano.specs -specs=nosys.specs and provide a _write or _write_r stub that pushes characters out of a UART.
A Makefile you can hold in your head
Twenty to forty lines are enough. Collect sources with a wildcard, compile each to an object file, link the objects, then convert the ELF into a binary. Your compile flags should include -mcpu, -mthumb, -mfloat-abi and -mfpu matching the chip, plus -ffunction-sections -fdata-sections and matching -Wl,--gc-sections to strip dead code, -Og -g3 for development, and -Wall -Wextra because there is no IDE to argue with you. Always pass -Wl,-Map=build/fw.map; the map file is your crash investigation tool. Produce the flash image with arm-none-eabi-objcopy -O binary -S fw.elf fw.bin, and an Intel HEX with -O ihex if your programmer prefers it.
Flashing and debugging from the terminal
Pick a probe and a command. OpenOCD is the most portable: point it at an interface config and a target config, then let it program, verify and reset in one shot — openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program build/fw.elf verify reset exit". Config filenames differ between OpenOCD versions and probe generations, so list the directories when something is not found. Alternatives worth knowing: pyocd flash -t stm32f407vg build/fw.elf, st-flash write build/fw.bin 0x08000000, dfu-util for chips that expose a USB bootloader, and JLinkExe with a small script file for SEGGER probes.
Debugging is just gdb over a socket. Start OpenOCD or JLinkGDBServer, then run arm-none-eabi-gdb build/fw.elf and issue target extended-remote :3333, monitor reset halt, load, break main, continue. Wrap both commands in make flash and make debug targets and you will never miss a debug button.
When the board sits there doing nothing
- Immediate hard fault: the vector table is not at the start of flash, or the stack pointer symbol points outside RAM.
- Globals read as garbage while locals work: the .data copy loop is missing, or the startup symbols do not match the linker script.
- Interrupts never fire: the NVIC enable bit is missing, the handler name does not match the vector entry, or you forgot -mthumb.
- "Region RAM overflowed": .bss plus your stack does not fit; check the map file before blaming the linker.
- Sorted fine, nothing runs: the write went to the wrong address, or the reset never happened after programming.
Keep the whole setup on one screen
Commit the linker script and startup file to the repository next to a plain Makefile, and treat the map file as part of the build output rather than an artefact from another era. Add a UART or semihosting printf early; it is the cheapest debugger you will ever have. From there, the natural next steps are a make size target that prints section sizes, a second linker script for a bootloader layout, or dropping in a small RTOS now that you know exactly what runs before main. The IDE can stay installed for reading other people's code.
Photo: Pixabay / Pexels

