Compile Rust for Raspberry Pi ARM

Tiziano Santoro
Oct 15 · 10 min read

In this article we will build a simple HTTP server application in Rust, cross-compile it for the Raspberry Pi ARM architecture, deploy it to a Raspberry Pi board over a network connection, and install it as a persistent service so that it restarts automatically at boot time and upon crash.

Background

For the rest of the article, there will be two devices involved:

  • a development machine, for instance a Linux or Mac laptop or desktop computer, from which we do most of the development.
  • a target board, for instance a Raspberry Pi 4, connected to the same network as the development machine, and to which we deploy the compiled binary. We assume that the target board is already configured to be reachable at a given IP address or hostname over the same network as the development machine, and has SSH exposed. We also assume that SSH is configured with public key based authentication, so that no password is required every time we connect to it.

Installing Rust

First, we install Rust on the development machine, following the instructions from https://www.rust-lang.org/tools/install via the interactive rustup installer.

Hello World!

We first create the directory that will contain our Rust project. The simplest way is to manually create an empty folder with the desired name (e.g. mkdir hello-world), cd into it, and run cargo init --bin; this creates all the necessary source files for an executable (binary) hello world application.

We test that things are working fine by running cargo run from within this folder, which compiles and runs the hello world application (for now we are still doing everything on the development machine itself). If everything is working, we see an Hello, world! message printed to the terminal. So far so good!

Hello Web!

To make things more interesting, we will build an HTTP server application, which keeps running indefinitely in the background.

There are a few excellent alternatives when it comes to HTTP server frameworks in Rust. Rocket is my personal favorite, but it requires a nightly version of the Rust compiler, so for this tutorial we will go with Actix to keep things simple.

First we add a dependency on the actix-web crate to the Cargo.toml file:

[dependencies]
actix-web = "3.1.0"

Then we (mostly) copy the basic example from the Actix home page into ./src/main.rs (by replacing all its existing content), with some modifications:

The main change we make is that instead of binding to 127.0.0.1:8080, which is a loopback address and not actually exposed over any physical network interface, we instead bind to [::]:8080 , which is a shorthand notation for an IPv6 “unspecified” address, meaning the server will bind to all the available interfaces.

We also add a println instruction so that we can easily tell whether and when the binary starts correctly, rather than having to stare at a blank terminal and guess.

We run this again locally via cargo run and then open a browser tab to http://127.0.0.1:8080/ and we verify we can see a page being served with the Hello World! message.

Creating a deployment script

Since we will be iterating over our program, and each iteration involves at a minimum compiling on the development machine and running on the target board, it is useful to create a simple script with the necessary commands, so that we can just run it and we don’t need to type the commands out every time.

We create a text file in the same directory, called deploy , with the following content (we will add more and more steps to it as we go):

The various set -o lines make bash behavior stricter in the event of errors (see https://kvz.io/bash-best-practices.html) and log the commands before executing them.

We then make the script executable with chmod +x ./deploy , so that we can then just run it directly as ./deploy , which we can try immediately.

At this point we have compiled our hello world in release mode (the default is debug mode, which is faster to compile, but results in a less optimized compiled binary). cargo writes out the compiled binary as ./target/release/hello-world .

We can inspect this compiled binary with the following command: file ./target/release/hello-world , which prints out something like

./target/release/hello-world: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=dfa8f424eff2133fc35a2a91d4a1f9546664f1bd, for GNU/Linux 3.2.0, with debug_info, not stripped

Note that this is (at least on my machine) a 64-bit binary for the x86–64 architecture. What would happen if we just tried to copy and run it on the target Raspberry Pi board? Let us add a line to our deploy script to try that!

The TARGET_HOST variable specifies the username and host name (or IP address) of the target board, which must be reachable over the network from the development machine.

The SOURCE_PATH variable specifies the path where the compiled binary is produced on the development machine (relative to the current directory), while TARGET_PATH determines where it will be copied on the target board (we use an absolute path for this).

We use rsync to efficiently copy the binary to the target board over the network.

The last line connects to the target board over SSH and tries to run the binary we just copied there. The -t flag to ssh ensures that the command is terminated when we hit Ctrl-C in our terminal; without it, the process may keep running in the background, which may become an issue later on.

If we run this script, we get an error similar to the following:

bash: /home/pi/hello-world: cannot execute binary file: Exec format error

Which indeed confirms to us that we need to do put more effort into this in order to end up with a binary that is compatible with the Raspberry Pi.

Let’s get back to work!

Cross-compile time!

Now let us try to compile the same program, this time for the correct target architecture.

First we need to install the appropriate Rust target platform for the current Rust toolchain.

For a Raspberry Pi 4 (and also for most other recent Raspberry Pi boards), we can use the armv7-unknown-linux-gnueabihf target.

Roughly, the target components have the following meaning:

  • armv7 : the architecture to use for the target processor, in our case ARM v7 (even though Raspberry Pi 4 actually supports up to ARM v8)
  • unknown : the sub-architecture to use; in our case this just means the default option
  • linux : the target Operating System
  • gnueabihf : the ABI to target; gnu means that it relies on the GNU C Library (also known as libc) for some functionality at runtime; hf means hard float, that is that the architecture supports hardware floating-point operations

The full command to run is:

rustup target add armv7-unknown-linux-gnueabihf

We can now specify this new architecture via the --target flag of cargo. We update our deploy script to reflect this:

Note that we also need to update the SOURCE_PATH variable to include TARGET_ARCH, since cargo puts compiled binaries for non-host architectures under subfolders named after the specific compilation target.

When we run this version of the deploy script, at this point most likely we get an error similar to following:

error: linking with `cc` failed: exit code: 1
|
= note: "cc" "-Wl,--as-needed" "-Wl,-z,noexecstack" "-L" [...]
= note: /usr/bin/ld: /home/tzn/src/raspberry-pi-rs/target/armv7-unknown-linux-gnueabihf/release/deps/hello_world-9195c885f830d71b.hello_world.c3yg8fqv-cgu.0.rcgu.o: relocations in generic ELF (EM: 40)

What is going wrong? The binary is actually getting compiled correctly by cargo, but then the final linking stage fails, because cargo is simply invoking cc and ld for this job, which on our development machine only know how to deal with x86 binaries (or whatever architecture the host has), and do not know anything about how to put together ARM binaries. It looks like we need to persuade cargo to use a more appropriate linker for this job. In fact, if this is the first time we compile (or link) anything ARM-related, it is likely we do not yet even have any tools installed that can do the job, so we first need to procure them. On Ubuntu we can do this with the following command:

sudo apt install gcc-arm-linux-gnueabihf

Now we should have the appropriate linker and compiler for ARM installed. We can try invoking it with the arm-linux-gnueabihf-gcc command (which will just terminate immediately if invoked without any arguments).

We still need to convince cargo to use it for the linking stage of our binary though. In order to do this, we create a new file named ./.cargo/config , in which we enter the following:

[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"

This instructs cargo to use the ARM-specific version of gcc for linking binaries compiled for the armv7-unknown-linux-gnueabihf Rust target.

One would think that we could have also used arm-linux-gnueabihf-ld for this, which should work identically, but, for reference, if we do that we get the following error:

error: linking with `arm-linux-gnueabihf-ld` failed: exit code: 1
|
= note: "arm-linux-gnueabihf-ld" "-L" [...]
= note: arm-linux-gnueabihf-ld: cannot find -lgcc_s

If we re-run the deploy script now, we finally see the Hello World server started log line from our server, which means that the server is finally correctly running on the target board!

We can open a browser tab and connect to the web server running there, for instance http://raspberrypi:8080 , and we should see the same web page as before, this time being served by the Raspberry Pi itself! We can also try connecting to it from another device (e.g. a smartphone connected to the same network) to confirm.

When we press Ctrl-C from the terminal, the attached server is also terminated (thanks to the -t flag passed to ssh), which lets us re-run the script again later on, and the new server will work as expected.

If for whatever reason a previous instance of the server were still running in the background, and we tried to start a new instance, we would get an erro message similar to the following:

Error: Os { code: 98, kind: AddrInUse, message: "Address already in use" }

If this happens, the simplest way to fix it is to manually SSH to the target board, and run something like sudo killall hello-world , which kills any instance of processes called hello-world (hopefully nothing else with that name is already running there). After this cleanup is done, we can re-run ./deploy and it should work again.

Persistence

We now want to make this binary behave like a proper service, which means restarting it if it crashes, and also automatically starting it at boot time, without human intervention.

There are a few ways to run programs at startup on a Raspberry Pi. For this article we use systemd, which requires more configuration, but it is also much more solid and reliable than other alternatives.

We start by SSH’ing to the target board. From the terminal, we then create a file called /lib/systemd/system/hello-world.service (we probably need to run our text editor as root via sudo) on the target board. The content of the newly created file may be something like:

We then save and exit the text editor, then run the following command to make systemd notice the new unit (which we will have to repeat if we make any changes to the unit file in the future):

sudo systemctl daemon-reload

We now enable the new unit with the following command:

sudo systemctl enable hello-world.service

This enables the unit but does not start it yet. In order to actually start the service in the background, we run the following command:

sudo systemctl start hello-world.service

If everything goes well, we do not see any error message or other output, but our server is running in the background. We can check this by navigating to the URL from before, and also by running the following command:

sudo systemctl status hello-world.service

which prints out something like

● hello-world.service - Hello World
Loaded: loaded (/lib/systemd/system/hello-world.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2020-10-15 00:40:59 BST; 47s ago
Main PID: 2310 (hello-world)
Tasks: 6 (limit: 4915)
CGroup: /system.slice/hello-world.service
└─2310 /home/pi/hello-world
Oct 15 20:54:18 raspberrypi systemd[1]: Started Hello World.
Oct 15 20:54:18 raspberrypi hello-world[2844]: Hello World server started

Confirming that the service is indeed active and running.

If necessary (especially for more complex applications) can check the full logs for the service (even across restarts) with the following command:

journalctl --unit=hello-world.service

Let us try to see what systemd does in order to keep the service up and running, by simulating a crash via the sudo killall hello-world command we saw earlier. If we do that, and then quickly (within the 10 second window we specified in the unit configuration) run sudo systemctl status hello-world.service again, we see the following output:

● hello-world.service - Hello World
Loaded: loaded (/lib/systemd/system/hello-world.service; enabled; vendor preset: enabled)
Active: activating (auto-restart) since Thu 2020-10-15 00:44:46 BST; 3s ago
Process: 2347 ExecStart=/home/pi/hello-world (code=exited, status=0/SUCCESS)
Main PID: 2347 (code=exited, status=0/SUCCESS)

Which tells us that the process is in the auto-restart phase, but has not been restarted yet. If we try (quickly!) to navigate to the URL in the browser, we indeed get a connection error. Predictably, if we wait a few more seconds, systemd restarts the service, and the HTTP server is up again.

We can now also try to manually restart the target board (e.g. by unplugging it from power, and plugging it back in), and the server should start again automatically after a few seconds / minutes, without us having to do anything on the board itself.

Updating server code

Let us try to make a change to the code and deploy a new version of the server, in a way that works nicely with systemd.

For instance, we change the Hello World greeting to Ciao Mondo in ./src/main.rs . If we now run the current ./deploy script again, we get the following error:

Error: Os { code: 98, kind: AddrInUse, message: "Address already in use" }

This is because the previous version of the service (managed by systemd) is still running in the background, and therefore we cannot run another instance (even though it is now a different binary) binding to the same port. What we actually want is to replace the previous binary, and ask systemd nicely to restart the service for us. In order to do this, we change the last line of our deploy script, so that we obtain the following:

This script now compiles and copies the binary as before, but instead of running it interactively as the final step, it just restarts the service via the systemctl restart command, which stops the previous instance and starts a new one, causing the new version of the binary to be executed. We can check that the new version is indeed running by navigating to the URL as we did before, and checking that the message got updated.

The Startup

Medium's largest active publication, followed by +720K people. Follow to join our community.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store