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:

FieldMeaning
archCPU architecture (x86_64, aarch64, arm, riscv64)
vendorWho made it (unknown, apple, pc)
osOperating system (linux, windows, none)
abiABI / 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-gnueabihf and cross-compile the same program for 32-bit ARM. Use file to confirm it is an ARM executable. Run it with qemu-arm. Check what sizeof(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_COMPILE and ARCH variables: 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-gcc or musl-cross-make and 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
  • -ffreestanding tells the compiler not to assume a hosted environment.
  • -nostdlib tells the linker not to link the standard library.
  • -T linker.ld provides 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-gnu is 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

  1. What does the "gnu" part of aarch64-unknown-linux-gnu specify? What would "musl" mean instead?

  2. You cross-compile a Rust program for aarch64-unknown-linux-gnu but linking fails. What is the most likely missing piece?

  3. 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. rustc can emit aarch64 code, but it needs aarch64-linux-gnu-gcc (or equivalent) to link. Configure this in .cargo/config.toml.

  • Mixing host and target libraries. If your Makefile picks up /usr/lib instead of the sysroot's lib/, 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 pack or #[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.