Visual walkthrough — Cross-compilation — toolchains, sysroot
This is the central result of the parent note, derived from absolute zero.
Step 1 — What is a "search path" at all?
WHAT we are doing: naming the mechanism before we bend it.
WHY: everything about cross-compilation is a change to this list. If you do not picture the list, --sysroot looks like magic. It is not magic — it is list-editing.
PICTURE: the compiler as a person holding a checklist of drawers, opening them top to bottom.

Step 2 — WHERE does that list come from?
WHAT: we trace each drawer back to the prefix that created it.
WHY: this is the exact fact that breaks in cross-compilation. The cross compiler's frozen defaults point at the target's layout, and the folders they name do not exist on your laptop. Something must supply them.
PICTURE: a stamp reading prefix = /usr pressing the paths into the compiler at build time.

Step 3 — The problem, drawn as two colliding worlds
WHAT: overlay the two roots and mark the collision.
WHY: to make the failure concrete. If the linker resolves printf against x86 code and drops it into an ARM binary, the target CPU cannot decode those bytes. This is the mistake "just copy the binary" from the parent — same disease, earlier stage.
PICTURE: two separate / trees, red arrow showing the compiler reaching into the WRONG one.

Step 4 — The sysroot: relocate the root, keep the shape
WHAT: we introduce the single new object, .
WHY: we do not want to rewrite the compiler's logic — the ordered-list idea from Step 1 is fine. We only want to move where "root" points, exactly like chroot moves the kernel's idea of /. One knob, minimal change.
PICTURE: the laptop's real / and, sitting inside it, a fake / labelled that has the target's contents.

Step 5 — Apply the transform to every drawer
WHAT: rewrite each path in the include list and the lib list. We write the transformed lists with a prime — just means "the include list after the transform"; the prime is our shorthand for "relocated version of".
WHY: to see there is no special case hiding — even the odd-ones-out /lib and /usr/lib (which were not under prefix) still get prefixed, because the rule keys on "starts with /", not on "came from prefix".
PICTURE: each drawer relabelled with glued to its front, a chalk-blue prefix highlighted.

Step 6 — Where your own -I and -L land
WHAT: place the user paths on top of both transformed lists — the include list (fed by -I) and the library list (fed by -L).
WHY: this is exactly why -I/usr/include is a trap (Step 3): your literal path bypasses the sysroot relocation and re-reaches the host root. The same warning applies to -L/usr/lib. The defaults are safe; your manual overrides are your responsibility.
PICTURE: two final ordered stacks side by side — user -I/-L on top (unprefixed), sysroot defaults below.

Step 7 — The degenerate case: no sysroot, no libc (aarch64-none-elf)
WHAT: the boundary case where the lists collapse. Note the symmetry: it is not only the library list that empties.
WHY: with -nostdlib there is no libc to link and no startup file crt0.o; with -ffreestanding (and, if you want no system headers at all, -nostdinc) the compiler stops assuming a hosted C library's headers. Applying would only relocate paths that hold nothing useful. The correct move is to remove them, not relocate them.
PICTURE: both drawer lists crossed out, replaced by a single user-provided linker script placing code at physical addresses.

The one-picture summary

The whole derivation on one board: a default ordered list of paths, born from prefix, is bent by the single rule so that a name like stdio.h resolves inside the target's world instead of the host's / — with your -I/-L sitting untouched on top, and the empty-list degenerate case (both includes and libs) when the target is bare metal.
Recall Feynman retelling — say it back in plain words
A compiler doesn't know where files are; it has an ordered checklist of drawers and opens them top to bottom until it finds the name you asked for — the first match wins, so order matters, which is why we always draw the list in square brackets, never as an unordered set. Those drawers were frozen into the compiler when it was built (from a setting called prefix, which is just its install root, usually /usr), and they all start at the root slash /. On your laptop that root is full of x86 stuff — wrong for an ARM target. So instead of teaching the compiler new logic, we hand it one string , a folder that looks like the target's root, and we path-join onto the front of every drawer that started with / (path-join, not raw glue — we tidy up any double slashes). Now stdio.h is found inside the target's world, libc.so is the ARM one, and the finished ELF is genuinely ARM. Anything you add by hand with -I (headers) or -L (libraries) is taken literally and searched first, so if you point it at the host you break everything — keep it inside . And if the target is a bare chip with no operating system, there's nothing for to point at, so we don't relocate the drawers — we throw both lists away with -nostdlib/-nostdinc and place the code ourselves with a linker script. One rule, all the cases.
Recall Quick self-test
What single operation does a sysroot perform on the default paths? ::: It path-joins the sysroot in front of every absolute default path: (normalizing away any double slash).
Why do we draw search paths with square brackets, not set braces? ::: Because they are ordered lists — the first matching folder wins, so order carries meaning a set would lose.
What do -I and -L do, and are they relocated by the sysroot? ::: -I adds a header dir, -L adds a library dir; both are taken literally and searched before the relocated defaults — not prefixed with .
What happens to the search lists for a bare-metal none target? ::: Both collapse to the empty list (-nostdlib for libs, -nostdinc for standard includes); you supply layout via a linker script.
Why not just fix it with -I/usr/include? ::: That re-points a drawer at the host root, pulling x86 headers into an ARM build.