Cross compiling Rust and how to get tiny binaries
If you are reading this then it’s quite likely you have discovered your love for Rust quite a while ago and are already somewhat proficient in using this amazing language.
I won’t boast with my rather lacking Rust-skills or try to provide another tutorial to get into this amazing beast of a language. There are tons of other great ressources to learn Rust, like. Rustlings or the Rust Book.
What I want to show you is another aspect of the Rust ecosystem that makes life so much easier.
Cross Compiling
As you might have guessed from the title I will focus on cross compiling Rust code to binaries that can run operating systems and/or CPU architecures different from the system you are compiling on.
In my case I need to use Mac OS X to compile some applications for Linux on ARM v7.
I hate Cross Compiling
There are millions of ways to ruin the operating system you are working on and cross compiling is one of them. It normally starts with the innocent idea of getting that one build chain going you need for a small program to run on a Linksys WRT 1900 ACS (OpenWRT and ARMv7).
Digging around you find several different snippets on Reddit or some issue in a GitHub project where a random person posted a few lines of bash that look like just that missing bit of information you needed.
Running the bash can then cause one of the following things:
- Create a working build chain (very unlikely)
- Create a build chain which fails after 80% of your build
- Add a local bitcoin miner while pretending to actually create a working build chain
Being a little more knowledgable (a.k.a. being old) I am a little cautious about these things. So I normally use Vagrant by the almighty Hashicorp for all my experiments.
I broke tons of Vagrant-boxes in the process of creating the build chains I needed (May their souls rest in peace).
This brought back so many bad memories from back in the 90s where I started as a C-coder, it made me almost go back to using either the JVM (using Graal native image) or Go cross compiling.
I could have saved myself so much frustration if I had searched for such a little longe before going into trial-and-error-mode.
I love Cross Compiling (and Docker)
After failing hard for several times I finally sat down and did a little research (it was basically one more search I did …) on what the Rust community had to offer.
And that’s how I found cross, a compile chain management tool based on Docker. And honestly: It’s all I ever wished for when it comes to cross-compiling.
Run the following command to get the cross command:
cargo install cross
From now on, building for another architecture is as simple as typing in the following command:
cross build --target=armv7-unknown-linux-musleabihf
This will produce a binary that can run on OpenWRT Arm v7 target platform.
Success!
What happens in the background is Docker containers being pulled which contain the individual build chain specified as the — target. Your code will be mounted into the container where the actual compiling takes place, producing the binary.
This approach has several advantages:
- There is no danger to ruin your system.
- Builds become reproducible across all operating systems running Docker
- Builds are easy to deploy to a CI-pipeline
Suddenly my bad C-memories were gone and I started playing around with build pipelines for the various ARM-SoCs I had lying around. Gone were the hours of fighting, replaced with browisng a list of available chains and quickly playing around with them.
The results and some size improvements
Let’s take a look at the results produced by the cross-compiler.
I did a little experiment, creating a Rust program that would render QR-codes for guest accounts to my home-wifi (the aformentioned Linksys running OpenWRT).
Using the following line I was able to create a binary:
cross build --target=armv7-unknown-linux-musleabihf
The size of the resulting binary was a whopping 5.7 MB. Which is quite a package for deploying it to a SoC where disk space isn’t exactly abundant.
Time to dig a little into the compiler options.
Using the — release option we are able to strip debug symbols from the binary:
cross build --release --target=armv7-unknown-linux-musleabihf
This brings the size down to 3.0 MB, a reduction by almos 50%. Impressive, but still a huge binary for such little functionality.
But we are out compile roptions so we have to look somewhere else.
Recently, while working on some Go-code for my current project at Instana, I discovered upx. UPX is a packager for executables which does a great job for reducing the size Go-binaries. I figured it might be worth a try with my newly cross compiled binary:
upx --brute <binary_name>
The results: The binary is down to 700 KB.
This is way better than anything I had expected. We are down by 88% from the initial size, way below a megabyte.
Summary
Not only is Rust an amazing language, its whole ecosystem is amazing and tackles the really painful things. Using a Docker-based solution for managing build chains removes a ton of pain from cross compiling.
But my favorite outcome is the size reduction achieved by UPX.