Top.Mail.Ru

Arduino I2C. Библиотека Wire. Описание и реальные примеры.

Доброго времени суток, сегодня мы продолжим осваивать периферию Arduino, и на очереди интерфейс I2C, который в проектах для Ардуино используется довольно часто, как для опроса датчиков, так и для вывода данных на дисплей или связи нескольких плат Arduino между собой. И снова, как и обычно, не будем изменять устоявшейся традиции и логике в соответствии с которой начнем с теоретического обзора, а закончим все целым рядом практических примеров.

Теоретическая часть.

Стандарт I2C придуман достаточно давно, несколько десятков лет назад, поэтому неудивительно, что  описан он многократно и всеобъемлюще ) Тем не менее пройдемся по основным нюансам... I2C (IIC – Inter-Integrated Circuit) – последовательная асимметричная шина, в которой для связи между устройствами используются всего две линии. В микроконтроллерах AVR, которые лежат в основе большинства плат Arduino, также используется название TWI (Two-Wire Interface), так что не удивляйтесь, если встретите данную терминологию.

Физико-электрические характеристики.

Как вскользь уже было упомянуто, физически интерфейс I2C использует две линии (два провода):

  • линия данных SDA
  • линия тактирования SCL

При этом по умолчанию (обмен данными отсутствует) на обеих линиях высокий уровень, то есть логическая единица. Когда же устройства хотят что-то выдать в шину, то прижимают линию к земле, то есть к 0 Вольт (на линии логический ноль). Для того, чтобы это обеспечить обе(!) линии подтягиваются к питанию:

Подтягивающие резисторы I2C

Здесь, как видите, присутствуют подтягивающие резисторы R_1 и R_2. Эти резисторы при работе с интерфейсом I2C обязательны, без них функционирование просто невозможно. И стандартной величиной является номинал в 4.7 КОм (либо 10 КОм), бывают нюансы, но в подавляющем большинстве случаев можно просто ставить два резистора с указанным сопротивлением и не беспокоиться ни о чем.

В данном курсе я использую плату Arduino Uno, которая питается от 5 В, поэтому к этому же уровню и подтягиваются линии. При организации сети I2C принимайте во внимание данный факт: если у вас плата, которая работает от 3.3 В, то к 3.3 В и надо подтягивать, также и все другие участники на шине должны поддерживать именно этот уровень. Подключив датчик, рассчитанный на работу с 3.3 В к плате, которая работает на 5 В, имеются прекрасные шансы вывести его из строя.

Как вы уже могли заметить, часто используется термин «шина», так вот физически эта самая шина представляет из себя две уже упомянутые линии. При этом I2C позволяет соединять вплоть до 127-ми различных устройств, используя всего лишь два провода (здесь подтяжка к 5V):

Шина I2C.

Подтягивающие резисторы, естественно, на своем законном месте и никак иначе. На линии SCL присутствует тактовый сигнал (тактовые импульсы), который нужен для синхронизации всех участников обмена данными, на SDA же непосредственно данные.

Из данной схемы вытекает еще одна отличительная особенность, которая заключается в том, что устройства делятся на два класса:

  • Ведущее – Master
  • Ведомое – Slave

Генерацией тактового сигнала занимается исключительно master, он же инициирует любые обменные процессы. То есть ведущий обращается к какому-либо устройству на шине, а затем ждет от него ответа (если требуется). Из этого вытекает простой вывод, что необходимо как-то идентифицировать разные устройства. Поэтому каждый slave имеет свой собственный уникальный номер (адрес), который и используется для работы. Таким образом, master отправляет запрос, который содержит в себе адрес подчиненного (slave), которому этот запрос адресован. Само по себе ведомое устройство не может по своей прихоти начать обмен данными с кем-либо еще. Иерархия жесткая 🙂

Структуру пакетов я описывать не буду, вот, например, здесь можно почитать (I2C), нет смысла повторяться, у нас сегодня другая задача.

Давайте резюмируем основное преимущество, которое дает использование I2C. И это, конечно, возможность соединить большое количество устройств в единую сеть с использованием всего лишь двух сигнальных линий. Во многих задачах это без преувеличения ключевой нюанс. Но при этом I2C абсолютно не подходит для длинных линий связи, когда расстояние между устройствами велико. В общем и целом, как и в любом другом вопросе, здесь все определяется условиями и требованиями конкретной задачи или проекта.

Тем временем, пришло время сосредоточиться конкретно на I2C в Ардуино, напоминаю, что я буду использовать в этой статье в основном Arduino Uno:

Arduino Uno R3, выводы I2C

Здесь помечены те самые две линии, которые мы обсуждали, причем на Arduino Uno R3 есть два варианта подключения, что может в некоторых случаях облегчить коммутацию. Для других плат можно без труда найти нужные выводы из документации или описания. Давайте перемещаться к практической части статьи, и начнем, разумеется, с обзора функций, которые могут использоваться для работы с I2C на Arduino.

Практическая часть.

Основные функции библиотеки Wire.

Чтобы обеспечить требуемое существует стандартная для Arduino библиотека Wire. В ее состав входит большое количество функций, на каждый случай жизни, в контексте работы с I2C, конечно ) Пойдем по порядку и сгруппируем основные функции по типу устройства:

  • функции как для master’а, так и для slave’а
  • функции только для ведущего
  • функции только для ведомого

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

#include <Wire.h>

Для начала работы используется функция Wire.begin(address). Единственный аргумент функции – это адрес, который будет присвоен устройству, то есть нашей плате, для которой мы пишем скетч. Из этого вытекает логичный вывод, что если Arduino выступает в роли master’а, то адрес ей присваивать не нужно, то есть для ведущего:

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

Для ведомого (slave) адрес необходим, поэтому задаем его, пусть будет 0x23:

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

Далее следует еще одна общая для всех устройств функция, а именно Wire.write(), причем здесь имеем три варианта для вызова:

  • Wire.write(value) – запись одного байта
  • Wire.write(data, length) – запись нескольких (length) байт из массива, указатель на который передан в качестве первого аргумента
  • Wire.write(string) - запись строки побайтно

Возвращаемое значение – количество успешно записанных байт.

Аналогично, есть функция для чтения данных – Wire.read() – позволяющая считать принятый байт. В комплекте к ней можно рассмотреть и Wire.available(), которая возвращает количество байт, доступных для чтения на данный момент. Использование всех этих функций мы рассмотрим на практических примерах, а пока продолжаем обзор.

Переходим ко второй группе, которая относится только к ведущему. Поскольку обмен данными может начать только master, должны быть функции для осуществления этого, и они действительно есть:

  • Wire.beginTransmission(address)
  • Wire.endTransmission()

Первая начинает процесс обмена данными с ведомым устройством, адрес которого передан в функцию в качестве аргумента, вторая же завершает обменные процессы. Таким образом, с точки зрения ведущего при отправке данных процесс разбивается на четкие шаги:

  • Инициируем передачу данных ведомому при помощи Wire.beginTransmission()
  • Передаем данные через Wire.write()
  • Завершаем - Wire.endTransmission()

Все очень логично 👍

Возвращаемое значение функции Wire.endTransmission() соответствует результату работы, возможные значения: 0 (успешное окончание операции), 1 (данные не поместились в буфер для передачи), 2 (получили NACK при передаче адреса), 3 (получили NACK при передаче данных), 4 (иная ошибка), 5 (ошибка таймаута).

Еще одна функция эксклюзивно для master’а:

Wire.requestFrom(address, quantity)
Wire.requestFrom(address, quantity, stop)

Позволяет запросить данные от ведомого устройства. Первый аргумент (address) – адрес slave’а, от которого хотим получить байты, второй аргумент (quantity) – необходимое количество байт. В версии с тремя аргументами - stop может быть равен true или false. При значении true после отправки запроса шина будет освобождена, при false - соединение останется активным.

Таким образом, получаем второй вариант развития событий для ведущего:

  • Запрашиваем данные через Wire.requestFrom()
  • Проверяем, приняты ли данные при помощи Wire.available()
  • Если приняты, читаем байты – Wire.read()

Снова все достаточно четко структурированно. А нам осталось рассмотреть несколько функций, специфичных для slave:

  • Wire.onReceive(handler) – регистрирует функцию, которая будет вызвана, если ведомое устройство получит данные от master’а. Регистрируемая функция принимает один аргумент типа int и возвращает void, например: void handler(int arg).
  • Wire.onRequest(handler) – регистрирует функцию, которая будет вызвана, если ведущий (master) запросит данные у ведомого (slave). В данном случае регистрируемая функция не имеет аргументов и ничего не возвращает: void handler(void).

Механизм работы здесь схожий, разные только аргументы. Итак, у нас есть slave, мы хотим обработать запросы от master’а, так это может выглядеть, добавляем две функции:

void receiveHandler(int length)
{
}

void requestHandler()
{
}

В функции setup() регистрируем наши обработчики:

void setup()
{
	// Здесь предшествует остальной код для инициализации
	Wire.onReceive(receiveHandler); 
	Wire.onRequest(requestHandler); 
}

Что в результате? А в результате при получении запроса от master’а соответствующая функция (receiveHandler() или requestHandler()) будет вызвана библиотекой Wire автоматически, вручную этого делать не нужно. Допустим, ведущий прислал нам 4 байта, после приема данных по I2C программа окажется в функции receiveHandler(), при этом в переменной length будет количество байт, то есть число 4. Внутри обработчика мы уже можем поместить код, который прочитает принятые данные, проанализирует и выполнит какие-либо действия в случае необходимости.

Все это мы увидим на практических примерах чуть ниже, тогда окончательно все расставится по своим местам. А пока резюмируем итоговой таблицей с перечнем функций:

Функция Кто использует
Wire.begin(address) Master / Slave
Wire.write(value) Master / Slave
Wire.write(data, length) Master / Slave
Wire.write(string) Master / Slave
Wire.read() Master / Slave
Wire.available() Master / Slave
Wire.beginTransmission(address) Master
Wire.endTransmission() Master
Wire.onReceive(handler) Slave
Wire.onRequest(handler) Slave

Пример 1. Обнаружение I2C-устройств.

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

// Подключаем библиотеку
#include <Wire.h>


void setup()
{  
  // Начинаем работу с I2C
  Wire.begin();    
  
  Serial.begin(115200);
  Serial.println("------------------------");
  Serial.println("I2C bus scanning started");

  // Счетчик найденных устройств
  uint8_t foundDevicesCounter = 0;

  for (uint8_t i = 0; i <= 127; i++)
  {
    // Инициируем обмен данными с устройством, адрес которого равен i
    Wire.beginTransmission(i);

    // Завершаем обмен данными
    uint8_t result = Wire.endTransmission();

    // Если результат вызова функции Wire.endTransmission(i) равен 0,
    // значит устройство с адресом i присутствует на шине
    if (result == 0)
    {
      // Устройство найдено, выводим его адрес в Serial Monitor
      Serial.print("Device with address 0x");
      Serial.print(i, HEX);
      Serial.println(" found");

      // Увеличиваем счетчик
      foundDevicesCounter++;
    }
  }

  Serial.println("I2C bus scanning finished");
  Serial.print("Devices found: ");
  Serial.println(foundDevicesCounter);
  Serial.println("------------------------");
}


void loop() 
{
  
}

Суть всей работы заключается в том, что мы вызываем Wire.beginTransmission(i), передавая аргументом в цикле все адреса от 0 до 127 (кстати часть из этих адресов считается зарезервированной, тем не менее пройдем по всем). Затем по результату, возвращаемому функцией Wire.endTransmission(), делаем вывод, присутствует ли устройство с таким адресом на шине или нет. И все, остальная часть скетча просто выводит в Serial Monitor разные данные, в том числе адреса обнаруженных устройств и их количество (foundDevicesCounter).

Результат работы скетча, если на шине отсутствуют устройства:

Пример работы с I2C

Подключаю ведомого с адресом 0x27:

Arduino I2C сканер

Slave обнаружен, отлично, следующий пример уже ждет…

Пример 2. Связь по I2C Arduino Uno и Arduino Nano.

Для того, чтобы рассмотреть полный цикл обмена данными в различных комбинациях, я решил взять две платы, а именно Arduino Uno и Arduino Nano. Соединяем их посредством I2C, пусть Uno будет master, а Nano - slave:

Связь двух плат Arduino по I2C

Итак, master последовательно: отправляет два байта (0x31, 0x41) slave’у, затем выжидает некоторое время, пусть будет 2500 мс, без разницы, и отправляет запрос на чтение 4-х байт. После чего master встает на ожидание этих самых данных, а после приема выводит их в Serial Monitor.

Ведомый же, в свою очередь, приняв данные от ведущего, тоже выводит их через Serial.println(), чтобы мы на них посмотрели, а, получив запрос, отправляет master'у 4 заветных байта: 0x03, 0x09, 0x22, 0x84. Таким образом, мы охватываем разные варианты обмена данными, и реализация задуманного такова:

// Arduino Uno, I2C master

// Подключаем библиотеку
#include <Wire.h>


// Адрес slave'а
constexpr int SLAVE_ADDRESS = 0x33;


// Данные для отправки
uint8_t masterTxData[2] = {0x31, 0x41};



void setup() 
{  
  // Начинаем работу с I2C
  Wire.begin();    
  
  Serial.begin(115200);
}



void loop() 
{
  Serial.println("------------------------");
  Serial.println("Master | Sending 2 bytes");

  // Инициируем работу с шиной
  Wire.beginTransmission(SLAVE_ADDRESS);

  // Отправляем данные, 2 байта из массива masterTxData
  Wire.write(masterTxData, 2);

  // Заканчиваем работу с шиной и сохраняем результат в переменную result
  uint8_t result = Wire.endTransmission();

  Serial.print("Master | Bytes sended, result: ");
  Serial.println(result);

  // Простейшая задержка на 2.5 секунды
  delay(2500);

  Serial.println("------------------------");
  Serial.println("Master | Receiving 4 bytes");

  // Запрашиваем у ведомого 4 байта данных
  Wire.requestFrom(SLAVE_ADDRESS, 4);

  // В цикле, пока есть доступные для чтения байты
  while (Wire.available()) 
  {
    // Выполняем чтение из шины, то есть от slave'а
    uint8_t masterRxData = Wire.read();
    
    Serial.print("Master | Received byte: 0x");
    Serial.println(masterRxData, HEX);
  }

  Serial.println("Master | Bytes received");

  // И еще раз простейшая задержка на 2.5 секунды
  delay(2500);
}

В целом, здесь все довольно понятно, даже не знаю, на чем акцентировать внимание. По шагам: отправляем 2 байта slave'у (Wire.write()), выдерживаем паузу (delay(2500)) и запрашиваем в свою очередь от ведомого 4 байта (Wire.requestFrom()). Адрес ведомого задаем константой:

constexpr int SLAVE_ADDRESS = 0x33;

Почему именно такой адрес? По большому счету, просто так - красивое число ) Главное в скетче для slave задать ему это же значение в качестве адреса, к чему и переходим, скетч для Arduino Nano (slave):

// Arduino Nano, I2C slave

// Подключаем библиотеку
#include <Wire.h>


// Адрес, который назначим данному устройству
constexpr int MY_ADDRESS = 0x33;


// Данные для отправки
uint8_t slaveTxData[4] = {0x03, 0x09, 0x22, 0x84};



void setup() 
{  
  // Начинаем работу с I2C
  Wire.begin(MY_ADDRESS);    

  // Регистрируем обработчик события после запроса master'ом данных
  Wire.onRequest(requestEvent);

  // Регистрируем обработчик события после приема данных от master'а
  Wire.onReceive(receiveEvent);
  
  Serial.begin(115200);
}


// Эта функция будет вызвана, если ведущий запросит данные
void requestEvent() 
{
  Serial.println("------------------------");
  Serial.println("Slave | Request event");

  // Отправляем данные
  Wire.write(slaveTxData, 4);
  
  Serial.println("Slave | Bytes sended");
}


// Эта функция будет вызвана, если ведущий пришлет данные
void receiveEvent(int bytesNum)
{
  Serial.println("------------------------");
  Serial.println("Slave | Receive event");

  // В цикле, пока есть доступные для чтения байты
  while(Wire.available())
  {
    // Выполняем чтение из шины, то есть от master'а
    uint8_t slaveRxData = Wire.read();
    
    Serial.print("Slave | Received byte: 0x");
    Serial.println(slaveRxData, HEX);
  }
}



void loop() 
{
  // Между тем основной цикл пустой
  // Вся работа в обработчиках, которые вызываются по событиям
}

Здесь все полезные действия сосредоточены в функциях requestEvent() и receiveEvent(). Они регистрируются в качестве обработчиков событий, соответственно, их вызов по мере надобности (при возникновении этих событий) библиотека Wire произведет автоматически. Нам остается только поместить внутри наш код. Кстати, по приему данных в переменной bytesNum будет количество принятых байт, а данном случае мы это значение просто не используем.

При вызове Wire.begin() в качестве аргумента указываем тот адрес, который мы хотим назначить нашей плате Arduino Nano, для которой этот скетч предназначен и которая на шине I2C будет выполнять роль ведомого, то есть slave. Разумеется используем то же самое значение, что и в скетче для master'а:

constexpr int MY_ADDRESS = 0x33;

Пора протестировать, запускаем скетч для master’а (Arduino Uno) и наблюдаем результат его работы:

Скетч для I2C Master

Все прошло по плану, проверяем код для slave (Arduino Nano):

Скетч для I2C Slave

И в комплексе с пояснительными заметками:

Arduino I2C

В точности соответствует тому, что мы и хотели, отлично!

Пример 3. Работа с дисплеем LCD 1602.

А следующий пример использования I2C можно найти в отдельной статье, в которой разобрана работа с дисплеем LCD 1602. По мере выхода статьи, а произойдет это в ближайшее время, я помещу здесь ссылку.

И на этом на сегодня завершаем деятельность, интерфейс I2C Arduino мы охватили достаточно широко и подробно. В случае возникновения каких-либо вопросов, пишите в комментарии, а еще лучше на форум, благодарю за прочтение, до скорых встреч 🤝

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

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x