Довольно часто возникает необходимость связать микроконтроллер STM32 с другим микроконтроллером или устройством, например, внешней памятью или датчиком. И тут на помощь приходит шина I2C, о которой до сих пор было не слишком много статей на нашем сайте. Пора исправлять это недоразумение 👍
Теоретическая часть.
Введение в I2C.
И снова для начала кратко обсудим теоретические аспекты. Итак, I2C – последовательная шина данных, разработанная Philips около тридцати лет назад. I2С очень часто применяется в различных блоках для реализации внутренней связи между устройствами. Возможные применения включают, например:
- Обмен информацией между микроконтроллерами.
- Доступ к модулям памяти.
- Обмен данными с модулями АЦП.
- Обмен данными с ЦАП.
И, естественно, это далеко не полный список. I2С гарантирует неплохую скорость работы при относительной простоте разработки и низкой себестоимости. К слову о скоростях:
- Обычный режим – до 100КГц
- Быстрый режим – до 400КГц
I2С использует две линии, которые подтянуты к напряжению питания. Одна линия – линия данных, другая – линия тактирования. Я не буду расписывать тут как работает I2С, потому что это уже много раз описано, без труда можно найти в интернете материал на свой вкус. А мы сразу перейдем к особенностям реализации I2С в STM32.
Основные характеристики шины:
- Каждое устройство может выступать как в роли Master, так и Slave.
- В режиме Master реализуется:
- Генерация тактового сигнала.
- Генерация старт-сигнала и стоп-сигнала.
- В режиме Slave реализуется:
- Механизм подтверждения адреса.
- Использование двух Slave адресов.
- Обнаружение стоп-бита, выданного ведущим на линию.
- Генерация и определение 7/10 битных адресов.
- Поддержка разных скоростей передачи данных.
- Наличие множества флагов, сигнализирующих о событиях, а также об ошибках на линии.
Возможна работа в одном из следующих режимов:
- Slave-приемник
- Slave-передатчик
- Master-приемник
- Master-передатчик
По умолчанию установлен режим Slave, но как только устройство генерирует старт-бит оно сразу же превращается из подчиненного в ведущего. Структурная схема модуля I2С в STM32:
Само собой на каждое событие I2С можно повесить прерывание:
Для работы с I2С в STM32 выделено 9 регистров. Не будем на этом останавливаться сейчас подробно, но даташитом лучше не пренебрегать даже в том случае, если непосредственно с регистрами в проекте работа и не ведется.
А мы перейдем как водится к практическому примеру использования.
Подключение датчика BMP280.
У меня как раз есть плата на базе STM32F103, на которой помимо всего прочего установлен датчик давления BMP280, который подключен как раз по I2C. Скоро я отдельно расскажу про эту плату и про все возможности BMP280, сегодня же мы задействуем интерфейс I2C для чтения данных из его регистров (и еще один пример использования I2C в STM32 вдогонку - дисплей на базе контроллера SSD1306).
Открываем даташит и, как и во многих других датчиках, первым в списке идет регистр "ID". И традиционно именно этот регистр используется для отладки коммуникации, потому что его значение строго фиксировано. Если считанное значение равно тому, которое указано в документации - связь работает, если нет... в общем, все понятно.
В данном случае "правильным" для нас будет значение 0x58. То есть план таков:
- конфигурируем I2C модуль
- отправляем датчику команду на чтение регистра ID
- проверяем полученное значение
В итоге мы получим минимально необходимый функционал, который уже можно использовать в своих проектах каким угодно образом. Пара слов по физической коммутации... На моей плате датчик подключен к I2C1:
- PB8 - I2C SCL
- PB9 - I2C SDA
По умолчанию для I2C1 в CubeMx активируются пины PB6 и PB7, так что в данном случае нужно будет поменять их на соседние. Да, кстати, микроконтроллер - STM32F103RET6, так что проект будет именно для него. Но, как и в любом проекте, созданном с помощью STM32CubeMx, проделать аналогичные действия для другого контроллера в случае необходимости будет максимально просто. И, конечно же, всегда при работе с I2C не забываем о подтягивающих резисторах, я обычно ставлю 4.7 КОм:
Кроме того, необходимо определиться с I2C адресом датчика. У BMP280 6 старших битов адреса фиксированы, а младший бит определяется сигналом на выводе SDO:
Таким образом, адрес имеет следующий вид - 0b111011x, где x = 1, если на SDO высокий уровень сигнала, и x = 0 при логическом нуле. У меня как раз этот вывод подтянут к земле, поэтому получаем адрес - 0b1110110 = 0x76.
Практическая часть.
Конфигурация модуля I2C STM32.
Итак, пора переходить к созданию проекта. Здесь все проходит как обычно (создание проекта и настройки тактирования). Только для разнообразия давайте задействуем в этом проекте внутренний источник тактирования вместо внешнего:
Включаем модуль I2C1 и настраиваем те выводы, которые у нас используются, то есть PB8 и PB9. Также выбираем Serial Wire в окне Debug для отладки:
На этом, в общем-то и все, настройки I2C пока менять нет никакой необходимости, оставляем все по умолчанию:
Обмен данными с датчиком BMP280.
Генерируем проект и переходим к его редактированию, сразу же определяем адрес датчика:
/* Private define ------------------------------------------------------------*/ /* USER CODE BEGIN PD */ #define I2C_ADDRESS 0x76 /* USER CODE END PD */
И вот тут есть один небольшой, но важный нюанс. Многие проблемы с работой I2C связаны именно с этим. При отправке данных BMP280 первый из отправляемых байтов является не просто адресом устройства, а адресом (который у нас состоит из 7 битов), дополненным еще одним битом. Этот бит RW отвечает за направление передачи данных, то есть определяет, выполняем мы чтение или запись:
И поскольку бит RW является младшим, то 7 битов адреса нам нужно будет сдвигать на одну позицию влево при передаче в функцию I2C HAL драйвера. Сам же бит RW библиотека HAL добавит к адресу автоматически, без нашего участия. Сейчас на примере со всем этим разберемся.
Итак, нам нужно прочитать значение регистра ID, адрес которого - 0xD0. Снова смотрим на схему - получается, что нам нужно отправить BMP280 команду с адресом самого датчика и адресом регистра, значение которого мы хотим получить. После этого мы снова выдаем на линию адрес и встаем на чтение. В HAL весь комплекс этих операций мы сможем осуществить всего двумя функциями:
HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout) HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout)
Аргументы функций идентичны:
hi2c
- указатель на структуру, в которой хранится информация о модуле I2C. Эту структуру STM32CubeMx уже сгенерировал, она используется при настройке модуля.DevAddress
- адрес устройства, не забываем сдвинуть на 1 бит влево.pData
- указатель на буфер данных. При записи - данные из этого буфера будут передаваться по интерфейсу I2C. При чтении, соответственно, в этот буфер будут сохраняться полученные значения.Size
- количество байтов данных для чтения/записи.Timeout
- величина таймаута, по истечении которого функция выдаст ошибку и прекратит выполнение. Этот механизм необходим для того, чтобы при каком-либо сбое в коммуникации программа не зависала намертво на ожидании ответа.
С этим разобрались, идем дальше. Зададим упомянутый таймаут равным 10 мс и определим дефайном:
/* Private define ------------------------------------------------------------*/ /* USER CODE BEGIN PD */ #define I2C_ADDRESS 0x76 #define I2C_ID_ADDRESS 0xD0 #define I2C_TIMEOUT 10 /* USER CODE END PD */
Кроме того, мы определили адрес регистра, с которым будем экспериментировать. Добавим еще парочку переменных:
/* USER CODE BEGIN PV */ uint8_t regData = 0; uint8_t regAddress = I2C_ID_ADDRESS; /* USER CODE END PV */
И, наконец-то, перейдем к отправке данных:
/* USER CODE BEGIN 2 */ HAL_I2C_Master_Transmit(&hi2c1, (I2C_ADDRESS << 1), ®Address, 1, I2C_TIMEOUT); HAL_I2C_Master_Receive(&hi2c1, (I2C_ADDRESS << 1), ®Data, 1, I2C_TIMEOUT); /* USER CODE END 2 */
В результате в переменной regData
у нас окажется верное значение регистра ID:
Связь STM32 с датчиком по I2C налажена 👍
Здесь мы работали в так называемом Polling режиме, то есть вызов I2C функций блокировал выполнение программы до тех пор, пока функции не завершат свою работу. Иногда это вполне допустимо, иногда так даже удобнее, но довольно часто такие задержки не ведут ни к чему хорошему, поэтому нельзя обойти вниманием режим работы с I2C на прерываниях. И здесь нам на помощь придут две другие функции:
HAL_StatusTypeDef HAL_I2C_Master_Transmit_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size)
HAL_StatusTypeDef HAL_I2C_Master_Receive_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size)
Если говорить об аргументах, то тут отличие только одно. Отсутствует последний аргумент, который определяет величину таймаута, попросту потому что здесь нет никакого ожидания, и необходимость в проверке таймаута отпадает.
Кроме того, как и при работе с другой периферией на прерываниях, HAL предлагает нам использовать ряд callback-функций. Остановимся на двух из них:
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c)
Первая вызывается по окончанию передачи, а вторая, соответственно по окончанию приема. Получается, что нам нужно действовать так:
- вызываем функцию передачи по I2C
- далее контроллер может выполнять любые задачи и операции, мы же ждем вызова callback функции
HAL_I2C_MasterTxCpltCallback()
- в теле этой функции мы запускаем процесс приема
- и ожидаем второго callback'а - по окончанию приема данных
Реализуем в проекте, но для начала включим прерывания I2C в STM32CubeMx:
Теперь можно переходить к коду. В функции main()
выполняем первый из этих этапов:
HAL_I2C_Master_Transmit_IT(&hi2c1, (I2C_ADDRESS << 1), ®Address, 1);
Теперь на очереди callback-функции:
/* USER CODE BEGIN 4 */ void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c) { HAL_I2C_Master_Receive_IT(&hi2c1, (I2C_ADDRESS << 1), ®Data, 1); } void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c) { // I2C data ready! } /* USER CODE END 4 */
Если мы поставим брейкпоинт в функции HAL_I2C_MasterRxCpltCallback()
, то можем под отладчиком увидеть, что в переменной regData
, как и при работе в Polling режиме, находится абсолютно верное значение. И на этом я на сегодня заканчиваю с модулем STM32 I2C, спасибо всем за внимание 🤝
Выкладываю полный проект к этой статье - ссылка.