Skip to content

Modern Embedded C++ Tutorial: Cross-Compilation Basics and CMake Multi-Target Builds

Introduction

In the embedded development field, we often face an interesting challenge: the development environment and the target runtime environment are usually completely different hardware platforms. You might write code on a powerful x86_64 workstation, but the final program needs to run on an ARM-based MCU (Microcontroller Unit) or a RISC-V processor. This is exactly why cross-compilation exists.

This article will dive into the fundamental concepts of cross-compilation and detail how to use CMake, a modern build system, to manage multi-target platform build workflows. Whether you are a newcomer to embedded development or a seasoned developer looking to optimize your existing build process, this article will provide you with practical knowledge and techniques.

Part 1: Cross-Compilation Basics

What is Cross-Compilation

Cross-compilation refers to the process of compiling on one platform (the Host Platform) to generate an executable program that runs on another platform (the Target Platform). This contrasts with native compilation, where the compiled program runs on the same platform that built it.

A simple example: when you compile a C++ program on your Ubuntu x86_64 laptop, and that program will run on a Raspberry Pi's ARM processor, you are cross-compiling.

Why We Need Cross-Compilation

This question is actually a no-brainer—would you dare to deploy a complete toolchain on your microcontroller? An MCU (Microcontroller Unit) with only a few megabytes of Flash and a few dozen kilobytes of RAM obviously cannot run the GCC compiler.

Furthermore, even if the target device could theoretically compile code, compiling on resource-constrained hardware would be incredibly slow. In contrast, compiling on a powerful development machine significantly shortens the development cycle and improves work efficiency. Desktop development environments also typically have a more complete tool ecosystem, including IDEs, debuggers, and profilers, which can significantly enhance the development experience.

Cross-Compilation Toolchain

A cross-compilation toolchain is a set of tools specifically designed for cross-compilation, typically including:

  • Cross Compiler: The core of the toolchain. For example, arm-none-eabi-gcc is used for bare-metal ARM development, and aarch64-linux-gnu-gcc is used for ARM64 Linux systems. The compiler is responsible for translating source code into the target platform's machine code.

  • Cross Assembler: Converts assembly language code into the target platform's machine code, usually used in conjunction with the compiler.

  • Cross Linker: Links multiple object files (.o files) generated by the compiler into the final executable or library files, handling symbol resolution and address relocation.

  • Standard Libraries: C/C++ standard libraries compiled for the target platform, including libc, libstdc++, and so on. These libraries must be compiled specifically for the target architecture.

  • Auxiliary Tools: Tools such as objcopy (converts object file formats), size (views program size), and nm (views the symbol table).

Target Triplet

In cross-compilation, we use a "target triplet" to precisely describe the target platform. This triplet usually consists of three or four parts:

cpp

<架构>-<厂商>-<操作系统>-<ABI>

Let's look at a few practical examples:

  • arm-none-eabi: ARM architecture, no vendor, no operating system (bare-metal), EABI (Embedded Application Binary Interface)
  • aarch64-linux-gnu: ARM64 architecture, Linux operating system, GNU toolchain
  • x86_64-w64-mingw32: x86_64 architecture, Windows operating system, MinGW toolchain
  • riscv64-unknown-elf: RISC-V 64-bit architecture, unknown vendor, ELF format

Understanding the target triplet is crucial for selecting the correct toolchain and configuring the build system. Different triplets imply different instruction sets, calling conventions, binary formats, and runtime environments.

Challenges of Cross-Compilation

Although cross-compilation is powerful, it brings several challenges:

Dependency management: When a program depends on third-party libraries, you need to ensure these libraries are also compiled for the target platform. You cannot link a library compiled for x86 into an ARM program.

System call differences: Different operating systems have different system call interfaces, which need to be properly handled in the code.

Endianness issues: Different architectures might use different byte orders (big-endian or little-endian), requiring special attention when handling network protocols or file formats.

Pointer size: The pointer size differs between 32-bit and 64-bit architectures, which can lead to subtle bugs.

Floating-point operations: Floating-point implementations may vary slightly across platforms, and some embedded platforms lack a hardware floating-point unit entirely.

CMake Build System Basics

Well, there are no hands-on exercises in this section, so just skim through it. We will dedicate a specific chapter to dive into this topic later.

Why Choose CMake

CMake (Cross-platform Make) is a cross-platform build system generator. It does not build programs directly; instead, it generates the files required by native build systems (such as Makefiles, Ninja build files, or Visual Studio project files).

For embedded development, CMake offers the following advantages:

Cross-platform support: The same set of CMake configurations can be used on Linux, Windows, and macOS to generate build files for the respective platforms.

Cross-compilation support: CMake natively supports cross-compilation, making it easy to configure the target platform through a Toolchain file.

Modular design: CMake's module system makes it easy to manage multiple components and dependencies in complex projects.

Modern features: Supports target-oriented build configurations, making dependency relationships clearer and configurations more intuitive.

Broad IDE support: Mainstream IDEs such as CLion, Visual Studio Code, and Qt Creator all have excellent CMake support.

Core CMake Concepts

Before diving into cross-compilation configuration, let's quickly review a few core CMake concepts:

CMakeLists.txt: This is the CMake configuration file that describes the project's structure, source files, dependencies, and build rules.

Target: Can be an executable file, a library file, or a custom target. Modern CMake recommends a target-centric configuration approach.

Generator: Determines what type of build system files CMake generates, such as Unix Makefiles, Ninja, or Visual Studio.

Build Tree and Source Tree: The source tree contains the source code and CMakeLists.txt, while the build tree is where the generated build files and compilation artifacts are stored. We recommend using out-of-source builds to keep the source directory clean.

Variables and Cache: CMake uses variables to store configuration information, and certain variables are cached for reuse in subsequent configurations.

CMake Cross-Compilation Configuration

3.1 The Role of the Toolchain File

The Toolchain file is the core of CMake cross-compilation. It is a CMake script file that describes all the information required for cross-compilation, including compiler paths, target system information, and compiler flags.

Benefits of using a Toolchain file:

  • Reusability: Configure once, share across multiple projects
  • Version control: Toolchain files can be checked into version control to ensure the team uses the same configuration
  • Clear separation: Separates platform-specific configurations from project logic

Writing a Toolchain File

Let's start with an ARM Cortex-M Toolchain file example:

cmake

# arm-none-eabi-toolchain.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)

# 指定交叉编译器
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)

# 指定工具链程序
set(CMAKE_OBJCOPY arm-none-eabi-objcopy)
set(CMAKE_OBJDUMP arm-none-eabi-objdump)
set(CMAKE_SIZE arm-none-eabi-size)

# 设置编译器标志
set(CMAKE_C_FLAGS_INIT "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16")
set(CMAKE_CXX_FLAGS_INIT "${CMAKE_C_FLAGS_INIT} -fno-exceptions -fno-rtti")

# 设置链接器标志
set(CMAKE_EXE_LINKER_FLAGS_INIT "-specs=nosys.specs -Wl,--gc-sections")

# 搜索路径配置
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

Let's break down the various parts of this file in detail:

CMAKE_SYSTEM_NAME: Specifies the target system type. Generic indicates a bare-metal environment without an operating system, but it can also be Linux, Windows, and so on.

CMAKE_SYSTEM_PROCESSOR: Specifies the target processor architecture, such as arm, aarch64, or riscv64.

Compiler settings: Explicitly specifies the cross-compiler to use. CMake will use these compilers instead of the system defaults.

Compiler flags:

  • -mcpu=cortex-m4: Specifies the target CPU model
  • -mthumb: Uses the Thumb instruction set (higher code density)
  • -mfloat-abi=hard: Uses the hardware floating-point ABI
  • -mfpu=fpv4-sp-d16: Specifies the floating-point unit type
  • -fno-exceptions: Disables C++ exceptions (common in embedded)
  • -fno-rtti: Disables Run-Time Type Information (RTTI)

CMAKE_FIND_ROOT_PATH_MODE series: Controls CMake's search behavior when looking for libraries, header files, and other resources, preventing the accidental use of host platform libraries.

A More Complex Toolchain Example: ARM Linux

For ARM devices running Linux (like the Raspberry Pi), the Toolchain file will be somewhat different:

cmake

# aarch64-linux-gnu-toolchain.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

# 工具链安装路径
set(TOOLCHAIN_PREFIX /usr/aarch64-linux-gnu)

# 编译器
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)

# Sysroot设置(包含目标系统的库和头文件)
set(CMAKE_SYSROOT ${TOOLCHAIN_PREFIX})
set(CMAKE_FIND_ROOT_PATH ${TOOLCHAIN_PREFIX})

# 编译器标志
set(CMAKE_C_FLAGS_INIT "-march=armv8-a")
set(CMAKE_CXX_FLAGS_INIT "${CMAKE_C_FLAGS_INIT}")

# 搜索配置
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

# pkg-config配置
set(ENV{PKG_CONFIG_PATH} "")
set(ENV{PKG_CONFIG_LIBDIR} "${CMAKE_SYSROOT}/usr/lib/pkgconfig:${CMAKE_SYSROOT}/usr/share/pkgconfig")
set(ENV{PKG_CONFIG_SYSROOT_DIR} ${CMAKE_SYSROOT})

This example introduces the concept of CMAKE_SYSROOT. A sysroot is a directory that contains a copy of the target system's root filesystem, including library files, header files, and so on. This is crucial for target platforms with a complete operating system.

Using the Toolchain File

To configure using a Toolchain file:

bash

# 创建构建目录
mkdir build-arm && cd build-arm

# 使用toolchain文件配置CMake
cmake -DCMAKE_TOOLCHAIN_FILE=../toolchains/arm-none-eabi-toolchain.cmake \
      -DCMAKE_BUILD_TYPE=Release \
      ..

# 构建
cmake --build .

Important note: The Toolchain file must be specified via -DCMAKE_TOOLCHAIN_FILE the first time you run CMake, and it will be cached afterward. If you need to switch Toolchains, you must delete the build directory and reconfigure.

Part 4: CMake Multi-Target Builds

What is a Multi-Target Build

A multi-target build means that the same set of source code can generate executable programs for different target platforms. In embedded development, this is very common:

  • Building for multiple hardware variants (STM32F4, STM32F7)
  • Supporting both development boards and production boards
  • Building test versions on the host platform and release versions on the target platform
  • Supporting multiple operating systems (Linux, RTOS (Real-Time Operating System), bare-metal)

Multi-Target Approach Based on Build Directories

The simplest multi-target build approach is to create independent build directories for each platform:

bash

# 项目结构
project/
├── src/
├── include/
├── toolchains/
   ├── arm-cortex-m4.cmake
   ├── arm-cortex-m7.cmake
   └── x86_64-linux.cmake
├── CMakeLists.txt
└── builds/
    ├── cortex-m4/
    ├── cortex-m7/
    └── host/

Build script example:

bash
#!/bin/bash

# 构建Cortex-M4版本
cmake -S . -B builds/cortex-m4 \
      -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-cortex-m4.cmake \
      -DCMAKE_BUILD_TYPE=Release
cmake --build builds/cortex-m4

# 构建Cortex-M7版本
cmake -S . -B builds/cortex-m7 \
      -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-cortex-m7.cmake \
      -DCMAKE_BUILD_TYPE=Release
cmake --build builds/cortex-m7

# 构建主机测试版本
cmake -S . -B builds/host \
      -DCMAKE_BUILD_TYPE=Debug
cmake --build builds/host

Conditional Compilation and Platform Detection

In CMakeLists.txt, we need to perform conditional configuration based on different platforms:

cmake
cmake_minimum_required(VERSION 3.20)
project(EmbeddedApp CXX C ASM)

# 检测目标平台
if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm")
    message(STATUS "Building for ARM architecture")

    # ARM特定配置
    add_compile_definitions(TARGET_ARM)

    if(CMAKE_SYSTEM_NAME STREQUAL "Generic")
        message(STATUS "Bare-metal ARM target")
        add_compile_definitions(BARE_METAL)
    endif()

elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64")
    message(STATUS "Building for x86_64 architecture")
    add_compile_definitions(TARGET_X86_64)

elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "riscv64")
    message(STATUS "Building for RISC-V 64-bit")
    add_compile_definitions(TARGET_RISCV64)
endif()

# 添加源文件
set(COMMON_SOURCES
    src/main.cpp
    src/application.cpp
)

# 平台特定源文件
if(CMAKE_SYSTEM_NAME STREQUAL "Generic")
    list(APPEND COMMON_SOURCES
        src/startup_arm.s
        src/hal_bare_metal.cpp
    )
else()
    list(APPEND COMMON_SOURCES
        src/hal_linux.cpp
    )
endif()

# 创建可执行目标
add_executable(app ${COMMON_SOURCES})

# 平台特定链接配置
if(CMAKE_SYSTEM_NAME STREQUAL "Generic")
    target_link_options(app PRIVATE
        -T${CMAKE_SOURCE_DIR}/linker/STM32F407VG.ld
        -Wl,-Map=${CMAKE_BINARY_DIR}/app.map
    )
endif()

Using Generator Expressions

CMake generator expressions provide a more flexible approach to conditional configuration:

cmake

# 根据配置类型设置不同的编译选项
target_compile_options(app PRIVATE
    $<$<CONFIG:Debug>:-O0 -g3>
    $<$<CONFIG:Release>:-O3 -DNDEBUG>
)

# 根据编译器类型设置选项
target_compile_options(app PRIVATE
    $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra>
    $<$<CXX_COMPILER_ID:Clang>:-Weverything>
)

# 根据平台设置链接库
target_link_libraries(app PRIVATE
    $<$<PLATFORM_ID:Linux>:pthread>
    $<$<PLATFORM_ID:Windows>:ws2_32>
)

Platform Abstraction Layer (HAL) Design

In multi-target projects, a good hardware abstraction layer design is crucial:

cmake

# 创建HAL接口库
add_library(hal_interface INTERFACE)
target_include_directories(hal_interface INTERFACE
    include/hal
)

# 为不同平台创建HAL实现
if(CMAKE_SYSTEM_NAME STREQUAL "Generic")
    add_library(hal_impl STATIC
        src/hal/gpio_stm32.cpp
        src/hal/uart_stm32.cpp
        src/hal/timer_stm32.cpp
    )
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    add_library(hal_impl STATIC
        src/hal/gpio_linux.cpp
        src/hal/uart_linux.cpp
        src/hal/timer_linux.cpp
    )
endif()

target_link_libraries(hal_impl PUBLIC hal_interface)

# 应用程序链接HAL
target_link_libraries(app PRIVATE hal_impl)

Configuration Variant Management

For different hardware variants of the same architecture, we can use CMake options and cache variables:

cmake

# 定义硬件变体选项
set(TARGET_BOARD "STM32F407_DISCOVERY" CACHE STRING "Target board")
set_property(CACHE TARGET_BOARD PROPERTY STRINGS
    "STM32F407_DISCOVERY"
    "STM32F429_DISCO"
    "CUSTOM_BOARD_V1"
    "CUSTOM_BOARD_V2"
)

# 根据板子配置
if(TARGET_BOARD STREQUAL "STM32F407_DISCOVERY")
    set(MCU_FLAGS "-mcpu=cortex-m4 -mfpu=fpv4-sp-d16")
    set(LINKER_SCRIPT "${CMAKE_SOURCE_DIR}/linker/STM32F407VG.ld")
    add_compile_definitions(STM32F407xx)

elseif(TARGET_BOARD STREQUAL "STM32F429_DISCO")
    set(MCU_FLAGS "-mcpu=cortex-m4 -mfpu=fpv4-sp-d16")
    set(LINKER_SCRIPT "${CMAKE_SOURCE_DIR}/linker/STM32F429ZI.ld")
    add_compile_definitions(STM32F429xx)

endif()

# 应用配置
add_compile_options(${MCU_FLAGS})
target_link_options(app PRIVATE -T${LINKER_SCRIPT})

Usage:

bash
cmake -B build-f407 -DTARGET_BOARD=STM32F407_DISCOVERY \
      -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-cortex-m4.cmake

cmake -B build-f429 -DTARGET_BOARD=STM32F429_DISCO \
      -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-cortex-m4.cmake

Built with VitePress