Series / STM32 Development with CMake and VSCode / Creating a Minimal STM32 CMake Project

Creating a Minimal STM32 CMake Project

Create the smallest useful STM32 CMake project structure before adding startup code, linker scripts, flashing, or debugging.

On this page

The previous article installed the tools needed for STM32 development with CMake on macOS. This article creates the first project structure.

The goal is intentionally small: configure CMake for an Arm Cortex-M target and compile one C source file with arm-none-eabi-gcc.

This project will not produce a complete flashable firmware image yet. That requires a linker script, startup code, a vector table, and reset handler behavior. Those pieces are important enough to deserve their own articles. For now, the useful milestone is a CMake project that can find the cross-compiler and compile code for the STM32 Nucleo L433RC-P target family.

What Minimal Means Here

Minimal does not mean production-ready.

In this article, minimal means:

  • The project has a clear folder layout.
  • CMake uses the Arm GNU compiler instead of the host macOS compiler.
  • The compiler receives Cortex-M4 flags appropriate for the STM32L433.
  • One C file compiles successfully.
  • The project stops before linking a real firmware image.

Stopping before linking is deliberate. A real STM32 executable needs target memory information from a linker script and runtime entry behavior from startup code. If those are skipped or treated as magic, the project may appear to work while hiding the most important embedded-specific pieces.

Project Folder Layout

Create a new folder for the project:

mkdir stm32-cmake-minimal
cd stm32-cmake-minimal
mkdir cmake src

The layout will be:

stm32-cmake-minimal/
├── CMakeLists.txt
├── cmake/
│   └── arm-none-eabi-gcc.cmake
└── src/
    └── main.c

This structure separates project-level build logic from toolchain setup and source code. That separation becomes more useful later when the project grows to include startup files, linker scripts, drivers, board configuration, and generated vendor code.

The Top-Level CMakeLists.txt

Create CMakeLists.txt at the project root:

cmake_minimum_required(VERSION 3.25)

project(stm32_cmake_minimal LANGUAGES C ASM)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)

add_library(firmware_objects OBJECT
    src/main.c
)

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

target_compile_definitions(firmware_objects PRIVATE
    STM32L433xx
)

This file creates an object library instead of an executable.

That is the key detail. An object library asks CMake to compile source files into object files without linking them into a final firmware image. This gives us a useful compile check while avoiding fake or incomplete linker behavior.

The target flags describe the CPU family:

  • -mcpu=cortex-m4 selects the Cortex-M4 core.
  • -mthumb generates Thumb instructions, which Cortex-M microcontrollers use.
  • -mfpu=fpv4-sp-d16 selects the single-precision FPU available on this class of part.
  • -mfloat-abi=hard uses the hardware floating-point calling convention.
  • -ffunction-sections and -fdata-sections place functions and data into individual sections so the linker can remove unused code later.

The STM32L433xx definition will matter once vendor headers or startup files are introduced. It is included now because it is part of the target identity for this board.

The Toolchain File

Create cmake/arm-none-eabi-gcc.cmake:

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_OBJCOPY arm-none-eabi-objcopy)
set(CMAKE_SIZE arm-none-eabi-size)

set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

The toolchain file tells CMake that this is not a normal macOS application.

CMAKE_SYSTEM_NAME Generic tells CMake the target is a bare-metal or generic system, not Darwin, Linux, or Windows. CMAKE_C_COMPILER points at the cross-compiler installed in the previous article.

CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY is important for cross-compiling. During configuration, CMake may test the compiler by trying to build a small program. On embedded targets, linking a normal executable may fail before the project has a linker script or runtime files. This setting tells CMake to use a static library for those checks instead.

That keeps the configure step focused on what we can verify now: whether the compiler can compile code.

A Placeholder main.c

Create src/main.c:

#include <stdint.h>

volatile uint32_t placeholder_counter;

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

This is not a useful firmware application yet. It is only a small C file that should compile for the target.

The global variable is marked volatile so the compiler has to preserve the increment in the loop. Without volatile, an optimizing compiler could remove the loop body because the counter value is never observed by normal C code.

In a later article, this placeholder will be replaced by code that runs after a real reset handler sets up the runtime.

Configure the Project

Configure the project from the root folder:

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

The important options are:

  • -S . tells CMake where the source tree is.
  • -B build tells CMake where to place generated build files.
  • -G Ninja selects Ninja as the build backend.
  • -DCMAKE_TOOLCHAIN_FILE=... tells CMake to use the Arm cross-compilation setup.

If configuration succeeds, CMake has found the compiler and generated the build files.

Build the Project

Build it with:

cmake --build build

This should compile src/main.c into an object file under the build directory.

You can also ask CMake to show the available targets:

cmake --build build --target help

At this stage, there is no .elf, .bin, or .hex file. That is expected. We created an object library, not a firmware executable.

What This Project Can and Cannot Do Yet

This project can prove that the host machine can configure a CMake project and compile C code with the Arm GNU toolchain.

It cannot run on the microcontroller yet.

To run on the STM32 Nucleo board, the project still needs:

  • A linker script that describes flash and RAM.
  • Startup code with the vector table and reset handler.
  • Runtime initialization for .data and .bss.
  • A linked firmware image such as an .elf file.
  • A flashing path through OpenOCD or another ST-LINK-capable tool.

Those missing pieces are not optional. They are the difference between compiling a C file and producing firmware that can boot on a microcontroller.

Common Mistakes

If CMake uses Apple Clang instead of arm-none-eabi-gcc, the toolchain file was not applied. Delete the build directory and configure again with -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-gcc.cmake.

If CMake cannot find arm-none-eabi-gcc, confirm the command works in the same terminal:

arm-none-eabi-gcc --version

If it works in Terminal but not inside VSCode, VSCode may not have inherited the same PATH. Launch VSCode from the terminal with code ., or fix the shell environment used by VSCode.

If you accidentally create an executable target and the build fails during linking, that is expected at this stage. Linking an STM32 firmware image correctly requires a linker script and startup files, which have not been added yet.

Next Steps

The next article explains the STM32 linker script.

That is where this project starts to move from "a C file that compiles for Cortex-M" toward "a firmware image with a real memory layout." After that, startup code will provide the reset path that makes main() meaningful on the microcontroller.