Cross-Compilation and Targets
Cross-compilation means building code on one machine (the host) to run on a different machine (the target). You compile on your x86-64 laptop and produce a binary for an ARM Raspberry Pi, a RISC-V board, or an embedded microcontroller. This is essential for embedded systems, driver development, and any scenario where the target cannot compile its own code.
Why Cross-Compile?
The target machine may be too slow, too resource-constrained, or not yet booted. You cannot compile a kernel for a board that has no operating system running. Embedded ARM devices, IoT sensors, and custom hardware all require cross-compilation from a development workstation.
+---------------------+ +---------------------+
| Host (x86-64) | | Target (aarch64) |
| - gcc / rustc | build | - no compiler |
| - full OS | -------> | - runs the binary |
| - cross-toolchain | | - limited resources|
+---------------------+ +---------------------+
The Target Triple
Both GCC and LLVM/Rust use a target triple (sometimes a quadruple) to identify the target platform:
<arch>-<vendor>-<os>-<abi>
Examples:
x86_64-unknown-linux-gnu Your typical desktop Linux
aarch64-unknown-linux-gnu 64-bit ARM Linux
arm-unknown-linux-gnueabihf 32-bit ARM Linux, hard float
riscv64gc-unknown-linux-gnu 64-bit RISC-V Linux
x86_64-unknown-linux-musl x86-64 Linux with musl libc
aarch64-unknown-none Bare-metal ARM (no OS)
thumbv7em-none-eabihf ARM Cortex-M4/M7, no OS
Each component:
| Field | Meaning |
|---|---|
arch | CPU architecture (x86_64, aarch64, arm, riscv64) |
vendor | Who made it (unknown, apple, pc) |
os | Operating system (linux, windows, none) |
abi | ABI / libc (gnu, musl, eabi, eabihf) |
The triple determines what instruction set the compiler emits, what system call conventions to use, and what C library to link against.
Cross-Compilation in C
Installing a Cross Toolchain
On Debian/Ubuntu, install cross-compilation tools:
sudo apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
This gives you aarch64-linux-gnu-gcc, aarch64-linux-gnu-ld,
aarch64-linux-gnu-objdump, and friends.
For 32-bit ARM:
sudo apt install gcc-arm-linux-gnueabihf
For RISC-V:
sudo apt install gcc-riscv64-linux-gnu
A Complete Cross-Compilation Example
/* hello_cross.c */
#include <stdio.h>
int main(void) {
printf("Hello from cross-compiled code!\n");
printf("sizeof(void*) = %zu\n", sizeof(void *));
printf("sizeof(long) = %zu\n", sizeof(long));
return 0;
}
Compile for aarch64:
aarch64-linux-gnu-gcc -O2 hello_cross.c -o hello_aarch64
Inspect the result:
file hello_aarch64
# hello_aarch64: ELF 64-bit LSB executable, ARM aarch64, ...
objdump -d hello_aarch64 | head -30
# You'll see ARM instructions, not x86
You cannot run it directly on x86-64:
./hello_aarch64
# bash: ./hello_aarch64: cannot execute binary file: Exec format error
But you can run it with QEMU user-mode emulation:
sudo apt install qemu-user qemu-user-static
qemu-aarch64 -L /usr/aarch64-linux-gnu ./hello_aarch64
Output:
Hello from cross-compiled code!
sizeof(void*) = 8
sizeof(long) = 8
Try It: Install
gcc-arm-linux-gnueabihfand cross-compile the same program for 32-bit ARM. Usefileto confirm it is an ARM executable. Run it withqemu-arm. Check whatsizeof(void*)reports -- it should be 4.
The Sysroot
A sysroot is a directory containing the target's headers and libraries. When
you install gcc-aarch64-linux-gnu, the sysroot is typically at
/usr/aarch64-linux-gnu/.
/usr/aarch64-linux-gnu/
include/ # target's C headers
lib/ # target's C library, crt*.o, etc.
The cross-compiler knows its sysroot. You can override it:
aarch64-linux-gnu-gcc --sysroot=/path/to/my/sysroot -O2 hello_cross.c -o hello
This is essential when building for custom Linux distributions or embedded systems with non-standard libraries.
Cross-compilation data flow:
hello_cross.c
|
v
aarch64-linux-gnu-gcc
|
+-- uses headers from /usr/aarch64-linux-gnu/include/
+-- links against /usr/aarch64-linux-gnu/lib/libc.so
|
v
hello_aarch64 (ELF for aarch64)
Cross-Compiling with a Makefile
Modify the Makefile to accept a CROSS_COMPILE prefix:
# Makefile
CROSS_COMPILE ?=
CC = $(CROSS_COMPILE)gcc
AR = $(CROSS_COMPILE)ar
STRIP = $(CROSS_COMPILE)strip
CFLAGS = -Wall -Wextra -O2
LDFLAGS =
SRCS = hello_cross.c
OBJS = $(SRCS:.c=.o)
TARGET = hello
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
clean:
rm -f $(OBJS) $(TARGET)
make # native build
make CROSS_COMPILE=aarch64-linux-gnu- # cross-compile for ARM64
make CROSS_COMPILE=arm-linux-gnueabihf- # cross-compile for ARM32
Driver Prep: The Linux kernel uses exactly this pattern. The kernel Makefile accepts
CROSS_COMPILEandARCHvariables:make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-This is how you build a kernel for a Raspberry Pi on your laptop.
Cross-Compilation in Rust
Rust makes cross-compilation substantially easier than C. The Rust compiler
uses LLVM, which can emit code for many targets from a single compiler binary.
You do not need a separate rustc for each target.
Adding a Target
List installed targets:
rustup target list --installed
Add a new target:
rustup target add aarch64-unknown-linux-gnu
rustup target add arm-unknown-linux-gnueabihf
rustup target add x86_64-unknown-linux-musl
A Simple Cross-Compile
// src/main.rs fn main() { println!("Hello from Rust cross-compilation!"); println!("Target arch: {}", std::env::consts::ARCH); println!("Target OS: {}", std::env::consts::OS); println!("Pointer size: {} bytes", std::mem::size_of::<*const u8>()); }
cargo build --target aarch64-unknown-linux-gnu
This compiles Rust code for aarch64 but fails at linking because Cargo does not know where the aarch64 linker is. You need to tell it.
Configuring the Linker
Create or edit .cargo/config.toml:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.arm-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
[target.riscv64gc-unknown-linux-gnu]
linker = "riscv64-linux-gnu-gcc"
Now:
cargo build --target aarch64-unknown-linux-gnu
file target/aarch64-unknown-linux-gnu/debug/myproject
# ELF 64-bit LSB pie executable, ARM aarch64, ...
Run with QEMU:
qemu-aarch64 -L /usr/aarch64-linux-gnu target/aarch64-unknown-linux-gnu/debug/myproject
Output:
Hello from Rust cross-compilation!
Target arch: aarch64
Target OS: linux
Pointer size: 8 bytes
Static Linking with musl
For maximum portability, link statically against musl libc. The resulting binary has zero runtime dependencies:
rustup target add x86_64-unknown-linux-musl
cargo build --target x86_64-unknown-linux-musl --release
file target/x86_64-unknown-linux-musl/release/myproject
# ELF 64-bit LSB executable, x86-64, statically linked, ...
ldd target/x86_64-unknown-linux-musl/release/myproject
# not a dynamic executable
This binary runs on any x86-64 Linux system regardless of the installed glibc version.
Rust Note: Rust's musl target produces fully static binaries by default. In C, achieving the same requires
musl-gccormusl-cross-makeand careful management of all dependencies. Rust makes this trivial.
Conditional Compilation for Target Architecture
// src/main.rs fn main() { #[cfg(target_arch = "x86_64")] println!("Running on x86-64"); #[cfg(target_arch = "aarch64")] println!("Running on ARM64"); #[cfg(target_arch = "arm")] println!("Running on 32-bit ARM"); #[cfg(target_os = "linux")] println!("Operating system: Linux"); #[cfg(target_os = "none")] println!("No OS (bare metal)"); #[cfg(target_pointer_width = "64")] println!("64-bit pointers"); #[cfg(target_pointer_width = "32")] println!("32-bit pointers"); }
The C equivalent uses preprocessor macros:
/* arch_detect.c */
#include <stdio.h>
int main(void) {
#if defined(__x86_64__)
printf("Running on x86-64\n");
#elif defined(__aarch64__)
printf("Running on ARM64\n");
#elif defined(__arm__)
printf("Running on 32-bit ARM\n");
#elif defined(__riscv)
printf("Running on RISC-V\n");
#else
printf("Unknown architecture\n");
#endif
#if defined(__linux__)
printf("Operating system: Linux\n");
#endif
printf("Pointer size: %zu bytes\n", sizeof(void *));
return 0;
}
gcc arch_detect.c -o arch_native
./arch_native
# Running on x86-64
# Operating system: Linux
# Pointer size: 8 bytes
aarch64-linux-gnu-gcc arch_detect.c -o arch_arm64
qemu-aarch64 -L /usr/aarch64-linux-gnu ./arch_arm64
# Running on ARM64
# Operating system: Linux
# Pointer size: 8 bytes
Cross-Compiling a C Library for ARM
Cross-compile a static library for aarch64 using the same tools:
aarch64-linux-gnu-gcc -c -O2 sensor.c -o sensor_arm64.o
aarch64-linux-gnu-ar rcs libsensor_arm64.a sensor_arm64.o
file sensor_arm64.o
# sensor_arm64.o: ELF 64-bit LSB relocatable, ARM aarch64
When using the cc crate in a Rust project, it automatically detects the
Cargo target triple and invokes the correct cross-compiler. If you run
cargo build --target aarch64-unknown-linux-gnu, the cc crate calls
aarch64-linux-gnu-gcc instead of gcc.
Caution: Struct layout across architectures can differ. Fields may have different alignment requirements on ARM vs x86. Always use
#[repr(C)]in Rust and fixed-width types (int16_t,uint32_t) in C to ensure consistent layout across platforms.
Checking Available Targets
GCC
GCC cross-compilers are separate binaries. List what is installed:
ls /usr/bin/*-gcc 2>/dev/null
# /usr/bin/aarch64-linux-gnu-gcc
# /usr/bin/arm-linux-gnueabihf-gcc
# /usr/bin/riscv64-linux-gnu-gcc
Rust
Rust shows all supported targets:
rustc --print target-list | wc -l
# Over 200 targets
rustc --print target-list | grep linux
# aarch64-unknown-linux-gnu
# arm-unknown-linux-gnueabihf
# riscv64gc-unknown-linux-gnu
# x86_64-unknown-linux-gnu
# x86_64-unknown-linux-musl
# ... many more
Get detailed target info:
rustc --print cfg --target aarch64-unknown-linux-gnu
This prints all cfg attributes that are true for that target, which
determines what code #[cfg(...)] includes or excludes.
Bare-Metal Cross-Compilation
For embedded targets with no OS, the approach changes. There is no libc,
no printf, no standard file I/O.
C for Bare Metal
/* bare.c -- for a bare-metal ARM target */
#include <stdint.h>
/* Memory-mapped UART register (hypothetical) */
#define UART0_DR (*(volatile uint32_t *)0x09000000)
void uart_putc(char c) {
UART0_DR = (uint32_t)c;
}
void uart_puts(const char *s) {
while (*s) {
uart_putc(*s++);
}
}
void _start(void) {
uart_puts("Hello, bare metal!\n");
while (1) {} /* hang */
}
aarch64-linux-gnu-gcc -ffreestanding -nostdlib -T linker.ld bare.c -o bare.elf
-ffreestandingtells the compiler not to assume a hosted environment.-nostdlibtells the linker not to link the standard library.-T linker.ldprovides a custom linker script.
Rust for Bare Metal
#![allow(unused)] fn main() { // src/main.rs #![no_std] #![no_main] use core::panic::PanicInfo; const UART0_DR: *mut u32 = 0x0900_0000 as *mut u32; fn uart_putc(c: u8) { unsafe { core::ptr::write_volatile(UART0_DR, c as u32); } } fn uart_puts(s: &str) { for b in s.bytes() { uart_putc(b); } } #[no_mangle] pub extern "C" fn _start() -> ! { uart_puts("Hello from bare-metal Rust!\n"); loop {} } #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } }
cargo build --target aarch64-unknown-none
The aarch64-unknown-none target means: aarch64 architecture, no vendor,
no operating system. Rust's core library is available (basic types,
iterators, Option, Result), but std is not (no heap, no file I/O,
no threads).
Driver Prep: Kernel modules operate in a similar environment to bare metal. There is no standard library, no heap by default, and you interact with hardware through memory-mapped registers. Cross-compilation to
aarch64-unknown-linux-gnuis how you build kernel modules for ARM64 boards from your x86 workstation.
Summary Diagram: Cross-Compilation Workflow
Development Machine (x86-64)
+--------------------------------------------------+
| |
| Source Code (.c / .rs) |
| | |
| v |
| Cross-Compiler |
| (aarch64-linux-gnu-gcc / rustc --target ...) |
| | |
| +-- Sysroot: target headers + libs |
| | |
| v |
| Cross-Compiled Binary (ELF aarch64) |
| | |
+-------+------------------------------------------+
|
| scp / flash / JTAG / TFTP
v
Target Machine (aarch64)
+--------------------------------------------------+
| |
| Runs the binary natively |
| |
+--------------------------------------------------+
Knowledge Check
-
What does the "gnu" part of
aarch64-unknown-linux-gnuspecify? What would "musl" mean instead? -
You cross-compile a Rust program for
aarch64-unknown-linux-gnubut linking fails. What is the most likely missing piece? -
Why can you not just copy a dynamically-linked x86-64 binary to an aarch64 machine and run it?
Common Pitfalls
-
Forgetting to install the cross-linker for Rust.
rustccan emit aarch64 code, but it needsaarch64-linux-gnu-gcc(or equivalent) to link. Configure this in.cargo/config.toml. -
Mixing host and target libraries. If your Makefile picks up
/usr/libinstead of the sysroot'slib/, you get x86 libraries linked into an ARM binary. The result may link but will crash at runtime. -
Assuming identical struct layout across targets. Padding and alignment differ between 32-bit and 64-bit architectures. Use fixed-width types and
#pragma packor#[repr(C, packed)]when layout must be exact. -
Not testing with QEMU. Before deploying to real hardware, test cross-compiled binaries with
qemu-user. It catches most issues without needing the physical device. -
Forgetting endianness in wire protocols. If you serialize a struct to bytes on one architecture and deserialize on another, byte order mismatches will corrupt every multi-byte field.