Back to Technology

GNU Make Mastery Part 10: Cross-Compilation & Toolchains

February 26, 2026 Wasil Zafar 20 min read

Produce firmware and embedded binaries for ARM, RISC-V, and MIPS from an x86 host: configure CROSS_COMPILE prefixes, sysroot directories, PKG_CONFIG_SYSROOT_DIR, and multi-target Makefiles that switch toolchains without code changes.

Table of Contents

  1. Cross-Compilation Concepts
  2. CROSS_COMPILE Variable
  3. Sysroots
  4. Multi-Target Makefile
  5. Embedded-Specific Extras
  6. Next Steps

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.

Architecture Triplets

Cross-toolchains are identified by a target triplet of the form arch-vendor-os or arch-vendor-kernel-os:

Common Triplets
TripletTarget
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

Toolchain Components

# 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

Embedded-Specific Extras

# ── Linker script (defines memory regions for bare-metal) ──
LDFLAGS += -T board/stm32f407.ld               # custom memory layout for STM32F407

# ── Inspect ELF section headers after build ──
.PHONY: sections
sections: $(TARGET_BIN)
	$(OBJDUMP) -h $<                            # -h shows section names, sizes, addresses

# ── Flash firmware to target via OpenOCD ──
.PHONY: flash
flash: $(TARGET_BIN).bin
	openocd -f interface/stlink.cfg \            # ST-Link debugger config
	        -f target/stm32f4x.cfg  \            # target MCU config
	        -c "program $< 0x08000000 verify reset exit"  # flash, verify, reset

# ── Start interactive GDB debug session ──
.PHONY: debug
debug: $(TARGET_BIN)
	arm-none-eabi-gdb -ex "target extended-remote :3333" $<  # connect to OpenOCD GDB server
Tip: Always check your binary is actually targeting the correct architecture after a cross-build: file build/arm/firmware should report "ARM, EABI5" — not "x86-64".

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.

Technology