Skip to content
C++
Build System
Updated 2025-01-01T00:00:00.000Z

Cross-Compilation in C++

Cross-compiling C++ with CMake toolchain files — ARM, RISC-V, Windows from Linux, Emscripten/WASM, sysroots, and CI integration.

TL;DR

Cross-compilation requires a toolchain file that sets CMAKE_SYSTEM_NAME, CMAKE_CXX_COMPILER, and sysroot. Use CMAKE_TOOLCHAIN_FILE to activate it. Separate build and host tool paths via CMAKE_FIND_ROOT_PATH_MODE_*.

bash
cmake -B build-arm \
  -DCMAKE_TOOLCHAIN_FILE=toolchain/arm-linux.cmake \
  -DCMAKE_BUILD_TYPE=Release
cmake --build build-arm

Toolchain File Structure

cmake
# toolchain/arm-linux-gnueabihf.cmake

# Target system
set(CMAKE_SYSTEM_NAME    Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)

# Compilers
set(CMAKE_C_COMPILER   arm-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
set(CMAKE_ASM_COMPILER arm-linux-gnueabihf-as)

# Sysroot (optional but recommended for finding target libraries)
set(CMAKE_SYSROOT /usr/arm-linux-gnueabihf)

# Search path configuration
# NEVER: don't use host programs/includes when cross-compiling
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
# ONLY: only look in sysroot for libs and includes
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

Common Targets

ARM 32-bit (Raspberry Pi, embedded Linux)

cmake
# toolchain/arm-linux-gnueabihf.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER   arm-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
set(CMAKE_FIND_ROOT_PATH /usr/arm-linux-gnueabihf)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

AArch64 (ARM64, Pi 4 64-bit, modern embedded)

cmake
# toolchain/aarch64-linux.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER   aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_FIND_ROOT_PATH /usr/aarch64-linux-gnu)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

Bare-metal ARM (no OS)

cmake
# toolchain/arm-none-eabi.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++)

# Bare metal: no OS, no dynamic linking
set(CMAKE_EXE_LINKER_FLAGS_INIT "-specs=nosys.specs")

set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)  # skip link test

RISC-V

cmake
# toolchain/riscv64-linux.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR riscv64)
set(CMAKE_C_COMPILER   riscv64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER riscv64-linux-gnu-g++)
set(CMAKE_FIND_ROOT_PATH /usr/riscv64-linux-gnu)

Windows from Linux (MinGW-w64)

cmake
# toolchain/mingw-w64.cmake
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER   x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER  x86_64-w64-mingw32-windres)

set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

Clang-Based Cross-Compilation

cmake
# toolchain/clang-arm.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

set(CMAKE_C_COMPILER   clang)
set(CMAKE_CXX_COMPILER clang++)

# Clang uses --target triple to select target
set(CMAKE_C_FLAGS_INIT   "--target=aarch64-linux-gnu")
set(CMAKE_CXX_FLAGS_INIT "--target=aarch64-linux-gnu")

set(CMAKE_SYSROOT /usr/aarch64-linux-gnu)

CMakePresets.json for Cross Builds

json
{
  "version": 6,
  "configurePresets": [
    {
      "name": "arm64",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/arm64",
      "toolchainFile": "toolchain/aarch64-linux.cmake",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      }
    },
    {
      "name": "windows",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/windows",
      "toolchainFile": "toolchain/mingw-w64.cmake",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      }
    }
  ]
}

Installing Cross Toolchains

bash
# Ubuntu/Debian — ARM 32-bit
sudo apt install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf

# AArch64
sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

# RISC-V
sudo apt install gcc-riscv64-linux-gnu g++-riscv64-linux-gnu

# MinGW-w64 (Windows from Linux)
sudo apt install mingw-w64

# Bare metal ARM
sudo apt install gcc-arm-none-eabi g++-arm-none-eabi

# Emscripten (WebAssembly)
git clone https://github.com/emscripten-core/emsdk.git
./emsdk install latest && ./emsdk activate latest

Testing Cross-Built Binaries

bash
# Run ARM binary on x86 via QEMU user-mode emulation
sudo apt install qemu-user-static

qemu-arm-static ./my-arm-binary
qemu-aarch64-static ./my-aarch64-binary

# Configure CTest to run via QEMU
set(CMAKE_CROSSCOMPILING_EMULATOR qemu-aarch64-static)
enable_testing()
add_test(NAME mytest COMMAND mytest_binary)
# ctest will run: qemu-aarch64-static ./mytest_binary

Docker-Based Cross Compilation

dockerfile
# Dockerfile.cross
FROM ubuntu:24.04

RUN apt-get update && apt-get install -y \
    cmake ninja-build \
    gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /workspace
COPY . .

RUN cmake -B build \
    -DCMAKE_TOOLCHAIN_FILE=toolchain/aarch64-linux.cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -GNinja \
  && cmake --build build
bash
docker build -f Dockerfile.cross -t builder .
docker run --rm -v ./artifacts:/artifacts builder \
    cp build/myapp /artifacts/

CI Example (GitHub Actions)

yaml
# .github/workflows/cross.yml
jobs:
  cross-build:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        target: [aarch64, arm32, riscv64]
    steps:
      - uses: actions/checkout@v4
      - name: Install toolchain
        run: |
          sudo apt-get install -y \
            gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
            gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf \
            gcc-riscv64-linux-gnu g++-riscv64-linux-gnu \
            qemu-user-static
      - name: Build
        run: |
          cmake -B build \
            -DCMAKE_TOOLCHAIN_FILE=toolchain/${{ matrix.target }}.cmake \
            -DCMAKE_BUILD_TYPE=Release -GNinja
          cmake --build build
      - name: Test (via QEMU)
        run: ctest --test-dir build -V

Emscripten — WebAssembly

Emscripten turns your C++ into WebAssembly in one command. Install the emsdk, activate it, then replace cmake with emcmake cmake — Emscripten injects its own toolchain file automatically so no -DCMAKE_TOOLCHAIN_FILE is needed.

bash
# Install emsdk
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh   # add em++/emcc to PATH
bash
# Configure and build
emcmake cmake -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasm

The emcmake wrapper sets CMAKE_TOOLCHAIN_FILE to Emscripten's bundled file, sets CMAKE_SYSTEM_NAME=Emscripten, and exports EMSCRIPTEN=1 so your CMakeLists.txt can branch on it. Pass extra flags via CMAKE_EXE_LINKER_FLAGS or target_link_options.

cmake
# CMakeLists.txt — Emscripten-specific options
if(EMSCRIPTEN)
  target_link_options(myapp PRIVATE
    -sUSE_PTHREADS=1              # enable threading (SharedArrayBuffer)
    -sFETCH=1                     # async HTTP via Emscripten Fetch API
    -sEXPORTED_FUNCTIONS='["_main","_my_fn"]'
    -sALLOW_MEMORY_GROWTH=1       # heap grows at runtime instead of fixed size
    --shell-file ${CMAKE_SOURCE_DIR}/shell.html  # wrap in HTML harness
  )
  set_target_properties(myapp PROPERTIES SUFFIX ".html")
endif()

Build output is myapp.html + myapp.js + myapp.wasm. Open the .html in a browser, or run the JS module directly in Node.js for headless testing:

bash
# Browser: serve with any HTTP server (file:// won't work for WASM)
python3 -m http.server 8080

# Node.js smoke test (no browser needed)
node build-wasm/myapp.js

Android NDK Cross-Compilation

The Android NDK ships its own CMake toolchain file, so all you need is the path to the NDK and a choice of ABI. Set ANDROID_NDK in your environment or pass it directly.

bash
cmake -B build-android \
  -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-28 \
  -DCMAKE_BUILD_TYPE=Release \
  -GNinja
cmake --build build-android
ABIArchitectureUse case
arm64-v8aAArch64Modern phones (preferred)
armeabi-v7aARMv7 + NEONOlder 32-bit devices
x86_64x86-64Android emulator
x86x86Legacy emulator

Deploy and test on a connected device or emulator via ADB:

bash
adb push build-android/myapp /data/local/tmp/myapp
adb shell chmod +x /data/local/tmp/myapp
adb shell /data/local/tmp/myapp

Encode the NDK path in a CMakePresets.json Android preset so every developer and CI agent picks it up consistently:

json
{
  "version": 6,
  "configurePresets": [
    {
      "name": "android-arm64",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/android-arm64",
      "cacheVariables": {
        "CMAKE_TOOLCHAIN_FILE": "$env{ANDROID_NDK}/build/cmake/android.toolchain.cmake",
        "ANDROID_ABI": "arm64-v8a",
        "ANDROID_PLATFORM": "android-28",
        "CMAKE_BUILD_TYPE": "Release"
      }
    }
  ]
}
bash
# Set NDK path in environment (add to .bashrc / CI secrets)
export ANDROID_NDK=$HOME/Library/Android/sdk/ndk/26.3.11579264
cmake --preset android-arm64

Package Manager Cross-Compilation Integration

Each major C++ package manager has a cross-compilation story, but they plug in at different points.

vcpkg uses triplets to describe target + toolchain combinations. Set the target triplet before running CMake and vcpkg will build all dependencies for the target:

bash
cmake -B build-arm64 \
  -DCMAKE_TOOLCHAIN_FILE=toolchain/aarch64-linux.cmake \
  -DVCPKG_TARGET_TRIPLET=arm64-linux \
  -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \
  -DVCPKG_CHAINLOAD_TOOLCHAIN_FILE="${CMAKE_SOURCE_DIR}/toolchain/aarch64-linux.cmake"

Conan 2 separates host (target device) and build (developer machine) profiles. Create a profile for your ARM target and pass both at install time:

bash
# ~/.conan2/profiles/armlinux
[settings]
os=Linux
arch=armv8
compiler=gcc
compiler.version=13
compiler.libcxx=libstdc++11
build_type=Release

[buildenv]
CC=aarch64-linux-gnu-gcc
CXX=aarch64-linux-gnu-g++
bash
conan install . \
  --profile:host=armlinux \
  --profile:build=default \
  --build=missing \
  --output-folder=build-arm64

CPM.cmake requires no special handling. Because CPM uses CMake's FetchContent under the hood, every package it downloads is configured with the same CMAKE_TOOLCHAIN_FILE already active in the parent build — they are built for the target automatically:

cmake
# CMakeLists.txt — CPM just works when CMAKE_TOOLCHAIN_FILE is set
include(CPM.cmake)
CPMAddPackage("gh:fmtlib/fmt#10.2.1")   # compiled for target, not host

xrepo (xmake's package manager) supports cross-compilation via --plat and --arch flags:

bash
xrepo install --plat=cross --arch=arm64 \
  --toolchain=aarch64-linux-gnu fmt

Debugging Cross-Compiled Binaries

You cannot run the debugger natively on the host when the binary is built for a different ISA, but two strategies cover most cases: GDB remote debugging and QEMU full-system emulation.

GDB remote is the standard embedded and embedded-Linux workflow. Run gdbserver on the target (or inside QEMU) and connect from the host with the cross-GDB:

bash
# On the target device (or QEMU guest)
gdbserver :1234 ./arm-binary

# On the host — use the cross-prefixed GDB that understands ARM ELF
arm-linux-gnueabihf-gdb ./arm-binary
(gdb) target remote hostname:1234
(gdb) break main
(gdb) continue

For AArch64 targets substitute aarch64-linux-gnu-gdb. Pass -g (or CMAKE_BUILD_TYPE=RelWithDebInfo) at build time so the binary contains DWARF info.

QEMU full-system emulation lets you boot a complete ARM virtual machine on your x86 workstation — useful when you need kernel-level behaviour, not just userspace testing:

bash
qemu-system-aarch64 \
  -M virt -cpu cortex-a57 -m 2G \
  -kernel vmlinuz-aarch64 \
  -append "root=/dev/sda console=ttyAMA0" \
  -drive file=rootfs.img,format=raw \
  -nographic

OpenOCD bridges the gap for bare-metal targets that expose a JTAG or SWD debug port (STM32, NXP, etc.):

bash
# Connect to an STM32F4 via ST-Link
openocd \
  -f interface/stlink.cfg \
  -f target/stm32f4x.cfg
# Then in a second terminal:
arm-none-eabi-gdb firmware.elf
(gdb) target extended-remote :3333
(gdb) monitor reset halt
(gdb) load

Common Cross-Compilation Pitfalls

Cross builds expose assumptions that native builds hide. Here are five recurring bugs and how to avoid them.

1. sizeof(void*) differs between host and target. On a 64-bit host sizeof(void*) is 8, but on a 32-bit ARM target it is 4. Code that stores a pointer in a plain int silently truncates:

cpp
// WRONG — truncates on 32-bit target when sizeof(int) < sizeof(void*)
int addr = reinterpret_cast<int>(ptr);

// CORRECT — always the same width as a pointer
#include <cstdint>
uintptr_t addr = reinterpret_cast<uintptr_t>(ptr);

2. find_program() resolving to target-sysroot binaries. CMake's find_program by default respects CMAKE_FIND_ROOT_PATH, which means it may find an ARM-compiled binary instead of a host tool (e.g., protoc, code generators). Fix with CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER in your toolchain file — this is already shown in the toolchain template above, but easy to forget:

cmake
# toolchain file — keep programs from the host
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
# libs/headers must come from the sysroot
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

3. Endianness surprises. Most ARM and x86 systems are little-endian, but some embedded targets (MIPS, some PowerPC) are big-endian. Detect at compile time rather than relying on host-side assumptions:

cpp
#include <bit>     // C++20
#include <cstdint>

void write_u32(uint8_t* buf, uint32_t val) {
    if constexpr (std::endian::native == std::endian::little) {
        // host and most ARM targets
        std::memcpy(buf, &val, 4);
    } else {
        // big-endian: swap bytes
        buf[0] = (val >> 24) & 0xFF;
        buf[1] = (val >> 16) & 0xFF;
        buf[2] = (val >>  8) & 0xFF;
        buf[3] =  val        & 0xFF;
    }
}

// Pre-C++20 fallback
#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
// big-endian path
#endif

4. Alignment requirements are stricter on some targets. ARMv7 and older MIPS trap on unaligned memory accesses that x86 handles silently. Packed structs and reinterpret_cast across alignment boundaries are the usual offenders:

cpp
// DANGEROUS on strict-alignment targets
struct __attribute__((packed)) Header {
    uint32_t magic;    // may be at offset 1 — unaligned read!
    uint16_t version;
};

// SAFE — use alignas or read byte-by-byte
struct alignas(4) Header {
    uint32_t magic;
    uint16_t version;
    uint16_t _pad;
};

5. try_compile and check_* tests execute on the host. CMake's check_type_size(), check_symbol_exists(), and custom try_compile() calls compile and run test programs. In a cross build the compiled output is for the target ISA, which cannot execute on the host — these checks may silently return wrong results or fail entirely:

cmake
# Prevent try_compile from running target binaries on the host
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# Or, for check_* macros, provide the result explicitly
set(HAVE_POSIX_FALLOCATE 1 CACHE INTERNAL "cross: assume available")

See Also

  • CMake — build system fundamentals and target-based design
  • GCC — compiler flags, optimization, and diagnostic options
  • Clang — LLVM-based cross-compilation with --target triples
  • vcpkg — package manager with cross-compilation triplets
  • GDB — debugger usage including remote and core-file workflows
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z