Добавить в корзинуПозвонить
Найти в Дзене
K12 :: О ESP32 и не только

Новое API I2C для ESP-IDF 5.2.0 и выше

Добрый день, уважаемые читатели! Когда-то я уже описывал на данном сайте работу с шиной Inter-Integrated Circuit ( I2C ) на ESP32 на данном сайте. I2C — это последовательный, синхронный, полудуплексный протокол связи, который позволяет нескольким ведущим и ведомым устройствам работать на одной шине. I2C использует две двунаправленные линии с открытым стоком: последовательную линию данных (SDA) и последовательную линию синхронизации (SCL), который должны быть подтянуты резисторами (от 1 кОм до 10 кОм, для ESP32 рекомендуется 2 кОм до 5 кОм). ESP32 имеет два контроллера I2C, каждый из которых может быть либо ведущим (master) либо ведомым (slave). Но время идет, разработчики ESP-IDF работают, и в какой-то момент они “запилили” новую версию API для весьма популярной шины I2C. Об этом и поговорим в данной статье. Начиная с версии ESP-IDF 5.2, структура API для I2C выглядит так, как это представлено на рисунке ниже. Уже знакомая нам библиотека i2c.h названа на схеме как “legacy API”, как вид
Оглавление

Добрый день, уважаемые читатели!

Когда-то я уже описывал на данном сайте работу с шиной Inter-Integrated Circuit ( I2C ) на ESP32 на данном сайте. I2C — это последовательный, синхронный, полудуплексный протокол связи, который позволяет нескольким ведущим и ведомым устройствам работать на одной шине. I2C использует две двунаправленные линии с открытым стоком: последовательную линию данных (SDA) и последовательную линию синхронизации (SCL), который должны быть подтянуты резисторами (от 1 кОм до 10 кОм, для ESP32 рекомендуется 2 кОм до 5 кОм). ESP32 имеет два контроллера I2C, каждый из которых может быть либо ведущим (master) либо ведомым (slave).

Но время идет, разработчики ESP-IDF работают, и в какой-то момент они “запилили” новую версию API для весьма популярной шины I2C. Об этом и поговорим в данной статье.

Начиная с версии ESP-IDF 5.2, структура API для I2C выглядит так, как это представлено на рисунке ниже. Уже знакомая нам библиотека i2c.h названа на схеме как “legacy API”, как видно из схемы, “обращается” напрямую к hardware abstraction layer. Старая версия не совместима с новой версией драйвера и их нельзя использовать одновременно. На момент написания статьи i2c.h ещё доступна программисту, но она признана устаревшей и будет удалена в новых релизах ESP-IDF. Поэтому, хотим ли мы этого или нет, но нам придется переходить на новую версию.

На рисунке слева как legacy API обозначено “старое” API, а правее – новое. Новое API I2C разделено на две отдельные библиотеки i2c_master.h и i2c_slave.h, а также общий модуль i2c_types.h, в котором объявлены необходимые общие типы данных.

Чем же так сильно отличается новое API от прежнего? В первую очередь разработчики перешли с модели "драйвер шины" - "передача данных" на модель "шина" - "устройство".

В старом API сразу после инициализации шины мы могли сразу же начинать запрос передачу данных по шине на произвольный адрес путем создания виртуальных “команд”.  Но процесс передачи выглядел для программиста довольно “утомительно” – мы должны были вручную сформировать пакет передачи, включая стартовый и стоповый биты, биты подтверждения ACK, а потом запустить созданный пакет (команду) на выполнение.  Если очень кратко и опуская второстепенные детали, то процесс выглядел так:

Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/

Настройку конфигурации я опустил для краткости. Если интересно, вы можете ознакомиться с деталями здесь.

В новом API разработчики добавили ещё одну сущность – device (устройство), к которому при инициализации привязывается его адрес и тактовая частота SCL (а значит и скорость передачи данных). Таки да, теперь на одной и той же шине могут одновременно сосуществовать устройства с разной тактовой частотой SCL, например 100 кГц и 400 кГц. Кроме того, формирование всех служебных бит и прочей технической шелупони берет на себя само API. Теперь I2C API стало гораздо проще и ближе по духу к ардуиновским библиотекам – “бери и делай не вникая в детали реализации”.

Если очень кратко и также опуская второстепенные детали, то новый процесс будет выглядеть уже так:

Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/

Зацените красоту реализации! Ну а теперь обсудим все это более подробно.

Примечание: поскольку лично я не использую I2С на ESP32 в slave-режиме (не вижу в этом особой необходимости, потому что для связи двух ESP между собой практичнее использовать modbus из-за “дальности”), поэтому в данной статье я буду рассматривать только master-режим. Прошу понять и простить.

Настройка шины в master-режиме

Для работы с шиной I2C нам придется заинклудить две IDF-ные библиотеки (в терминах ESP-IDF это “компоненты”):

#include "driver/i2c_types.h"
#include "driver/i2c_master.h"

Первое, что придется сделать – это настроить контроллер I2C. Напомню, ESP32 имеет на борту два контроллера, и если ранее мы могли их обозначать просто числами 0 и 1, то начиная с ESP-IDF 5.0 мы должны передавать API специальный тип i2c_port_num_t, который может принимать значение I2C_NUM_0 или I2C_NUM_1.

Параметры конфигурации шины определяются структурой i2c_master_bus_config_t:

Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
  • clk_source – источник тактовых сигналов для контроллера шины, может принимать значения I2C_CLK_SRC_APB и I2C_CLK_SRC_DEFAULT = SOC_MOD_CLK_APB, что, в общем-то, без разницы.
  • i2c_port – здесь мы должный указать номер порта I2C_NUM_0 или I2C_NUM_1.
  • scl_io_num и sda_io_num – номера GPIO, которые будут использоваться для линий SCL и SDA передачи данных.
  • flags.enable_internal_pullup – разрешить ли использование встроенной “слабой” подтяжки выводов SCL и SDA. Обычно встроенной подтяжки недостаточно для работы шины на высокой частоте, так что рекомендуется использовать внешние резисторы от 2 до 5 килоом.
  • glitch_ignore_cnt – устанавливает длительность сбоя данных на шине. Если помех на линии меньше этого значения, их можно отфильтровать. По умолчанию равно 7.
  • intr_priority – уровень приоритета прерываний. Может принимать значения 0, 1, 2 или 3. Если установлено 0, то уровень приоритета прерываний будет выбран автоматически.
  • trans_queue_depth – Длина внутренней очереди данных. Действительно только при асинхронной передаче данных с использованием callback-ов. При блокирующей работе можно указать 0.

Затем просто передаем эту структуру в функцию i2c_new_master_bus(&i2c_master_config, &bus_handle). Вместе с параметрами мы должны переждать указатель на буфер i2c_master_bus_handle_t, в который будет помещен указатель на созданный экземпляр шины. Как видите, теперь мы не настраиваем ни тактовую частоту передачи, как это было в старом API. И это все, можно переходить к конфигурированию устройства.

Настройка slave-устройства

Настройка slave-устройства выглядит не сильно сложнее:

Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/

Параметры ведомого устройства определяются структурой i2c_device_config_t:

  • dev_addr_length – здесь можно указать длину адреса в битах для ведомого устройства, можно выбрать из I2C_ADDR_BIT_LEN_7 или I2C_ADDR_BIT_LEN_10.
  • device_address – необработанный адрес ведомого устройства. То есть без дополнительного бита записи/чтения.
  • scl_speed_hz –  тактовая частота обмена данными в Гц, например 100000 или 400000. Тактовая частота SCL в master режиме не должна превышать 400 кГц.
  • scl_wait_us – время ожидания SCL в микросекундах (например, если устройство “занято” и прижимает SCL к земле после запроса данных). Обычно это значение не должно быть слишком маленьким, поскольку обработка данных на подчиненном устройстве может занять довольно длительное время (возможно даже растяжение на 12 миллисекунд). Установка 0 означает использование значения по умолчанию.
  • flags.disable_ack_check – отключить проверку ACK. Если установлено значение false, это означает, что проверка ACK включена – при обнаружении NACK транзакция будет остановлена ​​и API вернет ошибку.

Затем передаем заполненную структуру в i2c_master_bus_add_device(bus_handle, &i2c_device_config, &dev_handle). Как и в предыдущем случае, функция в случае успеха вернет указатель (хэндл) на устройство, которое мы будет использовать в дальнейшем.

Передача данных

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

  • i2c_master_transmit – запись данных на подчиненное устройство
  • i2c_master_receive – чтение данных с подчиненного устройства
  • i2c_master_transmit_receive – отправить запрос на подчиненное устройство и прочитать ответные данные

Рассмотрим их немного поподробнее.

Запись данных

Для передачи данных необходимо воспользоваться функцией:

i2c_master_transmit (i2c_master_dev_handle_t i2c_dev, const uint8_t * write_buffer, size_t write_size, int xfer_timeout_ms)

  • i2c_dev – хэндл устройства, который мы получили на предыдущем этапе
  • write_buffer – указатель на буфер с данными готовыми к передаче. Это может быть указатель на простую переменную или массив байт
  • write_size – длина этих данных в байтах
  • xfer_timeout_ms – время ожидания передачи в миллисекундах, или -1 для бесконечного ожидания. Транзакция будет выполняться до тех пор, пока не завершится или не истечет указанный тайм-аут.

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

-6

Например это может выглядеть так:

uint8_t value = 0x00;
i2c_master_transmit(dev_handle, &value, sizeof(value), -1);

Как видите, это сильно проще и короче, чем это было необходимо в предыдущей версии ESP-IDF.

Чтение данных

Для того, чтобы прочитать данные с устройства, есть еще одна функция:

i2c_master_receive(i2c_master_dev_handle_t i2c_dev, uint8_t * read_buffer, size_t read_size, int xfer_timeout_ms)

  • i2c_dev – хэндл устройства, который мы получили на предыдущем этапе
  • read_buffer – указатель на буфер, куда будут помещены прочитанные данные. Это может быть указатель на простую переменную или массив байт
  • read_size – длина этого массива в байтах
  • xfer_timeout_ms – время ожидания передачи в миллисекундах, или -1 для бесконечного ожидания. Транзакция будет выполняться до тех пор, пока не завершится или не истечет указанный тайм-аут.

При этом данные по шине будут передаваться следующим образом:

-7

Простейший пример получения одного байтика:

uint8_t value;
i2c_master_recieve(dev_handle, &value, sizeof(value), -1);

Запрос данных (отправка и чтение данных)

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

i2c_master_transmit_receive(i2c_master_dev_handle_t i2c_dev, const uint8_t * write_buffer, size_t write_size, uint8_t * read_buffer, size_t read_size, int xfer_timeout_ms)

  • i2c_dev – хэндл устройства, который мы получили на предыдущем этапе
  • write_buffer – указатель на буфер с данными готовыми к передаче. Это может быть указатель на простую переменную или массив байт
  • write_size – длина этих данных в байтах
  • read_buffer – указатель на буфер, куда будут помещены прочитанные данные. Это может быть указатель на простую переменную или массив байт
  • read_size – длина этого массива в байтах
  • xfer_timeout_ms – время ожидания передачи в миллисекундах, или -1 для бесконечного ожидания. Транзакция будет выполняться до тех пор, пока не завершится или не истечет указанный тайм-аут.

Данная функция сформирует указанную ниже последовательность данных:

-8

Например отправляем I2C-датчику команду чтения температуры и влажности и ждем результатов:

uint8_t bufCmd[3] = {0xAC, 0x33, 0x00};
uint8_t bufData[7] = {0, 0, 0, 0, 0, 0, 0};
i2c_master_transmit_recieve(dev_handle, &bufCmd[0], sizeof(bufCmd), &bufData[0], sizeof(bufData), -1);

Сканирование шины

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

i2c_master_probe( i2c_master_bus_handle_t bus_handle, uint16_t address, int xfer_timeout_ms)

  • bus_handle – хендл шины, на которой производится проба пера.
  • address – предполагаемый адрес устройства
  • xfer_timeout_ms – время ожидания передачи в миллисекундах, или -1 для бесконечного ожидания

Принцип работы этой функции заключается в отправке адреса устройства с помощью команды записи. Если устройство подключено к шине I2C, оно ответит битом ACK, и функция вернет ESP_OK. Если устройство не подключено, будет сигнал NACK, и функция вернет ESP_ERR_NOT_FOUND.

-9

Например поиск всех устройств на 7-битной шине выглядит так:

for (uint8_t i = 1; i < 128; i++) {
if (i2c_master_probe(bus_handle, i, -1) == ESP_OK) {
ESP_LOGI(logTAG, "Found device on bus 0 at address 0x%.2X", i);
};};

Пример 1. Мигаем светодиодами через PCF8574

В качестве простейшего примера я собрал такой примитивный макет из ESP32, PCF8574 и двух светодиодов (на самом деле можно от 1 до 8, мне было лень подключать больше):

-10

Код для этого примера получился такой:

Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/

Все работает, светодиоды переключаются. Лог работы:

-13

Пример 2. Читаем датчик AHT20

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

Для использования датчика температуры и относительной влажности AHT20 совершенно необходимо:

  • Перед началом работы инициализировать его и загрузить калибровочные коэффициенты командой из трех байт 0xBE, 0x08, 0x00
  • Для измерения:Отправляем команду  0xАС, 0x33, 0x00
    Ждем ~75 мс, после чего читаем 1 байт. Если в этом байте установлен бит 0x80, то сенсор занят, ждем еще немного
    Читаем 7 байт и декодируем результат

Очевидно, что командой i2c_master_transmit_recieve воспользоваться не удастся (ждать аж 75 миллисекунд!, да еще и статус проверять), поэтому я придумал такой алгоритм:

Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/
Скопировать код можно здесь https://kotyara12.ru/iot/esp32_i2c_new/

Проверку контрольной суммы для краткости я делать не стал, но в реальной эксплуатации я рекомендую всегда это делать. Остальной код остался почти без изменений, кроме адреса датчика – 0x38. Внимательные читатели могут найти еще и логическую ошибку, которая, впрочем, но нормальное чтение датчика не влияет.

Результаты работы приведены ниже:

-16

Асинхронный режим

Приведенные выше примеры работают в синхронном или, по другому – блокирующем режиме. То есть на время обмена данными с периферийным устройством поток выполнения задачи полностью приостанавливается. А если вы укажете, например, xfer_timeout_ms = -1, то задача может и хорошенько зависнуть.

Хотя I2C является синхронным протоколом связи, новое API также поддерживает асинхронный режим работы. Таким образом, API I2C становится неблокирующим интерфейсом передачи данных. В этом случае функции передачи данных i2c_master_transmit, i2c_master_recieve и i2c_master_transmit_recieve завершаются сразу же, без ожидания, а результаты их работы передаются через функцию обратного вызова (callback).

Для того, чтобы перевести I2C API необходимо “всего-лишь” создать и зарегистрировать должным образом обработчики ISR (Interrupt Service Routine),  путем вызова функции i2c_master_register_event_callbacks(). Регистрация callback-ов должна быть выполнена после i2c_master_bus_add_device, но до любых функций передачи данных (i2c_master_transmit, i2c_master_recieve и i2c_master_transmit_recieve).

i2c_master_register_event_callbacks(i2c_master_dev_handle_t i2c_dev, const i2c_master_event_callbacks_t * cbs, void * user_data)

  • i2c_dev – хэндл устройства
  • cbs – группа функций обратного вызова, которая на момент написания статьи включает всего один callback:
    i2c_master_callback_t
    on_trans_done;  /*!< I2C master transaction finish callback */
  • user_data – указатель на любые данные, которые могут быть переданы в функции обратного вызова

Прототип функции обратного вызова on_trans_done выглядит так:

typedef bool (*i2c_master_callback_t)(i2c_master_dev_handle_t i2c_dev, const i2c_master_event_data_t *evt_data, void *arg);

  • i2c_dev – хэндл устройства
  • evt_data – сюда будет помещен результат выполнения асинхронной передачи данных: I2C_EVENT_ALIVE или I2C_EVENT_DONE или I2C_EVENT_NACK или I2C_EVENT_TIMEOUT.
  • arg – при вызове callback-а сюда будет передан указатель на данные, которые были указаны в аргументе user_data при регистрации

Таким образом API I2C уведомляет ваше приложение о том, каким именно образом была завершена запущенная асинхронная транзакция. Предупреждение! На одной шине только одно устройство может использоваться для выполнения асинхронной транзакции.

Поскольку зарегистрированные функции обратного вызова вызываются в контексте прерывания, вы должны убедиться, что созданные вами callback-и выполняются минимально возможное время и не блокируются планировщиком FreeRTOS (например, внутри ISR-обработчиков допускаются вызовы других функций API FreeRTOS только  с ISR суффиксом). Кроме того, функции обратного вызова должны возвращать логическое значение, чтобы сообщить ISR, пробуждена ли она более высокоприоритетной задачей.

Отменить асинхронный режим можно в любой момент, вызвав i2c_master_register_event_callbacks() ещё раз, но с NULL в качестве второго аргумента.

Потокобезопасность

Почти все функции API I2C не являются потокобезопасными (кроме двух – i2c_new_master_bus()и i2c_new_slave_device())! А это значит, что не стоит пытаться обращаться к одной и той же шине из разных задач / потоков выполнения. И в самом деле, если две задачи попытаются одновременно писать в шину свои данные, то вместо упорядоченных команд мы получим “неудобоваримую кашу”.

Разделение “ресурса” I2c с помощью средств синхронизации (таких как мьютекс, например) не всегда решает проблему совместного доступа к одной и той же шине. Поэтому при создании реальных устройств следует стремиться к тому, чтобы физический доступ к одному контроллеру I2C имела одна единственная задача. Особенно это актуально для устройств, которым требуется передача больших порций данных – например дисплеев.

Ссылки и связанные статьи