Cross-Compilation Concepts
Part 10 of 16 — GNU Make Mastery Series. Cross-compilation is the norm in embedded development: your development machine (the host) is typically x86_64 Linux or macOS, while the target is an ARM Cortex-M, RISC-V SoC, or MIPS router. GNU Make bridges the gap through a handful of well-named variables.
1
Build Systems Foundations
Why Make, compilation pipeline, basics
2
Targets, Prerequisites & Execution
Rule anatomy, PHONY, build workflows
3
Variables, Expansion & Scope
= vs :=, ?=, +=, CLI overrides
4
Automatic Variables & Pattern Rules
$@, $<, $^, $?, %.o: %.c patterns
5
Built-in Functions & Make Language
subst, wildcard, foreach, $(shell)
6
Conditionals & Configurable Builds
ifeq, ifdef, platform detection, flags
7
Automatic Dependency Generation
-M, -MM, -MD, .d files, header deps
8
Compilation Workflow & Libraries
Static/shared libs, ar, -fPIC, SONAME
9
Project Architecture & Multi-Directory
Recursive make, include, out-of-source
10
Cross-Compilation & Toolchains
Toolchain prefixes, sysroots, embedded
You Are Here
11
Parallel Builds & Performance
make -j, jobserver, race conditions
12
Testing, Coverage & Debug Tooling
Test targets, gcov/lcov, sanitizers
13
Make as Automation & DevOps Tool
Task runner, Docker, install, packaging
14
CI/CD Integration
Deterministic builds, GitHub Actions, cache
15
Advanced Make & Debugging
--debug, dynamic rules, evaluation traps
16
Ecosystem, Alternatives & Mastery
CMake, Ninja, Meson, Bazel, production
Architecture Triplets
Cross-toolchains are identified by a target triplet of the form arch-vendor-os or arch-vendor-kernel-os:
Common Triplets
| Triplet | Target |
arm-linux-gnueabihf- | ARM 32-bit, Linux, hard-float ABI (Raspberry Pi, BeagleBone) |
aarch64-linux-gnu- | ARM 64-bit (AArch64), Linux |
riscv64-linux-gnu- | RISC-V 64-bit, Linux |
mipsel-linux-gnu- | MIPS little-endian, Linux (routers) |
arm-none-eabi- | ARM bare-metal, no OS (Cortex-M MCUs) |
x86_64-w64-mingw32- | Windows 64-bit, from Linux host |
# Each toolchain provides prefixed versions of standard tools:
arm-linux-gnueabihf-gcc # C compiler
arm-linux-gnueabihf-g++ # C++ compiler
arm-linux-gnueabihf-as # assembler
arm-linux-gnueabihf-ld # linker
arm-linux-gnueabihf-ar # archive tool
arm-linux-gnueabihf-objcopy # binary conversion (ELF → binary/hex)
arm-linux-gnueabihf-objdump # disassembler
arm-linux-gnueabihf-size # section size reporter
arm-linux-gnueabihf-strip # symbol stripping
The CROSS_COMPILE Variable
The Linux kernel popularised the convention of a single CROSS_COMPILE variable that prefixes every tool name. This is now standard in most embedded projects:
# Set on the command line: make CROSS_COMPILE=arm-linux-gnueabihf-
CROSS_COMPILE ?= # empty = native build
CC := $(CROSS_COMPILE)gcc # C compiler
CXX := $(CROSS_COMPILE)g++ # C++ compiler
AS := $(CROSS_COMPILE)as # assembler
AR := $(CROSS_COMPILE)ar # archive tool (static libs)
LD := $(CROSS_COMPILE)ld # linker
OBJCOPY := $(CROSS_COMPILE)objcopy # binary format converter
OBJDUMP := $(CROSS_COMPILE)objdump # disassembler / section inspector
SIZE := $(CROSS_COMPILE)size # section size reporter
STRIP := $(CROSS_COMPILE)strip # symbol stripper
# Native (host) build — CROSS_COMPILE defaults to empty:
make
# Cross-build for ARM Linux:
make CROSS_COMPILE=arm-linux-gnueabihf-
# Cross-build for AArch64:
make CROSS_COMPILE=aarch64-linux-gnu-
# Cross-build for bare-metal ARM (Cortex-M):
make CROSS_COMPILE=arm-none-eabi-
Full Toolchain Variable Pattern
CROSS_COMPILE ?=
ARCH ?= $(shell uname -m) # detect host arch if not set
CC := $(CROSS_COMPILE)gcc
AR := $(CROSS_COMPILE)ar
OBJCOPY := $(CROSS_COMPILE)objcopy
SIZE := $(CROSS_COMPILE)size
# Base CFLAGS — add arch-specific below
CFLAGS := -Wall -Wextra -std=c11 -O2
# Architecture-specific flags
ifeq ($(ARCH),arm)
CFLAGS += -mcpu=cortex-a53 # target Cortex-A53 core
CFLAGS += -mfpu=neon-fp-armv8 # enable NEON SIMD unit
CFLAGS += -mfloat-abi=hard # hardware floating point ABI
endif
ifeq ($(ARCH),aarch64)
CFLAGS += -march=armv8-a # 64-bit ARMv8 baseline
endif
Sysroots
A sysroot is a directory tree that mirrors the target's root filesystem: it contains target-architecture headers (usr/include/) and pre-built libraries (usr/lib/) for linking against. Without a sysroot, the compiler would find your host's /usr/include headers — wrong architecture.
--sysroot and -I/-L Paths
SYSROOT := /opt/sysroots/arm-linux-gnueabihf # target root filesystem mirror
CC := $(CROSS_COMPILE)gcc
CFLAGS += --sysroot=$(SYSROOT) # tells gcc where target headers/libs live
LDFLAGS += --sysroot=$(SYSROOT) # same for the linker
# Explicit include/lib search inside sysroot (sometimes needed):
CFLAGS += -I$(SYSROOT)/usr/include # target's C headers
LDFLAGS += -L$(SYSROOT)/usr/lib -L$(SYSROOT)/lib # target's pre-built libraries
# Verify the compiler found the right headers:
arm-linux-gnueabihf-gcc --sysroot=/opt/sysroots/arm-linux-gnueabihf \
-v -x c /dev/null -o /dev/null 2>&1 | grep "include"
PKG_CONFIG_SYSROOT_DIR
When your cross-build needs third-party libraries (OpenSSL, zlib, etc.), pkg-config must be pointed at the target's .pc files, not the host's. Two environment variables control this:
# Tell pkg-config to look inside the sysroot:
export PKG_CONFIG_SYSROOT_DIR=/opt/sysroots/arm-linux-gnueabihf
export PKG_CONFIG_PATH=/opt/sysroots/arm-linux-gnueabihf/usr/lib/pkgconfig
# Now pkg-config returns target-appropriate flags:
pkg-config --cflags --libs openssl
# => -I/opt/sysroots/arm-linux-gnueabihf/usr/include -L/opt/sysroots/arm-linux-gnueabihf/usr/lib -lssl -lcrypto
# In Makefile — use target pkg-config or set env before running make:
PKG_CONFIG_SYSROOT_DIR := $(SYSROOT)
PKG_CONFIG_PATH := $(SYSROOT)/usr/lib/pkgconfig
SSL_CFLAGS := $(shell PKG_CONFIG_SYSROOT_DIR=$(SYSROOT) PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --cflags openssl)
SSL_LDFLAGS := $(shell PKG_CONFIG_SYSROOT_DIR=$(SYSROOT) PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) pkg-config --libs openssl)
Multi-Target Makefile
Support native, ARM, and AArch64 builds from a single Makefile parameterized entirely from the command line:
# Multi-target cross-compilation Makefile
# Usage:
# make → native x86_64
# make TARGET=arm → ARM32 Linux
# make TARGET=aarch64 → AArch64 Linux
# make TARGET=arm-bare ARCH=arm → bare-metal Cortex-M
TARGET ?= native
# ── Per-target toolchain and flags ────────────────────────
ifeq ($(TARGET),native)
CROSS_COMPILE := # no prefix = host compiler
CFLAGS_ARCH :=
LDFLAGS_ARCH :=
SYSROOT :=
else ifeq ($(TARGET),arm)
CROSS_COMPILE := arm-linux-gnueabihf-
CFLAGS_ARCH := -mcpu=cortex-a72 -mfpu=neon-fp-armv8 -mfloat-abi=hard
SYSROOT ?= /opt/sysroots/arm-linux-gnueabihf
CFLAGS_ARCH += --sysroot=$(SYSROOT) -I$(SYSROOT)/usr/include
LDFLAGS_ARCH += --sysroot=$(SYSROOT) -L$(SYSROOT)/usr/lib
else ifeq ($(TARGET),aarch64)
CROSS_COMPILE := aarch64-linux-gnu-
CFLAGS_ARCH := -march=armv8-a+crc # ARMv8-A with CRC extension
SYSROOT ?= /opt/sysroots/aarch64-linux-gnu
CFLAGS_ARCH += --sysroot=$(SYSROOT) -I$(SYSROOT)/usr/include
LDFLAGS_ARCH += --sysroot=$(SYSROOT) -L$(SYSROOT)/usr/lib
else ifeq ($(TARGET),arm-bare)
CROSS_COMPILE := arm-none-eabi- # bare-metal toolchain (no OS)
CFLAGS_ARCH := -mcpu=cortex-m4 -mthumb \ # Thumb instruction set
-mfpu=fpv4-sp-d16 -mfloat-abi=hard \
-ffunction-sections -fdata-sections \ # enable dead code stripping
-nostdlib # no standard library (bare metal)
LDFLAGS_ARCH := -T linker.ld -Wl,--gc-sections # use linker script, remove unused sections
endif
# ── Derived tool variables ──
CC := $(CROSS_COMPILE)gcc
AR := $(CROSS_COMPILE)ar
OBJCOPY := $(CROSS_COMPILE)objcopy # convert ELF → .bin/.hex
SIZE := $(CROSS_COMPILE)size # report section sizes
CFLAGS := -Wall -Wextra -std=c11 -O2 $(CFLAGS_ARCH)
LDFLAGS := $(LDFLAGS_ARCH)
BUILDDIR := build/$(TARGET) # separate output per target
SRCDIR := src
SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
TARGET_BIN := $(BUILDDIR)/firmware
.PHONY: all clean size
all: $(TARGET_BIN)
# ── Link ──
$(TARGET_BIN): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(LDFLAGS) -o $@ $^
# For bare-metal: also produce raw binary and Intel HEX
ifeq ($(TARGET),arm-bare)
$(OBJCOPY) -O binary $@ $@.bin # flat binary for flashing
$(OBJCOPY) -O ihex $@ $@.hex # Intel HEX for programmers
endif
# ── Compile ──
$(BUILDDIR)/%.o: $(SRCDIR)/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
size: $(TARGET_BIN)
$(SIZE) $< # show text/data/bss sizes
-include $(OBJS:.o=.d)
clean:
rm -rf build
# ── Variable Inspector ──────────────────────────────────────
.PHONY: info
info:
@echo "TARGET = $(TARGET)"
@echo "CROSS_COMPILE= $(CROSS_COMPILE)"
@echo "CC = $(CC)"
@echo "AR = $(AR)"
@echo "OBJCOPY = $(OBJCOPY)"
@echo "SIZE = $(SIZE)"
@echo "CFLAGS = $(CFLAGS)"
@echo "LDFLAGS = $(LDFLAGS)"
@echo "BUILDDIR = $(BUILDDIR)"
@echo "SRCS = $(SRCS)"
@echo "OBJS = $(OBJS)"
@echo "TARGET_BIN = $(TARGET_BIN)"
Create Source Files
The Makefile uses $(wildcard src/*.c). Create minimal sources so every target resolves:
mkdir -p src
cat > src/main.c << 'EOF'
#include <stdio.h>
int firmware_version(void) { return 1; }
int main(void) {
printf("Firmware v%d running\n", firmware_version());
return 0;
}
EOF
cat > src/hal.c << 'EOF'
#include <stdio.h>
void hal_gpio_init(void) { printf("GPIO initialized\n"); }
void hal_uart_init(void) { printf("UART initialized\n"); }
EOF
Try It — Native Build
Cross-compilation requires specific toolchains; test the native path first to validate the Makefile logic:
# Inspect variables (default: native target)
make info
# Build native
make
./build/native/firmware
# Check binary architecture
file build/native/firmware
# Show sizes
make size
make clean
Next in the Series
In Part 11: Parallel Builds & Performance, we maximize build throughput with make -j$(nproc), understand the jobserver protocol that safely coordinates parallel sub-makes, and learn how to detect and fix race conditions in Makefiles.
Continue the Series
Part 11: Parallel Builds & Performance
Maximize build throughput with make -j, the jobserver token protocol, and reproducible dependency ordering.
Read Article
Part 9: Project Architecture & Multi-Directory
Recursive $(MAKE) -C, non-recursive include, and structured out-of-source builds for scaling large projects.
Read Article
Part 12: Testing, Coverage & Debug Tooling
Add test targets, gcov/lcov coverage, and AddressSanitizer/UBSan integration to your Makefile-based project.
Read Article