Go program the Raspberry Pi Pico… with Go

I have been working with Go professionally, building a cross-platform CLI tool. I’m rather enjoying the language, so I wondered if I could use it to program the Raspberry Pi RP2040 too. A little Googling revealed TinyGo, an implementation of the language for microcontrollers.

There are clear install instructions on the site, which led me to install TinyGo via Homebrew. But I’m on Go 1.21, and the installed version supports 1.18 through 1.20. So I removed it and built TinyGo from source, using the latest pre-release in the dev branch. I’m on a Mac, so I needed to install Homebrew’s version of the LLVM compiler and some extra LLVM code; the TinyGo source includes a makefile for the latter, and one to generate the source files for TinyGo itself.

Update TinyGo 0.30.0 supports any Go from 1.19 and up, so the easiest way now to install it on a Mac is to use:
brew tap tinygo-org/tools
brew install tinygo

Then you can skip the following list of steps and go straight to flashing the board.

So the install sequence was:

  1. git clone --recurse-submodules https://github.com/tinygo-org/tinygo.git
  2. cd tinygo
  3. git checkout dev
  4. git submodule update --init
  5. brew install llvm
  6. go install
  7. make llvm-source
  8. make gen-device
  9. go build -o build/tinygo -tags=llvm15
  10. cp build/tinygo /usr/local/bin/tinygo

Now you can restart your terminal, switch to your app directory and build your code as follows:

tinygo flash -target pico

The pico of course represents the type of board you’re flashing to. TinyGo supports many others.

TinyGo builds to a temporary directory, so if you want a copy of your compiled app for subsequent manual installs or archiving, just run:

mkdir build
tinygo build -o build/wumpus.elf -target pico

You’ll have noticed from the line above that TinyGo generates the usual .elf file. You’ll need to convert it to a .uf2 file for transfer to your RP2040. The tinygo flash command performs this conversion for you and even auto-mounts the board too and copies the binary artifact over so there should be no fiddling around with BOOTSEL buttons.

Update For a manual build, switch to your pico-sdk directory and then to tools/elf2uf2. Run cmake -S . -B build and then cmake --build build to compile the utility. Now you can run path/to/elf2uf2 path/to/wumpus.elf path/to/wumpus.uf2 to generate the game’s .uf2 file for manual transfer to the hardware.

Of course, just mucking around with a language’s Hello World samples doesn’t tell you very much. It’s better to begin work on a real project that forces you to go and look stuff up and figure out how things work in this new environment. So I reached for Hunt the Wumpus, a two-breadboard game that makes use of the RP2040’s GPIO (in and out), Analog-to-Digital Converter (ADC) and I²C — all key embedded features, and very SDK dependent if you want to avoid all that tedious mucking about with registers.

SPI, UART, PWM and USB are supported by TinyGo too, though not used in Wumpus. TinyGo, unlike Go itself, supports interrupts. However, it doesn’t support multi-core operation. That’s not a blocker for me right now, but it could be in future — especially since sporting a pair of cores is a key RP2040 stand-out. But if you need multi-core, you’re probably favouring C or C++ — maybe even Rust — anyway.

TingGo’s own SDK, the Go equivalent of the C/C++ SDK offered by Raspberry Pi, supports all those features with Go structs and methods delivered through its machine package, which provides a generic API that’s tailored under the bonnet for each MCU. Here’s my I²C set up code:

i2c := machine.I2C0
err := i2c.Configure(machine.I2CConfig{SCL: PIN_SCL, SDA: PIN_SDA})
if err != nil {
    return false
}

machine.I2C0 maps to the RP2040’s i2c0; here it’s assigned to the variable i2c using Go’s := operator, which both declares the variable and gives it an initial value. i2c is a struct instance; Configure() is a function that operates on such an instance. It’s how Go does its sort-of-object-orientation. Configure() takes an instance of an I2CConfig struct, here populated inline with constants representing my chosen SCL and SDA pins. These are declared thus:

const (
    ...
    PIN_SDA machine.Pin = machine.GP8
    PIN_SCL machine.Pin = machine.GP9
    ...
)

Again I’m using members of the machine package: machine.Pin is the type, machine.GPx a constant identifying a specific GPIO pin. The := operator forces the compiler to infer the type; here, because I’m declaring consts, I have to provide the type and use =.

Note the Go feature that lets you bundle any number of constants under a single const keyword by including them in brackets. The same syntax can be used for var and import, for example, as you’ll see if you examine the Wumpus source code.

GPIO digital outs are configured this way:

const PIN_GREEN machine.Pin = machine.GP20
PIN_GREEN.Configure(machine.PinConfig{Mode: machine.PinOutput})
PIN_GREEN.Low()

The second call sets the pin to 0V; it’s mirrored by High().

Why start method names with capitals? Just style? No, that’s how Go marks a function as exportable.

The game’s fire button, is connected to a digital input, declared this way:

const PIN_BUTTON machine.Pin = machine.GP19
PIN_BUTTON.Configure(machine.PinConfig{Mode: machine.PinInputPulldown})

Here’s the ADC code for one of the two pins that connect to the game’s joystick:

var PIN_Y machine.ADC = machine.ADC{Pin: machine.GP27}
machine.InitADC()
err = PIN_Y.Configure(machine.ADCConfig{})
if err != nil {
    return false
}

Later I read the value of PIN_Y with:

y := PIN_Y.Get()

Unlike digital pins, for which Get() returns a boolean, y takes a 16-bit unsigned integer (uint16).

Back to the I²C bus: it’s used to drive an HT16K33-controlled 8×8 matrix LED. I rewrote my HT16K33 matrix driver as a Go struct:

type HT16K33 struct {
    // Host I2C bus
    bus machine.I2C
    // Internal data: brightness level, buffer
    address uint8
    brightness uint
    buffer []byte
}

And here’s a typical method:

func (p *HT16K33) Power(isOn bool) {
    if isOn {
        p.i2cWriteByte(HT16K33_GENERIC_SYSTEM_ON)
        p.i2cWriteByte(HT16K33_GENERIC_DISPLAY_ON)
    } else {
        p.i2cWriteByte(HT16K33_GENERIC_DISPLAY_OFF)
        p.i2cWriteByte(HT16K33_GENERIC_SYSTEM_OFF)
    }
}

The bracketed parameter before the function name is Go’s way of specifying that this function works with, and only with, an instance of the HT16K33 struct, provided as a pointer to a given instance (hence the C-style asterisk). You can see the instance variable, p, is used to call other member methods in the body of the function. Again, the function name has an initial capital, so it’s exportable. Not so the called i2cWriteByte() method:

func (p *HT16K33) i2cWriteByte(value byte) {
    // Convenience function to write a single byte to the matrix
    data := [1]byte{value}
    p.bus.Tx(uint16(HT16K33_ADDRESS), data[:], nil)
}

The key line is the last one: the Tx() method of the bus struct member of the HT16K33 instance i2cWriteByte() is called on. Tx() usually performs a write then a read (handy for commanding a sensor to return a reading, say) but I’ve passed nil in place of the read buffer argument to force write only behaviour.

The upshot of all this is a fully working game: flash the compiled Go code on top of the C code and get exactly the same behaviour and not have to adjust the hardware. Of course, that wasn’t literally the case: cut-paste-edit errors prevented that, and differences between the Pico SDK and TinyGo in the range of values returned by the ADC caused a little head-scratching at first. But the porting process wasn’t painful.

I’ll certainly continue with the experiment. I like Go, and it’s good to be able to apply it to Pico application development.

You can find the code and hardware construction details in my pi-pico-go repo.