Build and Flash your Bare-Metal Firmware
Ever wondered about the journey your firmware takes from being a few lines of code on your screen to running on the microcontroller? Along this journey, it encounters a couple of key stages, ensuring the system behaves the way it was designed to.
In this article, we try to explore how GPIO pins of a microcontroller (like the STM32) are configured, blink an LED by accessing the registers, flash the firmware, and understand what happens at each stage of the development.
Some prior knowledge about embedded systems, microcontroller architecture and GPIO is recommended.
We will be using the STM32F446RE Nucleo Board, which features an ARM Cortex-M4 processor and comes with an integrated ST-LINK debugger.
Development Stages
The development cycle can broadly be categorized into the following stages:
- Firmware : Our main program (main.c) and startup code (startup.s).
- Compilation and Assembly : In this stage, the C files (.c) are compiled and the assembly files (.s) are assembled into Relocatable Object Files (.o). These files contain the machine code and data of the respective source files, but their addresses are not fixed yet and can be relocated to their final memory addresses later during the linking stage. Hence, these files are called “Relocatable”.
- Linker : Combines the relocatable object files, resolves symbol references between them, and assigns final memory addresses to the code and data according to the linker script, producing the final executable (.elf).
- Flashing : The final executable (.elf) is flashed into the microcontroller using OpenOCD through a debugger.
Writing the firmware
//main.c
#include<stdint.h>
typedef struct{
volatile uint32_t MODER;
volatile uint16_t OTYPER;
uint16_t reserved0;
volatile uint32_t OSPEEDR;
volatile uint32_t PUPDR;
volatile uint16_t IDR;
uint16_t reserved1;
volatile uint16_t ODR;
} GPIO;
//Setting the address for GPIO Port A registers
#define GPIOA ((GPIO*) 0x40020000)
#define RCC_AHB1ENR (*(volatile uint32_t *) 0x40023830)
#define pin 6
int main(void){
//Enabling GPIOA
RCC_AHB1ENR |= 1;
//////Configuring GPIO Pin (PA6)
//Setting pin to output mode
GPIOA -> MODER &=~(3<<(2*pin));
GPIOA -> MODER |=(1<<(2*pin));
//Push-pull output configuration
GPIOA -> OTYPER &=~(1<<(pin));
//Output speed
GPIOA -> OSPEEDR &=~(3<<(2*pin));
//No pull-up, no pull-down configuration
GPIOA -> PUPDR &=~(3<<(2*pin));
while(1){
GPIOA -> ODR |= (1<<pin); //Turn LED ON
for(int i=0;i<300000;i++);
GPIOA -> ODR &=~ (1<<pin); //Turn LED OFF
for(int i=0;i<300000;i++);
}
}In our main.c file, we first defined the addresses for the RCC AHB1 peripheral clock enable register (RCC_AHB1ENR) and the GPIO Port-A registers. These addresses can be found in the Reference Manual of the respective microcontroller. We enabled the GPIO Port by setting the first bit (that is assigned for GPIO Port-A ) of RCC_AHB1ENR to 1 and configured a pin that we will be using for blinking the LED by accessing the different registers associated with that port.
An LED is connected with the pin on the development board corresponding to the pin that we configured. Inside the while loop, the value on the Output Data Register (ODR) that is assigned for that pin is set and reset after some delays which will blink the LED.
Linker Script
The linker script (linker.ld) indicates how the object files are to be merged. It defines the memory layouts and maps the code sections to memory.
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(SRAM)+LENGTH(SRAM);
SECTIONS{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text :
{
. = ALIGN(4);
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
_sidata = LOADADDR(.data);
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
}>SRAM AT>FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > SRAM
}The major two components are MEMORY and SECTIONS. We also used ENTRY(Reset_Handler) to set the entry point for execution.
In MEMORY, we define the memory regions, mainly FLASH and SRAM, by providing their ORIGIN addresses and also the LENGTH. These can be found from the reference manual.
In SECTIONS, we determine at which location of the memory the different parts of the code are to be placed.
.isr_vector:This section is placed at the beginning of FLASH and contains the vector table. Vector table holds the memory addresses pointing to the interrupt handler functions, the Reset_Handler and the stack pointer..text:This section is also placed in FLASH after the.isr_vectorsection. It holds the code and also the constants that are fixed throughout the program execution cycle..data:Holds the initialized global and static variables. Since, the initial values are required to be preserved, these are stored at FLASH. During startup, the values are loaded to their memory addresses at SRAM. Here>SRAM AT> FLASHindicates that the section will be placed in SRAM (Virtual Memory Address or VMA), while the initial values are to be loaded from FLASH (Load Memory Address or LMA)..bss:The uninitialized global and static variables are placed in this section, which stays within the SRAM region. During startup, the values of these variables are to be set to 0.
We also set some symbols to identify different boundaries which will be useful later on:
_estack:End of Stack, pointing to the memory address whereSRAMends._sidata:Points to the starting memory address within the FLASH region, which holds the initial values for the.datasection. Since this is the Load Memory Address (LMA) we need to use LOADADDR(.data) to obtain this address._sdata:Start address of.datasection._edata:End address of.datasection._sbss:Start address of.bsssection._ebss:End address of.bsssection.
Startup File
.syntax unified //Enables the Unified Assembly Language
.cpu cortex-m4 //Using cortex-m4 processor for this example
.thumb //Using thumb instructions
.global vtable
.global Reset_Handler
.section .isr_vector,"a",%progbits
.type vtable, %object
vtable:
.word _estack
.word Reset_Handler
.word NMI_Handler
.word HardFault_Handler
// Remaining Handlers (refer to the Reference Manual)
.word 0 // use this for Reserved addresses
.section .text.Default_Handler, "ax", %progbits
.type Default_Handler, %function
.thumb_func
Default_Handler:
b Default_Handler
.weak NMI_Handler
.thumb_set NMI_Handler, Default_Handler
.weak HardFault_Handler
.thumb_set HardFault_Handler, Default_Handler
.section .text.Reset_Handler , "ax", %progbits
.type Reset_Handler, %function
.thumb_func
Reset_Handler:
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
copyData:
cmp r0,r1
bhs endOfCopyData
ldr r3, [r2], #4
str r3, [r0], #4
b copyData
endOfCopyData:
ldr r0, =_sbss
ldr r1, =_ebss
mov r2, #0
copyBSS:
cmp r0,r1
bhs endOfCopyBSS
str r2, [r0], #4
b copyBSS
endOfCopyBSS:
bl main
Loop:
b LoopThe startup file serves two main functions:
- Defines the vector table and initializes the stack pointer.
- Implements the Reset_Handler.
The vector table will hold the addresses to the interrupt handlers. For simplicity we have included only four addresses. The first entry is the stack pointer, initially pointing to _estack , which we defined in our linker script, and the second one points to our Reset_Handler. These two addresses are very important as the program accesses them during startup. For the remaining handlers, we define a Default_Handler, which will be executed if the interrupt handlers are not implemented elsewhere. This is done by marking the interrupt handlers as weak.
When the program resets, the stack pointer is initially pointed towards _estack and then enters the Reset_Handler. The main tasks of the Reset_Handler are as follows:
- Initialize the variables in the
.datasection of SRAM by copying their initial values from FLASH. The symbols_sdataand_edatamark the start and end of the section in SRAM, while_sidatapoints to the location in FLASH where these initial values are stored. - Set the uninitialized variables in
.bsssection to 0 (the addresses between_sbssand_ebss). - Jump to
main.
Building
Now that we have all of our necessary files, we can move on to building our firmware. We will be using the ARM GNU Toolchain ( arm-none-eabi-gcc ).
Get Irtisam Sajin’s stories in your inbox
Join Medium for free to get updates from this writer.
Folder Structure:
LED Blinking/
├── main.c
├── startup.s
├── linker.ld
└── MakefileCompilation to object files:
arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 -Wall -O0 -o main.o main.c
arm-none-eabi-gcc -c -mcpu=cortex-m4 -mthumb -std=gnu11 -Wall -O0 -o startup.o startup.s-c: only compile and assemble but not link.-mcpu: specifies target cpu architecture.-mthumb: using thumb instructions.-std: determines the language standard.-Wall: enables all warnings.-O0: performs zero optimization.-o main.o: output file name
Read more about the flags in the compiler command documentation.
Linking our object files:
arm-none-eabi-gcc -nostdlib -T linker.ld -o main.elf main.o startup.o-nostdlib: not using the std library.-T linker.ld: link the files based on the linker scriptlinker.ld.-o main.elf: ouput file name (with .elf extension)
If the build is successful, we will have our final executable main.elf .
Flashing
We will now flash our main.elf to our microcontroller using OpenOCD. Ensure that the board is connected to the host computer via USB.
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program main.elf verify reset exit"This command uses the stlink interface and stm32f4x target (for our particular board) to flash our firmware, verify it and reset our microcontroller. The LED should be blinking if the flash is successful.
Makefile
We can automate our build and flash by creating a Makefile. For this we need to install GNU make. Then place the following code inside Makefile .
CC=arm-none-eabi-gcc
MACH=cortex-m4
TARGET = main
CFLAGS= -c -mcpu=$(MACH) -mthumb -std=gnu11 -Wall -O0
LDFLAGS= -nostdlib -T linker.ld
OPENOCD_INTERFACE = interface/stlink.cfg
OPENOCD_TARGET = target/stm32f4x.cfg
all: $(TARGET).o startup.o $(TARGET).elf
$(TARGET).o: $(TARGET).c
$(CC) $(CFLAGS) -o $@ $^
startup.o: startup.s
$(CC) $(CFLAGS) -o $@ $^
$(TARGET).elf: $(TARGET).o startup.o
$(CC) $(LDFLAGS) -o $@ $^
flash: $(TARGET).elf
openocd -f $(OPENOCD_INTERFACE) -f $(OPENOCD_TARGET) -c "program $(TARGET).elf verify reset exit"
openocd:
openocd -f $(OPENOCD_INTERFACE) -f $(OPENOCD_TARGET)
clean:
rm -rf *.o *.elfWe can then use the following commands in the root directory:
- Build:
make - Build and Flash:
make flash- Clean the generated output files:
make clean- Run OpenOCD server:
make openocdConclusion
We have thus been successful in getting our firmware running on the microcontroller. Understanding what happens during this journey will help us design better systems and troubleshoot issues more quickly when something goes wrong. Also, it’s always fun and exciting to dive deeper into how systems work under the hood.
Resources:





