CMake Configuration — Building an STM32 Build System from Scratch
I'm staring at the CMakeLists.txt on my screen, and my coffee has gone cold. If you've followed the previous two articles, you should now have a cross-compilation toolchain and the STM32 firmware library downloaded. But the real challenge is just beginning: how do we get everything to compile and link into a .bin file that we can flash onto the chip? The first time I did this, I spent half an afternoon just trying to convince CMake that "this is a bare-metal ARM project, don't try to run test programs." Today, we're going to break down this build system from start to finish.
First, Look at the Complete CMakeLists.txt
No nonsense, let's put the complete configuration out first, and then we'll dissect it section by section. This file lives in the project root directory, right next to build.sh:
Expand (104 lines)Collapse
cmake_minimum_required(VERSION 3.20)
project(STM32BareMetal C CXX ASM)
# 1. Cross-compilation settings
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR ARM)
# Specify toolchain prefix
set(TOOLCHAIN_PREFIX arm-none-eabi-)
set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}as)
set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy)
set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
# 2. Project Paths
set(PROJECT_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
set(LIB_ROOT ${PROJECT_ROOT}/lib)
# STM32 CMSIS and HAL paths
set(CMSIS_DIR ${LIB_ROOT}/CMSIS)
set(HAL_DIR ${LIB_ROOT}/HAL)
# 3. Source Files
set(STARTUP_FILE ${CMSIS_DIR}/Device/ST/STM32F1xx/Source/Templates/gcc/startup_stm32f103xb.s)
set(SYSTEM_SRC ${CMSIS_DIR}/Device/ST/STM32F1xx/Source/Templates/system_stm32f1xx.c)
file(GLOB HAL_SOURCES
${HAL_DIR}/Src/*.c
)
# Filter out template files
list(FILTER HAL_SOURCES EXCLUDE REGEX "\\.*_template\\.c$")
file(GLOB_RECURSE APP_SOURCES
${PROJECT_ROOT}/src/*.cpp
${PROJECT_ROOT}/src/*.c
)
add_executable(${PROJECT_NAME}
${STARTUP_FILE}
${SYSTEM_SRC}
${HAL_SOURCES}
${APP_SOURCES}
)
# 4. Include Directories
target_include_directories(${PROJECT_NAME} PRIVATE
${CMSIS_DIR}/Include
${CMSIS_DIR}/Device/ST/STM32F1xx/Include
${HAL_DIR}/Inc
${PROJECT_ROOT}/inc
)
# 5. Compiler Options
target_compile_options(${PROJECT_NAME} PRIVATE
-mcpu=cortex-m3
-mthumb
-O2
-Wall
-ffunction-sections
-fdata-sections
)
# C++ specific options
target_compile_options(${PROJECT_NAME} PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:
-fno-exceptions
-fno-rtti
-std=c++17
>
)
# 6. Linker Options
target_link_options(${PROJECT_NAME} PRIVATE
-T${PROJECT_ROOT}/STM32F103C8Tx_FLASH.ld
-nostartfiles
-specs=nano.specs
-specs=nosys.specs
-Wl,--gc-sections
)
# 7. Post-build commands
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary ${PROJECT_NAME} ${PROJECT_NAME}.bin
COMMAND ${CMAKE_SIZE} ${PROJECT_NAME}
)
# 8. Flash targets (optional)
add_custom_target(flash
DEPENDS ${PROJECT_NAME}
COMMAND st-flash write ${PROJECT_NAME}.bin 0x8000000
)
add_custom_target(flash-openocd
DEPENDS ${PROJECT_NAME}
COMMAND openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c "program ${PROJECT_NAME}.bin verify reset exit 0x8000000"
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
Alright, I know this file looks a bit intimidating. When I first wrote this, I "translated" it line by line from a Makefile generated by STM32CubeIDE. But if we break it down, you'll find that every part has its purpose.
Basic Cross-Compilation Settings
The first few lines are the "standard way" to handle CMake cross-compilation:
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR ARM)2
Setting CMAKE_SYSTEM_NAME to Generic tells CMake: "This is not a Linux/Windows/macOS program, this is a bare-metal environment." If you set it to Linux, CMake will try to find Linux headers, and you'll be greeted with a screen full of red squiggly lines.
CMAKE_SYSTEM_PROCESSOR is mainly for scripts that detect the CPU architecture. It's optional in our case, but it doesn't hurt to set it.
Next, we specify the toolchain. Note the TOOLCHAIN_PREFIX here. Once you add arm-none-eabi-, CMake automatically derives the full toolchain paths. If you can see arm-none-eabi-gcc by running the which or where command, this will work.
The most critical line is this one:
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)This setting saved my life. By default, when configuring a project, CMake compiles a small program and tries to run it to verify that the toolchain works. The problem is: we are compiling an ARM program, which cannot run on an x86_64 development machine! Without this line, CMake throws an error that the try_compile run failed. By setting it to STATIC_LIBRARY, CMake only compiles the test program but doesn't try to link or run it, solving the problem.
The last line, CMAKE_EXPORT_COMPILE_COMMANDS, while not mandatory, is highly recommended. It generates a compile_commands.json file that clangd and VSCode's C++ extensions read to get the correct compiler options. Without it, your IDE won't be able to find STM32 header files, and every call like HAL_GPIO_WritePin will be flagged as an "undefined symbol."
Source File Collection — That Damn Template Issue
Next, let's gather all the necessary source files. First, the startup file:
set(STARTUP_FILE ${CMSIS_DIR}/Device/ST/STM32F1xx/Source/Templates/gcc/startup_stm32f103xb.s)Pay attention to the filename here: startup_stm32f103xb.s. If you are using a Blue Pill, the chip model is STM32F103C8T6, which corresponds to the xb suffix (indicating medium-density devices, 64KB~128KB Flash). I made a typo the first time and wrote xl, and CMake couldn't find the file, throwing a very obscure error. Remember: C8T6 uses xb.
Besides the startup file, we also need system_stm32f1xx.c. This file contains the SystemInit function, which is called in the startup file to set up the system clock and Flash configuration. If you miss this file, the linker will report an undefined reference to SystemInit, and you'll spend an hour figuring out where this function actually is.
Then there are the HAL library source files. I was naive at first and thought I could just GLOB all .c files:
file(GLOB HAL_SOURCES
${HAL_DIR}/Src/*.c
)2
3
If you write it this way, halfway through compilation you will see this error:
multiple definition of `HAL_TIM_IRQHandler'The problem lies in the STM32 HAL library, which contains a bunch of _template.c files, like stm32f1xx_hal_tim_timebase_template.c. These template files provide default implementations for certain functions, but they shouldn't be compiled in along with normal HAL files. The solution is to add a filter:
list(FILTER HAL_SOURCES EXCLUDE REGEX "\\.*_template\\.c$")This line kicks any file matching *_template.c out of the HAL_SOURCES list. The \\. in the regex needs to escape the dot, otherwise . would match any character, potentially deleting normal files. The first time I wrote this, I forgot to escape it, and even stm32f1xx_hal.c was excluded, causing the linker to report hundreds of undefined references.
Finally, the user code source files. Currently, we only have an empty main.cpp, but you can use GLOB_RECURSE or manually add more files.
Compiler Options — Watch Out for C++ Specific Options
There's not much to say about the common compiler options, mainly some ARM-specific flags:
target_compile_options(${PROJECT_NAME} PRIVATE
-mcpu=cortex-m3
-mthumb
-O2
-Wall
-ffunction-sections
-fdata-sections
)2
3
4
5
6
7
8
-mthumb is very important. The Thumb instruction set is ARM's 16-bit reduced instruction set, which generates smaller code. For a Blue Pill with only 64KB of Flash, every bit saved counts. -ffunction-sections and -fdata-sections put each function and data object into independent sections. Combined with the --gc-sections option at link time, this allows the removal of all unused code. If you don't add these two options, your final firmware might be ridiculously large.
Next are the language-specific options, which is where newcomers most often trip up:
target_compile_options(${PROJECT_NAME} PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:
-fno-exceptions
-fno-rtti
-std=c++17
>
)2
3
4
5
6
7
This $<...> syntax is called a generator expression, a CMake conditional expression meaning "only apply these options when compiling C++ files." You might ask: why not just put these options together with the common ones?
The problem is: -fno-exceptions and -fno-rtti are C++ specific options. GCC will warn that these options are invalid for the C language when compiling C files. Although it's just a warning and won't stop compilation, seeing a screen full of yellow warnings triggers my OCD. More severely, some toolchains (like certain versions of ARM GCC) will error out directly when encountering these options.
I tried to cut corners initially and added -fno-exceptions directly to the common options. As a result, every single C file in the HAL library threw a warning during compilation. There were over fifty warnings, drowning out the actual error messages. I later learned that generator expressions could be used to separate options by language, and finally, peace was restored.
Linker Options — Why We Need nosys.specs
The linker options section has a few key points that need explaining:
target_link_options(${PROJECT_NAME} PRIVATE
-T${PROJECT_ROOT}/STM32F103C8Tx_FLASH.ld
-nostartfiles
-specs=nano.specs
-specs=nosys.specs
-Wl,--gc-sections
)2
3
4
5
6
7
-nostartfiles tells the linker not to use the standard library startup files (like crt0.o). We have our own startup file specifically written for STM32; the standard library one would use the wrong memory layout.
-specs=nano.specs links against newlib-nano, a stripped-down version of the newlib C standard library. It removes floating-point formatting support, thread safety, and other features useless in embedded scenarios, significantly reducing code size. If you don't add this option, your final firmware might be several KB larger.
-specs=nosys.specs is interesting. It tells the linker: "Don't provide implementations for system calls." On Linux, C standard library functions like printf operate on file descriptors via system calls. But in a bare-metal environment, there is no OS, so we need to implement these system calls ourselves (like _read, _write, etc.). nosys.specs provides a set of empty system call stubs to prevent the linker from complaining about undefined reference. We will provide our own implementation in a syscalls.c file later (detailed in the next article).
--gc-sections is link-time garbage collection. Combined with -ffunction-sections and -fdata-sections during compilation, it deletes all unreferenced sections. If you only use GPIO and UART, the code for SPI, I2C, and ADC will be discarded, making the final firmware much smaller.
The last line specifies the linker script file. This file defines the layout of Flash and RAM, which we will analyze in detail shortly.
Linker Script Breakdown
The linker script is something many engineers don't fully grasp; I was clueless when I first touched it. Simply put, it tells the linker: which code goes in Flash, which variables go in RAM, how big the stack and heap are, and where execution starts. Below is a simplified linker script for STM32F103C8T6; let's break down the key parts.
First is the MEMORY definition:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}2
3
4
5
Here r, w, and x are permission flags: r = readable, w = writable, x = executable. Flash is read-only (can't be changed after flashing), so it only gets rx; RAM is readable, writable, and executable, so it gets rwx. ORIGIN is the start address, and LENGTH is the size. STM32F103C8T6 has 128KB Flash and 20KB RAM; you can find this data in the chip's datasheet.
Next is the SECTIONS definition, the most critical part:
Expand (25 lines)Collapse
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
. = ALIGN(4);
} > FLASH
.data :
{
. = ALIGN(4);
*(.data)
*(.data*)
. = ALIGN(4);
} > RAM AT > FLASH
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ENTRY(Reset_Handler) specifies the program entry point. Reset_Handler is a function in the startup file that executes when the chip resets.
The .isr_vector section holds the interrupt vector table, the first thing STM32 reads when it starts. Note the use of the KEEP instruction here. If you don't add KEEP, the linker might think the vector table is unreferenced (because the code doesn't access it directly) and delete it during --gc-sections. The result is that the chip can't find the vector table after reset, and the code runs wild. The first time I compiled, I forgot KEEP, and the chip showed no sign of life after flashing. I spent the whole night troubleshooting.
The .text section holds all code and read-only data (like string literals). They all live in Flash.
The .data section holds initialized global and static variables, like int a = 5. There is a very critical syntax here: > RAM AT > FLASH. Its meaning is: these variables ultimately reside in RAM (because they need to be modified at runtime), but their initial values are stored in Flash. Why? Because Flash content survives power loss, while RAM data is lost when power is cut. The startup code in Reset_Handler copies the initial values from Flash to RAM, a process called "data segment initialization."
If you forget AT > FLASH, the linker assumes the initial values are in RAM. But RAM is empty after power loss, so all variable initial values will be wrong. I've seen people debugging and finding global variables always had random values, only to find out the linker script was wrong.
Finally, the stack and heap settings:
_stack_start = ORIGIN(RAM) + LENGTH(RAM);
_heap_end = _stack_start - 1024; /* Reserve 1KB for stack */2
The stack grows downward from the end of RAM, and the heap grows upward from the end of the BSS segment. Here we reserve 1KB for the stack. If your function call hierarchy is deep or you use large local arrays, you might need to increase this value. If the stack overflows, program behavior becomes completely unpredictable—it might crash, or it might jump to a random address and execute.
Post-Processing and Custom Targets
After compilation and linking, we need to convert the ELF file into a raw binary format so we can flash it using st-flash or OpenOCD:
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary ${PROJECT_NAME} ${PROJECT_NAME}.bin
COMMAND ${CMAKE_SIZE} ${PROJECT_NAME}
)2
3
4
objcopy -O binary concatenates all sections of the ELF file (including .text, .data, .rodata, etc.) in address order into a pure binary file, stripping all ELF metadata. The resulting .bin file can be flashed directly into Flash.
The size command displays the size of each section, helping you judge if the firmware exceeds Flash capacity:
text data bss dec hex filename
4512 120 2048 6680 1a18 firmware.elf2
Here text is the code segment, data is the initialized data segment (initial values in Flash), and bss is the uninitialized data segment (allocated directly in RAM). You can use text + data to estimate the occupied Flash space.
Finally, two custom targets: flash and flash-openocd. They call the build.sh and openocd.sh scripts we wrote earlier, allowing you to flash firmware directly with cmake --build build --target flash without manually typing st-flash commands.
Common Compilation Error Quick Reference
Even if you follow the steps above, you might still run into various issues. Here are a few pitfalls I've fallen into and their solutions.
Error: startup_stm32f103xb.s: No such file or directory
You got the startup file name wrong. Blue Pill uses startup_stm32f103xb.s (medium-density), not xl. Go to the CMSIS directory and ls to confirm the filename.
Error: undefined reference to SystemInit
You are missing the system_stm32f1xx.c file, or this file doesn't define the necessary macros. Ensure your include path includes the HAL driver's Inc directory, and system_stm32f1xx.c exists. Usually, there is a template version of this file in the CMSIS folder that needs to be copied to your project and modified.
Error: multiple definition of ...
You compiled the _template.c files as well. Check your source file list and ensure you filtered out these template files using list(FILTER ... EXCLUDE REGEX ...).
Error: undefined reference to _sbrk or undefined reference to _write
This is a newlib issue. _sbrk is a function called when C++ global objects are constructed, but the bare-metal environment doesn't provide an implementation. You need to create a syscalls.c file providing empty implementations for _sbrk and _write. We will cover how to implement your own system call stubs in detail in the next article.
Warning: command line option ... is valid for C++/ObjC++ but not for C
You added C++ specific options to the common compiler options, causing GCC to warn when compiling C files. Use a generator expression to wrap these options: $<$<COMPILE_LANGUAGE:CXX>:...>.
Now you can try running ./build.sh. If everything goes well, you should see firmware.elf and firmware.bin in the build directory, and the terminal will display the firmware size information. If there are errors, check them one by one against the error list above.
In the next article, we will cover how to implement syscalls.c to solve the undefined reference issues for _sbrk and others, and how to rewrite the startup code in C++ so that global object construction and destruction execute correctly. Then, you can write C++ code directly in main and use standard library containers like std::vector, std::map, etc.