О чём будет и для кого
В данной статье я собираюсь написать о таком интерфейсе общения между чипами и датчиками на платах как I2C(IIC / 2Wire).
Он, является очень простым в реализации. При разработке схем необходимо лишь два провода(линии), линия для синхронизации(SCL) и линия для передачи данных(SDA). Хотя ко-во допустимых "Рабов" у него ограничено в 127, да и скорость передачи довольно низкая в сравнении с тем же SPI.
Я бы хотел разобраться в том, как работает данный интерфейс ибо надо для моего проекта. Пока ничего рассказать о нём не могу, потому что может даже не смогу его реализовать. Очень много переменных предстоит учесть и многое предстоит изучить.
Но в чём суть проблемы. Есть нужный мне чип - LIS2DH12TR(акселерометр). Туториалов по нему не то чтобы много, их нет. Есть только Datasheet, и карта регистров для него. Вернее сказать, есть одна библиотека, но она для плюсов. Мой проект на чистом Си поэтому нет. А переписывать библиотеку я не собираюсь.
Да и при том всём, возможно я захочу использовать какой-нибудь другой чип, если этот не подойдёт, и под него может точно не быть ничего. Поэтому было решено, во что бы то ни стало, разобраться с интерфейсом I2C и без каких либо библиотек пообщаться с целевым чипом. Пока на примере общения MPU6050 с ATmega328P.
Задача разобраться в том, как всё это работает и применить на практике прочитав и записав в парочку регистров, пару байт информации. Давай начнём.
Про I2C в целом, теория
А начну я, конечно же, с теории. Я разберу формат отправляемых пакетов. Как эти пакеты перемещаются между устройствами, как эти устройства вообще понимают, что и куда отправлять, а главное как эти пакеты вообще читать, обо всём этом и будет в этой главе.
Роли в I2C, процесс общения
Для того чтобы общаться с другими датчиками, необходимо понять как I2C вообще работает. И как происходит обмен пакетами. Да именно пакетами. С этого в принципе и можно начать.
Каждый пакет длиною в 1 байт отправляется поочерёдно по SDA линии. Причём, он может путешествовать в двух направлениях. От мастера к рабу и от раба к мастеру. Чуть ниже я предоставлю используемую терминологию:
Мастер/Ведущий — устройство которое начинает и заканчивает передачу. Так же генерирует синхронизирующий сигнал.
Раб/Ведомый — устройство к которому обращается ведущий.
Передатчик — устройство передающее данные на шину.
Приёмник — устройство читающее данные с шины.
Из имеющихся ролей, можно сделать такой вывод, что общение возможно в двух вариантах. Первый, есть ведущий-передатчик и он передаёт данные рабу-приёмнику. Второй, есть ведущий-приёмник и он получает данные с раба-передатчика.
На практике роли постоянно меняются. Я конечно немного забегаю вперёд, но чтобы мастеру хоть что-нибудь прочитать, ему сначала придётся указать(т.е. записать соответствующий регистр), а потом перейти в состояние приёмника и начать читать передаваемые данные от раба. Но давай по порядку.
Настройка битрейта
Перед тем, как вообще что-либо делать, необходимо задать тактовую частоту на которой будет происходить общение между мастером и рабом. Битрейт сохраняется в специальном регистре TWBR (Two Wired Bitrate Register).
Битрейт не сохраняется в абсолютных числах, а в специальных значениях. Так, для МК(ATmega328P) с тактовой частотой в 16MHz, при необходимости достижения тактовой частоты передачи в 400kHz, необходимо задать TWBR в значение 12. Откуда 12 взялось? Из формулы, которую можно найти в документации.

В коде это будет выглядеть вот так:
Завершив с инициализацией частоты передачи битов. Можно приступать к настройке и подготовке к реальному общению между устройствами.
Режим мастера передатчика
В этом режиме, ведущее устройство, записывает данные на регистр, который указал ранее. Как выглядит типичный обмен данных между мастером и рабом в таком режиме? Давай взглянем.
Вот условные обозначения:
- Start - Условие начала передачи
- Stop - Условие конца передачи
- Adr - Адрес раба
- RAdr - Адрес регистра
- +W - Бит записи
- Ack - Сигнал принят
- Data - Данные
Последовательность записи одного бита.
| Ведущий | Start | Adr+W | RAdr(Data) | Data | Stop | |||
| Ведомый | Ack | Ack | Ack |
А вот последовательность записи нескольких битов.
| Ведущий | Start | Adr+W | RAdr(Data) | Data | Data | Stop | ||||
| Ведомый | Ack | Ack | Ack | Ack |
В начале происходит генерация сигнала Start. Это делается путём записи в регистр TWCR битов TWINT, TWSTA, TWEN. Вот так: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)).
В этом выражении (1 << TWINT) мы делаем побитовый сдвиг 1 (то есть 0000 0001) на 7 шагов влево. Почему на 7? Просто TWINT = 7. В результате получится 1000 0000. То же самое происходит и с другими выражениями (1 << TWSTA) и (1 << TWEN). Во втором случае передвигаем 1 на 5‑й бит, а в третьем на 2‑й бит. Потом мы просто объединяем (побитовое ИЛИ) все получившиеся бинарные числа. Вот так:
1000 0000
|
0010 0000
|
0000 0100
Должно получится следующее число - 1010 0100.
Дальше, согласно табличке необходимо отправить адрес устройства (Раба) и бит чтения/записи. В этом случае записи.
Как это сделать? Для этого в регистр TWDR нужно сохранить адрес раба и добавить в конец бит записи. Вот так: TWDR = (0x68 << 1) | 0. Здесь 0x68 это адрес MPU6050 указанный в документации. Мы побитово сдвигаем его влево на один бит. То есть, было 0110 1000 стало 1101 0000 . После чего добавляем бит записи - 0.
После сохранения адреса и бита записи в TWDR, нужно отправить его на шину по SDA (об этом в главе про подключение I2C). Для этого мы записываем в TWCR следующие биты, вот так: TWCR = ((1 << TWINT) | (1 << TWEN)). Получится 1000 0100.
В случае если ведомое устройство с указанным адресом существует, он перехватывает SDA линию и понижает её ( то есть отправляет 0, то есть Ack сигнал). Если нет, линия SDA будет не тронута и сигнал NAck будет получен, то есть 1.
После подтверждения, что ведомое устройство с таким адресом существует, отправляется байт информации, 1й отправляемый байт это не всегда адрес регистра. Для большинства датчиков и других сложных устройств это так. Сначала отправляется адрес регистра, а потом уже данные для записи. Но если это что-то простое, например некоторые АЦП, они сразу принимаю данные. Чтобы сказать наверняка, необходимо уточнять это в соответствующей документации.
Так, работая с MPU6050, сначала необходимо указать адрес регистра в который мы собираемся записывать. И лишь потом данные, которые хотим записать.
Делается это так.
Запись и отправка данных рабу
Мы повторяем запись (то что в блоке кода) до тех пор, пока не будет отправлен сигнал Stop или Repeated Start. Делается это следующим образом.
Для генерации Stop сигнала в контрольный регистр записываем следующее: TWCR = ((1 << TWINT) | (1 << TWSTO) | (1 << TWEN)). Должно получится следующее - 1001 0100.
Для генерации Repeated Start сигнала в контрольный регистр записываем следующее: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)). Должно получится следующее - 1010 0100.
Режим мастера/ведущего приёмника
В этом режиме ведущее устройств принимает данные от раба. Как он это делает? И как он указывает, что конкретно нужно прочитать? Для этого ему сначала придётся указать адрес нужного регистра (Режим мастера передатчика), указывает он его путём записи в этот регистр. После чего происходит смена режима на мастера приёмника. Вот как выглядит такая последовательность на схеме:
Вот условные обозначения:
- Start - Условие начала передачи
- Stop - Условие конца передачи
- Adr - Адрес раба
- RAdr - Адрес регистра
- +W - Бит записи
- +R - Бит чтения
- Ack - Сигнал принят
- NAck - Сигнал не принят
- Data - Данные
Последовательность чтения одного бита.
| Ведущий | Start | Adr+W | RAdr | Start | Adr+R | NAck | Stop | ||||
| Ведомый | Ack | Ack | Ack | Data |
Последовательность чтения нескольких битов
| Ведущий | Start | Adr+W | RAdr | Start | Adr+R | Ack | Nack | Stop | |||||
| Ведомый | Ack | Ack | Ack | Data | Data |
В начале, как и в режиме передачи, происходит генерация сигнала Start. Это делается путём записи в регистр TWCR битов TWINT, TWSTA, TWEN. Вот так: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)).
В этом выражении (1 << TWINT) мы делаем побитовый сдвиг 1 (то есть 0000 0001) на 7 шагов влево. Почему на 7? Просто TWINT = 7. То же самое происходит и с другими выражениями (1 << TWSTA) и (1 << TWEN). Во втором случае передвигаем 1 на 5‑й бит, а в третьем на 2‑й бит. Потом мы просто объединяем (побитовое или) все получившиеся бинарные числа. Должно получится следующее число - 1010 0100.
Дальше, согласно табличке необходимо отправить адрес устройства (Раба) и бит чтения/записи. Так как я работаю с MPU6050, адресом будет 0x68 + 0 бит для записи.
Как это сделать? Для этого в регистр TWDR нужно сохранить адрес раба и добавить в конец бит записи. Вот так: TWDR = (0x68 << 1) | 0. Здесь 0x68 это адрес MPU6050 указанный в документации. Мы побитово сдвигаем его влево на один бит. То есть было 0110 1000 стало 1101 0000 . После чего добавляем бит записи - 0.
После сохранения адреса и бита чтения/записи в TWDR, нужно отправить его на шину по SDA (об этом в главе про подключение I2C). Для этого мы записываем в TWCR следующие биты, вот так: TWCR = ((1 << TWINT) | (1 << TWEN)). Получится 1000 0100.
В случае если ведомое устройство с указанным адресом существует, он перехватывает SDA линию и понижает её ( то есть отправляет 0, то есть Ack сигнал). Если нет, линия SDA будет не тронута и сигнал NAck будет получен, то есть 1.
После подтверждения, что ведомое устройство с таким адресом существует, отправляется байт информации, 1й отправляемый байт это не всегда адрес регистра. Для большинства датчиков и других сложных устройств это так. Сначала отправляется адрес регистра, а потом уже данные для записи. Но если это что-то простое, например некоторые АЦП они сразу принимаю данные. Чтобы сказать наверняка, необходимо уточнять это в соответствующей документации.
Так работая с MPU6050, сначала необходимо указать адрес регистра который мы собираемся читать. Отправить и подождать ответа.
Вышеописанная последовательность выглядит вот так:
Если данные с адресом регистра были отправлены успешно (получен Ack сигнал) то, после этого нужно перевести мастера в режим приёмника. Делается это при помощи Repeates Start сигнала. Делается ровно так же как и обычный Start сигнал: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN))
Дальше, отправляем адрес устройства (Раба) и бит чтения. Так как я работаю с MPU6050, адресом будет 0x68 + 1 ,бит для чтения. Делается вот так: TWDR = (0x68 << 1) | 1.
Отправляем адрес Раба на чтение
Получив Ack сигнал в ответ, можно начать читать передаваемые данные, при этом уже ведущее устройство отправляет Ack или NAck сигнал. Какой конкретно сигнал отправить выбирает ведущий, то есть ты, мой дорогой читатель. И здесь очень важно всё не напутать. Ведь если неправильно ответить, линия передачи данных от Раба к Мастеру зависнет в бесконечном цикле ожидания.
Смотри, когда происходит чтение последнего байта информации, необходимо отправить NAck сигнал. Иначе только Ack. Если нужно прочитать первый и последний байт из регистра Раба, сразу отправляем NAck сигнал.
Если это первый и последний байт то, чтобы получить данные и ответить сигналом NAck, делаем это так: TWCR = (1<<TWINT) | (1<<TWEN).
Если это первый или любой другой байт, но точно не последний и мы хотим прочитать ещё после него - то запрашиваем данные и отправляем Ack, вот так: TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWEA). То есть целенаправленно задаём бит TWEA, о нём подробнее в главе о регистрах.
После, необходимо подождать пока данные не появятся в регистре TWDR. Делается это вот так:
Когда мы закончили читать и отправили соответствующий NAck сигнал, необходимо прекратить передачу данных, либо Stop, либо Repeated Start сигналом. Делается это так же как и в случае с записью:
Для генерации Stop сигнала в контрольный регистр записываем следующее: TWCR = ((1 << TWINT) | (1 << TWSTO) | (1 << TWEN)). Должно получится следующее - 1001 0100.
Для генерации Repeated Start сигнала в контрольный регистр записываем следующее: TWCR = ((1 << TWINT) | (1 << TWSTA) | (1 << TWEN)). Должно получится следующее - 1010 0100.
Вот такая вот последовательность действий необходима при чтении данных с подключённого устройства по I2C. Да, это гораздо сложнее и запутаннее чем запись, но что есть, то есть.
Коды ответов
В этой главе я собрал все возможные коды ответов и их значения. Так как каждый из них уникален и есть некоторые общие коды для разных режимов, все они будут под одной главой.
| 0x8 | TW_START | Условие старта общения (Start) было успешно выполнено |
| 0x10 | TW_REP_START | Условие повторного старта (Repeated Start) было успешно выполнено |
| 0x18 | TW_MT_SLA_ACK | Adr+W байт был отправлен и получен сигнал Ack |
| 0x20 | TW_MT_SLA_NACK | Adr+W байт был отправлен и получен сигнал NAck |
| 0x28 | TW_MT_DATA_ACK | Данные в 1 байт были отправлены и получен сигнал Ack |
| 0x30 | TW_MT_DATA_NACK | Данные в 1 байт были отправлены и получен сигнал NAck |
| 0x38 | TW_MT_ARB_LOST или TW_MR_ARB_LOST | Ведомое устройство по указанному адресу Adr+W/+R сейчас занят общением с другим Мастером |
| 0x40 | TW_MR_SLA_ACK | Adr+R байт был отправлен и получен сигнал Ack |
| 0x48 | TW_MR_SLA_NACK | Adr+R байт был отправлен и получен сигнал NAck |
| 0x50 | TW_MR_DATA_ACK | Данные в 1 байт были получены и отправлен сигнал Ack |
| 0x58 | TW_MR_DATA_NACK | Данные в 1 байт были получены и отправлен сигнал NAck |
| Код ответа | Макрос | Объяснение кода ответа |
|---|
Что бы узнать и проверить код ответа после каждого шага, необходимо прочитать регистр TWSR. Здесь, под каждым шагом, я подразумеваю действие сразу после очистки бита TWINT в TWCR. В коде это выглядит вот так: while (!(TWCR & (1 << TWINT)));. Что, как бы говорит, состояние регистра TWSR изменилось, можно его проверить и узнать.
Например, вот так: (TWSR & 0xF8) != 0x18
В таком случае если вернётся какой-нибудь не тот код, мы войдём в процедуру и выполним, например, предупреждение по UART протоколу в консоль, на компьютер разработчика.
Можно так же подключить заголовок util/twi.h для того, чтобы вместо статусов кодов можно было писать более дружелюбные к человеку названия (макросы). В практическом примере ниже, я этого не делал, ибо хотел чтобы ты, мой читатель, понимал и видел их явное использование.
Используемы регистры на стороне Мастера (ATmega328P)
TWCR (Two Wired Control Register) - регистр контроля хода коммуникации. То есть начало, конец коммуникации. Контроля и удержания шины занятой, пока идёт передача. Для генерации Ack сигнала.
TWDR (Two Wired Data Register) - регистр, где хранятся данные. В этот регистр попадают данные отправленные рабом при чтении, туда же ведущее устройство записывает свои данные, которые хочет отправить. В том числе и адрес раба.
TWSR (Two Wired Status Register) - регистр отвечающий за наблюдение состояния общения между рабом и мастером. От туда же можно узнать обо всех ошибках произошедших во время передачи или приёмки данных.
TWBR (Two Wired Bit Rate Register) - регистр в котором хранится число(делитель) тактовой частоты мастера, что бы можно было узнать частоту передачи данных.
Про подключение рабов через I2C
Теперь, зная как работает передача данных по данному протоколу, необходимо понять, как подключать датчики, сенсоры и прочие устройства к микроконтроллеру в качестве рабов, чтобы можно было коммуницировать и обмениваться данными с ними.
Основное преимущество перед тем же SPI интерфейсом является ко-во используемых проводов в схеме. Их необходимо только 2. Это конечно же не считая линию для напряжения и земли. В общем, необходима линия синхронизации(SCL) и линия данных(SDA). Ниже я предоставлю схему:

У устройств семейства AVR, то есть всякие ардуино, пины для работы с другими устройствами через I2C, обозначены как А5 и А4. А именно, для SCL это A5, для SDA это А4.
Давай пообщаемся, или то как работать с периферией через I2C
Подготовка (Заголовки и структуры)
Вот такая вот небольшая теория. Если понять суть данного протокола, то он не такой уж и сложный. Давай покажу на примере. Мы будем общаться с датчиком ускорения и углового поворота. Короче с акселерометром MPU-6050.
Так же, нам потребуется выводить куда-нибудь данные, чтобы понимать, работает ли код вообще. Можно использовать встроенную библиотеку и класс Serial от Ардуино, но в примерах кода я буду использовать свою реализацию работы UART интерфейса (когда-нибудь напишу и про этот интерфейс).
Наша задача в этой главе, просто прочитать данные с гироскопа и акселерометра. Вообще, данный чип имеет ещё и температурный датчик, но мы проигнорируем его и отключим. Итак, с чего начнём? С подключения заголовков.
Дальше определим структуру для хранения данных с датчика. Назовём его MPU6050.
Функция для того, чтобы получить данные, пока пуста и ничего не делает. Я заполню её чуть позже, а пока просто подготавливаем почву. Заметь мы храним данные датчиков в int16_t, ибо в документации к регистрам данного датчика видно, что для каждой оси по два байта.

С подготовительной частью мы закончили, давай теперь перейдём к части где мы настраиваем работу самого I2C протокола.
Настраиваем протокол I2C + немного абстракций
Сейчас я собираюсь настроить работу протокола и датчика так, чтобы была вообще возможность обмениваться данными и чтобы можно было бы понять, подключились ли мы вообще к датчику MPU6050. Для этого в функции setup пишем:
Первую строку с инициализацией интерфейса обмена данными по UART мы игнорируем сегодня, переходим дальше. Ты наверняка мог заметить, что я позволил себе немного абстрагировать I2C интерфейс в 4 функции: i2c_init, i2c_start, i2c_read, i2c_write.
Про небольшую надстройку над I2C интерфейсом (абстракция)
И конечно же, я пройдусь по всем им. С твоего разрешения я уже не буду описывать работу самого интерфейса, ибо сделал это в начале, но полезные подсказки будут втречаться то тут, то там по ходу чтения кода, которые должны будут помочь в понимании процесса.
i2c_init делает всё то, что описано в главе про настройку битрейта, плюс ещё задаёт флаг TWEN в TWCR для активации интерфейса.
i2c_start задаёт условие начала общения (START,REPEATED START) и отправляет адрес устройства с которым необходимо настроить связь.
i2c_write отправляет байт данных по шине и соответственно, ожидает возврата сигнала ( Ack или NAck).
i2c_read читает данные с шины и возвращает их если всё прошло успешно и ни один флаг в TWSR регистре не появился.
Так же данная функция позволяет указать будет ли это последнее прочтение или нет. Если да (чтение будет последним), то убираем флаг TWEA из TWCR.
Разберём подробнее инициализирующий код
На вход функции i2c_start мы подаём адрес на чтение, который мы заранее вычислили и сохранили в макросе. Вот так:
Дальше, мы отправляем адрес регистра в который хотим, что-нибудь записать. В нашем случае это 0x6B регистр: [i2c_write(0x6B);] Это регистр управления подачей питания на датчик. А после, записываем данные, которые должны быть записаны в указанный ранее адрес.
Я записываю следующее: [i2c_write(0x00 | 1 << 3);] Где 3 это позиция бита в указанном адресе, что это вообще значит?

Смотри мы по факту отправляем 0000 1000. Бит сна, поставленный в 0 пробуждает датчик, а поставив 1 в 3-й бит мы отключаем тепловой сенсор.
И последнее что я делаю при инициализации это читаю ID самого датчика, просто на всякий случай.
Мы должны получить в ответ 0x68, то есть адрес датчика. Если у тебя, конечно же, именно этот датчик.
Мы закончили предварительную настройку и проверку протокола I2C. Заметь я не вызвал i2c_stop, ибо это не нужно. Нам и дальше нужно читать данные с шины, по этому нет смысла её отпускать. Ровно так же, как и в случае с повторным стартом для чтения ID датчика. Мы просто отправили REPEATED START сигнал и коммуникация продолжилась.
Прочтём данные с датчика
Нам нужно получать свежие данные с датчика, по этому одним чтением всё не обойдётся. Будем постоянно его читать и обновлять. Вот как это можно делать в цикле:
Мы, задав изначальный адрес(0x3B) в режиме записи сразу после этого переходим в режим чтения. Первый байт который мы прочтём, будет байт находящийся в 0x3B регистре. С каждым чтением внутренний счётчик датчика будет увеличиваться на 1. По этому, после прочтения адреса 0x3B мы прочтём адрес 0x3C и так далее пока мы не ответим сигналом NAck.
После успешного прочтения всей последовательности мы завершаем передачу и отпускаем шину функцией i2c_stop. Где просто ставим соответствующий флаг TWSTO: TWCR = (1<<TWINT) | (1<<TWEN) | (1<<TWSTO);.
Запишем полную реализацию функции getData для получения данных с датчика.
Начну с замечания. Так как адреса для каждого значения ускорения, температуры и гироскопа находятся последовательно то код гораздо проще и нам не приходится постоянно менять адрес чтения. Что удобно.
Так же, данные датчиков хранятся в 2 байтах, по этому, необходимо сделать 2 чтения подряд и объединить их в одну переменную с целочисленным значением int16_t. Для этого, первый байт мы сдвигаем вперёд на половину, то есть 8 битов, чтобы данные со второго чтения влезли.
Под конец, просто читаем с отправкой NAck сигнала подключённому датчику.
И вот таким вот образом мы успешно прочитали данные с датчика используя только встроенные регистры, документацию и внутреннюю логику работы устройств. Твои выходные данные в консоли могут выглядеть примерно так:

Заметь, что температура не изменилась, хотя я трогал) Это значит, что мы успешно отключили температурный датчик. Так же взгляни на WHO_AM_I. Он равен 0x70. Это нормально, как было описано выше.
В заключение
В заключении я бы хотел сказать, что вот так вот программировать и работать с устройствами гораздо интереснее и "надёжнее" я бы сказал. Ведь в своей реализации не запутаешься) Плюс приходит более глубокое понимание интерфейсов и сути работы микроконтроллеров и прочих устройств.
Я надеюсь, что помог тебе разобраться в устройстве I2C интерфейса, и теперь ты сможешь подключить любое устройство и прочитать его данные за минуты.