Cross-Compilation
Someone once asked me: “Why do I need this complicated cross-compilation environment. Why can’t I just build the program on my Ubuntu Laptop, copy it to my Android phone and run it there?”
I’m sure you have thought about what a compiler does, it makes a binary from source code. But what is actually in that binary depends a lot on where it will be run.
CPU Instruction Set
First there is the CPU instruction set, like x86_64
or
aarch64
. As the name says, this defines what instructions
your CPU can understand. So the first reason the program you built
on/for your laptop can’t run on your phone just like that, is that they
probably have very different CPUs, and use different instruction sets.
The compiler has to build the binary specifically with the instructions
your CPU can process.
For example when you consider the following C code:
int i = 0;
i++;
In assembly, it will (simplified) translate to something containing this:
x86_64
:
c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
83 45 fc 01 addl $0x1,-0x4(%rbp)
aarch64
:
52800000 mov w0, #0x0
11000400 add w0, w0, #0x1
The left column is the actual binary, written as hexadecimal. The
assembly instructions that are meant by these bytes are added on the
right. One could notice is that x86 instructions have variable length
while the aarch64
instructions all have the same length.
But also the instructions for setting a register or incrementing it have
totally different binary representations.
Kernel / syscall ABI
But even when two computers share the same CPU instruction set, they
can have other differences. For example consider an Intel
(x86_64
) Mac, an x86_64
PC running Linux and
an x86_64
PC running Windows. They all have the same CPU
instruction set, but if you build a binary on/for one of them, it will
still not run on the others without some compatibility layer.
One reason for this might be because of some differences when
communicating with the operating systems kernel. For example, printing
some text requires talking to the kernel and on
x86_64-apple-darwin
it might contain these
instructions:
b8 04 00 00 02 mov $0x2000004,%eax ; sys_write syscall
bf 01 00 00 00 mov $0x1,%edi ; file handle 1 -> stdout
48 be 00 10 00 00 01 00 00 00 movabs $0x100001000,%rsi ; address of the message
ba 0e 00 00 00 mov $0xf,%edx ; length of the message
0f 05 syscall
Where 0x2000004 is the number of the sys_write
syscall.
0x100001000 is the address of the text to be printed.
On x86_64-pc-linux-*
, the same thing might look like
this:
b8 01 00 00 00 mov $0x1,%eax ; write syscall
bf 01 00 00 00 mov $0x1,%edi ; file handle 1 -> stdout
48 be 00 20 40 00 00 00 00 00 movabs $0x402000,%rsi ; address of the message
ba 0d 00 00 00 mov $0xe,%edx ; length of the message
0f 05 syscall
Where 1 is the number of the write
syscall on linux
(see, totally different than Mac OS!), and 0x402000 is the address of
the text to be printed (different because of the way the executable is
put together).
On x86_64-windows-*
the way to do syscalls is completely
different, but I won’t go into details here.
External libraries / linking
It is also important to note, that when building for other systems
all external libraries need to be built for the correct target
architecture. By default your system might dynamically link against
shared libraries. This means the executable file produced does not
actually contain all the code needed to run the program, but instead
some parts are loaded from .so
files when you run the
program. If you copy the dynamically linked executable to another
system, that system must have compatible .so
files for all
the libraries used.
You might find it easier to statically link the executable. This means including the libraries that are required to run the program in one executable, so fully statically linked executables should be able to run on all systems with the instruction set and kernel they were built for.
However, not all libraries can be statically linked. For example, on various systems you are expected to use the system libc instead of bringing your own, or certain libcs might not like being statically linked. This is why many Rust and Golang binaries might not run across systems of the same kernel and instruction set. To build fully-static binaries on Linux, one needs to build against musl libc or uClibc instead of glibc. The details of this restriction are again out of scope for this post, but I might write another one specifically on that topic.
Conclusion
So now you know, why you need cross-compilers; A normal compiler will
usually produce binaries for the environment it’s running on. If you
want to build something on your x86_64
Laptop and run it on
your aarch64
phone later, you will need a cross-compiler to
specifically produce instructions compatible with your phone’s
environment.