Series / STM32 Development with CMake and VSCode / Adding Startup Code to a Bare-Metal STM32 Project

Adding Startup Code to a Bare-Metal STM32 Project

Add the vector table, reset handler, data initialization, BSS clearing, and main entry path needed by a bare-metal STM32 firmware image.

On this page

The previous article added a linker script for the STM32 Nucleo L433RC-P. That script described flash, RAM, .text, .data, .bss, the vector table location, and the symbols startup code needs.

This article adds that startup code.

Startup code is the bridge between reset and main(). It gives the CPU an initial vector table, defines Reset_Handler, prepares initialized and zero-initialized data, and then calls the C application entry point.

After this article, the project can link a real ELF firmware image. It still will not be flashed to the board yet. Flashing and command-line firmware loading come next.

What Startup Code Has To Do

When an Arm Cortex-M microcontroller starts from flash, it does not search for main().

Instead, it reads the vector table at the beginning of the firmware image. The first word is the initial stack pointer. The second word is the reset handler address. The CPU loads the stack pointer, jumps to the reset handler, and the reset handler is responsible for preparing the C runtime.

For this minimal project, startup code must:

  • Define the vector table in .isr_vector.
  • Put _estack as the initial stack pointer.
  • Put Reset_Handler as the reset entry point.
  • Copy .data initial values from flash to RAM.
  • Clear .bss to zero.
  • Call main().
  • Provide default handlers for unexpected exceptions or interrupts.

The linker script defines the memory regions and symbols. Startup code uses those symbols.

Add a Startup Source File

Add a startup source file beside main.c:

src/
├── main.c
└── startup_stm32l433xx.c

Create src/startup_stm32l433xx.c:

#include <stdint.h>

extern uint32_t _estack;
extern uint32_t _sidata;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

void Reset_Handler(void);
void Default_Handler(void);
int main(void);

void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));

typedef struct
{
    uint32_t *initial_stack_pointer;
    void (*reset_handler)(void);
    void (*nmi_handler)(void);
    void (*hard_fault_handler)(void);
} vector_table_t;

__attribute__((section(".isr_vector")))
const vector_table_t vector_table = {
    &_estack,
    Reset_Handler,
    NMI_Handler,
    HardFault_Handler,
};

void Reset_Handler(void)
{
    uint32_t *source = &_sidata;
    uint32_t *destination = &_sdata;

    while (destination < &_edata)
    {
        *destination++ = *source++;
    }

    destination = &_sbss;

    while (destination < &_ebss)
    {
        *destination++ = 0;
    }

    main();

    while (1)
    {
    }
}

void Default_Handler(void)
{
    while (1)
    {
    }
}

This is a small startup file, not a complete STM32 interrupt table. It is enough to show the reset path and build a minimal firmware image. Later projects can expand the vector table when interrupts are needed.

Declare Linker Symbols

The startup file begins with symbols from the linker script:

extern uint32_t _estack;
extern uint32_t _sidata;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;

These are not normal variables allocated by C code. They are addresses exported by the linker.

The declarations let C code refer to those addresses. For example, &_sdata means "the address where the .data section starts in RAM." The startup code uses address ranges, not variable values.

This is one reason linker scripts and startup code should be learned together. The linker script describes memory placement. Startup code turns that placement into runtime initialization.

Build the Vector Table

The vector table is placed into the .isr_vector section:

typedef struct
{
    uint32_t *initial_stack_pointer;
    void (*reset_handler)(void);
    void (*nmi_handler)(void);
    void (*hard_fault_handler)(void);
} vector_table_t;

__attribute__((section(".isr_vector")))
const vector_table_t vector_table = {
    &_estack,
    Reset_Handler,
    NMI_Handler,
    HardFault_Handler,
};

The linker script places .isr_vector at the beginning of flash and protects it with KEEP(*(.isr_vector)).

The first field is the initial stack pointer. The second field is the reset handler. The next fields are exception handlers. A full STM32 vector table includes many more exception and interrupt entries, but this minimal table is enough to explain the mechanism.

The typed structure keeps the initial stack pointer as a data address and the handlers as function pointers. That avoids -Wpedantic warnings from mixing object pointers and function pointers in a plain const void * array.

The handlers after reset are weak aliases:

void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));

weak means application code can provide a stronger definition later. alias("Default_Handler") means that if no stronger handler exists, the symbol points to Default_Handler.

Write Reset_Handler

Reset_Handler is the first C function that runs after reset:

void Reset_Handler(void)
{
    uint32_t *source = &_sidata;
    uint32_t *destination = &_sdata;

    while (destination < &_edata)
    {
        *destination++ = *source++;
    }

    destination = &_sbss;

    while (destination < &_ebss)
    {
        *destination++ = 0;
    }

    main();

    while (1)
    {
    }
}

The first loop copies initialized data from flash to RAM.

The linker script said:

_sidata = LOADADDR(.data);
.data : { ... } > RAM AT > FLASH

That means .data runs from RAM, but its initial bytes are stored in flash. The startup loop copies those bytes before main() uses any initialized global variables.

The second loop clears .bss.

Any zero-initialized global or static variable must be zero before main() starts. The firmware image does not store a large block of zeros. Startup code clears the .bss RAM range instead.

Finally, Reset_Handler calls main(). If main() ever returns, the handler loops forever. Returning from bare-metal firmware has nowhere meaningful to go.

Update main.c

Keep main.c simple for now:

#include <stdint.h>

volatile uint32_t placeholder_counter;

int main(void)
{
    while (1)
    {
        placeholder_counter++;
    }
}

This still does not touch a peripheral. It gives the linker and startup code a real main() symbol and gives the compiler a small loop to preserve.

The first useful hardware behavior will come after the project can build, flash, and debug reliably.

The previous project used an object library so it could compile without linking. Now the project can become an executable target.

Replace the object-library target in CMakeLists.txt with:

add_executable(firmware
    src/startup_stm32l433xx.c
    src/main.c
)

target_compile_options(firmware PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfpu=fpv4-sp-d16
    -mfloat-abi=hard
    -ffunction-sections
    -fdata-sections
    -Wall
    -Wextra
    -Wpedantic
)

target_compile_definitions(firmware PRIVATE
    STM32L433xx
)

target_link_options(firmware PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfpu=fpv4-sp-d16
    -mfloat-abi=hard
    -T${CMAKE_SOURCE_DIR}/linker/STM32L433RCTx_FLASH.ld
    -Wl,--gc-sections
    -Wl,-Map=${CMAKE_BINARY_DIR}/firmware.map
)

add_custom_command(TARGET firmware POST_BUILD
    COMMAND ${CMAKE_SIZE} $<TARGET_FILE:firmware>
)

The compile options and link options both include the CPU and floating-point settings. The compiler and linker must agree about the target ABI.

The -T option selects the linker script from the previous article. The map file helps inspect what the linker placed into flash and RAM. The post-build command prints the firmware size after linking.

Build the ELF

Configure again from a clean build directory:

rm -rf build
cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-gcc.cmake

Then build:

cmake --build build

This time the project should produce an ELF file for the firmware target.

Depending on the generator and platform, the file will usually be under the build directory and named something like:

build/firmware

Even without a .elf extension, this is an ELF file. You can choose to name the target firmware.elf later, but the file format comes from the linker output, not the filename.

Inspect Firmware Size

The post-build command should print a size summary similar to:

   text    data     bss     dec     hex filename
    200       0       4     204      cc firmware

The exact numbers will vary with compiler version and code changes.

The important fields are:

  • text: code and read-only data, usually stored in flash.
  • data: initialized data that runs from RAM and has initial contents in flash.
  • bss: zero-initialized data that occupies RAM but does not need stored bytes in flash.

This connects directly to the linker script sections from the previous article.

What Still Will Not Happen Yet

The project can now link a firmware image, but this article still does not flash the board.

It also does not prove that the firmware runs correctly on the STM32. To prove that, the next step is to convert or load the ELF through a flashing tool, connect through ST-LINK, and observe the target.

That separation is useful. If the project does not link, the problem is in the build, linker script, or startup code. If it links but will not flash, the problem is in the flashing path. If it flashes but will not run, the problem is in runtime behavior or target configuration.

Keeping those steps separate makes failures easier to reason about.

Common Mistakes

If the linker reports undefined reference to Reset_Handler, make sure startup_stm32l433xx.c is part of the add_executable source list.

If the linker reports missing _sdata, _edata, _sbss, or related symbols, check that the linker script has those symbols and that CMake passes the linker script with -T.

If the vector table disappears from the output, check that the startup file uses __attribute__((section(".isr_vector"))) and that the linker script contains KEEP(*(.isr_vector)).

If the project builds with the host compiler instead of arm-none-eabi-gcc, delete the build directory and configure again with the toolchain file.

If you see warnings about unused handlers, that can be normal in a small startup file. The vector table references the handlers by address, and the linker script keeps the vector table.

Next Steps

The next article builds and flashes STM32 firmware without STM32CubeIDE.

Now that the project can produce a linked firmware image, the next task is to get that image onto the Nucleo board through the command-line workflow. After that, VSCode debugging can connect to the same build output instead of relying on IDE-owned project settings.