I have a little ARM box on my network, a Raspberry Pi doing a job too boring to name, and I wanted to run a small Go service on it. The obvious thing was to compile on the Pi. The Pi is slow, the compile took an age, and I lost patience about thirty seconds in. So I did what I should have done first and built the binary on my laptop instead. This is the whole point of Go, and I keep forgetting it.
The headline: cross-compiling Go is two environment variables.
GOOS=linux GOARCH=arm GOARM=7 go build -o myservice .
That's it. Copy the resulting binary to the Pi, chmod +x, run it. No toolchain to install, no apt install gcc-arm-linux-gnueabihf, no fiddling with sysroots. The Go toolchain ships every target it supports, and on a fresh Go 1.8 install they're all just there. Coming from C, where cross-compilation is a rite of passage involving sacrifice and a long weekend, this still feels like cheating.
what the variables actually mean
GOOS is the target operating system: linux, darwin, windows, and so on. GOARCH is the architecture: amd64, 386, arm, arm64. For a 32-bit Raspberry Pi running Raspbian you want linux and arm.
GOARM is the one people miss. It only applies when GOARCH=arm, and it selects the ARM version, which mostly governs floating-point behaviour. The values are 5, 6 and 7:
GOARM=5is software floating point. Safe everywhere, slow.GOARM=6suits the original Pi and Pi Zero (ARMv6 with VFP).GOARM=7suits the Pi 2, Pi 3 and most modern ARMv7 boards.
Get GOARM wrong and you don't always get a clean failure. A binary built for ARMv7 will simply refuse to run on an ARMv6 chip with an illegal instruction, which is a confusing way to find out. If you're not certain what your board is, check /proc/cpuinfo on the target and read the model line.
the CGO trap
Here is the bit that catches everyone, including me, every single time.
The pure-Go cross-compile above works because no C is involved. The moment your program, or one of its dependencies, uses cgo, you need a C cross-compiler too, and the simple two-variable trick falls over with a linker error that has nothing helpful to say.
The most common offender is the standard library's net package, which on Linux defaults to a cgo-based resolver. If you import net (and you almost certainly do, transitively), a naive cross-build can pull cgo in. The fix is to disable cgo explicitly and let Go use its pure-Go network stack:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o myservice .
CGO_ENABLED=0 also has a nice side effect: it produces a fully static binary. No dynamic linking against the target's libc, which means no surprises about glibc versions or musl on the other end. For a small service that talks HTTP and reads a config file, you lose nothing by turning cgo off, and you gain a binary you can drop onto almost any Linux box of the right architecture and run.
If you genuinely need cgo (SQLite via mattn's driver is the classic case), then you do need a cross-toolchain after all, and you wire it in with CC:
CGO_ENABLED=1 GOOS=linux GOARCH=arm GOARM=7 \
CC=arm-linux-gnueabihf-gcc \
go build -o myservice .
At that point you're back in C cross-compilation territory and the magic is gone, so avoid it where you can.
checking you built the right thing
Before you copy a binary across, it's worth a five-second sanity check that you actually targeted the architecture you meant to. file will tell you:
$ file myservice
myservice: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV),
statically linked, stripped
That one line confirms three things at once: it's ARM not amd64, it's statically linked (so cgo really is off), and it's stripped (so the ldflags worked). I've shipped an amd64 binary to a Pi more than once, watched it fail with cannot execute binary file: Exec format error, and spent a baffled minute before remembering to run file. Now it's a reflex. If you want the full list of what your Go toolchain can target, go tool dist list prints every valid GOOS/GOARCH pair, which is a longer list than you'd expect and includes things like linux/mips and freebsd/arm.
a word on the build cache and reproducibility
One pleasant consequence of all this is that the build is hermetic in a way C rarely is. The same source, the same Go version and the same environment variables produce the same binary on anyone's machine. There's no system header drift, no "works on my box because I have a different libc". For a home service that hardly matters, but it's the reason this approach scales: the exact two-variable invocation I run on my laptop is what a CI job would run, and what a colleague would run, and they'd all get a byte-for-byte equivalent artefact. That's a genuinely nice property to get for free, and it's a big part of why Go won the small-services-on-odd-hardware niche so thoroughly.
a small script so I stop typing it
I got tired of remembering the incantation, so it lives in a Makefile target now:
pi:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 \
go build -ldflags="-s -w" -o build/myservice .
scp build/myservice [email protected]:/home/pi/
The -s -w ldflags strip the symbol table and DWARF debug info, which shaves a useful chunk off the binary size. On a small service it's the difference between an 8MB and a 5MB file, and over a slow home network that matters more than it should.
The whole loop is now: edit on the laptop, make pi, and a few seconds later it's running on the Pi. No waiting for the little ARM core to grind through a build it was never going to enjoy. I knew Go did this. I'd read about it. I still spent thirty seconds compiling on the Pi out of habit before remembering, which is roughly the right amount of self-deprecation to end a post on.