Right, so you want to use Go, a language built for concurrency and web servers the size of small planets, on a microcontroller with less RAM than your keyboard buffer. I love it. This is where TinyGo comes in—it’s our savior, our magic wand that shrinks Go programs down to fit on these tiny, delightful chunks of silicon. But like any good magic trick, it has its limits. Not every board is created equal, and neither is TinyGo’s support for them. Let’s talk about which boards will be your new best friends and which might give you a bit of a fight.

The Unofficial Champions: Arduino Uno and friends

The classic ATmega328P-based Arduino Uno (and Nano, etc.) is the “Hello, World” of embedded hardware. TinyGo supports it, which is a fantastic feat of engineering. But you need to understand the constraints immediately: you have 2KB of RAM and 32KB of flash. Your average Go string is already side-eyeing that RAM.

You can’t just import "fmt" and start printing willy-nilly. Serial output works, but it’s bare metal. You set up the UART and write bytes directly. Here’s the obligatory blink, because embedded law demands it:

package main

import (
    "machine"
    "time"
)

func main() {
    led := machine.LED
    led.Configure(machine.PinConfig{Mode: machine.PinOutput})
    for {
        led.Low()
        time.Sleep(time.Millisecond * 500)
        led.High()
        time.Sleep(time.Millisecond * 500)
    }
}

To build and flash this for an Arduino Uno, you’d use: tinygo flash -target=arduino ./main.go

Why this works: TinyGo compiles your Go code directly to machine code for the AVR chipset. The machine package provides a hardware abstraction layer, so machine.LED refers to the correct pin for your target board. The event loop (for {}) is your superloop—there’s no operating system here to schedule your tasks, so you run forever or you run never.

The Pitfall: The most common “it doesn’t work!” moment is assuming all pins are available. machine.D0 might not be the same as the Arduino’s D0 pin label. Always check the board-specific pin mapping in the TinyGo documentation. Also, say goodbye to most of the standard library. It’s not there. Dynamic memory allocation is risky business with 2KB; use globals and slices carefully.

The New Hotness: Raspberry Pi Pico (and RP2040)

This is where things get genuinely exciting. The Raspberry Pi Pico, with the RP2040 chip, is a gorgeous piece of hardware for the price: dual ARM Cortex-M0+ cores, 264KB of RAM, and a delicious set of programmable I/O (PIO) blocks. TinyGo support for this board is first-class. The RAM is plentiful enough that you can actually write Go without constantly fearing the garbage collector.

Here’s where we can do something more interesting. Let’s use one of the Pico’s PIO blocks to blink an LED. This is like having a tiny, programmable co-processor for bit-banging protocols, and it’s absurdly cool.

package main

import (
    "machine"
    "time"
    "runtime/volatile"
)

var (
    // The PIO assembly program. It sets a pin high, waits, low, waits.
    program = `
.program blink
    set pins, 1 [1] ; Set pin high and wait 1 extra cycle (nop)
    set pins, 0     ; Set pin low
    nop [1]         ; Wait 1 extra cycle (nop)
`
)

func main() {
    sm := machine.PIO0.Tx // Use State Machine 0 on PIO0
    led := machine.LED
    pin := sm.ConfigPin(led, machine.PinModePIO0)

    // Load the program and configure the state machine
    offset, err := machine.PIO0.AddProgram([]byte(program), "blink")
    if err != nil {
        print(err.Error())
        return
    }
    sm.Init(offset, pin)
    sm.SetEnabled(true)

    // The main loop doesn't need to do anything! The PIO is handling it.
    for {
        time.Sleep(time.Hour)
    }
}

Why this works: TinyGo has a robust machine package for the RP2040 that exposes the PIO subsystem directly. You write a small assembly program for the PIO, load it, and let it run completely independently of the main CPU. This is the kind of power TinyGo unlocks.

The Pitfall: Don’t expect to use both cores out of the box. TinyGo’s scheduler is currently single-core. Also, while the support is excellent, some deeper RP2040 peripherals might require you to drop down to manipulating registers directly, which is absolutely possible via the volatile package.

The Wider World of Supported Boards

The TinyGo support matrix is always expanding, but it broadly breaks down into tiers:

  1. Tier 1: Excellent Support (e.g., Raspberry Pi Pico, Arduino Nano33 IoT, BBC micro:bit v2): These are the gold standard. Most features work, including the key peripherals like I2C, SPI, and ADC. You’ll have the least friction here.
  2. Tier 2: Good Support (e.g., Arduino Uno, ESP32): The core features work (GPIO, basic UART), but you might run into limitations with specific peripherals or memory constraints that force you to write code in a very specific, minimalist way.
  3. Tier 3: Experimental (e.g., older micro:bits, various ESP8266 boards): It compiles and flashes. Maybe you can blink an LED. Consider it a victory.

Best Practice: Before you buy a board for a TinyGo project, check the TinyGo source repo. Look at the src/device directory for your target chip. If there’s a well-defined machine_*.go file, you’re probably in good shape. If it’s missing or sparse, you’re signing up to be a pioneer, and that means writing a lot of low-level driver code yourself.

The bottom line is this: TinyGo makes embedded programming in Go not just possible, but genuinely enjoyable. You trade some of Go’s runtime luxuries for the raw thrill of talking to hardware directly with a clean, modern language. Just choose your board wisely and respect its limits.