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_*.
cmake -B build-arm \
-DCMAKE_TOOLCHAIN_FILE=toolchain/arm-linux.cmake \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-armToolchain File Structure
# 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)
# 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)
# 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)
# 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 testRISC-V
# 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)
# 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
# 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
{
"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
# 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 latestTesting Cross-Built Binaries
# 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_binaryDocker-Based Cross Compilation
# 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 builddocker build -f Dockerfile.cross -t builder .
docker run --rm -v ./artifacts:/artifacts builder \
cp build/myapp /artifacts/CI Example (GitHub Actions)
# .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 -VEmscripten — 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.
# 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# Configure and build
emcmake cmake -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasmThe 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.
# 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:
# 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.jsAndroid 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.
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| ABI | Architecture | Use case |
|---|---|---|
arm64-v8a | AArch64 | Modern phones (preferred) |
armeabi-v7a | ARMv7 + NEON | Older 32-bit devices |
x86_64 | x86-64 | Android emulator |
x86 | x86 | Legacy emulator |
Deploy and test on a connected device or emulator via ADB:
adb push build-android/myapp /data/local/tmp/myapp
adb shell chmod +x /data/local/tmp/myapp
adb shell /data/local/tmp/myappEncode the NDK path in a CMakePresets.json Android preset so every developer and CI agent picks it up consistently:
{
"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"
}
}
]
}# Set NDK path in environment (add to .bashrc / CI secrets)
export ANDROID_NDK=$HOME/Library/Android/sdk/ndk/26.3.11579264
cmake --preset android-arm64Package 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:
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:
# ~/.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++conan install . \
--profile:host=armlinux \
--profile:build=default \
--build=missing \
--output-folder=build-arm64CPM.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:
# 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 hostxrepo (xmake's package manager) supports cross-compilation via --plat and --arch flags:
xrepo install --plat=cross --arch=arm64 \
--toolchain=aarch64-linux-gnu fmtDebugging 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:
# 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) continueFor 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:
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 \
-nographicOpenOCD bridges the gap for bare-metal targets that expose a JTAG or SWD debug port (STM32, NXP, etc.):
# 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) loadCommon 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:
// 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:
# 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:
#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
#endif4. 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:
// 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:
# 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")