View Categories

Интерфейс I2C (Wire)

I2C (Inter-Integrated Circuit, IIC) - интерфейс связи между цифровыми устройствами, по нему МК может общаться с датчиками и модулями или с другими МК. Среди основных интерфейсов связи (UART, I2C, SPI) I2C - самый технически сложный и медленнный, но позволяет подключить к МК до 127 устройств по 2 проводам и на уровне протокола следит за доставкой данных и статусом устройств в сети.

  • Синхронный (есть тактовая линия)
  • Последовательный (данные передаются бит за битом по одному проводу)
  • Количество пинов: 2
  • Скорость: стандартная 100 кГц, ускоренная (fast mode) 400 кГц, fast mode plus 1 MHz, некоторые устройства поддерживают более высокую скорость, почти все могут работать на более низкой
  • Топология: одно ведущее устройство, но можно организовать и мульти-мастер
  • Количество ведомых на шине: до 127 (7-бит адресация). Существует и стандарт 10-битной адресации

Произносится как "ай-сквар-си", т.е. под числом 2 имеется в виду квадрат (i "в квадрате")

I2C - разработка компании Philips, право на использование этого названия в своих устройствах стоит денег. Поэтому "аналоги" I2C называют по-разному, например в AVR это TWI (Two Wire), а в Arduino библиотека называется Wire

Основы #

Роли #

I2C является шиной, т.е. к одним и тем же выводам может быть подключено несколько устройств. По стандарту на шине есть только одно ведущее устройство - Master, остальные - ведомые, Slave. Ведущее выбирает, с каким из ведомых взаимодействовать, может отправлять и читать с него данные. Ведущим обычно является основной МК в схеме, а остальные - различные цифровые микросхемы, датчики или вспомогательные МК.

Пины #

I2C использует 2 пина для подключения:

  • SDA (Serial Data) - линия данных, передача в обе стороны. На модуле может быть подписан как D
  • SCL (Serial Clock) - линия синхронизации, управляет мастер. На модуле может быть подписан как C, SCK

Оба пина работают как open drain, т.е. активный сигнал - GND. Высокий сигнал обеспечивается внешней подтяжкой пина к питанию (на стороне ведомого устройства, обычно 10 кОм), это позволяет избежать короткого замыкания, если ведущее и ведомое вдруг решат одновременно подать разноимённые сигналы. В то же время это ограничивает скорость шины, т.к. скорость возврата сигнала к высокому уровню ограничена резистором и ёмкостью цепи.

Подключение #

SDA к SDA, SCL к SCL:

Расположение аппаратных выводов I2C микроконтроллера или отладочной платы всегда отмечено на распиновке как SDA и SCL

Адресация #

Каждое устройство на шине имеет уникальный адрес от 1 до 127, двух устройств с одинаковым адресом на шине быть не должно. Адрес может быть:

  • Один фиксированный - указан в документации на микросхему
  • Настраиваемый из 2/4/8 фиксированных, обычно перемычками на модуле (соединяют выводы микросхемы и/или резисторы)
  • Программируемый - задаётся любым по другому интерфейсу

Адрес 0 зарезервирован под broadcast-запросы

Процесс передачи #

Не будем углубляться в аппаратную реализацию самого интерфейса, рассмотрим основные моменты:

  • Передача по шине идёт в виде сообщений (message), оно состоит из байта заголовка и пакетов данных (frame)
  • Сообщения могут быть двух типов: мастер отправляет данные ведомому, либо мастер читает данные с ведомого
  • В заголовке указывается адрес ведомого устройства (7 бит) + тип сообщения (1 бит)
  • Данные передаются по 1 байту старшим битом вперёд, мастер тактирует каждый бит линий SCL (как в SPI)
  • Сообщения не имеют длины - передача просто идёт до тех пор, пока одно из устройств не завершит её
  • После каждого байта (данных и заголовка) идёт подтверждение приёма - специальный сигнал ACK (Acknowledgement, признание). Если он не был принят - считается сигналом NACK, т.е. устройство не ответило или это конец передачи
  • На шине также есть специальный сигнал START - мастер отправляет его перед началом передачи. И сигнал STOP - после окончания

Отправка #

  • Мастер отправляет START
  • Мастер отправляет заголовок: адрес ведомого + режим передачи "отправка"
  • Ведомый с нужным адресом отправляет ACK
  • Мастер проверяет ACK: если он есть - ведомый с таким адресом существует и готов принимать данные
  • Мастер отправляет данные байт за байтом, проверяя ACK от ведомого после каждого байта
  • Мастер отправляет STOP


Серый фон - мастер отправляет по SDA

Чтение #

  • Мастер отправляет START
  • Мастер отправляет заголовок: адрес ведомого + режим передачи "чтение"
  • Ведомый с нужным адресом отправляет ACK
  • Мастер проверяет ACK: если он есть - ведомый с таким адресом существует и готов отправлять данные
  • Ведомый отправляет данные байт за байтом (мастер тактирует шину), проверяя ACK от мастера после каждого. В свою очередь, мастер отправляет ACK после каждого принятого байта
  • Когда мастер прочитает нужное количество, он отправляет NACK в конце последнего фрейма, чтобы ведомый освободил линию
  • Мастер отправляет STOP


Серый фон - мастер отправляет по SDA

Повторный старт #

  • Вместо отправки STOP мастер может сразу отправить START, если хочет сразу начать новое сообщение и не хочет освобождать шину (например, для другого мастера в мульти-мастер шине)
  • После этого снова идёт заголовок и далее как в обычной передаче

На практике #

На практике ведомые устройства обычно имеют систему регистров (команд): первым байтом отправляется номер регистра, в который мы хотим писать или читать, поэтому работа с ними выглядит так:

Отправка #

  • Старт
  • Заголовок (режим "мастер отправляет")
  • Отправка номера регистра для записи
  • Отправка данных (один или несколько байт)
  • Стоп


Серый фон - мастер отправляет по SDA

Чтение #

  • Старт
  • Заголовок (режим "мастер отправляет")
  • Отправка номера регистра для чтения
  • Повторный старт
  • Заголовок (режим "мастер читает")
  • Чтение данных (один или несколько байт)
  • Стоп


Серый фон - мастер отправляет по SDA

Далее мы рассмотрим это "в железе".

Библиотека Wire.h #

Полное описание смотри в справочнике

В фреймворке Arduino есть удобный инструмент для работы с шиной I2C - библиотека Wire.h. Рассмотрим общий пример использования.

Отправка #

Отправка реализована через буфер - внутри библиотеки есть буфер на 32 байта (в разных платформах может отличаться), т.е. все отправляемые нами данные буферизируются и по факту отправляются в "конце" передачи. С одной стороны это удобно - не нужно беспокоиться о таймингах, т.е. между отправками частей данных можно выполнять длительные операции. С другой стороны - не получится отправить больше 32 байт за раз, придётся перезапускать передачу и отправлять остаток.

#include <Wire.h>

void setup() {
    // запустить шину в роли мастера
    Wire.begin();

    // начать отправку по адресу
    // (отправит START + заголовок)
    Wire.beginTransmission(адрес);

    // отправить байт (в буфер)
    Wire.write(0xab);

    // отправить массив 3 байта (в буфер)
    uint8_t buf[3];
    Wire.write(buf, 3);

    // отправить на устройство (с контролем всех ACK + STOP в конце)
    // вернёт 0, если успешно
    bool res = Wire.endTransmission();

    if (res) {
        // обработка ошибки
    }

    // остановить шину (опционально)
    Wire.end();
}

void loop() {
}

Также Wire наследует Stream, т.е. можно делать Wire.print("hello!") и прочие подобные вещи при организации текстовой связи между платами.

Чтение #

Чтение так же буферизируется, по умолчанию 32 байта. Т.е. мы запрашиваем с ведомого устройства данные, библиотека получает их в буфер, затем уже оттуда мы можем их прочитать. Опять же, нельзя принять больше 32 байт за раз.

#include <Wire.h>

void setup() {
    // запустить шину в роли мастера
    Wire.begin();

    // запросить N байт с адреса, вернёт сколько получено
    // (отправит START и заголовок, запросит и обработает все ACK + NACK + STOP)
    uint8_t res = Wire.requestFrom(адрес, N);

    if (res != N) {
        // обработка ошибки
        // данные не получены в желаемом количестве
    } else {
        // кол-во данных в буфере
        Wire.available();

        // прочитать байт
        uint8_t v = Wire.read();

        // прочитать несколько байт в массив
        uint8_t buf[3];
        Wire.readBytes(buf, 3);
    }

    // остановить шину (опционально)
    Wire.end();
}

void loop() {
}

Также из Stream доступны методы парсинга, например Wire.parseInt(), Wire.readStringUntil() и прочие. Могут пригодиться при организации текстовой связи между ардуинками, но обычно так не делают.

Отправка + чтение #

Как говорилось выше, при работе с реальными микросхемами обычно бывает нужно отправить номер регистра (команду), а затем прочитать данные. Чтобы реализовать "повторный старт", нужно передать false в endTransmission():

// (отправит START + заголовок)
Wire.beginTransmission(адрес);

Wire.write(регистр);

// если успешно отправлено
// (с контролем всех ACK, БЕЗ STOP в конце)
if (!Wire.endTransmission(false)) {
    // запрашиваем данные
    uint8_t res = Wire.requestFrom(адрес, N);

    // ...
}

Сканер шины #

Зная, как работает I2C и библиотека Wire.h, можно написать "сканер" шины: перебрать в цикле все адреса, с каждым сделать beginTransmission() и проверить результат endTransmission(). Если микросхема ответит - там будет 0:

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin();
}

void loop() {
  Serial.println("Begin scan");

  for (uint8_t addr = 1; addr <= 127; addr++) {
    Wire.beginTransmission(addr);
    if (!Wire.endTransmission()) {
      Serial.print("0x");
      Serial.println(addr, HEX);
    }
  }

  Serial.println("End scan");
  Serial.println();
  delay(1000);
}

Если подключить к шине несколько устройств - их адреса будут выводиться списком в монитор порта.

Пример с DS3231 #

Теперь рассмотрим модуль часов реального времени DS3231 (урок про него). Адрес на шине - 0x68.

В наборе GyverKIT START IOT EXTRA
Модуль часов


Таблица регистров из документации

Микросхема хранит время в двоично-десятичном формате. Не вдаваясь в подробности, нам понадобятся вспомогательные функции для конвертации в BCD и обратно:

// перевод из десятичного в двоично-десятичное
uint8_t encodeBCD(uint8_t data) {
    return ((data / 10) << 4) | (data % 10);
}

// перевод из двоично-десятичного в десятичное
uint8_t decodeBCD(uint8_t data) {
    return (data >> 4) * 10 + (data & 0xF);
}

В качестве примера рассмотрим запись и чтение минут и секунд. С часами всё сложнее, посмотреть реализацию можно например в моей библиотеке.

Для установки нужно выбрать регистр секунд (адрес 0x00 в таблице) и записать следом значение секунд. Можно продолжать отправлять данные - микросхема будет смещать адрес, т.е. следом можно отправить минуты, часы и так далее по списку:

Wire.beginTransmission(DS_ADDR);
Wire.write(DS_SEC_REG);     // регистр секунд

Wire.write(encodeBCD(12));  // 12 секунд
Wire.write(encodeBCD(24));  // 24 минуты
Wire.endTransmission();

Для чтения нужно опять же выбрать нужный регистр, в нашем случае секунды, и прочитать нужное количество байт - микросхема будет сама увеличивать адрес для следующего чтения. Мы записали только минуты и секунды, так что будет логично только их и прочитать:

Wire.beginTransmission(DS_ADDR);
Wire.write(DS_SEC_REG);
Wire.endTransmission(false));

Wire.requestFrom(DS_ADDR, 2);
uint8_t secs = decodeBCD(Wire.read());  // секунды
uint8_t mins = decodeBCD(Wire.read());  // минуты

Полная программа, которая устанавливает время при запуске на 24 минуты 12 секунд, а затем каждую секунду читает его с микросхемы и выводит в монитор порта:

#include <Wire.h>

// перевод из десятичного в двоично-десятичное
uint8_t encodeBCD(uint8_t data) {
    return ((data / 10) << 4) | (data % 10);
}

// перевод из двоично-десятичного в десятичное
uint8_t decodeBCD(uint8_t data) {
    return (data >> 4) * 10 + (data & 0xF);
}

#define DS_ADDR 0x68
#define DS_SEC_REG 0x00

void setup() {
    Serial.begin(115200);
    Wire.begin();

    Wire.beginTransmission(DS_ADDR);
    Wire.write(DS_SEC_REG);     // регистр секунд
    Wire.write(encodeBCD(12));  // 12 секунд
    Wire.write(encodeBCD(24));  // 24 минуты

    if (Wire.endTransmission()) {
        Serial.println("Time set error");
    }
}

void loop() {
    Wire.beginTransmission(DS_ADDR);
    Wire.write(DS_SEC_REG);  // регистр секунд

    if (!Wire.endTransmission(false)) {
        if (Wire.requestFrom(DS_ADDR, 2) == 2) {
            uint8_t secs = decodeBCD(Wire.read());
            uint8_t mins = decodeBCD(Wire.read());
            Serial.print(mins);
            Serial.print(':');
            Serial.println(secs);
        } else {
            Serial.println("Time get error 2");
        }
    } else {
        Serial.println("Time get error 1");
    }

    delay(1000);
}

Пример с Arduino #

Wire.h позволяет работать в том числе в режиме ведомого устройства. Рассмотрим примеры с передачей данных между платами Arduino, они соединяются SDA-SDA и SCL-SCL. Если питание плат раздельное, то нужно соединить ещё GND.

Мастер отправляет #

Отправим текст, на ведомом тоже выведем его как текст:

// Master
#include <Wire.h>

void setup() {
    Wire.begin();
}

int i;

void loop() {
    // отправляем на адрес 123
    Wire.beginTransmission(123);

    // текст hello x
    Wire.print("hello ");
    Wire.print(i);
    Wire.endTransmission();

    i++;
    delay(1000);
}

Для приёма на ведомом устройстве нужно подключить обработчик - он будет вызван при поступлении данных. Выведем данные в порт:

// Slave
#include <Wire.h>

// придёт количество принятых данных
// оно должно совпадать с Wire.available()
void receiveCb(int amount) {
    while (Wire.available()) {
        Serial.print((char)Wire.read());
    }
    Serial.println();
}

void setup() {
    Serial.begin(115200);
    Wire.begin(123);    // наш адрес - 123
    Wire.onReceive(receiveCb);
}

void loop() {
}

Мастер запрашивает #

Запросим данные с ведомого устройства:

// Master
#include <Wire.h>

void setup() {
    Serial.begin(115200);
    Wire.begin();
}

void loop() {
    // запросить 5 байт с адреса 123
    Wire.requestFrom(123, 5);

    // вывести в порт
    while (Wire.available()) {
        Serial.print((char)Wire.read());
    }
    Serial.println();

    delay(1000);
}

Когда мастер запросит данные - на ведомом устройстве вызовется обработчик. В нём можно отправить мастеру данные. Сколько байт запросил мастер - неизвестно, интерфейс не предоставляет такой информации. Ведомое устройство должно само знать, сколько байт отправить. Именно поэтому обычно мастер выбирает регистр, а затем последовательно читает данные, как в примерах с датчиками выше. В этом примере мы знаем, что мастер запросил 5 байт - 5 и отправим. В виде текста:

// Slave
#include <Wire.h>

void requestCb() {
    Wire.write("Hello");  // отправить 5 байт
}

void setup() {
    Wire.begin(123);    // наш адрес - 123
    Wire.onRequest(requestCb);
}
void loop() {
}

Комплексный пример #

Более комплексный пример - сделаем ведомую Ардуину реалистичным устройством с картой регистров и целым рядом возможностей:

  • 0x01 (запись) - управление светодиодом на пине LED_BUILTIN (0 выкл, 1 вкл)
  • 0x02 (запись) - строка, которая будут выведена в порт
  • 0x03 (чтение) - получить счётчик обращений по шине от мастера (1 байт)
  • 0x04 (чтение) - получить millis() ведомого устройства (4 байта)

Программа мастера:

// master
#include <Wire.h>

// адрес
#define SLAVE_ADDR 123

// команды
#define REG_LED 0x01
#define REG_STRING 0x02
#define REG_COUNTER 0x03
#define REG_MILLIS 0x04

void writeLED(bool state) {
    Wire.beginTransmission(SLAVE_ADDR);
    Wire.write(REG_LED);
    Wire.write(state);
    Wire.endTransmission();
}

void sendStr(const char* str) {
    Wire.beginTransmission(SLAVE_ADDR);
    Wire.write(REG_STRING);
    Wire.print(str);
    Wire.endTransmission();
}

uint8_t getCounter() {
    Wire.beginTransmission(SLAVE_ADDR);
    Wire.write(REG_COUNTER);
    Wire.endTransmission(false);

    Wire.requestFrom(SLAVE_ADDR, 1);
    return Wire.read();
}

uint32_t getMillis() {
    Wire.beginTransmission(SLAVE_ADDR);
    Wire.write(REG_MILLIS);
    Wire.endTransmission(false);

    Wire.requestFrom(SLAVE_ADDR, 4);
    uint32_t ms;
    Wire.readBytes((uint8_t*)&ms, 4);
    return ms;
}

void setup() {
    Serial.begin(115200);
    Wire.begin();
}

// вызываем все команды через паузу
void loop() {
    writeLED(1);
    delay(1000);

    writeLED(0);
    delay(1000);

    sendStr("hello");
    delay(1000);

    sendStr("from master");
    delay(1000);

    Serial.println(getCounter());
    delay(1000);

    Serial.println(getMillis());
    delay(1000);
}

Программа ведомого:

// slave
#include <Wire.h>

// адрес
#define SLAVE_ADDR 123

// команды
#define REG_LED 0x01
#define REG_STRING 0x02
#define REG_COUNTER 0x03
#define REG_MILLIS 0x04

// последняя выбранная команда
// в обработчике приёма
uint8_t cmd = 0;

// счётчик сообщений
uint8_t counter = 0;

// обработчик приёма
void receiveCb(int amount) {
    cmd = Wire.read();
    ++counter;

    switch (cmd) {
        case REG_LED:
            digitalWrite(LED_BUILTIN, Wire.read());
            break;

        case REG_STRING:
            while (Wire.available()) {
                Serial.print((char)Wire.read());
            }
            Serial.println();
            break;

        case REG_COUNTER: break;
        case REG_MILLIS: break;
    }
}

// обработчик запроса
void requestCb() {
    switch (cmd) {
        case REG_LED: break;
        case REG_STRING: break;

        case REG_COUNTER:
            Wire.write(counter);
            break;

        case REG_MILLIS: {
            uint32_t ms = millis();
            Wire.write((uint8_t*)&ms, 4);
        } break;
    }
}

void setup() {
    Serial.begin(115200);
    Wire.begin(SLAVE_ADDR);

    Wire.onReceive(receiveCb);
    Wire.onRequest(requestCb);

    pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
}

Ведомая Ардуинка будет мигать светодиодом и выводить в порт hello from master, а ведущая будет получать и выводить с неё значения счётчика и таймера аптайма.

В этом примере ведомое устройство имеет список команд, на которые оно знает как реагировать - где нужно принять ещё данные, а где нужно отправить оговоренное количество байт. Мы сохраняем последнюю полученную команду в reg, чтобы позже в обработчике запроса отправить соответствующие данные. Это простой пример, в нём нет обработки ошибок и таймаутов, чтобы не усложнять код - в реальной работе лучше добавить таймаут между получением команды и запросом данных.

Данный пример уже позволяет подключить гроздь ардуинок к одной главной, раздать им разные адреса и управлять по шине.

Дополнительно #

Дополнительный контент доступен владельцам набора GyverKIT и по подписке, подробнее читай здесь. Блок содержит:

  • Пример с MPU6050
  • 1 изображений
  • 2 блоков кода

Полезные страницы #

Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх