Different toolchains support it differently. So for example, if you want to cross-compile with gcc and ld, you need to first compile gcc and ld for the target architecture. This is because you pick the host and target at compile time. So you get aarch64-elf-gcc instead of just plain gcc. This means that you either hope that someone else has already done this for you, or you have to build it yourself. It's not impossible but it is a pain. Contrast this with llvm; clang and lldb support multiple hosts and targets in one binary. You pass --target (or whatever) and you're good to go. This removes that setup step.
The outside world. If you want to target an OS that's not your OS... you need the API for that OS. On Linuxes, that's the actual syscall interface, but on many other OSes, for example, Windows, the syscall interface is not stable. You must use the library the OS provides. This means that, if you're on Linux and you want to cross-compile to Windows, you need to get a copy of all the stuff that's in C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.10.24728\lib or whatever, because your program is going to need it.
Is your project all in one language? If not, you'll need to do the above in multiple ways, maybe. For example, a pure Ruby program is easy to "cross compile", you just cross-compile the interpreter. (I don't know how hard that actually is these days, but it's an analogy, work with me here.) But if you use C extensions, you also need to have them cross-compiled. Use both Rust and C? You'll need the toolchains for both, and to coordinate them. (We've tried to generally make this very easy for Rust but there's still details where it's not.)
Then, you need to make sure that the software understands that host != target. So for example, in Rust, we now have compile time function evaluation. The way that this works is, we include a full interpreter into the compiler, and it runs CTFE stuff with the interpreter set to the properties of the target, not the host. Not all languages do this kind of thing. There's tons of other ways this can possibly go wrong too, as it's not the case that many people expect. For example, maybe the software uses linux-specific stuff. You can't just automatically cross-compile it then. But it's not like anything tells you you're using os-specific stuff, and so if you never attempt to cross-compile, then you may not even realize.
I wonder, could the GNU toolchain devs be convinced to add support for multiple hosts/targets in one binary? Do they have a philosophical objection to it? Or is it simply that it is a lot of work and nobody has done it? It would make cross-compiling with the GNU toolchain a lot easier.
That said, this is getting easier and easier to do. I think this is because debian packages have to be architecture specific just for Intel architectures now, to handle both x86 (32 bit) and x86_64 ISAs. Once you have the environment, creating and using a different compiler executable is the easy part.
Could cranelift or similar allow rust to support multiple targets easier than llvm?
So if you want to have a cross toolchain for, say, RISC-V on x86, it's not enough to have the compiler and assembler, you need a RISC-V linker script (for your particular RISC-V target), a RISC-V build of the C library (again target-specific), the libgcc helper library, headers for all the relevant platform code (they seem simple, but in my experience headers are routinely the biggest mess).
And unlike the host toolchain where there is an unambiguous "correct" answer for all that stuff, a cross environment might be asked to support all kinds of crazy alternatives. Your ARM gcc might be used to produce a binary to run on Fedora, or for a busybox/uclibc build on an ancient kernel, or to target Zephyr (my world)...
It gets messy very fast. But it's not "hard". Putting it together for one system isn't any harder than it is for any other.
Building the cross-compiling toolchain is relatively easy, compared to dealing with the problems in software
I ran into a bug in bash [0], for example, that you would really only hit if you were cross-compiling it (there are other ways, because it wasn't related to being cross-compiled just features that were disabled automatically when cross-compiling). It took a while to convince people that this bug existed because it wasn't obvious that it was caused by being cross-compiled.
The real problems come from "modern" package managers as well as scripting languages in general.
Scripting languages and their extension mechanism are, in general, absolutely ignorant of cross-compiling and the idea that you might be building an extension to the language for a different platform than the running interpreter is completely foreign to most. Ruby fails especially hard here -- it looks ONLY at the running interpreter to determine how to build the extension. Python is a bit better because, but still rigid in versions. Perl's cross-compilation story requires having access to a system to SSH into (though there are alternative autoconf-based build systems that make this sane -- I don't know if they work for extensions, I just abandoned the idea of including Perl based on how poor their build system was). Tcl was the best, since it's extension system (TEA) is autoconf-based.
LuaJIT can't be cross-compiled from a 32-bit system for a 64-bit system [1] since it, at build-time, tries to do some stuff and fails at it.
[0] https://lists.gnu.org/archive/html/bug-bash/2015-06/msg00042... [1] http://luajit.org/install.html#cross
It tends to be the dependencies (of the compiler and the libraries) that assume target==host.
Clang isn't broken like that, and for clang you are right that the issue is normally dependencies. Again GNU's libc is kind of broken.
Cross compiling with Clang and Musl is not too bad.
If my chip vendor decides to replace the ARM core on a SoC with a RISC-V core but leave all peripherals and memory mappings the same, I want to simply change the target and rebuild.
I mean, I don't know what distro you're working on (and whether they package these toolchains conveniently), but on Arch you just change your configured target in your conf/build system, and make sure you have the other one installed. If you're using autotools or cmake, you may not even need to change the configure scripts.
If you're doing embedded with memory mapped peripherals, surely you're building all your deps statically anyway.
If you don't have hardcoded addresses and are fully abstracted by a modern HAL and VM like on Linux then it's mostly a problem of the Linux distro or the OS vendor.
Ex: GOOS=windows go build .
GOOS=linux GOARCH=arm go build .
And you can run that from any os / arch, I can build a single Windows binary from my Raspberry Pi with the above example.
EDIT: Try to build NetBSD for some esoteric platform from the comfort of your desktop machine. Remember to pick up your jaw from the floow when you're done. ;-) My point is, it does not have to be hard.
Rust projects make much more use of C libraries than Go projects do, though, so for things other than the Rust compiler, this is still a good point.
Or is it about the helper programs / scripts around the core compilation process that are too often hard coded to read out the configuration from the host system? So basically there is no technical hurdle, just the social norm that target arch == host arch, and thus make procedures aren't sufficiently tested for cross compilation from the get go?