42.1 TinyGo: A Go Compiler for Microcontrollers and WebAssembly
Right, so you’ve heard Go is for the cloud and servers, and you’re looking at that 8-bit microcontroller with 32KB of flash and thinking, “Yeah, no.” I get it. The standard Go runtime and its sprawling, beautiful, heap-hungry ways were not made for this world. But that’s where TinyGo comes in. Think of it as Go on a serious diet, one that trades the luxury of a multi-gigabyte heap for the discipline of living within 128KB of RAM. It’s a reimagining of the Go toolchain specifically for microcontrollers (think ARM Cortex-M), WebAssembly, and other places where “small” is the primary feature.
The magic trick here is that it’s still Go. You’re not learning a new language. You’re using the same if, the same struct, the same glorious channels and goroutines. The difference is in the runtime. The standard Go runtime has a relatively massive garbage collector, a hefty scheduler, and uses a lot of memory for its bookkeeping. TinyGo strips almost all of that out. Its scheduler is cooperative, not preemptive, which is a fancy way of saying a goroutine runs until it explicitly yields control (with a time.Sleep or a channel operation). This is far less elegant but dramatically more predictable and lightweight, which is exactly what you need when a single hardware interrupt might need to change the state of a pin.
The Installation and tinygo tool
You install it like any other Go tool, but you’re getting a completely separate compiler binary. Don’t worry, it won’t interfere with your standard go command.
go install tinygo.org/x/tinygo@latest
Now you have a tinygo command. It feels familiar on purpose. tinygo build, tinygo run, tinygo test – it’s the same vibe. The crucial difference is the -target flag. This is where you tell it what world you’re building for. -target=arduino-nano33 is wildly different from -target=wasi (WebAssembly), and the compiler needs to know this to pull in the correct runtime and libraries.
Your First Blink: The “Hello, World” of Hardware
Let’s get an LED blinking. This is the microcontroller equivalent of printing “Hello, World” to a screen. I’m using an Arduino Nano 33 IoT here, but the principle is the same for any board. We import a hardware-specific package to get to the pins. This is one of those places where TinyGo’s “separate but equal” status shows; you can’t use the standard io or machine packages from regular Go.
package main
import (
"machine"
"time"
)
func main() {
led := machine.LED
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
led.High()
time.Sleep(500 * time.Millisecond)
led.Low()
time.Sleep(500 * time.Millisecond)
}
}
You’d build and flash this with a command like:
tinygo flash -target=arduino-nano33 /path/to/your/blink.go
Notice what’s not here? There’s no fmt.Println to tell you it’s working. You just have to trust the process and look for the blinking light. Welcome to embedded development.
Concurrency on a Microcontroller
This is where it gets cool. You can use channels and goroutines to structure your embedded application in a way that’s miles ahead of the super-loop-with-a-million-flags pattern you see in typical C firmware.
Let’s say you want to blink an LED at one speed but also listen for a button press to change the speed. Instead of a tangled mess of if statements and volatile variables, you can use a channel.
package main
import (
"machine"
"time"
)
func main() {
led := machine.LED
button := machine.D2 // Assuming a button is connected here
speedChange := make(chan int)
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
button.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
go func() {
for {
// Button is pressed when the pin is low (because of the pullup)
if !button.Get() {
speedChange <- 250 // Send a new speed value
time.Sleep(500 * time.Millisecond) // Crude debounce
}
time.Sleep(10 * time.Millisecond) // Check the button every 10ms
}
}()
blinkSpeed := 500
for {
select {
case newSpeed := <-speedChange:
blinkSpeed = newSpeed
default:
led.High()
time.Sleep(time.Duration(blinkSpeed) * time.Millisecond)
led.Low()
time.Sleep(time.Duration(blinkSpeed) * time.Millisecond)
}
}
}
This is a revelation. The button monitoring happens in its own little world, a separate goroutine, and it just sends a message on a channel when something important happens. The main loop is clean; it just blinks and checks for new messages. This structure is infinitely more manageable as complexity grows.
The Rough Edges and Pitfalls
TinyGo is brilliant, but it’s not the full Go spec. You must be aware of the constraints.
- Reflection is Limited: The
reflectpackage is heavily cut down. If a library relies on complex reflection, it probably won’t work. This is why you often see code generation used instead (e.g., for USB descriptors). - The Heap is a Precious Resource: You must be mindful of allocations. Escape analysis is your best friend. Use the
-gc=noneflag for the tiniest builds if you can guarantee you’ll never allocate on the heap after initialization. Yes, you can actually run without a garbage collector. - Standard Library Support is Partial:
net/httpis a no-go on a microcontoller. You use the lower-levelnetpackage or device-specific drivers for WiFi.fmtworks for basic things, but often output goes to a serial port, not stdout. - Debugging is… Different: You’re not running
dlvon your microcontroller. Your best debugger is a blinking LED and a serial console.printlnstatements become your lifeline.
Why Bother? Why Not Just Use C?
Because Go’s syntax, concurrency model, and tooling are a massive quality-of-life improvement. Writing safe, understandable, concurrent firmware in Go is easier than in C. The go tool manages dependencies beautifully. You trade a few cycles of raw performance and a bit of memory overhead for a dramatic increase in development speed and code maintainability. For most applications, that’s a trade worth making. TinyGo lets you make it without completely abandoning the ecosystem you know.