Tristan Penman

Robotics in Go: Building a Raspberry Pi Rover

22 May 2026

When we consider programming languages for robotics, those that come to mind often include established players such as C and C++, Python, or even those specifically designed for scientific and numerical computing, such as MATLAB. In this post, we look at Go as a viable programming language for robotics.

This post builds upon content I presented at the Melbourne Go Meetup on May 14, 2026. My talk centred around a project called Rover Kit, a small rover that uses Go for motor control and sonar readings. The Rover Kit source code has been published on GitHub. The Rover Kit v0.1.0 release contains the version of the code discussed below.

Contents

Introduction

So much of the appeal of robotics is its breadth as a subject. It touches upon mathematics, mechanical and electrical engineering, and (of course) software. Just as broad are the many different kinds of robots we can build. The most ambitious include bipedal humanoid robots, actuated robots that operate in factories, or even surgical devices.

The Hardware

This post focuses on a small rover that includes four DC motors (driven by an Adafruit Motor HAT) and several HC-SR04 ultrasonic distance sensors. The chassis and wheels are from a Whippersnapper Runt Rover kit. With everything connected, the rover looks something like this:

rover kit hardware

Hidden within the chassis is a 12V battery pack to power the DC motors.

It also includes a 5V USB power supply that powers a Raspberry Pi Zero W - this is the brains of the operation:

raspberry pi zero w

Although fairly modest as far as robotics is concerned, this kit allows us to explore the software stack while steering clear of advanced topics (e.g. inverse kinematics and frames of reference).

Robotics Fundamentals

A typical robot incorporates actuation (wheels, motors, or other moving parts), sensors to perceive the environment, and some degree of planning and control. Tying this all together are software systems that ensure the robot achieves the task it was designed to do.

The demands placed on robotics software are non-trivial. They include:

  • Real-time control - operating within precise timing constraints
  • Embedded environments - low power and harsh operating conditions (heat, dust, moisture)
  • Probabilistic planning - working with sensor data that might be noisy or incomplete
  • Safety systems and critical infrastructure - ensuring safe and correct operation, under a wide range of conditions

To address these concerns, large-scale hardware projects will often turn to a mature robotics framework that offers component isolation, message passing, device drivers, diagnostics, and deployment tooling.

A popular choice today is ROS, otherwise known as the Robot Operating System.

Robot Operating System

ROS provides middleware, standard message types, navigation and perception packages, and tools for visualisation and debugging. It also offers broad device support, which makes it appealing for research and prototyping.

ROS has historically had two major release lines. ROS 1 was built around a central roscore process, but reached end-of-life in 2025. ROS 2 moved away from that central control node architecture and added features aimed at commercial and industrial robotics, including QoS, device discovery, and improved security.

Message Passing

A common theme, though, is message passing. ROS 1 and ROS 2 are both designed around message passing as a core concept. Individual components can be responsible for just one aspect of a robot’s behaviour, with components coordinating towards larger goals by publishing and subscribing to different topics on a message bus:

message bus

Although the message passing concept has proven scalable and robust, ROS itself has a steep learning curve. It also comes with resource demands that may strain small devices. This complexity may distract from the actual goal of a hobbyist: having fun with a project that combines hardware and software. For that we want something more akin to “bare metal robotics”.

Bare Metal Robotics

Bare metal robotics captures the notion of doing robotics with as little overhead as possible.

Python is a common choice for hobbyist robotics, thanks in part to its simplicity. Python features a wide range of libraries for GPIO communication and other hardware interfaces. However, a major drawback is performance, as it suffers from being an interpreted language. Deployment can also be challenging, due to the number of dependencies that must be installed on the target device.

Go for Robotics

It turns out that Go is also a great option for bare metal robotics.

While it may not be perfect, Go has several properties that make it an appealing choice:

  • Concurrency model - goroutines and channels offer a clean mapping to asynchronous sensor/actuator loops
  • Memory safety - no pointer arithmetic and a memory-safe runtime make it safer than C++ (for many classes of bugs… in theory)
  • Simplicity & readability - Go is often easier to read than C++ or Python
  • Cross-compilation - often produces self-contained binaries with minimal runtime dependencies
  • Iteration - fast builds, simple dependency management, good testing support

The concurrency model and support for cross-compilation are two of the most interesting properties (in my opinion!).

Concurrency Model

Robots combine multiple activities that are conceptually independent, but must coordinate with one another to achieve various tasks. A simple robot may combine sensors, actuators (e.g. motor controllers) and basic navigation. Each of these components (or processes) needs to run concurrently.

Go’s concurrency model maps nicely to that problem. A goroutine can own a long-running activity, such as sampling a sonar sensor once per second, while a channel becomes the handoff point for the values it produces. In turn, Go’s treatment of channels as first-class language features leads to several nice concurrency patterns.

Go’s context package also provides clean shutdown semantics. Commands can create a context tied to SIGINT or SIGTERM, pass it down into individual goroutines, and block until cancellation.

Cross-Compilation

Cross-compilation is another major benefit! The Go compiler makes it extremely easy to cross-compile binaries that run natively on the Raspberry Pi Zero W.

The compiler provides built-in support for ARMv6, which is the instruction set used by the Pi Zero. Cross-compilation is as simple as running go build on your host machine with the appropriate command line arguments and environment variables:

env GOOS=linux GOARCH=arm GOARM=6 \
  go build -ldflags "-w" -o bin/motor-control cmd/motor-control/main.go

Go Drawbacks

The primary drawback of Go is the garbage collector (GC), which can lead to unpredictable latency spikes. Timing discrepancies can impact perception and coordination. For certain motor configurations, this could lead to overshooting or undershooting actuation targets.

While it’s possible to configure garbage collection, or even switch it off entirely, these are beyond the scope of this post.

Hardware Interfaces

Embedded devices support a range of hardware I/O interfaces, each designed to accommodate different hardware design goals. A common characteristic of the interfaces described below is that they are all serial.

Readers who are old enough may recall owning a computer that had both serial and parallel ports:

serial vs parallel

Image credit: Difference between Serial Port and Parallel Port by GeeksForGeeks

An obvious difference between the two ports is the number of pins required. The parallel port has a wider bus, which means it can transmit more bits at the same time, while serial is designed to transmit one bit at a time.

Serial may seem limiting, but it forms the foundation of various hardware interfaces. This includes the ubiquitous USB (Universal Serial Bus) and the GPIO pins commonly found on embedded devices.

GPIO

GPIO (or General-Purpose Input/Output) refers to a set of digital pins that can be controlled via software. In hobby electronics projects, it is common to wire up GPIO pins to LEDs, sensors, and other small components. Each SBC has its own GPIO pin configuration, such as this one for the Raspberry Pi:

gpio pinout

Image credit: How to Use Raspberry Pi GPIO Pins – Python Tutorial by Seeed Studio.

Groups of GPIO pins can be used to implement more sophisticated protocols or hardware interfaces. We’ll introduce some of those now.

UART

One of the most common interfaces you’ll find on embedded devices is UART, short for Universal Asynchronous Receiver/Transmitter. UART is a protocol for serial communication between two devices, using separate transmit and receive signal wires plus a shared ground. Two UART devices are connected via cross-over wiring:

UART Wiring

For signalling purposes, each character is preceded by a start bit (always a zero) and ends with one or more stop bits (logical ones). The signal line is held in the ‘one’ state when nothing is being transmitted:

UART Signal

Standard UART data rates range from 9600 bits per second (bps) up to 115200 bps. High-speed UART implementations exist, but are less common. A more common alternative is USART, short for Universal Synchronous/Asynchronous Receiver-Transmitter, which includes an additional clock wire for synchronisation, enabling faster transfer rates.

SPI

SPI (Serial Peripheral Interface) is a fast synchronous serial bus commonly used to talk to chips on the same board. In a typical setup, there is one SPI controller, which is responsible for communicating with one or more SPI peripherals. Peripheral chips are each targeted using a dedicated chip select pin, often labelled CS or SS.

The following diagram shows three SPI peripherals wired up to a single SPI controller. Note the presence of three CS/SS pins:

spi bus

Examples of SPI peripherals include displays, sensors, and storage devices (think SD cards). When working with embedded devices you may encounter SPI Flash, a kind of non-volatile flash memory that is capable of retaining data without power. SPI Flash is well suited to storing data and program code that needs to be loaded when a device is first powered up.

I²C

I²C (Inter-Integrated Circuit) is a two-wire synchronous serial bus designed for connecting multiple chips via the same circuit.

Unlike SPI, I²C is an addressed bus, which means that individual chips are targeted via an address encoded into the bitstream. Chips are connected to the bus via two wires: serial data (SDA) and serial clock (SCL). It is assumed that there is an I²C controller which controls the bus and clocks data from target devices when reading.

i2c communication

A start of a frame consists of a 7-bit address, a read/write bit which indicates whether the controller is sending or receiving data, and an ACK bit. The control then sends data, or receives data, depending on the R/W bit.

The Adafruit Motor HAT in this project uses an I²C-based PWM driver chip. This means that, with just two signal wires connected to the Raspberry Pi, we can control four DC motors!

Go Libraries

We’ve seen above that Go can be used to target platforms such as the Raspberry Pi, BeagleBone or Jetson Nano, and we’ve introduced some of the hardware interfaces available on these devices. The next question is how we can make use of those hardware interfaces.

This section will look at three in detail: Gobot, Periph and Serial. These are the three libraries used in Rover Kit.

There are many others we could look at (such as gpiocdev which gets an honourable mention), but unfortunately we don’t have time to cover them all.

Gobot

Gobot is a robotics framework for Go that provides APIs for devices, sensors, and actuators across a wide range of platforms. Using the provided drivers and adaptors, you can write higher-level behaviour once and retarget from one board or device family to another with minimal changes.

The following example uses the firmata adaptor to control an LED connected to an Arduino:

package main

import (
  ...
)

func main() {
  firmataAdaptor := firmata.NewAdaptor("/dev/ttyACM0")
  led := gpio.NewLedDriver(firmataAdaptor, "13")

  work := func() {
    gobot.Every(1*time.Second, func() {
      led.Toggle()
    })
  }

  robot := gobot.NewRobot("bot",
    []gobot.Connection{firmataAdaptor},
    []gobot.Device{led},
    work,
  )

  // Start the main event loop
  robot.Start()
}

Gobot provides adaptors for a wide range of SBC devices, including the Raspberry Pi, BeagleBone, and Tinker Board. It also supports drones and robotics kits.

Periph

Periph is a low-level hardware I/O library for Go. It provides direct access to GPIO, I²C, SPI, PWM, and other interfaces commonly found on SBCs. It is especially useful when you want tight control of peripheral communication without needing a heavier robotics framework.

We will see later that Periph can be used to drive motors via the Adafruit Motor HAT, and measure distance using the HC-SR04 ultrasonic sensors.

Serial

The last library I want to mention is go.bug.st/serial, a cross-platform serial port library for Go.

As you can see, this library makes it easy to open and read from a serial device:

// configure
mode := &serial.Mode{
  BaudRate: 115200,
  DataBits: 8,
  StopBits: serial.OneStopBit,
  Parity:   serial.NoParity,
}

// open port
port, err := serial.Open("/dev/ttyACM0", mode)
if err != nil {
  return nil, err
}

// read bytes from UART connection
buff := make([]byte, 128)
bytesRead, err := port.Read(buff)
if err != nil {
  return nil, err
}
_ = bytesRead

This can be used to delegate reading from ultrasonic sensors to a separate microcontroller, which is something we’ll explore in a future post!

Rover Kit

In this section, we take a closer look at the hobby project that inspired this post: Rover Kit. The project adopts a modular architecture, with separate Go executables to drive motors, read sonar sensors, and send commands via a web interface.

MQTT provides pub/sub messaging between components:

rover kit architecture

MQTT is a standard protocol for communication between IoT devices. This is similar to the message bus we saw in ROS, and has the advantage of being highly portable. At the time of writing, there are over 20 server implementations and even more client libraries.

MQTT Client

We make use of the Eclipse Paho MQTT Go client to connect to an MQTT broker and pass messages between components.

Basic connection:

client := mqtt.NewClient(opts)
connectToken := client.Connect()
connectToken.Wait()
if err := connectToken.Error(); err != nil {
  log.Fatalf("failed to connect to broker=%s err=%v", brokerURL, err)
}

Subscription example:

opts.SetOnConnectHandler(func(client mqtt.Client) {
  log.Printf("connected to broker=%s", brokerURL)
  token := client.Subscribe(motorCmdTopic, 1, subscriber(ctx, driver, gate))
  token.Wait()
  if err := token.Error(); err != nil {
    log.Printf("failed to subscribe topic=%s err=%v", motorCmdTopic, err)
    return
  }
  log.Printf("subscribed topic=%s", motorCmdTopic)
})

Publishing example:

s.mqttClient.Publish(defaultMotorCmdTopic, 0, false, message)

Peripherals

Recall that our rover hardware included:

  • Four DC motors
  • Four HC-SR04 ultrasonic distance sensors

The motor HAT is controlled over I²C through the Raspberry Pi’s GPIO header, while the ultrasonic sensors use ordinary GPIO pins. Rover Kit provides motor driver implementations for Gobot and Periph. Supporting both is not strictly necessary for such a simple project. However, it serves some pedagogical value by allowing us to compare the implementations side-by-side.

Project Structure

Rover Kit follows a standard Go project structure. The three commands live in cmd/, while supporting code lives in pkg/. There is no internal/ directory, as the project is intended to be completely open.

You will also find configuration files for deployment on the Raspberry Pi:

  • deploy/ - systemd configuration files
  • mqtt/ - mosquitto configuration files

A Makefile has also been included, making it very easy to cross-compile for ARMv6.

make

Next, we’ll look at the three commands: motor-control, sonar-reader, and web-bridge.

Motor Control

The motor-control command knows how to interact with our DC motors, via the Adafruit Motor HAT. This is backed by the Driver interface, which declares the main behaviours that we need to support for our four DC motors:

type Driver interface {
	Forwards(ctx context.Context) error
	Backwards(ctx context.Context) error
	SpinCW(ctx context.Context) error
	SpinCCW(ctx context.Context) error
	Stop(ctx context.Context) error
	Close(ctx context.Context) error
}

Pulse Width Modulation

Pulse Width Modulation (or PWM) controls a DC motor by rapidly switching the motor’s power supply on and off instead of varying the voltage directly.

You can picture this as a cycle. During the “on” part of each cycle, the motor receives 100% voltage. During the “off” part of the cycle, it receives no voltage. Because this happens so quickly, the motor behaves as if it is receiving an average voltage. The proportion of “on” vs “off” time is called the “duty cycle”. A 20% duty cycle means the motor is powered 20% of the time, and unpowered for the remaining 80%.

The Adafruit Motor HAT handles this for us. However, we still need to tell it what the desired duty cycle is for each motor. This is communicated via I²C.

Gobot Driver

Gobot provides an adaptor for the Raspberry Pi and an I²C driver for the Adafruit Motor HAT:

// Create an adaptor for the Raspberry Pi I2C interface
adaptor := raspi.NewAdaptor()
if err := adaptor.Connect(); err != nil {
  return nil, fmt.Errorf("connect raspi adaptor: %w", err)
}

// Initialise the motor hat driver
hat := i2c.NewAdafruitMotorHatDriver(
  adaptor,
  i2c.WithBus(1),
  i2c.WithAddress(defaultGobotMotorHatI2C),
)

// Start the driver
if err := hat.Start(); err != nil {
  _ = adaptor.Finalize()
  return nil, fmt.Errorf("start Adafruit motor hat driver: %w", err)
}

Although Gobot also offers a high-level event loop, we won’t use it for such a simple project. We simply send commands to the motor HAT, using a setMotor helper function:

func (d *GobotDriver) setMotor(motor int, throttle float64) error {
	t := math.Max(-1, math.Min(1, throttle))
	speed := int32(math.Round(math.Abs(t) * 255))

	if err := d.hat.SetDCMotorSpeed(motor, speed); err != nil {
		return err
	}

	switch {
	case t > 0:
		return d.hat.RunDCMotor(motor, i2c.AdafruitForward)
	case t < 0:
		return d.hat.RunDCMotor(motor, i2c.AdafruitBackward)
	default:
		return d.hat.RunDCMotor(motor, i2c.AdafruitRelease)
	}
}

Using the helper simplifies the implementation of individual actions, as we can see in Forwards below:

func (d *GobotDriver) Forwards(context.Context) error {
	d.mu.Lock()
	defer d.mu.Unlock()

	values := [4]float64{-1, 1, -1, 1}
	for i, value := range values {
		motor := i + 1
		if err := d.setMotor(motor, value); err != nil {
			return fmt.Errorf("forwards motor %d: %w", motor, err)
		}
	}
	return nil
}

Periph Driver

Another option is Periph, which also happens to support the PCA9685 chip on the Adafruit Motor HAT.

Unlike Gobot’s Motor HAT driver, we must manually specify the chip’s PWM motor configuration:

var periphMotorChannels = [4]periphMotorChannel{
	{pwm: 8, in1: 10, in2: 9},
	{pwm: 13, in1: 11, in2: 12},
	{pwm: 2, in1: 4, in2: 3},
	{pwm: 7, in1: 5, in2: 6},
}

The equivalent setMotor helper is a little more involved. Some error handling has been omitted for brevity:

func (d *PeriphDriver) setMotor(motor int, throttle float64) error {
	if motor < 1 || motor > len(periphMotorChannels) {
		return fmt.Errorf("unsupported motor index %d", motor)
	}
	ch := periphMotorChannels[motor-1]

	t := math.Max(-1, math.Min(1, throttle))
	duty := pwmDutyFromThrottle(t)

	switch {
	case t > 0:
		d.setChannel(ch.in1, true)
		d.setChannel(ch.in2, false)
	case t < 0:
		d.setChannel(ch.in1, false)
		d.setChannel(ch.in2, true)
	default:
		d.setChannel(ch.in1, false)
		d.setChannel(ch.in2, false)
	}

	if duty == 0 {
		return d.pca.SetFullOff(ch.pwm)
	}

	return d.pca.SetPwm(ch.pwm, 0, duty)
}

Sonar Reader

The sonar-reader command is responsible for taking readings from each of the HC-SR04 ultrasonic sensors. This is backed by the Provider interface:

type Reading struct {
	DistanceCM float64
	DurationUS float64
	Timestamp  time.Time
}

type Provider interface {
	Open(ctx context.Context) chan Reading
	Close(ctx context.Context) error
}

Sonar Sensors

Interacting with the HC-SR04 sensors is a little tricky, due to their timing sensitivity. Each sensor component has four pins, a transmitter (T / TX), and a receiver (R / RX):

hc sr04 pinout

To measure the distance to the nearest obstacle, the transmitter first emits an ultrasonic pulse, then the receiver detects the echo of that pulse. The time between these two events is used to calculate the distance.

We can tell the sensor to emit a pulse by holding the trigger pin high for a short period. The sensor then raises the echo pin for the duration of a reflected ultrasonic pulse. Measuring that echo duration gives the round-trip travel time. We multiply by the speed of sound to calculate the total distance, then divide by two since we only want the distance in one direction:

hc sr04 distance

Voltage Divider

It’s also worth noting voltage requirements. The HC-SR04 accepts 3.3V or 5V on the trigger pin, but the echo pin outputs 5V. However, the GPIO pins on the Raspberry Pi are not 5V tolerant. This means we must use a voltage divider to drop the voltage on the echo pin to 3.3V.

Voltage divider circuits are a dime-a-dozen online, and you’ll want to choose one that suits your particular hardware design. Here’s a photo from when I was halfway through soldering the small voltage divider board included in the rover:

voltage divider

This iteration of the board supported two HC-SR04 sensors.
The final board supports four HC-SR04 sensors.

Periph Provider

Using Periph, we can interact with the HC-SR04 via GPIO pins on the Raspberry Pi:

// First we get references to appropriate GPIO pins:
trig := gpioreg.ByName("GPIO18")
echo := gpioreg.ByName("GPIO24")

// Example: Set trigger pin to low
trig.Out(gpio.Low)

// Example: Read from the echo pin
value := echo.In(gpio.Float, gpio.NoEdge)

To take sonar readings indefinitely, we use a for-ever loop inside a goroutine:

for {
  // set trigger pin high
  err := p.trig.Out(gpio.High)

  // sleep for long enough that the sonar will register the signal
  time.Sleep(triggerPulse)

  // set trigger low
  err = p.trig.Out(gpio.Low)

  // wait for echo high
  start := time.Now()
  for p.echo.Read() != gpio.High {
    if time.Since(start) > echoTimeout {
      log.Printf("timed out waiting for echo high")
      return
    }
  }

  // wait for echo low
  start = time.Now()
  for p.echo.Read() != gpio.Low {
    if time.Since(start) > echoTimeout {
      log.Printf("timed out waiting for echo low")
      return
    }
  }

  // calculate distance based on duration
  end := time.Now()
  duration := end.Sub(start)
  distance := duration.Seconds() * soundSpeedCMPerS / 2

  c <- Reading{
    DistanceCM: distance,
    DurationUS: float64(duration.Microseconds()),
    Timestamp:  time.Now(),
  }

  time.Sleep(sampleInterval)
}

UART Provider

Due to the timing-sensitive nature of ultrasonic readings, we also provide a UART provider. This can be used to gather readings from an external microcontroller (such as an STM32-based MCU).

We will look at how this works in the next post.

Web Bridge

Finally, we have the Web Bridge, which allows us to control the rover from a web browser. The web interface is intentionally small. It consists of a single HTML page (index.html), which loads a stylesheet and vanilla JavaScript:

Web Bridge

After loading, the web client opens a WebSocket connection back to /ws on the same host. This connection sends rover commands to the server and displays messages received from the server, including distance measurements.

WebSocket Server

The WebSocket endpoint is where the web UI meets MQTT. The web bridge is responsible for validating messages and publishing them via MQTT.

For example, when the browser sends the message {"type":"spin_ccw"}, the web bridge parses and validates the JSON payload. Malformed or unsupported payloads are rejected, and valid payloads are forwarded to the motor command topic (rover/motor/cmd).

The web bridge also subscribes to the sonar sample topic (rover/sonar/sample). When an ultrasonic reading is received, the web bridge publishes it to every connected WebSocket client.

Gamepad Support

The web UI can also translate browser gamepad input into the same command messages used by the on-screen controls. This keeps the backend unchanged: whether a command comes from a button click, a keyboard shortcut, or a gamepad axis, the WebSocket server still receives JSON, validates it, and forwards it to MQTT.

For testing I used an Xbox 360 Gamepad, connected via USB:

xbox gamepad

Next Steps

The next challenge in this project is to delegate reading from ultrasonic sensors to a separate microcontroller. I will be using an STM32F407 Discovery Board, a common microcontroller development board based on the Arm Cortex-M4 processor. This will be used to send distance readings back to the Raspberry Pi via a USB UART connection.

Stay tuned for the next post, where we’ll use TinyGo to compile and run Go binaries on the STM32.

References

Libraries and frameworks:

Videos:

Embedded Go:

  • Embedded Go - Bare-metal programming with Go
  • Embedded Go STM32 - Support for the STM32 microcontrollers
  • TinyGo - Bring Go to embedded systems and to the modern web using a compiler based on LLVM