Embedded

I2C и SPI устройства: датчики и дисплеи через Raspberry Pi

Введение

Помимо простых цифровых GPIO-пинов, Raspberry Pi поддерживает шины I2C и SPI — те же протоколы, что разбирались в Arduino-цикле для подключения дисплеев, RTC-модулей и датчиков (статьи 282-283, 299). В статье разберём, как обращаться к устройствам на этих шинах непосредственно из Qt-приложения на Linux, без посредничества микроконтроллера.

Концепция

Linux предоставляет доступ к шинам I2C через файлы устройств /dev/i2c-N и к SPI через /dev/spidevN.N — взаимодействие происходит через стандартные системные вызовы ioctl()/read()/write(), для которых существуют удобные библиотеки-обёртки (libi2c-dev для I2C). В отличие от Arduino, где Wire.h/SPI.h дают высокоуровневый интерфейс прямо в составе ядра прошивки, на Linux взаимодействие с шинами идёт через файловую систему /dev, что требует прав доступа (аналогично GPIO из статьи 343) и явного указания I2C-адреса устройства при каждой операции.

Пример кода: I2C и SPI устройства

# Включение I2C/SPI через raspi-config или напрямую в конфигурации загрузки
sudo raspi-config  # Interface Options -> I2C / SPI -> Enable

# Проверка доступных устройств на шине I2C
sudo apt install i2c-tools
i2cdetect -y 1  # сканирование шины 1 (стандартная для большинства моделей Raspberry Pi)
// i2csensor.h — чтение данных с I2C-датчика (например, BMP280 для температуры/давления)
#pragma once
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#include <stdexcept>

class I2cSensor
{
public:
    I2cSensor(const std::string &devicePath, int deviceAddress)
    {
        m_fd = open(devicePath.c_str(), O_RDWR);
        if (m_fd < 0) {
            throw std::runtime_error("Не удалось открыть устройство I2C: " + devicePath);
        }

        if (ioctl(m_fd, I2C_SLAVE, deviceAddress) < 0) {
            close(m_fd);
            throw std::runtime_error("Не удалось установить адрес устройства I2C");
        }
    }

    ~I2cSensor() { close(m_fd); }

    uint8_t readRegister(uint8_t reg)
    {
        if (write(m_fd, &reg, 1) != 1) {
            throw std::runtime_error("Ошибка записи адреса регистра");
        }
        uint8_t value;
        if (read(m_fd, &value, 1) != 1) {
            throw std::runtime_error("Ошибка чтения значения регистра");
        }
        return value;
    }

    void writeRegister(uint8_t reg, uint8_t value)
    {
        uint8_t buffer[2] = {reg, value};
        if (write(m_fd, buffer, 2) != 2) {
            throw std::runtime_error("Ошибка записи в регистр");
        }
    }

private:
    int m_fd;
};
// Использование — чтение идентификатора чипа BMP280 (регистр 0xD0, ожидаемое значение 0x58)
I2cSensor bmp280("/dev/i2c-1", 0x76);
uint8_t chipId = bmp280.readRegister(0xD0);

if (chipId == 0x58) {
    qDebug() << "Обнаружен датчик BMP280";
} else {
    qWarning() << "Неожиданный идентификатор чипа:" << Qt::hex << chipId;
}
// SPI-дисплей — аналогичный подход через /dev/spidevN.N
#include <linux/spi/spidev.h>

class SpiDevice
{
public:
    SpiDevice(const std::string &devicePath, uint32_t speedHz)
    {
        m_fd = open(devicePath.c_str(), O_RDWR);
        uint8_t mode = SPI_MODE_0;
        ioctl(m_fd, SPI_IOC_WR_MODE, &mode);
        ioctl(m_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speedHz);
    }

    void transfer(const uint8_t *txBuffer, uint8_t *rxBuffer, size_t length)
    {
        spi_ioc_transfer transfer{};
        transfer.tx_buf = reinterpret_cast<unsigned long>(txBuffer);
        transfer.rx_buf = reinterpret_cast<unsigned long>(rxBuffer);
        transfer.len = length;
        ioctl(m_fd, SPI_IOC_MESSAGE(1), &transfer);
    }

private:
    int m_fd;
};

Пояснения к коду

I2cSensor показывает типичный паттерн работы с I2C на Linux — ioctl(m_fd, I2C_SLAVE, deviceAddress) устанавливает адрес устройства на шине для всех последующих операций с этим файловым дескриптором, после чего readRegister()/writeRegister() реализуют стандартный протокол доступа к регистрам устройства (запись адреса интересующего регистра, затем чтение значения) — концептуально идентично тому, что Wire.h делает на Arduino, но через более низкоуровневый интерфейс файловых операций Linux. i2cdetect -y 1 — практичный диагностический инструмент, показывающий, какие адреса устройств реально отвечают на шине, что помогает быстро проверить физическое подключение и правильность адреса устройства до написания какого-либо кода.

Подводные камни

  • Не включённая в конфигурации Raspberry Pi поддержка I2C/SPI (через raspi-config или прямое редактирование /boot/config.txt) — без явного включения соответствующего интерфейса файлы устройств /dev/i2c-1//dev/spidev0.0 просто не будут существовать, и попытка их открыть завершится понятной, но легко упускаемой при первой настройке ошибкой «файл не найден».
  • Использование неправильного номера шины I2C — разные модели и ревизии Raspberry Pi могут иметь разное соответствие физических пинов и номеров шин (/dev/i2c-0 против /dev/i2c-1), и код, написанный для одной модели, может не работать на другой без проверки актуальной разводки конкретной используемой модели.
  • Отсутствие проверки прав доступа к файлам /dev/i2c-N//dev/spidevN.N для запуска не от root — аналогично GPIO из статьи 343, эти устройства обычно принадлежат специальным группам (i2c, spi), и пользователь, под которым работает Qt-приложение, должен быть явно добавлен в соответствующую группу для доступа без повышенных привилегий.
  • Игнорирование задержек, требуемых датчиком между операциями записи и чтения (например, время на выполнение измерения внутри самого датчика) — в отличие от простого протокола из примера, многие реальные датчики требуют выждать определённое время после команды «начать измерение» перед попыткой чтения результата, и слишком быстрое чтение результата вернёт устаревшее или некорректное значение — даталист (datasheet) конкретного используемого датчика всегда должен быть проверен на наличие таких требований к тайминга.