This page is the "hands dirty" companion to Cross-compilation — toolchains, sysroot (index 5.3.10) . The parent told you what a toolchain, triplet and sysroot are. Here we run the machine through every kind of situation it can hit — every combination of "which machine builds", "does the target have an OS", "static or dynamic", "does it produce an executable or a shared library", "does the sysroot help or hurt". You should never meet a cross-compile case in real life that isn't one of the cells below.
Definition Two words we use on every line —
<arch> and "prefix"
<arch> is the first field of a target triplet (parent's shape <arch>-<vendor>-<os>-<abi/libc>). It is a plain string naming the instruction set the CPU understands — e.g. x86_64, arm, aarch64, mips, mipsel. Concretely <arch> in arm-linux-gnueabihf is the token arm; in aarch64-none-elf it is aarch64. It decides which raw machine instructions come out of the compiler.
"prefix" here means the compiler's installation prefix : the single top directory the compiler was configured to live under when it was built (like /usr or /opt/arm-tc). Every default place it looks for headers and libraries is computed relative to that prefix — that's why a native gcc defaults to /usr/include (prefix /usr) and a cross gcc defaults to headers inside its own install directory. Remember: prefix = where the compiler lives; sysroot = where the target's / lives. They are different directories.
Definition Feature-probing (why "can't run" bites you)
Build systems like autotools often check "does this platform have function X?" by compiling a tiny test program and running it to see the result. That run step is feature-probing . In a cross build the tiny program is target code, so it won't execute on your host → the probe hangs or errors. You disable/replace it by (a) making --host differ from --build so configure switches probes to compile-only mode, and (b) supplying *_cache answer files (e.g. ac_cv_...=yes) so autotools takes your word instead of running anything.
Each row is a case class — a genuinely different situation. Each has at least one worked example that lands on it (the "Cell" tag tells you which).
Cell
Case class
Distinguishing input
What changes
A
Native (control case)
build = host = target
nothing special; the baseline to compare against
B
Plain cross, dynamic libc
build = host ≠ target, has OS
needs --sysroot, links libc.so
C
Cross, static libc
same but -static
sysroot's libc.a baked in; no runtime deps
D
Bare metal (degenerate: no OS, no libc)
<os> = none
-nostdlib -ffreestanding, linker script
E
Canadian cross (all three differ)
build ≠ host ≠ target
two toolchains + a target sysroot to build
F
Zero/degenerate : header not found
sysroot missing/empty
the error, and why
G
Sign/ABI mismatch (soft vs hard float)
gnueabi vs gnueabihf
links fine, crashes at run
G2
Endianness mismatch (big vs little)
mips vs mipsel
links or silently reads bytes backwards
H
Real-world word problem
Raspberry-Pi image build
pick the whole toolchain from a description
I
Exam twist
reason about ALL search phases
derive the full include + library search order
J
Output-kind axis : shared library (PIC/PIE)
-shared -fPIC (or -fPIE)
produces a .so (not an executable), position-independent code
The matrix really is a set of independent yes/no axes . The figure below shows the arch / OS / static-vs-dynamic / output-kind cube, and the decision tree that routes you to each cell.
We now walk the cells A → J .
Cell A. Compile hello.c on your x86-64 laptop, for your x86-64 laptop.
gcc -o hello hello.c
file hello
Forecast: before reading on, guess what file hello prints. Which arch? Which sysroot got used?
Steps.
gcc emits x86-64 machine code.
Why this step? Native gcc was built with its own <arch> baked in as the default; you never told it otherwise.
It reads headers from /usr/include and links libc.so. On a multi-arch Debian/Ubuntu system the real default library directory is not bare /usr/lib but the arch-tagged sub-directory /usr/lib/x86_64-linux-gnu (and /lib/x86_64-linux-gnu); /usr/lib is only a fallback.
Why this step? Those are its built-in defaults, computed from its installation prefix /usr (see the definition above). Multi-arch splits libraries into <prefix>/lib/<triplet> so several architectures' .sos can coexist on one disk — worth knowing because a cross sysroot mirrors that same tagged layout (e.g. S/usr/lib/arm-linux-gnueabihf), not a flat usr/lib.
No sysroot needed.
Why this step? Sysroot only matters when host ≠ target. Here they're equal, so the host's / is the target's /.
Verify: file hello → ELF 64-bit LSB executable, x86-64. You can literally run ./hello. This is the only cell where running the output is allowed — remember that; it's why cross builds must disable feature-probing (defined above; used in Cell H).
Cell B. Build hello.c on x86-64, to run on 32-bit ARM Linux (Raspberry-Pi-class), linking libc dynamically.
arm-linux-gnueabihf-gcc \
--sysroot=/opt/arm-sysroot \
-o hello hello.c
file hello
Forecast: which stdio.h gets read — your laptop's or the one in /opt/arm-sysroot? What does file print?
Steps.
The command prefix on the tool name, arm-linux-gnueabihf-, selects a compiler whose default <arch> is arm.
Why this step? This name-prefix is the triplet (parent note) — note it is a naming prefix, distinct from the installation prefix defined earlier; it guarantees ARM instructions come out, not x86.
--sysroot=/opt/arm-sysroot relocates the root for header/lib search.
Why this step? Without it, the compiler would read your laptop's /usr/include/stdio.h — x86 struct layouts baked into an ARM binary → corruption.
The linker resolves printf against the target libc.so inside the sysroot — on a multi-arch sysroot that is /opt/arm-sysroot/usr/lib/arm-linux-gnueabihf/libc.so.
Why this step? Dynamic linking leaves a reference to libc.so; on the Pi the loader fills it in at runtime. See Static vs dynamic linking .
Verify: file hello → ELF 32-bit LSB executable, ARM, EABI5 ... dynamically linked, interpreter /lib/ld-linux-armhf.so.3. The word ARM confirms the arch; dynamically linked confirms libc stayed external. Copy to the Pi, ./hello runs.
Cell C. Same ARM target, but the device has no matching libc.so installed — bake libc in.
arm-linux-gnueabihf-gcc \
--sysroot=/opt/arm-sysroot \
-static -o hello_static hello.c
file hello_static
Forecast: will file still say "dynamically linked"? Will the binary be bigger or smaller than Cell B's?
Steps.
-static tells the linker to pull object code out of libc.a and copy it into the executable.
Why this step? The target lacks the runtime libc.so; a self-contained binary needs no external loader.
The sysroot is still required — libc.a lives at /opt/arm-sysroot/usr/lib/arm-linux-gnueabihf/libc.a (again, multi-arch tagged).
Why this step? Static vs dynamic changes which file (.a vs .so) is read, not where we look; the sysroot still supplies the target's copy.
Result: bigger file, zero external .so dependencies.
Why this step? Every used libc function is now physically present, so nothing is resolved at runtime.
Verify: file hello_static → ELF 32-bit ... ARM ... statically linked. Compare with Cell B: same arch (ARM), different linkage word. ldd hello_static → not a dynamic executable.
Definition Linker script (what
-T link.ld actually does)
A linker script is a small text file that tells the linker where in memory each section of your program goes . Recall from ELF object file format that compiled code is grouped into sections : .text (instructions), .data (initialised globals), .bss (zero-initialised globals), .rodata (constants). On a hosted OS the loader picks addresses for you. On bare metal there is no loader , so you write directives like:
MEMORY { ROM (rx) : ORIGIN = 0x08000000, LENGTH = 256K
RAM (rwx): ORIGIN = 0x20000000, LENGTH = 64K }
SECTIONS {
. = 0x08000000; /* start placing at ROM base */
.text : { *(.text*) } > ROM /* all code into ROM */
.rodata : { *(.rodata*) } > ROM
.data : { *(.data*) } > RAM AT> ROM /* lives in RAM, stored in ROM */
.bss : { *(.bss*) } > RAM
}
Read it as a map : "put .text starting at address 0x08000000, then .rodata right after, then send .data/.bss to RAM." The figure below draws this mapping.
Cell D. Build firmware for an aarch64-none-elf chip — no kernel, no printf, no malloc.
aarch64-none-elf-gcc -nostdlib -ffreestanding \
-T link.ld -o firmware.elf start.s main.c
Forecast: what happens if you forget -nostdlib here? Which of Cells B/C could you copy? (Answer: none — there's no OS to load anything.)
Steps.
<os> = none in the triplet ⇒ there is no hosted C library at all.
Why this step? The parent note's rule: none means "no kernel, no libc unless you supply it."
-nostdlib stops the linker pulling crt0.o/libc.
Why this step? Those startup files assume an OS hands control to main with a stack and argv. Bare metal has none of that yet.
-ffreestanding tells the compiler main is not magic and no library function is guaranteed.
Why this step? In a hosted environment the compiler may assume printf exists and even optimise around it; freestanding forbids that.
-T link.ld places code/data at physical addresses using the linker script defined above.
Why this step? No OS loader means you decide where the reset vector and .text sit in ROM/RAM — the script is that decision.
Verify: file firmware.elf → ELF 64-bit ... ARM aarch64 ... statically linked, and readelf -l firmware.elf shows your custom load addresses (0x08000000 for .text, 0x20000000-region for RAM) from link.ld. There is no interpreter line — proof nothing external will be loaded. See ELF object file format and Linkers and the linking process .
Cell E. You are on x86-64 and must produce a compiler that itself runs on ARM but emits MIPS code.
build = x86_64-linux-gnu
host = aarch64-linux-gnu
target = mips-linux-gnu
Forecast: how many distinct compilers do you need present on your x86 laptop before you can even start? And where do the MIPS libraries come from? (Guess before reading.)
Steps.
You need a build→host compiler: an x86 compiler that produces ARM binaries.
Why this step? The final compiler must run on ARM , so it must be built by something that emits ARM code.
You need a build→target compiler: an x86 compiler that produces MIPS libraries.
Why this step? The final ARM-hosted compiler ships with a MIPS libc; those MIPS libs must be built now on x86, since ARM isn't handy.
Acquire the MIPS sysroot/libs concretely. You do NOT hand-write them — you either (a) apt install a prebuilt libc6-dev-mips-cross sysroot, or (b) build glibc from source using the build→target compiler from step 2 and make install DESTDIR=/opt/mips-sysroot, which populates usr/include and usr/lib under that DESTDIR. DESTDIR is exactly the mechanism that turns an install into a sysroot tree.
Why this step? Steps 1–2 give you compilers ; the ARM-hosted product still needs target headers + libs to link MIPS programs, and those must physically exist somewhere the linker can find them.
The output compiler binary is ARM code that, when run, emits MIPS code.
Why this step? That's exactly what "host = arm, target = mips" means.
Verify: the answer to the forecast is two cross compilers on the laptop (build→host and build→target), plus one MIPS sysroot you build/acquire. Sanity check by counting distinct arch pairs you must already possess: (x86→arm) and (x86→mips) = 2; the ARM (arm→mips) compiler is the product , not a prerequisite. So required-count = 2 , sysroot-count = 1 .
Cell F. You run the Cell B command but forget --sysroot (or point it at an empty dir).
arm-linux-gnueabihf-gcc -o hello hello.c
# fatal error: stdio.h: No such file or directory
Forecast: the native gcc always found stdio.h. Why does the cross one fail on the identical source?
Steps.
The cross compiler's built-in default header path is inside its own install prefix , and may be empty until a sysroot is set.
Why this step? A cross gcc is deliberately not told "use /usr/include" — that's the host's x86 headers, which would be wrong (Cell B step 2).
With no sysroot, the target's usr/include is nowhere on the search list ⇒ stdio.h genuinely does not exist for this compiler.
Why this step? You removed the only place the target headers could come from.
Fix: add --sysroot=/opt/arm-sysroot (back to Cell B) or use a toolchain that pre-bakes one.
Verify: re-run with the sysroot ⇒ compiles. The degenerate case (empty sysroot) reproduces the same error, confirming the error is about absence of the target usr/include , not about the arch.
Cell G. Your code is built arm-linux-gnueabihf (hard float) but the device's libm.so was built arm-linux-gnueabi (soft float). Everything links. It runs. Then a double calculation returns garbage or it crashes.
Forecast: if the linker accepted it, how can it be wrong? What is actually mismatched?
Steps.
gnueabihf passes float/double in VFP registers — the ARM floating-point registers named s0, s1, ... (32-bit) and d0, d1, ... (64-bit). gnueabi (soft) passes them in the integer/core registers r0, r1, ....
Why this step? The trailing ABI field of the triplet decides the calling convention — where each argument physically sits — see ABI and calling conventions .
Both are "ARM Linux ELF", so the linker (which matches by symbol name, not by register convention) sees no conflict.
Why this step? The linker resolves the name sqrt; it cannot see that caller and callee disagree on which register holds the argument.
At runtime the caller writes the argument to s0/d0 (VFP) but the callee reads it from r0/r1 (integer) ⇒ wrong bits ⇒ nonsense or crash.
Why this step? Because the two sides never agreed on where the argument lives , the callee reads whatever happened to be in the integer registers rather than the real value — this is precisely the "links but crashes" ABI trap from the parent's mistakes list, and it can't be caught at link time (step 2).
Verify: match the entire triplet end-to-end. If caller and callee are both ...gnueabihf, the double argument goes to d0 on both sides and the result is correct. The bug is not in the arch (both ARM) — it's purely in the float-ABI field.
Cell G2. You build for mips-linux-gnu (big-endian by convention) but the actual chip is a mipsel (little-endian) part. It may fail to link, or worse — link and read every multi-byte value backwards.
Forecast: "endianness" is just which byte comes first for a number wider than one byte. Guess: does a byte-order mismatch corrupt a single char, or a 4-byte int?
Steps.
Big-endian stores the most-significant byte first; little-endian stores the least-significant byte first. For the 4-byte value 0x01020304, big-endian writes bytes 01 02 03 04; little-endian writes 04 03 02 01.
Why this step? This is the whole disagreement — same number, opposite byte order in memory/files.
The triplet field encodes it: mips = big-endian, mipsel = little-endian (the trailing el literally means "little-endian"). arm has armeb for the big variant.
Why this step? Just like the float-ABI field (Cell G), a sub-field of the triplet silently changes binary layout.
If you link big-endian objects with little-endian libraries, modern ld usually errors ("endianness incompatible"); but data read from files/network built on the wrong side is silently byte-swapped — a char is fine, every int/float/pointer is scrambled.
Why this step? The linker can catch object-level mismatch, but it cannot catch data your program reads at runtime.
Verify: pick the matching triplet end-to-end (mipsel-linux-gnu for a little-endian board) and confirm with readelf -h binary → the "Data:" line reads 2's complement, little endian. A single byte is unaffected; a 4-byte int is fully reversed — that's the forecast answer.
Cell H. "I have an x86-64 Ubuntu laptop. I want to compile a C program with autotools that will run on a Raspberry Pi 4 (64-bit) running Raspberry Pi OS (Debian, glibc, hard-float) , dynamically linked. Give me the exact triplet, prefix and command, and stop configure from trying to run test binaries."
Forecast: write down the triplet you'd choose before reading the answer.
Steps.
Decode the requirements into the four triplet fields.
Why this step? The triplet is the single knob that pins arch + os + libc + ABI.
<arch>: 64-bit ARM ⇒ aarch64
<os>: Linux ⇒ linux
<abi/libc>: Debian glibc, hard float ⇒ gnu
⇒ triplet aarch64-linux-gnu. (On aarch64 hard-float is the only ABI, so there's no hf suffix — that suffix is a 32-bit ARM thing.)
The command prefix is the triplet: aarch64-linux-gnu-gcc.
Why this step? Name-prefix = triplet, so native and cross gcc coexist in $PATH.
Configure with --host ≠ --build (this is the switch that turns off feature-probing runs) and point at a Pi sysroot.
./configure --host=aarch64-linux-gnu --build=x86_64-linux-gnu \
CC=aarch64-linux-gnu-gcc \
CFLAGS="--sysroot=/opt/pi-sysroot" \
LDFLAGS="--sysroot=/opt/pi-sysroot"
make
Why this step? --host differing from --build makes autotools compile-only its probes; the sysroot (Cell B) supplies the Pi's headers + libc.so. Any probe that still insists on running is fed ac_cv_... cache answers.
Verify: file app → ELF 64-bit LSB executable, ARM aarch64 ... dynamically linked. Forecast check: correct triplet is aarch64-linux-gnu — if you wrote aarch64-linux-gnueabihf, that's a common wrong answer (the eabihf suffix does not exist for aarch64). See Build systems CMake and autotools .
Cell I. Exam question: "Given sysroot S = /opt/arm-sysroot, GCC install prefix P = /opt/arm-tc for triplet T = arm-linux-gnueabihf version V, and these flags: -Ione, -iquote qq, -isystem sysdir, -idirafter after, plus -Lmylibs -static. List the complete ordered include search set AND the library search order. Then name the one path that would silently corrupt the build."
Forecast: guess how many include phases there are, and which single added path is poison.
Steps.
Include search — the full phase order (this is what an exam wants, not just -I). GCC searches, in order:
Why this step? The parent's relocation rule (below) applies to the default phases; the flag phases sit around them.
-iquote dirs — only for #include "…" : qq
-I dirs (and, after them, the phase below) : one
-isystem dirs : sysdir
default system dirs (relocated by the sysroot, see step 2)
-idirafter dirs (appended last) : after
Apply the relocation rule to the defaults. Let p range over each built-in default directory that begins at root / ; the rule rewrites every such p as S / p . Crucially the defaults are not just two entries — GCC's real built-in include list is:
Why this step? The parent showed the rule; a correct exam answer must list all the compiler's own header dirs, not only /usr/include.
the compiler's internal fixed/builtin headers : P/lib/gcc/T/V/include (e.g. stddef.h, stdarg.h) — not relocated, they live under the install prefix P
P/lib/gcc/T/V/include-fixed — also under P, not relocated
S/usr/include — a default that does start at /, so it becomes S/usr/include
(S/usr/local/include is dropped for cross toolchains, but if present it relocates too)
So the relocated set is { S / usr/include } plus the two prefix-P compiler dirs that were never under / to begin with.
Assemble the final ordered include list (7 dirs):
qq → one → sysdir → P/lib/gcc/T/V/include → P/lib/gcc/T/V/include-fixed → S/usr/include → after.
Why this step? Every directory is either an explicit flag, under the install prefix P, or under the sysroot S — never under the host /. Good.
Library search order (the include story's twin, for the linker):
Why this step? The reviewer's point — include-path relocation has a matching library -path relocation and precedence.
your -L dirs first, in command order : mylibs
then the toolchain's default lib dirs, relocated and multi-arch tagged: S/lib/T, S/usr/lib/T, then S/lib, S/usr/lib (and the compiler's own P/lib/gcc/T/V)
with -static the linker picks libX.a; without it, libX.so — same dirs, different file (Cell C)
The poison path: adding -I/usr/include (or -L/usr/lib).
Why this step? Those are the host's x86 dirs, outside both S and P — pulling x86 struct sizes / x86 .sos into an ARM build ⇒ silent ABI corruption (parent's mistake #3).
Verify: the final include list has 7 ordered directories; exactly 0 of them lie outside { S , P , explicit flags } . Adding /usr/include raises the "host-path" count to 1 — that is the silent-corruption trigger.
Definition PIC and PIE — position-independent code
A normal executable can assume it is loaded at one fixed address. A shared library (.so on Linux, .dll on Windows) is loaded at whatever address is free at runtime, possibly different every run. So its code must not hard-wire absolute addresses — it must be position-independent code (PIC) : instead of "jump to address 0x4050", it uses "jump relative to wherever I am ", resolving absolutes through a small table (the GOT — Global Offset Table) at load time. -fPIC tells the compiler to emit that style for a library; -fPIE is the same idea for a whole executable so the OS can randomise where it loads (security hardening). See Static vs dynamic linking .
Cell J. Cross-build a shared library libgreet.so for ARM Linux (the "output kind" is a .so, not an executable).
arm-linux-gnueabihf-gcc --sysroot=/opt/arm-sysroot \
-fPIC -c greet.c -o greet.o
arm-linux-gnueabihf-gcc --sysroot=/opt/arm-sysroot \
-shared -o libgreet.so greet.o
file libgreet.so
Forecast: what does file call a .so — "executable" or something else? Does the sysroot still matter for a library?
Steps.
Compile each source with -fPIC.
Why this step? The output will be loaded at an unpredictable address (definition above); non-PIC code would carry wrong absolute addresses and crash — this is the new output-kind axis, orthogonal to arch/OS/static-dynamic.
Link with -shared (not -o exe).
Why this step? -shared tells the linker to produce a loadable library with no main/entry-point-as-program, exporting its symbols for other binaries to link against later.
The sysroot is still needed — even a library #includes target headers and may reference target libc symbols.
Why this step? Same reasoning as Cell B: any target-visible header/symbol must come from the target's copy, not the host's.
Verify: file libgreet.so → ELF 32-bit LSB shared object, ARM, EABI5 ... dynamically linked. Note the words shared object , not "executable" — that's the output-kind giveaway. readelf -d libgreet.so | grep SONAME shows its library name. A consumer later links it with -lgreet -L..
The flow below shows how a single decision tree lands you in each cell.
all three machines differ
Recall Quick self-test
Which cells REQUIRE a sysroot? ::: B, C, F, H, J, and Cell I's derivation — every case that reads target headers/libs. (D uses -nostdlib, A needs none.)
Cross build, links cleanly, crashes on a double — which cell and why? ::: Cell G — soft vs hard float ABI mismatch; the linker matches names, not calling conventions (VFP d0 vs core r0).
Cross build reads every 4-byte int backwards — which cell? ::: Cell G2 — endianness mismatch (mips big-endian vs mipsel little-endian).
How many pre-existing cross compilers does a Canadian cross need, and what else? ::: Two (build→host and build→target) plus one target sysroot you build (e.g. via make install DESTDIR=…) or apt install.
Difference between installation prefix and sysroot? ::: Prefix = where the compiler itself lives (its default paths derive from it); sysroot = where the target's / lives (target headers/libs). Different directories.
What is feature-probing and how do you disable its run step? ::: Compiling+running a tiny test program to detect features; make --host ≠ --build so autotools goes compile-only, and supply ac_cv_* cache answers.
What extra flag does building a .so need and why? ::: -fPIC (position-independent code) plus -shared, because a shared library is loaded at an unpredictable address.
On multi-arch Debian, where do default libraries really live? ::: In arch-tagged subdirs like /usr/lib/x86_64-linux-gnu; a cross sysroot mirrors this as S/usr/lib/<triplet>.
What does a linker script (-T) do? ::: Maps program sections (.text/.data/.bss) to explicit memory addresses when there is no OS loader to place them.
Mnemonic Three knobs, every time
A rch (which CPU) · R oot (the sysroot) · R un? (never — so disable feature probing). "ARR , matey — never run the cross output on the host." (Plus the fourth knob in Cell J: what kind of file — exe or .so.)
Cross-compilation — toolchains, sysroot (index 5.3.10) — the parent topic
5.3.10 Cross-compilation — toolchains, sysroot (Hinglish)
Static vs dynamic linking · Linkers and the linking process · ELF object file format
ABI and calling conventions · Build systems CMake and autotools · Chroot and namespaces