The Mad Scientist's Lab

(Don’t touch anything!!!)

Building a Dockerized I²C Development Platform for Raspberry Pi

Background

I acquired a Freenove toy robot to explore ROS2 and enhance its capabilities. My plan involved leveraging a CI/CD-like workflow, using a Raspberry Pi 5 for development and testing, and deploying the final software to the robot’s Raspberry Pi 4. Docker containers provide isolated ROS2 development environments, while GitLab CI/CD automates the build, test, and deployment process.

The project presented technical challenges like mastering I²C (Also written as I2C) communication with the robot’s hardware, cross-compiling between Raspberry Pi models, optimizing Docker images, and configuring GitLab CI pipelines. Ultimately, the goal was to add new features to the robot, streamline the development process, and gain valuable experience with ROS2, Docker, GitLab CI/CD, and embedded systems development.

What is I2C?

I2C, or Inter-Integrated Circuit, is a versatile communication protocol widely used in embedded systems, particularly for connecting microcontrollers and sensors. It enables efficient data exchange between multiple devices using just two wires: SDA (Serial Data Line) and SCL (Serial Clock Line).

I2C’s simplicity and flexibility make it a popular choice for various applications, from robotics and industrial automation to consumer electronics and medical devices. It supports multiple masters and slaves on the same bus, allowing for complex communication networks.

In the context of my Freenove robot project, the PCA9685 I2C interface serves as a critical bridge between the Raspberry Pi and the robot’s servos. This 16-channel PWM controller allows me to precisely manipulate the servos responsible for each leg’s movement, enabling fluid and coordinated locomotion.

I2C/SMBus Subsystem on Linux

In Linux, the I2C/SMBus subsystem provides a framework for interacting with I2C devices. It comprises kernel drivers, device files, and user-space tools that allow applications to communicate with I2C peripherals efficiently.

Key aspects of the subsystem include:

  • SMBus Functionality: While I2C focuses on simple data transfer, SMBus (System Management Bus) extends the protocol with features like clock synchronization, alerts, and block transfers. The Linux subsystem supports both I2C and SMBus.
  • Device Detection and Enumeration: The i2c-tools package provides utilities like i2cdetect to scan the I²C bus, identify connected devices, and determine their addresses.
  • Data Transfer: Functions like i2cget and i2cset, available through user-space libraries or directly within the /dev/i2c-* device files, facilitate reading from and writing data to specific registers within I²C devices.

For my Freenove robot project, understanding the Linux I2C/SMBus subsystem and utilizing the i2c-tools package will be essential for configuring the I2C bus, detecting and interacting with the PCA9685 controller, and developing C++ drivers to control the robot’s servos.

My Driver

While Freenove’s examples often utilize the Python smbus2 package for convenience, this project emphasizes building my C++ skills. Therefore, I’ll focus on employing C libraries and system calls to interact with the I2C bus directly, providing a deeper understanding of the underlying mechanisms.

i2c_driver.hpp
/**
 * @file i2c_driver.hpp
 * @brief Provides a C++ wrapper for I2C communication on Linux.
 *
 * This header defines the `hexapod::I2CDriver` class, which simplifies interaction with I2C devices.
 */
#ifndef HEXAPOD_I2C_DRIVER_HPP_
#define HEXAPOD_I2C_DRIVER_HPP_

extern "C"
{
    #include<linux/i2c-dev.h>
    #include <i2c/smbus.h>
}
#include <string>
#include <memory>

namespace hexapod
{

    /**
     * @class I2CDriver
     * @brief A class for managing I2C communication.
     */
    class I2CDriver
    {

    public:

        /**
         * @brief Constructs an I2CDriver object.
         * @param busNumber The I2C bus number to use (default is 1).
         */
        I2CDriver(unsigned int busNumber = 1);

        /**
         * @brief Destructor, closes the I2C device if open.
         */
        ~I2CDriver();

        /**
         * @brief Opens the I2C device at the specified address.
         * @param address The I2C device address.
         * @throws std::runtime_error If there's an error opening the device or setting the slave address.
         */
        void open(long address);

        /**
         * @brief Writes a byte of data to the specified register.
         * @param reg The register address.
         * @param data The data to write.
         * @throws std::runtime_error If there's an error writing to the device.
         */
        void writeByte(uint8_t reg, uint8_t data);

        /**
         * @brief Reads a byte of data from the specified register.
         * @param reg The register address.
         * @return The read data.
         * @throws std::runtime_error If there's an error reading from the device.
         */
        uint8_t readByte(uint8_t reg);

        /**
         * @brief Closes the I2C device.
         */
        void close();

    private:
        int fileDescriptor; ///< File descriptor for the I2C device.
        unsigned int busNumber; ///< The I2C bus number.
        std::string deviceFilePath; ///< The file path to the I2C device.
    };
} // namespace hexapod
#endif // HEXAPOD_I2C_DRIVER_HPP_
i2c_driver.cpp
/**
 * @file i2c_driver.cpp
 * @brief Implementation of the I2CDriver class.
 */

#include "hexapod/i2c_driver.hpp"
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>

namespace hexapod
{

    I2CDriver::I2CDriver(unsigned int busNumber)
        : busNumber(busNumber)
    {
        this->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");
        }

        if (ioctl(fileDescriptor, I2C_SLAVE, address) < 0)
        {
             perror("Failed to set I2C slave address:"); 
            close();
            throw std::runtime_error("Failed to set 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 device");
        }
        
    }

    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 device");
        }
        

        return static_cast<uint8_t>(result);
    }

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

} // namespace hexapod

The extern "C" Block: Ensuring C Compatibility

C++
extern "C"
{
    #include<linux/i2c-dev.h>
    #include <i2c/smbus.h>
}

In C++, function names are often “mangled” to include information about their arguments, enabling function overloading. However, the I2C subsystem is written in C, which doesn’t mangle names.

The extern "C" block instructs the C++ compiler to treat the enclosed function declarations as if they were written in C, preventing name mangling. This ensures seamless linking between the C++ code and the I2C functions.

In essence, extern "C" acts as a bridge, allowing the C++ code to interact correctly with the C-based I2C subsystem on Linux.

Controller vs Responder: Who’s in Charge

C++
    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");
        }

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

If you are familiar with C++ then everything in my I2C driver is pretty self-explanatory or can be easily explained with a quick look at the official documentation for the I2C/SMBus subsystem except for the open function. Not only do we need to open the device here, but we need to tell it in which mode to function. We use the Linux ioctl function to describe the relationship of operation.

By using ioctl with I2C_SLAVE and the desired address, we’re essentially:

  1. Informing the Linux kernel: We’re telling the kernel that this particular I2C device should operate as a responder on the bus.
  2. Assigning the Address: We’re providing the specific address that the responder device should respond to when addressed by a controller.

Once this configuration is done, the Linux kernel and its I2C driver take over the low-level control of the I2C communication.

Raspberry Pi and I2C:

So, my poor planning concerning which Raspberry Pi to purchase for my robot ended up being a lesson in hardware configurations. It was not my intention to use multiple Raspberry Pis in this project, but I errantly purchased a Raspberry Pi 5 for the robot, which wasn’t supported at the time. So, I decided to put it to work after buying the correct Raspberry Pi 4 for the robot.

As I learned how to use Gitlab CI/CD Runners, I realized I could use the Raspberry Pi 5 in the project as a build server. I have it set up to build the docker image where the ROS2 app and its dependencies reside. Then I use it to build the ROS2 app inside the container it just built before finally running the Gtests I wrote to test my code. Getting there was a challenge due to different hardware configurations and I didn’t have an actual PCA6985 I2C device connected to the Raspberry Pi 5.

While the Raspberry Pi 4 and Pi 5 both offer I2C capabilities, it’s important to be aware of their differences when designing and implementing projects.

I2C on Raspberry Pi 4:

  • Software I2C (bit-banging): It’s also possible to implement I2C communication in software on any available GPIO pins, though this method is generally slower and less efficient.
  • Hardware I2C: The Pi 4 has two hardware I2C buses (referred to as I2C-0 and I2C-1) available on specific GPIO pins.

I2C on Raspberry Pi 5:

  • Hardware I2C: The Pi 5 still supports hardware I2C, but there’s a change in the number of buses and their configuration.
  • Reduced Number of Buses: The Pi 5 has only one hardware I2C bus available by default.
  • RP1 I/O Controller: The I2C bus is now managed by the new RP1 I/O controller chip, which may lead to some differences in behavior compared to the Pi 4.

To enable the i2c functionality on the Raspberry Pi, you need to use the raspi-config package. If you are using the Raspberry PI OS, this will already be installed. If you are using Ubuntu for Raspberry Pi, like I am, you will need to install it. You will also need to install the i2c-tools package.

Bash
sudo apt-get update && sudo apt-get install raspi-config i2c-tools

raspi-config: Everything Raspberry Pi

Once installed you will need to run the command.

Bash
sudo raspi-config

This will open the interactive configuration screen for the Raspberry Pi hardware. You will want to select option 3 Interface Options.

Then you want to select option I5 I2C.

From there you just enable the I2C Kernel Module by selecting Yes.

i2c-tools: Everything I2C

With the I2C Kernel Module enabled, we can now take a look at what I2C device buses are currently configured. We will use the i2cdetect command from the i2c-tools package.

Bash
pi@hexapod-pi:~$ i2cdetect -l
i2c-1   i2c             bcm2835 (i2c@7e804000)                  I2C adapter

We currently have one bus on the Raspberry Pi 4 in the robot. We can further deep dive into that bus to look at the registers available on bus 1.

Bash
pi@hexapod-pi:~$ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: 40 41 -- -- -- -- -- -- 48 -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
70: 70 -- -- -- -- -- -- --

If you’re using the robot I am and have connected everything appropriately, the output will look very similar. You can interpret this as having i2c devices on bus 1 at addresses 0x40, 0x41, 0x48, 0x68, and 0x70. For the Freenove Robot, there are two PCA9685 devices on bus 1. They are at 0x40 and 0x41.

We can view the contents of the registers for a device using the i2cdump command.

Bash
pi@hexapod-pi:~$ i2cdump -y 1 0x41 b
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f    0123456789abcdef
00: 11 04 e2 e4 e8 e0 00 00 00 10 00 00 00 10 00 00    ??????...?...?..
10: 00 10 00 00 00 10 00 00 00 10 00 00 00 10 00 00    .?...?...?...?..
20: 00 10 00 00 00 10 00 00 00 10 00 00 00 10 00 00    .?...?...?...?..
30: 00 10 00 00 00 10 00 00 00 10 00 00 00 10 00 00    .?...?...?...?..
40: 00 10 00 00 00 10 XX XX XX XX XX XX XX XX XX XX    .?...?XXXXXXXXXX
50: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
60: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
70: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
80: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
90: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
a0: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
b0: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
c0: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
d0: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
e0: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX    XXXXXXXXXXXXXXXX
f0: XX XX XX XX XX XX XX XX XX XX 00 00 00 00 1e 00    XXXXXXXXXX....?.

As we develop code, we don’t want all the testing done on the hardware itself. I have already broken servos just playing with the code provided by Freenove. This is an inexpensive robot and a great place to make these mistakes, but we want to save as much hardware stress from testing as we can. To do this, we can create virtual devices from the devices we currently have.

Simulating I2C Devices with i2c-stub

I set this up on a separate Raspberry Pi because I had the device available, this is still completely acceptable to set up on the same device that is connected to your hardware. The idea is to have a mock device where you can test drivers and functions without causing damage to other hardware.

The i2c-stub module is installed along with i2c-tools that we installed earlier. If you have gotten this far, then it should be available to you. We will use the modprobe command to enable the i2c-stub module. The modprobe function is usually already installed with the Linux kernel which you should have updated when you installed your OS. You can install the kmod package if it’s not installed.

Bash
sudo apt-get update && sudo apt-get kmod

Now we enable the i2c-stub module.

Bash
sudo modprobe i2c-stub

While this enables our i2c-stub module if you do not tell Linux to enable it permanently you will have to run the modprobe command after every reboot. So, let’s tell Linux to load this module on startup.

Bash
sudo nano /etc/modules-load.d/i2c-stub.conf

Then you just add the i2c-stub module to the file and click control-x to close, then yes to save the file and accept the default filename.

Previously we went over the i2cdump command to view what data is in our registers for the PCA9685 chips from the Freenove Robot. Now we are going to use that command and the i2c-stub-from-dump command to create our virtual hardware device. To do this, we want to redirect the output of our i2cdump to file.

Bash
i2cdump -y 1 0x40 b > chip40.dump
i2cdump -y 1 0x41 b > chip41.dump

Now that we have the dump files of the devices we want to emulate, all we need to do is create the stub device using those device dumps.

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

Now we run the i2cdetect command to take a look at our new i2c-stub bus.

Bash
pi@builder-pi:~$ i2cdetect -l
i2c-1   i2c             Synopsys DesignWare I2C adapter         I2C adapter
i2c-11  i2c             107d508200.i2c                          I2C adapter
i2c-12  i2c             107d508280.i2c                          I2C adapter
i2c-13  smbus           SMBus stub driver                       SMBus adapter

The bus number will be automatically selected for you. You can easily identify it. This is the stub device on my Raspberry Pi 5. Linux chose bus 13 for me, but depending on your setup, it could be different for you. Now if we take a look at that bus we should see the two addresses we designated in the i2c-stub-from-dump command, 0x40 and 0x41.

Bash
pi@builder-pi:~$ i2cdetect -y 13
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: 40 41 -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

So we have built our stub device, but we still need to make sure this i2c-stub device loads on startup. The best way to do this is to create a systemd service to build the i2c-stub on restart.

First, we need to put the dump files in a place that will be accessed by systemd. We don’t want them in our user home directory. It would be too easy to delete them or move them by accident and keep your service from running. So we will move them to the /lib/firmware/ directory.

Bash
sudo mv *.dump /lib/firmware/

Next, we need to do is create the service file for the i2c-stub service. This will create a file to control your new service.

Bash
sudo nano /etc/systemd/system/i2c-stub.service

This will open the nano editor where you will want to copy the below if you’re following along with me. If you’re not following along with me, you can derive everything you need to set up your i2c-stub from below.

Bash
[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

Now we have the hardware stub set up, but my entire ROS2 application is run in a Docker container and needs access to the actual hardware and virtual stub.

Bridging the Gap: Accessing I2C Hardware from Docker Containers

I don’t want to turn this post into a complete Docker engine education. We will save that for another post. With that said, there are only two things we need to do now to ensure we have access to the hardware of the Raspberry Pi.

User Access

Assuming you’re using a Linux image, you need to give your Docker container user permission to access the hardware just like you would directly on the Raspberry Pi. Since you’re not installing i2c-tools in your Docker image (you won’t need it) you need to create the i2c group and give your user access to it in your Dockerfile.

Dockerfile
RUN groupadd i2c
RUN useradd -ms /bin/bash pi
RUN usermod -aG i2c,dialout pi 
USER pi

Device Pass Through

Now when you run your container, you just need to pass through the i2c-devices in the run command.

Bash
docker run --device /dev/i2c-1:/dev/i2c-1 --device /dev/i2c-13:/dev/i2c-13 my_ros2_image

Let’s break down the --device option in the docker run command.

In Docker, the --device option allows you to share specific devices from your host system (your computer) directly with a running container. This is crucial when your containerized application, such as your ROS 2 node, needs to interact with hardware connected to your host.

  • The first /dev/i2c-1: This represents the path to the device on your host system.
  • The second :/dev/i2c-1: This is the path where the device will be accessible inside the container. Often, you’ll keep the same path for consistency, but you have the flexibility to change it if needed.

We did this for both devices, the stub, and the actual hardware. This enables the driver to access both.

In my case, I am using a Gitlab Runner to run the docker image. So in my gitlab-runner config.toml I set the devices to pass through for both.

Bash
  [runners.docker]
    tls_verify = false
    image = "docker:stable"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/certs/client", "/cache", "/dev:/dev"]
    shm_size = 0
    network_mtu = 0
    allowed_pull_policies = ["always", "if-not-present"]
    devices = ["/dev/i2c-1", "/dev/i2c-13"]

Discover more from The Mad Scientist's Lab

Subscribe now to keep reading and get access to the full archive.

Continue reading