In the last lesson, we compiled main() for an STM32. That gave us an object file, but it didn’t tell the chip where to begin after reset. A Cortex-M processor needs an initial stack pointer and an address for its reset handler. Before that handler calls main(), it must also prepare the memory that C code expects to use.
We’ll build on the CMake project from lesson 2 by adding a short assembly startup routine and changing the C program to use global variables. This is an educational, compile-only exercise: its interrupt vector table is incomplete and it has no linker script. It does not produce firmware you can flash.
Before main(): the reset path
A Cortex-M processor does not begin at main(). After reset, it reads a vector table: a list of values that tells it where to begin and which functions handle exceptions and interrupts.
- Initial stack pointer: The table’s first entry gives the starting address of the stack in RAM. The stack grows downward as function calls and interrupts save registers and other temporary state.
- Reset handler: The second entry points to the function the processor runs after reset. This function prepares the C environment—including initialized and zero-filled data—before calling
main(). - Interrupt handlers: Later entries point to functions for exceptions and peripheral interrupts. This lesson shows only the first two entries to explain reset; that is not a complete table for firmware you can flash.
Continue the project from lesson 2
Continue in the stm32-cmake-lesson workspace you created in lesson 2. If you’re in its parent directory, enter it now:
cd stm32-cmake-lesson
We’ll replace main.c, update the two CMake files, and add one assembly file. After those changes, the workspace will look like this (the existing build/ directory is from lesson 2):
stm32-cmake-lesson/
├── CMakeLists.txt
├── build/
├── main.c
├── startup_stm32l433.s
└── cmake/
└── gcc-arm-none-eabi.cmake
main.c will show why startup must prepare memory, and the new startup_stm32l433.s will show the reset path. We’ll update the existing CMake files to compile both C and assembly, then reuse the build/ directory from lesson 2.
Give main() something to initialize
In lesson 2, main.c had only an empty loop, so there was no data for startup to prepare. We’ll add two global variables—one with an initial value and one without—to give the reset handler a reason to set up RAM before calling main(). Replace the contents of main.c with:
volatile unsigned int initialized_counter = 7;
volatile unsigned int zero_counter;
int main(void)
{
zero_counter = initialized_counter;
while (1) {
}
}
The two variables show why startup has work to do before main():
initialized_countermust be writable in RAM, but its initial value,7, is stored in Flash as part of the firmware image. Startup copies that value into RAM’s.datasection.zero_counterhas no explicit initial value. Startup clears the.bsssection in RAM to zero.
The volatile qualifiers keep the variables visible in this small example; the loop keeps main() from returning.
Describe what happens after reset
Create startup_stm32l433.s. The .s extension marks it as an assembly source file: the Arm assembler will turn it into an object file alongside the one compiled from main.c. We use assembly here to provide the vector table and show how the reset handler prepares memory before calling main():
.syntax unified
.cpu cortex-m4
.thumb
/* Educational excerpt: a real firmware image needs the remaining vectors. */
.section .isr_vector,"a",%progbits
.align 2
.word _estack
.word Reset_Handler
.section .text.Reset_Handler,"ax",%progbits
.global Reset_Handler
.type Reset_Handler, %function
.thumb_func
Reset_Handler:
/* Copy initialized data from Flash to RAM. */
ldr r0, =_sidata
ldr r1, =_sdata
ldr r2, =_edata
1:
cmp r1, r2
bcs 2f
ldr r3, [r0], #4
str r3, [r1], #4
b 1b
/* Clear uninitialized data in RAM. */
2:
ldr r1, =_sbss
ldr r2, =_ebss
movs r3, #0
3:
cmp r1, r2
bcs 4f
str r3, [r1], #4
b 3b
4:
bl main
5:
b 5b
.size Reset_Handler, .-Reset_Handler
The startup routine follows these steps:
- Start at the vector table: The first
.wordcontains_estack, which the linker will define at the top of RAM in the next lesson. The second containsReset_Handler..thumb_funcmarks the handler as Thumb code. - Copy initialized data:
Reset_Handlercopies.datafrom Flash (_sidata) to its RAM range (_sdatathrough_edata). - Clear uninitialized data: It fills
.bss(_sbssthrough_ebss) with zeroes. Both loops work four bytes at a time. - Enter the application:
bl maincalls the C function. If it returns, the handler stays in a loop rather than running into unrelated memory.
Names such as _sidata and _sdata are linker symbols: placeholders for addresses that a linker script will supply later. In the assembly, labels such as 1b and 2f mean “the nearest 1 backward” and “the nearest 2 forward.”
This short routine omits the other exception and interrupt vectors, runtime library initialization, and hardware setup. It is a way to study startup, not a replacement for complete device startup code or something to flash.
Compile C and assembly with CMake
Update the existing cmake/gcc-arm-none-eabi.cmake to select a compiler and flags for assembly as well as C:
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(MCU_FLAGS "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
set(CMAKE_C_FLAGS_INIT "${MCU_FLAGS}")
set(CMAKE_ASM_FLAGS_INIT "${MCU_FLAGS}")
Compared with lesson 2, this version also sets CMAKE_ASM_COMPILER and applies the same Cortex-M4 flags to assembly. For a different STM32, the processor flags must match that device. STATIC_LIBRARY still lets CMake check the compilers without linking a test executable before we have a linker script; it does not turn our own target into a static library.
Update CMakeLists.txt to enable assembly and add the startup file to the existing object target:
cmake_minimum_required(VERSION 3.22)
project(stm32-cmake-lesson LANGUAGES C ASM)
add_library(firmware OBJECT main.c startup_stm32l433.s)
set_target_properties(firmware PROPERTIES
C_STANDARD 11
C_STANDARD_REQUIRED ON
)
target_compile_options(firmware PRIVATE $<$<COMPILE_LANGUAGE:C>:-Wall;-Wextra>)
Enabling both C and ASM lets CMake compile the two source files. As in lesson 2, the OBJECT target stops before linking. That means the linker symbols in the assembly can remain unresolved for now. The warning flags apply only to C, not to assembly.
From stm32-cmake-lesson, reconfigure the existing build directory and compile:
cmake -S . -B build
cmake --build build
CMake keeps the Arm toolchain and Ninja generator selected in lesson 2, so you don’t need to specify them again. It enables assembly as it reconfigures the project. The build produces two object files, one for each source. To see what the startup object defines and what it still needs from the linker, run:
arm-none-eabi-nm build/CMakeFiles/firmware.dir/startup_stm32l433.s.obj
For this example, the output looks like this (addresses may vary by toolchain):
00000000 T Reset_Handler
U _ebss
U _edata
U _estack
U _sbss
U _sdata
U _sidata
U main
T means Reset_Handler is defined in a code section. U means a symbol is still undefined in this object file. The linker will provide addresses for _estack, _sidata, and the other memory symbols, and resolve main from the C object file. For now, the assembly has only been compiled; there is still no firmware image to flash.
What’s next
We now know how reset reaches main() and why startup needs addresses for Flash and RAM. In the next lesson, we’ll write a linker script to place the program in memory and give those symbols their values.