In the last lesson, we followed the path from source code to running firmware. Now we’ll work on the first stage: compilation. Before adding startup code or a memory layout, we need to know that our build uses a compiler and settings for the microcontroller rather than the default settings for our computer.
We’ll use an STM32L433 with a Cortex-M4 processor as the example. The same approach applies to other STM32 devices, but their processor and floating-point settings may differ. This exercise produces an object file: the compiler’s output for one source file. It contains machine code and information the linker needs to combine it with other compiled files, but it has not been placed into the chip’s memory layout or joined with startup code. It is one ingredient of firmware, not a program you can flash and run.
Prepare a workspace
Let’s put that first build step into practice. We’ll make a small, standalone CMake project that compiles one C file with the Arm toolchain. Start by creating a workspace for this exercise:
mkdir -p stm32-cmake-lesson/cmake
cd stm32-cmake-lesson
After you create the three files in the next steps, your workspace will look like this:
stm32-cmake-lesson/
├── CMakeLists.txt
├── main.c
└── cmake/
└── gcc-arm-none-eabi.cmake
main.c contains the code we’ll compile, cmake/gcc-arm-none-eabi.cmake selects the Arm toolchain, and CMakeLists.txt defines the build. Use any text editor to create them. CMake will generate the build/ directory when we configure the project later.
Start with a C source file
Create main.c:
int main(void)
{
while (1) {
}
}
The loop gives the compiler a small program to translate, but it won’t run on a board yet. We haven’t supplied the startup code that reaches main(), a memory layout, or a way to load a finished program onto the chip. For now, we’re isolating the question: can we compile C for the intended processor?
Tell CMake which compiler to use
Create cmake/gcc-arm-none-eabi.cmake:
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_C_FLAGS_INIT "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
This toolchain file tells CMake what kind of system and compiler to use before it configures the project. Generic means the target isn’t a conventional operating system, and arm-none-eabi-gcc selects the cross-compiler instead of a host compiler. The flags specify a Cortex-M4, Thumb instructions, the processor’s floating-point hardware, and a compatible floating-point calling convention. For another STM32, we’d check its processor and adjust these flags. Any code we link together later must use compatible settings.
When CMake checks whether the compiler works, it normally tries to link a test program. We can’t link firmware without the startup and linker setup we haven’t written yet. CMAKE_TRY_COMPILE_TARGET_TYPE makes those checks build a static library instead; it does not decide what our application target will produce.
Compile, but don’t link an executable
Create CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
project(stm32-cmake-lesson LANGUAGES C)
add_library(firmware OBJECT main.c)
set_target_properties(firmware PROPERTIES
C_STANDARD 11
C_STANDARD_REQUIRED ON
)
target_compile_options(firmware PRIVATE -Wall -Wextra)
project(... LANGUAGES C) enables the C compiler chosen by the toolchain file. The OBJECT library tells CMake to compile main.c without linking an executable or creating a library archive. This is separate from the static-library setting used for CMake’s own compiler checks. We get a real cross-compiled object file while leaving startup and memory placement for later lessons.
From stm32-cmake-lesson, configure a fresh build directory and compile:
cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/gcc-arm-none-eabi.cmake
cmake --build build
The first command configures the project: -S selects the source directory, -B creates a build directory, -G Ninja selects Ninja, and -DCMAKE_TOOLCHAIN_FILE loads the Arm compiler settings. The second command runs the build. Pass the toolchain file when creating a new build directory; CMake caches its compiler choice, so changing it in an existing directory is unreliable.
To check what the compiler produced, inspect the object file:
arm-none-eabi-objdump -f build/CMakeFiles/firmware.dir/main.c.obj
You should see output like this (the flags may vary with the toolchain):
build/CMakeFiles/firmware.dir/main.c.obj: file format elf32-littlearm
architecture: armv7e-m, flags 0x00000010:
HAS_SYMS
start address 0x00000000
architecture: armv7e-m shows that we compiled for the Cortex-M4 rather than your computer. elf32-littlearm names the file format: object files can use ELF too. This is still an object file, not a linked, runnable firmware image. Don’t try to flash it; compilation is only one step toward firmware.
What’s next
We now have a CMake project that selects the Arm toolchain and compiles source code for an STM32. To make that code run after reset, the chip will need a path to main() and, eventually, a place in memory for the finished program. In the next lesson, we’ll look at what happens before main().