Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

building Go on the laptop, running it on the Pi

Cross-compiling Go binaries from an x86 laptop to ARM for a Raspberry Pi, the one thing that nearly tripped me up, and why it's still my favourite Go feature.

A code editor open on a programming project

I have a small fleet of ARM boxes around the house: a couple of Raspberry Pis, a little single-board thing running a sensor, the usual homelab clutter. For a long time my workflow for getting code onto them was to compile on them, which is a special kind of tedious. The Pi is not fast, the SD card is not fast, and watching a build crawl along over SSH while my actual laptop sits idle felt like a waste of everyone's silicon.

Then I properly learned how Go cross-compiles, and the whole problem just evaporated. This is, genuinely, one of my favourite things about the language, and I want to lay out exactly how it works because the first time you see it, it feels like cheating.

the entire trick

Here is the whole thing. To build a binary for a 32-bit ARM Raspberry Pi from my x86-64 laptop:

GOOS=linux GOARCH=arm GOARM=7 go build -o sensord ./cmd/sensord

That's it. No cross-toolchain to install, no apt-get ritual of gcc-arm-linux-gnueabihf, no fiddling with sysroots. The Go toolchain ships with the ability to target every platform it supports, out of the box. You set two or three environment variables and you get a binary for that platform. scp it over, run it, done.

GOOS is the target operating system, GOARCH is the architecture, and GOARM is the ARM version, which matters because a Pi 2 or 3 is happy with ARMv7 but an original Pi or a Pi Zero wants GOARM=6. Get that wrong and the binary won't run; get it right and it just works.

Source code on screen, mid-build

the one thing that nearly tripped me up

For weeks this worked flawlessly, and then one day I added a dependency and the cross-compiled binary refused to run on the Pi with a baffling error about a missing library. The build on the laptop succeeded. The binary on the Pi died on launch. I'd done nothing different except pull in a package, so I stared at it for a while.

The culprit was cgo. Pure Go cross-compiles trivially because the toolchain does it all itself. But the moment a dependency uses cgo, that is, calls into C, the build needs an actual C cross-compiler for the target, and suddenly you're back in the world of toolchains and sysroots that Go had so kindly let me forget. The dependency I'd added pulled in a C library for, of all things, talking to an I2C device.

There are two honest ways out, and I've used both depending on the box.

The first is to disable cgo entirely and find a pure-Go alternative for whatever you needed C for:

CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o sensord ./cmd/sensord

CGO_ENABLED=0 forces the build to refuse anything that needs C, which means it'll fail loudly at build time on the laptop rather than mysteriously at runtime on the Pi. That's the failure mode you want: loud, early, and on the machine with the good keyboard. For my I2C case there was a perfectly good pure-Go library, and switching to it meant my whole build went back to being a single clean command with no C anywhere in sight.

The second, for the rare case where you genuinely need a C library that has no pure-Go equivalent, is to install the cross-toolchain and point Go at it:

CC=arm-linux-gnueabihf-gcc CGO_ENABLED=1 \
  GOOS=linux GOARCH=arm GOARM=7 go build -o sensord ./cmd/sensord

This works, but it gives up the lovely toolchain-free property, so I treat it as a last resort and try hard to stay in pure-Go territory. Most of the time you can.

A deploy script running in a terminal

why this is the feature I evangelise

The thing I keep coming back to is the static binary. A Go build, with cgo off, produces a single self-contained executable with no runtime to install, no shared libraries to match, no interpreter, nothing. You copy one file to the Pi and run it. Compare that to dragging a Python environment onto an SD card and praying the system Python is the version you tested against, or compiling C on the device itself.

That property compounds beautifully on small ARM boxes:

  • Builds happen on the fast machine, deploys are a single file copy.
  • The target needs nothing installed beyond the kernel; no runtime, no package manager dance.
  • The same source builds for x86 in development and ARM in production from the identical command, just different env vars, so "works on my machine" stops being a lie.

I now have a one-line make deploy that cross-compiles, copies the binary over, and restarts the service on the Pi, and the whole cycle is faster than the Pi used to take just to start compiling. For homelab work, where you've got a zoo of little ARM things that all want bespoke binaries, this is transformative. The compiler does the hard part. You just have to remember to turn cgo off and keep your dependencies honest, and that's the entire war.