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

building go binaries for the pi from my laptop

Cross-compiling a Go service for an ARM single-board computer with GOOS and GOARCH, why GOARM matters, and the one cgo gotcha that ruins the magic.

Code on a screen

The thing that sold me on Go for small boxes is this: I can build a binary for a Raspberry Pi on my laptop in about two seconds, scp a single file across, and run it. No toolchain on the device, no dependency hell, no compiling on an underpowered ARM core whilst it thermal-throttles itself into next week. Coming from cross-compiling C, this still feels slightly illegal.

The whole incantation is two environment variables:

$ GOOS=linux GOARCH=arm GOARM=6 go build -o myservice .
$ file myservice
myservice: ELF 32-bit LSB executable, ARM, EABI5 ...

GOOS=linux sets the target operating system, GOARCH=arm sets the architecture, and the binary that drops out runs on the Pi with nothing else needed. Since the standard library is recompiled per target automatically, you don't manage any of that yourself.

GOARM, and why your binary might SIGILL

The one that bites people is GOARM. ARM has had several floating-point variants over the years, and Go lets you pick which the binary assumes is present. The values that matter in practice:

  • GOARM=5 software floating point, the safe lowest common denominator, slowest
  • GOARM=6 hardware float as on the original Pi and Pi Zero (ARM11)
  • GOARM=7 for Cortex-A7 and friends, the Pi 2 and similar

Pick too high and the CPU hits an instruction it doesn't have, and your shiny binary dies with an illegal instruction the moment it touches a float. Pick too low and it runs, just slower than it needs to. For a first-gen Pi or a Zero, GOARM=6. For a Pi 2 or 3 running a 32-bit OS, GOARM=7. When in doubt, 6 is the conservative choice that runs everywhere.

The cgo gotcha

Pure Go cross-compiles cleanly. The moment you import a package that uses cgo, the wheels come off, because now you need a C cross-compiler for the target and CGO_ENABLED=1 with CC pointed at it, and the lovely "single static binary" property evaporates.

The usual offender is net doing DNS lookups via the system resolver, and os/user. Both have pure-Go fallbacks. If you don't actually need cgo, force it off and the build stays trivial:

$ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o myservice .

CGO_ENABLED=0 also gets you a genuinely static binary with no libc dependency, which is exactly what you want for something you're going to drop onto a minimal ARM image and forget about.

So the recipe I actually use, wrapped in a one-line Makefile target:

build-pi:
	CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o build/myservice-arm .

That's the entire cross-compilation story for most homelab services. Two variables, one to disable cgo, and you've turned "compile on the slow box" into "compile on the fast box and copy a file". It remains one of the most quietly excellent things about the language.