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

cross-compiling go for an arm box, the easy way and the cgo way

Building a Go binary on an x86 laptop to run on a 32-bit ARM single-board computer, including the GOARM gotcha and what happens the moment cgo gets involved.

A code editor showing source on screen

The single best thing about Go, on the days I'm feeling generous towards it, is cross-compilation. I had a small daemon to put on an ARM single-board computer, the kind of underpowered widget where compiling anything locally is an act of patience bordering on faith. With Go you don't. You set two environment variables on your fast laptop and out comes a binary the little box can run.

$ GOOS=linux GOARCH=arm GOARM=7 go build -o sensord ./cmd/sensord
$ file sensord
sensord: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked

That's it. No cross toolchain to install, no sysroot, no --host triplet incantations from the autotools era. The Go toolchain ships every target it supports.

The one thing that trips people up is GOARM. GOARCH=arm means 32-bit ARM, but that family spans a lot of years and instruction set versions. GOARM=7 targets ARMv7 with hardware floating point, which is what most boards from the last decade actually are. Set it too high and the binary illegal-instructions on older hardware; leave it off and Go defaults to a conservative value that runs everywhere but slower. For 64-bit boards the question doesn't arise: use GOARCH=arm64 and there's no GOARM to worry about. Check what you've got with uname -m on the target. If it says armv7l, you want GOARM=7. If it says aarch64, you want arm64.

Lines of source code on a dark background

All of that lovely simplicity holds right up until you import something that uses cgo. The moment a dependency links against C, the pure-Go cross-compile evaporates, because now you need an actual C cross-compiler for the target. SQLite drivers are the usual culprit, as is anything touching the system's DNS resolver in certain configurations.

If you can avoid cgo, avoid it:

$ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build ./...

CGO_ENABLED=0 also gives you a fully static binary, which means no fretting about glibc versions on the target. For a daemon that just talks to sensors and pushes data over the network, that's usually achievable. Swap the cgo SQLite driver for a pure-Go one, use the Go resolver, and you're back to the two-variable life.

When you genuinely can't avoid cgo, you point Go at a cross C compiler:

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

Now you're installing toolchains and minding glibc again, which is precisely the world Go cross-compilation usually saves you from. So my honest advice: design your ARM-targeting code to keep CGO_ENABLED=0 if you possibly can. The static, dependency-free, copy-it-and-run binary is one of Go's real joys, and it's worth a little effort to keep it.

scp the result over, chmod +x, run it. The board doesn't know it didn't compile anything, and frankly neither do I most of the time.