Перейти к содержимому

Работа с шиной I2C на ESP32 и ESP-IDF версий 4.х – 5.1

Метки:

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

В данной статье я поделюсь информацией, как работать с шиной I2C на ESP32 из Espressif IoT Development Framework (ESP-IDF). Внимание! Информация, изложенная в статье, справедлива для версий ESP-IDF до 5.1.3 включительно! В ESP-IDF 5.2.0 и выше API существенно изменились!

I2C — это последовательный, синхронный, полудуплексный протокол связи, который позволяет сосуществовать нескольким ведущим и ведомым устройствам на одной шине. Шина I2C состоит из двух линий: последовательной линии данных (SDA) и последовательной синхронизации (SCL). Обе линии требуют подтягивающих резисторов. Ранее я уже немного касался теоретических и физических аспектов работы с интерфейсом I2C, кто еще не успел ознакомиться – прошу прочитать предыдущую статью: Интерфейс I2C: принципы функционирования или зачем ещё тут нужны какие-то резисторы?

 

ESP32 имеет два контроллера I2C (также называемых портами), отвечающих за управление связью по шине I2C. Оба порта вполне могут одновременно работать как ведущие, и таким образом вы сможете в два раза больше получать молока подключить сенсоров к своему устройству без применения аппаратных мультиплексоров.

Как вариант можно настроить один контроллер I2C как ведущий, а другой как ведомый. Это позволит легко и просто соединить два или несколько (до 128)  микроконтроллеров в некое подобие “локальной” сети с простым протоколом обмена данными. Однако физические расстояния в этой сети не могут быть очень большими.

Выводы GPIO вы также можете назначить любые благодаря встроенному в ESP32 мультиплексору IOMUX.


Инициализация шины

Прежде чем вы начнете пересылать по шине I2C какие-либо данные, необходимо настроить драйвер контроллера и активировать его.

Конфигурация

Первым делом необходимо настроить параметры контроллера: режим работы (master / slave), выводы GPIO для SDA и SCL и другие параметры. Для этого мы должны заполнить структуру i2c_config_t, которая выглядит следующим образом:

/**
 * @brief I2C initialization parameters
 */
typedef struct {
    i2c_mode_t mode;     /*!< I2C mode */
    int sda_io_num;      /*!< GPIO number for I2C sda signal */
    int scl_io_num;      /*!< GPIO number for I2C scl signal */
    bool sda_pullup_en;  /*!< Internal GPIO pull mode for I2C sda signal*/
    bool scl_pullup_en;  /*!< Internal GPIO pull mode for I2C scl signal*/

    union {
        struct {
            uint32_t clk_speed;      /*!< I2C clock frequency for master mode, (no higher than 1MHz for now) */
        } master;                    /*!< I2C master config */
#if SOC_I2C_SUPPORT_SLAVE
        struct {
            uint8_t addr_10bit_en;   /*!< I2C 10bit address mode enable for slave mode */
            uint16_t slave_addr;     /*!< I2C address for slave mode */
            uint32_t maximum_speed;  /*!< I2C expected clock speed from SCL. */
        } slave;                     /*!< I2C slave config */
#endif // SOC_I2C_SUPPORT_SLAVE
    };
    uint32_t clk_flags;              /*!< Bitwise of ``I2C_SCLK_SRC_FLAG_**FOR_DFS**`` for clk source choice*/
} i2c_config_t;

Обязательные параметры

Следующие параметры вы должны указать в любом режиме работы:

  • Режим работы mode: I2C_MODE_MASTER или I2C_MODE_SLAVE.
  • Номера GPIO для сигнальных линий SDAsda_io_num и для SCLscl_io_num. Обратите внимание, здесь номера GPIO должны быть заданы в виде обычных чисел int (а не gpio_num_t, как в функциях работы с GPIO)
  • Использовать ли встроенную слабую подтяжку к питанию weak pullup для сигнальных линий – sda_pullup_en и scl_pullup_en соответственно. Я никогда не использую эту возможность, ибо для встроенные резисторы подтяжки имеют сопротивление около 50 килоОм, а это слишком много для шины I2C.

Параметры для режима MASTER

Если вы указали режим работы mode = I2C_MODE_MASTER, то дополнительно потребуется указать:

  • частоту шины clk_speed, её необходимо указывать в Гц (герцах). То есть для стандартной скорости 100кГц необходимо записать 100000. На текущий момент нельзя задать частоту шины выше 1МГц. При этом следует учитывать, что частота таковых импульсов на SCL может достаточно сильно отличаться от заданной, это связано с какими-то особенностями реализации контроллеров шины на ESP32, и обсуждалось на форуме esp32.com (однажды случайно наткнулся на тему).

Параметры для режима SLAVE

Для настройки режима ведомого mode = I2C_MODE_SLAVE необходимо в три раза больше параметров.

  • Вы должны определить, будет ли поддерживать ваш контроллер 10-битные адреса – addr_10bit_en. Насколько я понимаю, в этом случае все другие устройства, подключенные к этой же шине, так же должны иметь 10-битные адреса (впрочем, я могу и ошибаться).
  • Указать адрес slave_addr вашего устройства на шине.
  • Дополнительно можно задать ожидаемую тактовую частоту на линии SCL – maximum_speed, но можно игнорировать этот параметр.

Настройка источника тактовых сигналов для шины

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

  • I2C_SCLK_SRC_FLAG_FOR_NOMAL (0) – может быть выбран любой источник синхронизации, доступный для указанной частоты. По умолчанию обычно используется генератор (часы) APB 80MHz.
  • I2C_SCLK_SRC_FLAG_AWARE_DFS (1 << 0) – не изменять частоту шины, если меняется частота генератора APB.
  • I2C_SCLK_SRC_FLAG_LIGHT_SLEEP (1 << 1) – задействовать источник таковых сигналов, доступный в режиме легкого сна. Про умолчанию генератор APB не работает в режиме легкого сна, следовательно и шина I2C будет так же не доступна.

Укажите 0 для использования источника таковых сигналов и настроек “по умолчанию”, этого достаточно в большинстве случаев.

Когда всё готово, необходимо загнать эти данные в выбранный контроллер с помощью функции:

esp_err_t i2c_param_config(i2c_port_t i2c_num, const i2c_config_t *i2c_conf)

где:

  • i2c_port_t i2c_num – номер шины (контроллера), может принимать два значения 0 или 1
  • i2c_config_t *i2c_conf – указатель на только что заполненную структуру конфигурации контроллера. Такой прием используется в ESP-IDF сплошь и рядом.

В ответ функция должна вернуть вам ESP_OK, если это не так – значит вы где-то ошиблись при указании какого-либо параметра.

Установка драйвера

После того, как драйвер контроллера I2C настроен, установите его, вызвав функцию i2c_driver_install() со следующими параметрами:

  • i2c_port_t i2c_num – номер шины (контроллера) – 0 или 1
  • i2c_mode_t mode – режим работы I2C_MODE_MASTER или I2C_MODE_SLAVE (да, да, ещё раз)

Для ведомого (slave) режима потребуется указать ещё несколько параметров:

  • size_t slv_rx_buf_len и size_t slv_tx_buf_len – размеры буфера для отправки данных по запросу ведущего. Поскольку I2C является шиной, ориентированной на ведущее устройство, данные могут передаваться от ведомого устройства к ведущему только по запросу ведущего. Для master режима нам не нужно использовать буфер для данных, API-интерфейсы будут выполнять основные команды и возвращать управление потоком выполнения программы после отправки всех команд или при возникновении ошибки (поэтому, когда мы отправляем мастер-команды, мы должны освобождать или изменять исходные данные только после возврата функции i2c_master_cmd_begin). Напротив, для slave режима нам нужен буфер данных для хранения отправляемых и получаемых данных, потому что аппаратный fifo имеет только 32 байта. Следовательно, ведомое устройство должно иметь буфер отправки, куда ведомое приложение записывает запрошенные мастером данные. Данные остаются в буфере отправки и могут быть прочитаны мастером по его запросу. Буферы, насколько я понимаю, будут выделены из общей динамической кучи (heap).
  • int intr_alloc_flags – опции (флаги) для обработчика прерывания шины.

Для master режима просто отправьте нули в каждом из этих аргументов.

i2c_config_t confI2C;
confI2C.mode = I2C_MODE_MASTER;
confI2C.sda_io_num = sda_io_num;
confI2C.sda_pullup_en = pullup_enable;
confI2C.scl_io_num = scl_io_num;
confI2C.scl_pullup_en = pullup_enable;
confI2C.master.clk_speed = clk_speed;
confI2C.clk_flags = 0;

esp_err_t err = i2c_param_config(i2c_num, &confI2C);
if (err != ESP_OK) {
  rlog_e(logTAG, "I2C bus #%d setup error: #%d (%s)!", i2c_num, err, esp_err_to_name);
  return false;
};

err = i2c_driver_install(i2c_num, confI2C.mode, 0, 0, 0);
if (err == ESP_OK) {
  rlog_i(logTAG, "I2C bus #%d started successfully on GPIO SDA: %d, SCL: %d", i2c_num, sda_io_num, scl_io_num);
  return true;
} else {
  rlog_e(logTAG, "I2C bus #%d initialization error: #%d (%s)!", i2c_num, err, esp_err_to_name);
  return false;
};

Как и в предыдущем случае, i2c_driver_install() должна вернуть ESP_OK, если драйвер был успешно установлен.

Во время установки драйвера по умолчанию устанавливается обработчик прерываний. Поэтому ваш вызов gpio_install_isr_service() после установки драйвера I2C скорее всего вернет ESP_ERR_INVALID_STATE, что означает, что обработчик прерываний уже был установлен.

После этого можно начинать обмен данными между устройствами по шине.

Если вы планируете использовать оба контроллера I2C, вам нужно будет повторить эти операции отдельно для каждой шины.

 


Передача данных в режиме MASTER

Контроллер I2C ESP32, работающий в качестве ведущего, отвечает за установление сеанса связи с ведомыми устройствами и отправку команд для ведомого устройства, например, для выполнения измерения и отправки показаний обратно на ведущее устройство.

Поскольку процесс передачи данных весьма чувствителен к временным задержкам, I2C API было разработано таким образом, чтобы весь сеанс связи проходил непрерывно, один блоком. Для этого API предоставляет контейнер команд, называемый «command link» (почему? лично мне он больше напоминает “пакет” – батник или скрипт), который должен быть предварительно заполнен необходимой последовательностью команд. И только затем его можно передать контроллеру I2C для непрерывного выполнения.

Пример сеанса связи с записью данных на ведомое устройство

Пример сеанса связи с чтением данных с ведомого устройства

Ниже описано, как настраивается командная ссылка для «основной записи» и что происходит внутри:

  • Создайте контейнер команд с помощью функции i2c_cmd_link_create() или i2c_cmd_link_create_static(). В первом случае необходимый блок памяти для контейнера будет выделен динамически из общей памяти (кучи, heap), во втором – вы должны будете предварительно позаботится о таком блоке памяти заранее, на этапе программирования. Что выбрать? В случае использования i2c_cmd_link_create() при каждом обращении к шине будет выделен новый блок памяти под контейнер, и это позволяет не заботится о возможной перезаписи данных в контейнере другим потоком / задачей. Но этот способ медленнее и потенциально может приводить к проблеме фрагментации кучи. Во втором случае ( i2c_cmd_link_create_static() ) вам придется позаботится о предварительном выделении переменной необходимого размера заранее, а так же синхронизировать доступ к шине из разных потоков / задач с помощью мьютексов или семафоров (при необходимости, конечно). Зато это быстрее и не приводит к фрагментации памяти от слова “совсем”. К слову, i2c_cmd_link_create_static() появился в ESP-IDF только относительно недавно – когда я начинал свою работу с ESP32 этой возможности не было. В какой конкретно версии добавили “статические” методы – я пропустил. После того, как контейнер создан, можно заполнять его необходимыми командами:
  1. В начале сеанса связи всегда идёт стартовый бит, который добавляется с помощью i2c_master_start(). В дальнейшем, при выполнении пакета команд, это заставит аппаратный контроллер IIC передать “стартовый бит”. Если вы не знаете, что это такое и для чего это надо, советую почитать другую статью.
  2. Добавьте адрес ведомого и направление передачи с помощью первого вызова i2c_master_write_byte(). Адрес ведомого устройства занимает в первом байте только 7 бит, последний бит указывает на направление передачи следующих данных. Поэтому адрес нужно сдвинуть на 1 бит влево, то есть первый байт нужно сформировать как (i2c_address << 1) | I2C_MASTER_WRITE для режима записи или (i2c_address << 1) | I2C_MASTER_READ для чтения.
  3. Теперь можно передавать и принимать собственно данные — один или несколько байтов с помощью функций i2c_master_write_byte() и i2c_master_write() для записи или i2c_master_read_byte() и i2c_master_read() для чтения. Только не забываем при чтении устанавливать / отправлять бит ACK (или NACK в конце передачи). И наоборот, при записи обе функции передачи имеют дополнительный аргумент, указывающий, должен ли ведущий убедиться, что он получил бит ACK.
  4. В конце сеанса связи добавьте стоповый бит через вызов i2c_master_stop() для master-режима.
  • После того, как пакет команд был сформирован, его можно запустить на выполнение контроллером I2C, вызвав i2c_master_cmd_begin() и дождаться завершения его выполнения. После запуска выполнения пакет уже не должен изменяться. Для выполнения пакета вы должны указать ссылку на сам пакет, номер контроллера (0 или 1) и время ожидания завершения в тиках операционной системы.
  • После завершения сеанса связи освободите ресурсы, используемые контейнером, вызвав i2c_cmd_link_delete() или i2c_cmd_link_delete_static() соответственно тому, какой методы вы использовали при создании контейнера.
esp_err_t writeI2C(i2c_port_t i2c_num, const uint8_t i2c_address, 
  uint8_t* cmds, const size_t cmds_size,
  uint8_t* data, const size_t data_size, 
  TickType_t timeout)
{
  esp_err_t error_code = ESP_ERR_NO_MEM;
  // Создаем контейнер для команд
  i2c_cmd_handle_t cmdLink = i2c_cmd_link_create(i2c_num);
  if (cmdLink) {
    // Стартовый бит
    error_code = i2c_master_start(cmdLink);
    if (error_code != ESP_OK) goto end;
    // Отправляем адрес и бит записи
    error_code = i2c_master_write_byte(cmdLink, (i2c_address << 1) | I2C_MASTER_WRITE, ACK_CHECK_EN);
    if (error_code != ESP_OK) goto end;
    // Отправляем команду
    if ((cmds) && (cmds_size > 0)) {
      error_code = i2c_master_write(cmdLink, cmds, cmds_size, ACK_CHECK_EN);
      if (error_code != ESP_OK) goto end;
    };
    // Отправляем дополнительные данные
    if ((data) && (data_size>0)) {
      error_code = i2c_master_write(cmdLink, data, data_size, ACK_CHECK_EN);
      if (error_code != ESP_OK) goto end;
    };
    // Стоп-бит
    error_code = i2c_master_stop(cmdLink);
    if (error_code != ESP_OK) goto end;
    // Исполняем сформированный пакет команд
    error_code = i2c_master_cmd_begin(i2c_num, cmdLink, pdMS_TO_TICKS(timeout));
    if (error_code != ESP_OK) goto end;
  };
end:  
  // Ошибка
  if (error_code != ESP_OK) {
    rlog_e(logTAG, ERROR_I2C_WRITE, i2c_num, i2c_address, error_code, esp_err_to_name(error_code));
  };
  _i2c_cmd_link_delete(i2c_num, cmdLink);
  return error_code;
}

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

Сложно? Да нет, просто громоздко. И требуется каждый раз создавать контейнер и пакет “вручную”, что несколько напрягает. Поэтому я в свое время создал библиотеку – обёртку, в которой объявлены две макрофункции для приема и передачи данных. Об ней я подробнее расскажу подробнее чуть ниже.

В последней версии ESP-IDF я обнаружил такие же как у меня макрофункции имеются уже и в самой ESP-IDF (наконец-то в Espressif догадались это сделать):

  1. Отправляем команду на изменение i2c_master_write_to_devicе()
  2. Ждем какое-то время
  3. Читаем измеренные данные с помощью i2c_master_read_from_device()

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

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

 


Передача данных в режиме SLAVE

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

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

  • i2c_slave_read_buffer() – чтение буфера приема данных от мастера. Всякий раз, когда ведущий отправляет данные ведомому, драйвер автоматически сохраняет их в приемном буфере. Это позволяет подчиненному приложению вызывать функцию i2c_slave_read_buffer() по своему усмотрению. Эта функция также имеет параметр для указания времени ожидания блока данных, если в приемном буфере на момент запроса ещё нет данных. Это позволит ведомому приложению ждать с заданным тайм-аутом поступления данных в буфер.
  • i2c_slave_write_buffer() – запись в буфер отправки. Этот буфер используется для хранения всех данных, которые ведомое устройство хочет отправить ведущему в порядке FIFO. Данные остаются там до тех пор, пока мастер не запросит их. Функция i2c_slave_write_buffer() имеет параметр для указания времени ожидания, если буфер отправки заполнен. Это позволит ведомому приложению ждать с заданным тайм-аутом, пока в буфере отправки не станет доступным достаточно места.

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

esp-idf/examples/peripherals/i2c at v5.0 · espressif/esp-idfgithub.com

 


Совместный доступ к шине I2C из разных потоков / задач

Все функции библиотеки driver/i2c.h уже имеют средства “дорожного регулирования” и являются потокобезопасными. То есть вы можете не заботиться о всяких там семафорах – мьютексах, а просто начинать работу с шиной.

Однако всё может измениться, если вы будете использовать статические буферы (с использованием i2c_cmd_link_create_static()) для создания командных ссылок. На практике имеет смысл объявлять их только как static, иначе они просто попадут в стек и никакой практической выгоды от них не будет. А это означает, что нам придется самим синхронизировать доступ к этому буферу, иначе может случиться конфуз. Самое простое – использовать мьютекс (двоичный семафор с правом захвата).

Но это нужно далеко не всегда. У меня как-то чаще бывает, что с шиной I2C работает всего одна задача. Ну и смысл тогда городить огород?


Моя библиотека для работы с шиной I2C

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

 

Вычисление контрольной суммы для 16-битных чисел

Первая принимает младший и старший байты числа раздельно, вторая – целиком, в виде 16-битного числа. Разные сенсоры используют разных подход.

 

Инициализация шины

bool initI2C(const i2c_port_t i2c_num, const int sda_io_num, const int scl_io_num, const bool pullup_enable, const uint32_t clk_speed)

За один вызов сразу выполняется и конфигурирование и запуск драйвера шины. Очень удобно.

#if defined(CONFIG_I2C_PORT0_SCL)
  initI2C(I2C_NUM_0, CONFIG_I2C_PORT0_SDA, CONFIG_I2C_PORT0_SCL, CONFIG_I2C_PORT0_PULLUP, CONFIG_I2C_PORT0_FREQ_HZ);
  scanI2C(I2C_NUM_0);
#endif // CONFIG_I2C_PORT0_SCL
#if defined(CONFIG_I2C_PORT1_SCL)
  initI2C(I2C_NUM_1, CONFIG_I2C_PORT1_SDA, CONFIG_I2C_PORT1_SCL, CONFIG_I2C_PORT1_PULLUP, CONFIG_I2C_PORT1_FREQ_HZ);
  scanI2C(I2C_NUM_1);
#endif // CONFIG_I2C_PORT1_SCL

 

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

Никакой “практической” ценности это не несет, кроме как для целей отладки: опрашиваются все адреса на шине, и если ведомое устройство отвечает, выводится адрес в лог.

void scanI2C(i2c_port_t i2c_num);

Очень помогает “увидеть” какие сенсоры и устройства подключились, а с каким физические проблемы (например перепутаны провода SCL и SDA).

 

Общий сброс шины

Где-то нашел информацию, что запись по адресу 0x00 байта 0x06 вызывает общий сброс всех устройств на шине. Написал реализацию.

esp_err_t generalCallResetI2C(i2c_port_t i2c_num);

Но практической пользы не ощутил.

 

Будильник для ведомого устройства

Отправив на устройство “пустую” команду 0x00, мы путаемся разбудить ведомое устройство и заставить его что-то делать.

esp_err_t wakeI2C(i2c_port_t i2c_num, const uint8_t i2c_address, TickType_t timeout)

Изобрели это китайцы для AM2320, насколько я помню. Больше никто этой фигней не мается.


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

esp_err_t writeI2C(i2c_port_t i2c_num, const uint8_t i2c_address, uint8_t* cmds, const size_t cmds_size, uint8_t* data, const size_t data_size, TickType_t timeout)

Эта макрофункция предназначена для записи на ведомое устройство с адресом i2c_address в регистр cmds (частое другое название – команда) блока данных data размером data_size. Поскольку адресация регистров может быть как однобайтовая, так и многобайтовая, то нужно указать размер буфера cmds_size.


Чтение регистра с ведомого устройства

esp_err_t readI2C(i2c_port_t i2c_num, const uint8_t i2c_address, uint8_t* cmds, const size_t cmds_size, uint8_t* data, const size_t data_size, const uint32_t wait_data_us, const TickType_t timeout)

Макрофункция чтения регистра cmds (размер команды указывается в cmds_size) с ведомого устройства i2c_address. В поле data необходимо передать указатель на буфер размером data_size под получаемые данные. Если на обработку запроса ведомому устройству требуется время, то указываем его в аргументе wait_data_us, и в этом случае чтение будет происходить за два разных сеанса связи. Если передать 0, то отправка команды cmds и чтение данных в буфер будут производиться за один сеанс связи.


Настройка библиотеки

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

// EN: I2C bus #0: pins, pullup, frequency
// RU: Шина I2C #0: выводы, подтяжка, частота
#define CONFIG_I2C_PORT0_SDA        21
#define CONFIG_I2C_PORT0_SCL        22
#define CONFIG_I2C_PORT0_PULLUP     false 
#define CONFIG_I2C_PORT0_FREQ_HZ    100000
#define CONFIG_I2C_PORT0_STATIC     3
// EN: I2C bus #1: pins, pullup, frequency
// RU: Шина I2C #1: выводы, подтяжка, частота
// #define CONFIG_I2C_PORT1_SDA     16
// #define CONFIG_I2C_PORT1_SCL     17
// #define CONFIG_I2C_PORT1_PULLUP  false
// #define CONFIG_I2C_PORT1_FREQ_HZ 100000
// #define CONFIG_I2C_PORT1_STATIC  2

В принципе, в ESP-IDF вы вольны использовать другие свободные выводы GPIO, а не обязательно как на скриншоте. Например если вам удобнее развести печатную плату с другими выводами. А вот встроенную подтяжку я никогда не использую, предпочитаю ставить внешние резисторы.

Если закоментарить CONFIG_I2C_PORT1_SDA и CONFIG_I2C_PORT1_SCL, то эта шина настраиваться и запускаться не будет.


Статические буферы для команд

Макросы CONFIG_I2C_PORT0_STATIC и CONFIG_I2C_PORT1_STATIC используются для определения того, чтобы определить, какой тип буфера под команды будет использован. Если CONFIG_I2C_PORT0_STATIC = 0, то будет использован динамический метод, если > 0 – то статический. Число обозначает, сколько транзакций может поместиться в буфер одного сеанса связи.

/**
 * @brief The following macro is used to determine the recommended size of the
 * buffer to pass to `i2c_cmd_link_create_static()` function.
 * It requires one parameter, `TRANSACTIONS`, describing the number of transactions
 * intended to be performed on the I2C port.
 * For example, if one wants to perform a read on an I2C device register, `TRANSACTIONS`
 * must be at least 2, because the commands required are the following:
 *  - write device register
 *  - read register content
 *
 * Signals such as "(repeated) start", "stop", "nack", "ack" shall not be counted.
 */
#define I2C_LINK_RECOMMENDED_SIZE(TRANSACTIONS)     (2 * I2C_INTERNAL_STRUCT_SIZE + I2C_INTERNAL_STRUCT_SIZE * \
                                                        (5 * TRANSACTIONS)) /* Make the assumption that each transaction
                                                                             * of the user is surrounded by a "start", device address
                                                                             * and a "nack/ack" signal. Allocate one more room for
                                                                             * "stop" signal at the end.
                                                                             * Allocate 2 more internal struct size for headers.
                                                                             */

 

Я использую 2отправка номера регистра + чтение или запись (размер блока данных чтения/записи в данном случае значения не имеет). Больше команд за один сеанс связи мне пока не приходилось использовать. Но если это понадобится, это легко устроить.

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

#if defined(CONFIG_I2C_PORT0_SDA) && defined(CONFIG_I2C_PORT0_STATIC) && (CONFIG_I2C_PORT0_STATIC > 0)
  #define I2C0_USE_STATIC 1
  static uint8_t _bufferI2C0[I2C_LINK_RECOMMENDED_SIZE(CONFIG_I2C_PORT0_STATIC)] = { 0 };
  #define __i2c0_cmd_link_create() i2c_cmd_link_create_static(_bufferI2C0, sizeof(_bufferI2C0))
  #define __i2c0_cmd_link_delete(cmd_handle) i2c_cmd_link_delete_static(cmd_handle)
#else
  #define I2C0_USE_STATIC 0
  #define __i2c0_cmd_link_create() i2c_cmd_link_create()
  #define __i2c0_cmd_link_delete(cmd_handle) i2c_cmd_link_delete(cmd_handle)
#endif // CONFIG_I2C_PORT0_STATIC

#if defined(CONFIG_I2C_PORT1_SDA) && defined(CONFIG_I2C_PORT1_STATIC) && (CONFIG_I2C_PORT1_STATIC > 0)
  #define I2C1_USE_STATIC 1
  static uint8_t _bufferI2C1[I2C_LINK_RECOMMENDED_SIZE(CONFIG_I2C_PORT1_STATIC)] = { 0 };
  #define __i2c1_cmd_link_create() i2c_cmd_link_create_static(_bufferI2C1, sizeof(_bufferI2C1))
  #define __i2c1_cmd_link_delete(cmd_handle) i2c_cmd_link_delete_static(cmd_handle)
#else
  #define I2C1_USE_STATIC 0
  #define __i2c1_cmd_link_create() i2c_cmd_link_create()
  #define __i2c1_cmd_link_delete(cmd_handle) i2c_cmd_link_delete(cmd_handle)
#endif // CONFIG_I2C_PORT0_STATIC

#define _i2c_cmd_link_create(i2c_num) (i2c_num == 0) ? __i2c0_cmd_link_create() : __i2c1_cmd_link_create()
#define _i2c_cmd_link_delete(i2c_num, cmd_handle) (i2c_num == 0) ? __i2c0_cmd_link_delete(cmd_handle) : __i2c1_cmd_link_delete(cmd_handle)

Теперь вместо i2c_cmd_link_create() я использую “свой” макрос _i2c_cmd_link_create(i2c_num), а он уже автоматически выбирает нужную API функцию и буфер, при необходимости. Для каждой шины используется свой личный буфер, что позволяет делать одновременные запросы к разным шинам из разных задач.

 


Пример получения данных с сенсора BME280

Как всем известно, для платформы Arduino написано просто огромное количество драйверов для самых разных устройств, датчиков, дисплеев и т.д. Увы и ах, но для ESP-IDF их практически нет, производители не обращают никакого внимания на эту платформу, она не популярна. Поэтому придется писать драйвера самому. Не пугайтесь – это совсем не сложно, давайте попробуем….

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

Кто знаком с этим творением Bosch, могут сказать: нафига начинать с него? Нам же чёрт ногу сломит со всеми этими вычислениями и калибровочными коэффициентами!

По сути это действительно так. Но я не буду вдаваться в эти детали, а просто воспользуюсь готовым API, который нам предоставляет производитель: GitHub – boschsensortec/BME280_driver: Bosch Sensortec BME280 sensor driver

Нам не потребуется вникать в тонкости регистров и протокола обмена данными, а останется только написать callback-функции обмена данными по шине, всю остальную внутреннюю работу API берет на себя.

Я использовал описанные выше макрофункции:

static BME280_INTF_RET_TYPE BME280_i2c_read(uint8_t reg_addr, uint8_t *reg_data, uint32_t length, void *intf_ptr)
{
  BME280* sensor = (BME280*)intf_ptr;
  if (sensor) {
    esp_err_t err = readI2C(sensor->getI2CNum(), sensor->getI2CAddress(), &reg_addr, sizeof(reg_addr), reg_data, length, 0, BME280_I2C_TIMEOUT); 
    if (err == ESP_OK) {
      return BME280_OK;
    } else {
      return BME280_E_COMM_FAIL;
    };
  };
  return BME280_E_NULL_PTR;
}

static BME280_INTF_RET_TYPE BME280_i2c_write(uint8_t reg_addr, const uint8_t *reg_data, uint32_t length, void *intf_ptr)
{
  BME280* sensor = (BME280*)intf_ptr;
  if (sensor) {
    esp_err_t err = writeI2C(sensor->getI2CNum(), sensor->getI2CAddress(), &reg_addr, sizeof(reg_addr), (uint8_t*)reg_data, length, BME280_I2C_TIMEOUT); 
    if (err == ESP_OK) {
      return BME280_OK;
    } else {
      return BME280_E_COMM_FAIL;
    };
  };
  return BME280_E_NULL_PTR;
}

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

int8_t rslt = bme280_set_sensor_mode(mode, &_dev);
if (rslt == BME280_OK) { _mode = mode; };

Просто? Не то слово. И зверей убивать не надо…

Конечно, Вам потребуется написать функции сброса датчика, его настройки и т.д., но сделать это не так уж и сложно, нужно только почитать datasheet.

 


Пример получения данных с часов реального времени

Давайте ещё рассмотрим пример обмена данными на шине без всяких там API. Возьмем кОльОсикО самый что ни на есть обычный RTC-таймер на чипе DS1307.

GitHub – kotyara12/reDS1307: Driver for real-time clock DS1307github.com

Регистры все у него однобайтовые, поэтому функции чтения / записи у меня выглядят так:

bool reDS1307::read_register(uint8_t reg, uint8_t *val, uint8_t size)
{
  esp_err_t err = readI2C(_numI2C, _addrI2C, &reg, 1, val, size, 0, I2C_TIMEOUT);
  if (err != ESP_OK) {
    rlog_e(logTAG, "Failed to read DS1307 register #%d: %d (%s)", reg, err, esp_err_to_name(err));
    return false;
  };
  return true;
}

bool reDS1307::write_register(uint8_t reg, uint8_t *val, uint8_t size)
{
  esp_err_t err = writeI2C(_numI2C, _addrI2C, &reg, 1, val, size, I2C_TIMEOUT);
  if (err != ESP_OK) {
    rlog_e(logTAG, "Failed to write DS1307 register #%d: %d (%s)", reg, err, esp_err_to_name(err));
    return false;
  };
  return true;
}

bool reDS1307::update_register(uint8_t reg, uint8_t mask, uint8_t val)
{
  uint8_t old;
  if (read_register(reg, &old, 1)) {
    uint8_t buf = (old & mask) | val;
    if (write_register(reg, &buf, 1)) {
      return true;
    };
  };
  return false;
}

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

bool reDS1307::get_time(struct tm *time)
{
  uint8_t buf[7];

  if (read_register(TIME_REG, (uint8_t*)&buf, sizeof(buf))) {
    time->tm_sec = bcd2dec(buf[0] & SECONDS_MASK);
    time->tm_min = bcd2dec(buf[1]);
    if (buf[2] & HOUR12_BIT) {
      // RTC in 12-hour mode
      time->tm_hour = bcd2dec(buf[2] & HOUR12_MASK) - 1;
      if (buf[2] & PM_BIT) time->tm_hour += 12;
    } else {
      time->tm_hour = bcd2dec(buf[2] & HOUR24_MASK);
    };
    time->tm_wday = bcd2dec(buf[3]) - 1;
    time->tm_mday = bcd2dec(buf[4]);
    time->tm_mon  = bcd2dec(buf[5]) - 1;
    time->tm_year = bcd2dec(buf[6]) + 100;

    rlog_i(logTAG, "Real time clock DS1307 read: year=%d, month=%d, mday=%d, wday=%d, hour=%d, min=%d, sec=%d",
      time->tm_year - 100, time->tm_mon + 1, time->tm_mday, time->tm_wday + 1, time->tm_hour, time->tm_min, time->tm_sec);
    return true;
  };
  return false;
}

Запись даты и времени производится в обратном порядке:

bool reDS1307::set_time(const struct tm *time)
{
  uint8_t buf[7] = {
    dec2bcd(time->tm_sec),
    dec2bcd(time->tm_min),
    dec2bcd(time->tm_hour),
    dec2bcd(time->tm_wday + 1),
    dec2bcd(time->tm_mday),
    dec2bcd(time->tm_mon + 1),
    dec2bcd(time->tm_year - 100)
  };

  if (write_register(TIME_REG, (uint8_t*)&buf, sizeof(buf))) {
    rlog_i(logTAG, "Real time clock DS1307 set: year=%d, month=%d, mday=%d, wday=%d, hour=%d, min=%d, sec=%d",
      time->tm_year - 100, time->tm_mon + 1, time->tm_mday, time->tm_wday + 1, time->tm_hour, time->tm_min, time->tm_sec);
    return true;
  };
  return false;
}

Где функции преобразования:

static uint8_t bcd2dec(uint8_t val)
{
  return (val >> 4) * 10 + (val & 0x0f);
}

static uint8_t dec2bcd(uint8_t val)
{
  return ((val / 10) << 4) + (val % 10);
}

Конечно, для того чтобы написать драйвер I2C устройства, придется заглянуть в datasheet на него, узнать какие адреса регистров для чего используются.
Ну или как вариант, взять готовый драйвер для четверга Arduino и заменить вызовы библиотеки Wire.Read и Wire.Write на свои собственные.

 


Тонкие настройки

В заключение хотелось бы упомянуть про возможность тонкой настройки параметров работы шины. Когда функция i2c_param_config() настраивает конфигурацию драйвера для порта I2C, она также устанавливает для нескольких параметров связи I2C значения по умолчанию, определенные в спецификации I2C. Некоторые параметры предварительно настраиваются в регистрах контроллера I2C.

Все эти параметры можно изменить на пользовательские значения, вызвав специальные функции, указанные в таблице ниже. Обратите внимание, что временные значения определяются в тактовых циклах APB. Частота APB указана в I2C_APB_CLK_FREQ.

https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2c.html#id7

https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2c.html#id7

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

 


Ссылки

  1. ESP-IDF: Inter-Integrated Circuit (I2C)
  2. Библиотека reI2C
  3. Набор драйверов для сенсоров

 

💠 Полный архив статей вы найдете здесь


Пожалуйста, оцените статью:
[ 0 из 5, всего 0 оценок ]

1 комментарий для “Работа с шиной I2C на ESP32 и ESP-IDF версий 4.х – 5.1”

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *