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:
- Development & Testing: Raspberry Pi 5 acting as a local GitLab CI/CD build runner and test node.
- Production Target: Raspberry Pi 4 mounted on the hexapod chassis controlling two PCA9685 16-channel PWM servo controllers over I²C.
- Hardware Isolation: Simulating I²C devices with
i2c-stubso automated GTest suites can run without physical hardware connected. - Containerization: Running the full ROS2 stack in Docker containers with direct hardware and stub passthrough.
What is I²C?
I²C (Inter-Integrated Circuit) is a synchronous, multi-controller/multi-target two-wire serial communication bus:
- SDA (Serial Data): Bidirectional data line.
- SCL (Serial Clock): Clock signal driven by the controller.
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.