Right, so you want to talk to hardware. You’ve got a shiny microcontroller, a board full of mysterious pins, and a burning desire to make an LED blink or a sensor read. This is where the rubber meets the road, or more accurately, where the electrons meet the silicon. Forget HTTP handlers and JSON marshaling for a moment; we’re about to get physical with GPIO, I2C, SPI, and UART. TinyGo is our tour guide here, and it does a remarkably good job of translating Go’s elegance into the sometimes-brutal world of electrical signals.

Let’s be clear: this isn’t abstract programming. When you set a pin high, a measurable 3.3V shoots out of a physical piece of metal. When you misconfigure an SPI bus, nothing happens. It’s not a runtime panic; it’s silent, electrical disappointment. My job is to make sure you see the former and avoid the latter.

The Humble GPIO: Your Digital Workhorse

GPIO stands for General Purpose Input/Output. These are the pins you can control manually to read a button press or drive an LED. The concepts are simple: output means you control the voltage (high/low), input means you read the voltage applied to it.

In TinyGo, you don’t just magically know a pin’s name. You have to refer to it by the label on your board’s schematic. For an Arduino Nano 33 IoT, the onboard LED is on pin D13. For a Raspberry Pi Pico, it’s LED.

Here’s how you blink it. Note the use of machine package, which is your gateway to all things hardware in TinyGo.

package main

import (
    "machine"
    "time"
)

func main() {
    led := machine.LED // Or machine.D13 for many boards
    led.Configure(machine.PinConfig{Mode: machine.PinOutput})

    for {
        led.High()
        time.Sleep(500 * time.Millisecond)
        led.Low()
        time.Sleep(500 * time.Millisecond)
    }
}

Why it works this way: The Configure step is crucial. Hardware pins can often be multiplexed to different internal peripherals (like UART, SPI, etc.). You’re telling the microcontroller’s hardware exactly what you want this pin to be: a simple digital output. For input, you’d use machine.PinInput. A common pitfall? Forgetting the configure step and wondering why your pin is doing absolutely nothing. The hardware is literal; it waits for your explicit instruction.

I2C (or I²C): The Two-Wire Party Bus

I2C is a synchronous, multi-controller, multi-peripheral serial protocol. I call it the “party bus” because you can hang dozens of devices on just two wires: a serial data line (SDA) and a serial clock line (SCL). One controller (your microcontroller) drives the clock and dictates who gets to talk on the data line.

It’s address-based. Every device has a 7-bit address (often configurable with jumpers). You start a transmission by sending the address and a read/write bit. Here’s how you’d talk to a common I2C temperature sensor like the BMP280.

package main

import (
    "fmt"
    "machine"
    "time"
)

func main() {
    machine.I2C0.Configure(machine.I2CConfig{
        Frequency: 400 * machine.KHz, // Standard and Fast speed
        SDA:       machine.SDA0_PIN,  // These are board-specific!
        SCL:       machine.SCL0_PIN,
    })

    var (
        bmp280Address = uint8(0x77)
        regCtrlMeas   = byte(0xF4)
    )
    // Write to the control register to start a measurement
    data := []byte{regCtrlMeas, 0x25} // Value 0x25 sets appropriate mode
    err := machine.I2C0.WriteRegister(bmp280Address, regCtrlMeas, data)
    if err != nil {
        println("Error writing:", err)
        return
    }

    time.Sleep(10 * time.Millisecond) // Wait for conversion

    // Read 2 bytes from register 0xFA (temperature MSB)
    tempData := make([]byte, 2)
    err = machine.I2C0.ReadRegister(bmp280Address, 0xFA, tempData)
    if err != nil {
        println("Error reading:", err)
        return
    }
    // ...process tempData to get actual temperature...
    fmt.Printf("Raw data: %v\n", tempData)
}

Common Pitfalls: The most common issue is electrical: missing pull-up resistors. I2C relies on pull-up resistors to bring the SDA and SCL lines back to a high state. Many development boards have these built-in, but if you’re building a custom circuit, you’ll need to add ~4.7kΩ resistors yourself. Without them, the lines never go high, and the entire bus is dead. The code will fail silently. Always check the hardware first.

SPI: The Speed Demon

SPI is faster and simpler than I2C but uses more pins. It’s a full-duplex, synchronous protocol. You have a Controller-Peripheral interface (formerly Master-Slave, thankfully renamed). One controller, potentially many peripherals, each selected with a dedicated Chip Select (CS) pin.

The pins are:

  • SDO (Serial Data Out / MOSI): Data from Controller to Peripheral.
  • SDI (Serial Data In / MISO): Data from Peripheral to Controller.
  • SCK (Serial Clock): Clock signal from the Controller.
  • CS (Chip Select): A pin per peripheral, activated low to select it.

Here’s how you’d configure SPI to talk to a device.

package main

import (
    "machine"
)

func main() {
    // Configure the SPI bus. Pins are incredibly board-specific.
    machine.SPI0.Configure(machine.SPIConfig{
        Frequency: 10000000, // 10 MHz
        LSBFirst:  false,    // Send Most Significant Bit first (common)
        Mode:      0,        // Clock polarity 0, phase 0 (most common mode)
        DataBits:  8,        // Standard data size
        SDO:       machine.SPI0_SDO_PIN, // e.g., machine.GP11 on Pico
        SDI:       machine.SPI0_SDI_PIN, // e.g., machine.GP12 on Pico
        SCK:       machine.SPI0_SCK_PIN, // e.g., machine.GP10 on Pico
    })

    // Configure a GPIO pin to use as Chip Select
    csPin := machine.GPIO9
    csPin.Configure(machine.PinConfig{Mode: machine.PinOutput})
    csPin.High() // De-select the device to start

    // To write a byte to the device:
    csPin.Low()                          // Select the device
    machine.SPI0.Write([]byte{0x01, 0x02}) // Write two command bytes
    csPin.High()                         // De-select the device
}

The Gotcha: SPI modes are a classic source of confusion. Mode is a combination of Clock Polarity (CPOL) and Clock Phase (CPHA). If your data sheets says “SPI Mode 1”, that means CPOL=0, CPHA=1. You must match the mode your peripheral expects, or you’ll be shifting data in and out on the wrong clock edges, resulting in garbage. TinyGo’s Mode: 0 parameter corresponds to the standard mode numbers.

UART: The Old Reliable

UART (Universal Asynchronous Receiver/Transmitter) is the classic serial port. No clock line; just a transmit (TX) pin and a receive (RX) pin. Both sides must agree on the speed (baud rate) beforehand. It’s simple, robust, and perfect for logging, talking to GPS modules, or old-school sensors.

package main

import (
    "machine"
)

func main() {
    // Configure UART for logging, often connected to USB-CDC on boards
    uart := machine.UART0
    uart.Configure(machine.UARTConfig{
        BaudRate: 115200,
        TX:       machine.UART0_TX_PIN,
        RX:       machine.UART0_RX_PIN,
    })

    uart.Write([]byte("Hello, over the serial wire!\r\n"))

    // Read a single byte if available
    if uart.Buffered() > 0 {
        data, _ := uart.ReadByte()
        uart.WriteByte(data) // Echo it back
    }
}

The Rough Edge: The biggest headache with UART in TinyGo is the buffering and reading. The Read methods can be blocking, and dealing with incoming data streams properly requires careful handling, often using a buffer and a state machine. It’s not as simple as reading from an io.Reader on a PC. You’re directly interacting with a hardware FIFO buffer that can overflow in the blink of an eye if you’re not reading fast enough. Always check Buffered() to see how much data is waiting for you before you read.

So there you have it. The four horsemen of the embedded apocalypse. They seem daunting, but once you get the hang of configuring them and, more importantly, debugging the electrical side, you can make your Go code interact with just about anything in the physical world. Now go make that LED blink. You’ve earned it.