Shawn MaybushENGINEERING WORKBENCH

Building a Dockerized I²C Development Platform for Raspberry Pi


Background

To explore ROS2 on real-world hardware, I acquired a Freenove big hexapod robot kit. Developing directly on the robot’s physical compute—a Raspberry Pi 4—can be clumsy and error-prone. One wrong pulse can strip servo gears, and rebuilding native dependencies on the robot drains battery and slows down the loop.

My goal was to establish a modern CI/CD robotics workflow:


What is I²C?

I²C (Inter-Integrated Circuit) is a synchronous, multi-controller/multi-target two-wire serial communication bus:

In the Freenove hexapod, two PCA9685 PWM driver chips sit on the I²C bus at addresses 0x40 and 0x41. Each chip drives 16 independent PWM channels, giving us 32 channels to control all 18 servos across the robot’s 6 legs.


Direct C++ I²C Driver Implementation

While Python packages like smbus2 are common in educational kits, production robotics demands predictable latency and tight lifecycle control. I implemented a native C++ wrapper (hexapod::I2CDriver) using Linux system calls (open, ioctl, i2c_smbus_*).

Header (i2c_driver.hpp)

#ifndef HEXAPOD_I2C_DRIVER_HPP_
#define HEXAPOD_I2C_DRIVER_HPP_

#include <cstdint>
#include <string>

extern "C" {
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#include <i2c/smbus.h>
}

namespace hexapod {

class I2CDriver {
public:
    explicit I2CDriver(unsigned int busNumber = 1);
    ~I2CDriver();

    void open(long address);
    void writeByte(uint8_t reg, uint8_t data);
    uint8_t readByte(uint8_t reg);
    void close();

private:
    int fileDescriptor{-1};
    unsigned int busNumber;
    std::string deviceFilePath;
};

} // namespace hexapod

#endif

Implementation (i2c_driver.cpp)

#include "hexapod/i2c_driver.hpp"
#include <stdexcept>
#include <cstdio>

namespace hexapod {

I2CDriver::I2CDriver(unsigned int busNumber)
    : busNumber(busNumber), deviceFilePath("/dev/i2c-" + std::to_string(busNumber)) {}

I2CDriver::~I2CDriver() {
    close();
}

void I2CDriver::open(long address) {
    fileDescriptor = ::open(deviceFilePath.c_str(), O_RDWR);
    if (fileDescriptor < 0) {
        perror("Error opening I2C device");
        throw std::runtime_error("Failed to open I2C device: " + deviceFilePath);
    }

    if (ioctl(fileDescriptor, I2C_SLAVE, address) < 0) {
        perror("Failed to set I2C slave address");
        close();
        throw std::runtime_error("Failed to configure I2C slave address");
    }
}

void I2CDriver::writeByte(uint8_t reg, uint8_t data) {
    if (i2c_smbus_write_byte_data(fileDescriptor, reg, data) < 0) {
        throw std::runtime_error("Failed to write byte to I2C register");
    }
}

uint8_t I2CDriver::readByte(uint8_t reg) {
    int result = i2c_smbus_read_byte_data(fileDescriptor, reg);
    if (result < 0) {
        throw std::runtime_error("Failed to read byte from I2C register");
    }
    return static_cast<uint8_t>(result);
}

void I2CDriver::close() {
    if (fileDescriptor >= 0) {
        ::close(fileDescriptor);
        fileDescriptor = -1;
    }
}

} // namespace hexapod

Simulating Hardware with i2c-stub

Testing code shouldn’t risk burning out physical servos. Linux provides a kernel module called i2c-stub that allows you to create virtual I²C devices from captured register dumps.

1. Dump Real Device Registers

From the physical robot Pi where the PCA9685 chips are connected to bus 1:

# Dump registers for PCA9685 at 0x40 and 0x41
i2cdump -y 1 0x40 b > chip40.dump
i2cdump -y 1 0x41 b > chip41.dump

2. Instantiate the Virtual Stub on the Build Server

On the Raspberry Pi 5 build host:

sudo modprobe i2c-stub
sudo i2c-stub-from-dump 0x40,0x41 chip40.dump chip41.dump

Running i2cdetect -l shows the newly allocated virtual SMBus adapter (e.g. i2c-13), and i2cdetect -y 13 confirms both addresses 0x40 and 0x41 are present and responsive!

3. Persist via Systemd

We move the dumps to /lib/firmware/ and create /etc/systemd/system/i2c-stub.service:

[Unit]
Description=I2C Stub Service with PCA9685 Emulation
After=network.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/i2c-stub-from-dump 0x40,0x41 /lib/firmware/chip40.dump /lib/firmware/chip41.dump

[Install]
WantedBy=multi-user.target

Container Passthrough in Docker & GitLab CI

To run our ROS2 node inside Docker while communicating with both real and stub devices:

Dockerfile User Permissions

FROM ros:humble-ros-base
RUN groupadd -g 998 i2c && \
    useradd -ms /bin/bash -u 1000 -g i2c pi && \
    usermod -aG i2c,dialout pi
USER pi

Passing Devices to the Container

docker run -it --rm \
  --device /dev/i2c-1:/dev/i2c-1 \
  --device /dev/i2c-13:/dev/i2c-13 \
  hexapod-ros2:latest

In the GitLab Runner configuration (/etc/gitlab-runner/config.toml):

[runners.docker]
  image = "docker:stable"
  privileged = true
  volumes = ["/certs/client", "/cache", "/dev:/dev"]
  devices = ["/dev/i2c-1", "/dev/i2c-13"]

With this setup, every commit to GitLab automatically triggers the Pi 5 runner, builds the containerized ROS2 package, executes GTest unit tests against the i2c-stub virtual controllers, and ensures hardware drivers compile cleanly before deployment to the robot.

← Back to Lab Notes