I2C (Inter-Integrated Circuit, IIC) - интерфейс связи между цифровыми устройствами, по нему МК может общаться с датчиками и модулями или с другими МК. Среди основных интерфейсов связи (UART, I2C, SPI) I2C - самый технически сложный и медленнный, но позволяет подключить к МК до 127 устройств по 2 проводам и на уровне протокола следит за доставкой данных и статусом устройств в сети.
- Синхронный (есть тактовая линия)
- Последовательный (данные передаются бит за битом по одному проводу)
- Количество пинов: 2
- Скорость: стандартная 100 кГц, ускоренная (fast mode) 400 кГц, fast mode plus 1 MHz, некоторые устройства поддерживают более высокую скорость, почти все могут работать на более низкой
- Топология: одно ведущее устройство, но можно организовать и мульти-мастер
- Количество ведомых на шине: до 127 (7-бит адресация). Существует и стандарт 10-битной адресации
Произносится как "ай-сквар-си", т.е. под числом 2 имеется в виду квадрат (i "в квадрате")
I2C - разработка компании Philips, право на использование этого названия в своих устройствах стоит денег. Поэтому "аналоги" I2C называют по-разному, например в AVR это TWI (Two Wire), а в Arduino библиотека называется Wire
Основы #
Роли #
I2C является шиной, т.е. к одним и тем же выводам может быть подключено несколько устройств. По стандарту на шине есть только одно ведущее устройство - Master, остальные - ведомые, Slave. Ведущее выбирает, с каким из ведомых взаимодействовать, может отправлять и читать с него данные. Ведущим обычно является основной МК в схеме, а остальные - различные цифровые микросхемы, датчики или вспомогательные МК.
Пины #
I2C использует 2 пина для подключения:
SDA
(Serial Data) - линия данных, передача в обе стороны. На модуле может быть подписан как DSCL
(Serial Clock) - линия синхронизации, управляет мастер. На модуле может быть подписан как C, SCK
Оба пина работают как open drain, т.е. активный сигнал - GND. Высокий сигнал обеспечивается внешней подтяжкой пина к питанию (на стороне ведомого устройства, обычно 10 кОм), это позволяет избежать короткого замыкания, если ведущее и ведомое вдруг решат одновременно подать разноимённые сигналы. В то же время это ограничивает скорость шины, т.к. скорость возврата сигнала к высокому уровню ограничена резистором и ёмкостью цепи.
Подключение #
SDA к SDA, SCL к SCL:
Расположение аппаратных выводов I2C микроконтроллера или отладочной платы всегда отмечено на распиновке как SDA и SCL
Адресация #
Каждое устройство на шине имеет уникальный адрес от 1
до 127
, двух устройств с одинаковым адресом на шине быть не должно. Адрес может быть:
- Один фиксированный - указан в документации на микросхему
- Настраиваемый из 2/4/8 фиксированных, обычно перемычками на модуле (соединяют выводы микросхемы и/или резисторы)
- Программируемый - задаётся любым по другому интерфейсу
Адрес 0
зарезервирован под broadcast-запросы
Процесс передачи #
Не будем углубляться в аппаратную реализацию самого интерфейса, рассмотрим основные моменты:
- Передача по шине идёт в виде сообщений (message), оно состоит из байта заголовка и пакетов данных (frame)
- Сообщения могут быть двух типов: мастер отправляет данные ведомому, либо мастер читает данные с ведомого
- В заголовке указывается адрес ведомого устройства (7 бит) + тип сообщения (1 бит)
- Данные передаются по 1 байту старшим битом вперёд, мастер тактирует каждый бит линий SCL (как в SPI)
- Сообщения не имеют длины - передача просто идёт до тех пор, пока одно из устройств не завершит её
- После каждого байта (данных и заголовка) идёт подтверждение приёма - специальный сигнал
ACK
(Acknowledgement, признание). Если он не был принят - считается сигналомNACK
, т.е. устройство не ответило или это конец передачи - На шине также есть специальный сигнал
START
- мастер отправляет его перед началом передачи. И сигналSTOP
- после окончания
Отправка #
- Мастер отправляет
START
- Мастер отправляет заголовок: адрес ведомого + режим передачи "отправка"
- Ведомый с нужным адресом отправляет
ACK
- Мастер проверяет
ACK
: если он есть - ведомый с таким адресом существует и готов принимать данные - Мастер отправляет данные байт за байтом, проверяя
ACK
от ведомого после каждого байта - Мастер отправляет
STOP
Серый фон - мастер отправляет по SDA
Чтение #
- Мастер отправляет
START
- Мастер отправляет заголовок: адрес ведомого + режим передачи "чтение"
- Ведомый с нужным адресом отправляет
ACK
- Мастер проверяет
ACK
: если он есть - ведомый с таким адресом существует и готов отправлять данные - Ведомый отправляет данные байт за байтом (мастер тактирует шину), проверяя
ACK
от мастера после каждого. В свою очередь, мастер отправляетACK
после каждого принятого байта - Когда мастер прочитает нужное количество, он отправляет
NACK
в конце последнего фрейма, чтобы ведомый освободил линию - Мастер отправляет
STOP
Серый фон - мастер отправляет по SDA
Повторный старт #
- Вместо отправки
STOP
мастер может сразу отправитьSTART
, если хочет сразу начать новое сообщение и не хочет освобождать шину (например, для другого мастера в мульти-мастер шине) - После этого снова идёт заголовок и далее как в обычной передаче
На практике #
На практике ведомые устройства обычно имеют систему регистров (команд): первым байтом отправляется номер регистра, в который мы хотим писать или читать, поэтому работа с ними выглядит так:
Отправка #
- Старт
- Заголовок (режим "мастер отправляет")
- Отправка номера регистра для записи
- Отправка данных (один или несколько байт)
- Стоп
Серый фон - мастер отправляет по SDA
Чтение #
- Старт
- Заголовок (режим "мастер отправляет")
- Отправка номера регистра для чтения
- Повторный старт
- Заголовок (режим "мастер читает")
- Чтение данных (один или несколько байт)
- Стоп
Серый фон - мастер отправляет по SDA
Далее мы рассмотрим это "в железе".
Библиотека Wire.h #
Полное описание смотри в справочнике
В фреймворке Arduino есть удобный инструмент для работы с шиной I2C - библиотека Wire.h. Рассмотрим общий пример использования.
Отправка #
Отправка реализована через буфер - внутри библиотеки есть буфер на 32 байта (в разных платформах может отличаться), т.е. все отправляемые нами данные буферизируются и по факту отправляются в "конце" передачи. С одной стороны это удобно - не нужно беспокоиться о таймингах, т.е. между отправками частей данных можно выполнять длительные операции. С другой стороны - не получится отправить больше 32 байт за раз, придётся перезапускать передачу и отправлять остаток.
#include <Wire.h>
void setup() {
// запустить шину в роли мастера
Wire.begin();
// начать отправку по адресу
// (отправит START + заголовок)
Wire.beginTransmission(адрес);
// отправить байт (в буфер)
Wire.write(0xab);
// отправить массив 3 байта (в буфер)
uint8_t buf[3];
Wire.write(buf, 3);
// отправить на устройство (с контролем всех ACK + STOP в конце)
// вернёт 0, если успешно
bool res = Wire.endTransmission();
if (res) {
// обработка ошибки
}
// остановить шину (опционально)
Wire.end();
}
void loop() {
}
Также Wire
наследует Stream, т.е. можно делать Wire.print("hello!")
и прочие подобные вещи при организации текстовой связи между платами.
Чтение #
Чтение так же буферизируется, по умолчанию 32 байта. Т.е. мы запрашиваем с ведомого устройства данные, библиотека получает их в буфер, затем уже оттуда мы можем их прочитать. Опять же, нельзя принять больше 32 байт за раз.
#include <Wire.h>
void setup() {
// запустить шину в роли мастера
Wire.begin();
// запросить N байт с адреса, вернёт сколько получено
// (отправит START и заголовок, запросит и обработает все ACK + NACK + STOP)
uint8_t res = Wire.requestFrom(адрес, N);
if (res != N) {
// обработка ошибки
// данные не получены в желаемом количестве
} else {
// кол-во данных в буфере
Wire.available();
// прочитать байт
uint8_t v = Wire.read();
// прочитать несколько байт в массив
uint8_t buf[3];
Wire.readBytes(buf, 3);
}
// остановить шину (опционально)
Wire.end();
}
void loop() {
}
Также из Stream доступны методы парсинга, например Wire.parseInt()
, Wire.readStringUntil()
и прочие. Могут пригодиться при организации текстовой связи между ардуинками, но обычно так не делают.
Отправка + чтение #
Как говорилось выше, при работе с реальными микросхемами обычно бывает нужно отправить номер регистра (команду), а затем прочитать данные. Чтобы реализовать "повторный старт", нужно передать false
в endTransmission()
:
// (отправит START + заголовок)
Wire.beginTransmission(адрес);
Wire.write(регистр);
// если успешно отправлено
// (с контролем всех ACK, БЕЗ STOP в конце)
if (!Wire.endTransmission(false)) {
// запрашиваем данные
uint8_t res = Wire.requestFrom(адрес, N);
// ...
}

Сканер шины #
Зная, как работает I2C и библиотека Wire.h, можно написать "сканер" шины: перебрать в цикле все адреса, с каждым сделать beginTransmission()
и проверить результат endTransmission()
. Если микросхема ответит - там будет 0
:
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin();
}
void loop() {
Serial.println("Begin scan");
for (uint8_t addr = 1; addr <= 127; addr++) {
Wire.beginTransmission(addr);
if (!Wire.endTransmission()) {
Serial.print("0x");
Serial.println(addr, HEX);
}
}
Serial.println("End scan");
Serial.println();
delay(1000);
}
Если подключить к шине несколько устройств - их адреса будут выводиться списком в монитор порта.
Пример с DS3231 #
Теперь рассмотрим модуль часов реального времени DS3231 (урок про него). Адрес на шине - 0x68
.
![]() |
В наборе GyverKIT | START | IOT | EXTRA |
---|---|---|---|---|
Модуль часов | ✔ |
Таблица регистров из документации
Микросхема хранит время в двоично-десятичном формате. Не вдаваясь в подробности, нам понадобятся вспомогательные функции для конвертации в BCD и обратно:
// перевод из десятичного в двоично-десятичное
uint8_t encodeBCD(uint8_t data) {
return ((data / 10) << 4) | (data % 10);
}
// перевод из двоично-десятичного в десятичное
uint8_t decodeBCD(uint8_t data) {
return (data >> 4) * 10 + (data & 0xF);
}
В качестве примера рассмотрим запись и чтение минут и секунд. С часами всё сложнее, посмотреть реализацию можно например в моей библиотеке.
Для установки нужно выбрать регистр секунд (адрес 0x00
в таблице) и записать следом значение секунд. Можно продолжать отправлять данные - микросхема будет смещать адрес, т.е. следом можно отправить минуты, часы и так далее по списку:
Wire.beginTransmission(DS_ADDR);
Wire.write(DS_SEC_REG); // регистр секунд
Wire.write(encodeBCD(12)); // 12 секунд
Wire.write(encodeBCD(24)); // 24 минуты
Wire.endTransmission();
Для чтения нужно опять же выбрать нужный регистр, в нашем случае секунды, и прочитать нужное количество байт - микросхема будет сама увеличивать адрес для следующего чтения. Мы записали только минуты и секунды, так что будет логично только их и прочитать:
Wire.beginTransmission(DS_ADDR);
Wire.write(DS_SEC_REG);
Wire.endTransmission(false));
Wire.requestFrom(DS_ADDR, 2);
uint8_t secs = decodeBCD(Wire.read()); // секунды
uint8_t mins = decodeBCD(Wire.read()); // минуты
Полная программа, которая устанавливает время при запуске на 24 минуты 12 секунд, а затем каждую секунду читает его с микросхемы и выводит в монитор порта:
#include <Wire.h>
// перевод из десятичного в двоично-десятичное
uint8_t encodeBCD(uint8_t data) {
return ((data / 10) << 4) | (data % 10);
}
// перевод из двоично-десятичного в десятичное
uint8_t decodeBCD(uint8_t data) {
return (data >> 4) * 10 + (data & 0xF);
}
#define DS_ADDR 0x68
#define DS_SEC_REG 0x00
void setup() {
Serial.begin(115200);
Wire.begin();
Wire.beginTransmission(DS_ADDR);
Wire.write(DS_SEC_REG); // регистр секунд
Wire.write(encodeBCD(12)); // 12 секунд
Wire.write(encodeBCD(24)); // 24 минуты
if (Wire.endTransmission()) {
Serial.println("Time set error");
}
}
void loop() {
Wire.beginTransmission(DS_ADDR);
Wire.write(DS_SEC_REG); // регистр секунд
if (!Wire.endTransmission(false)) {
if (Wire.requestFrom(DS_ADDR, 2) == 2) {
uint8_t secs = decodeBCD(Wire.read());
uint8_t mins = decodeBCD(Wire.read());
Serial.print(mins);
Serial.print(':');
Serial.println(secs);
} else {
Serial.println("Time get error 2");
}
} else {
Serial.println("Time get error 1");
}
delay(1000);
}
Пример с Arduino #
Wire.h позволяет работать в том числе в режиме ведомого устройства. Рассмотрим примеры с передачей данных между платами Arduino, они соединяются SDA-SDA и SCL-SCL. Если питание плат раздельное, то нужно соединить ещё GND.
Мастер отправляет #
Отправим текст, на ведомом тоже выведем его как текст:
// Master
#include <Wire.h>
void setup() {
Wire.begin();
}
int i;
void loop() {
// отправляем на адрес 123
Wire.beginTransmission(123);
// текст hello x
Wire.print("hello ");
Wire.print(i);
Wire.endTransmission();
i++;
delay(1000);
}
Для приёма на ведомом устройстве нужно подключить обработчик - он будет вызван при поступлении данных. Выведем данные в порт:
// Slave
#include <Wire.h>
// придёт количество принятых данных
// оно должно совпадать с Wire.available()
void receiveCb(int amount) {
while (Wire.available()) {
Serial.print((char)Wire.read());
}
Serial.println();
}
void setup() {
Serial.begin(115200);
Wire.begin(123); // наш адрес - 123
Wire.onReceive(receiveCb);
}
void loop() {
}
Мастер запрашивает #
Запросим данные с ведомого устройства:
// Master
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin();
}
void loop() {
// запросить 5 байт с адреса 123
Wire.requestFrom(123, 5);
// вывести в порт
while (Wire.available()) {
Serial.print((char)Wire.read());
}
Serial.println();
delay(1000);
}
Когда мастер запросит данные - на ведомом устройстве вызовется обработчик. В нём можно отправить мастеру данные. Сколько байт запросил мастер - неизвестно, интерфейс не предоставляет такой информации. Ведомое устройство должно само знать, сколько байт отправить. Именно поэтому обычно мастер выбирает регистр, а затем последовательно читает данные, как в примерах с датчиками выше. В этом примере мы знаем, что мастер запросил 5 байт - 5 и отправим. В виде текста:
// Slave
#include <Wire.h>
void requestCb() {
Wire.write("Hello"); // отправить 5 байт
}
void setup() {
Wire.begin(123); // наш адрес - 123
Wire.onRequest(requestCb);
}
void loop() {
}
Комплексный пример #
Более комплексный пример - сделаем ведомую Ардуину реалистичным устройством с картой регистров и целым рядом возможностей:
0x01
(запись) - управление светодиодом на пинеLED_BUILTIN
(0 выкл, 1 вкл)0x02
(запись) - строка, которая будут выведена в порт0x03
(чтение) - получить счётчик обращений по шине от мастера (1 байт)0x04
(чтение) - получитьmillis()
ведомого устройства (4 байта)
Программа мастера:
// master
#include <Wire.h>
// адрес
#define SLAVE_ADDR 123
// команды
#define REG_LED 0x01
#define REG_STRING 0x02
#define REG_COUNTER 0x03
#define REG_MILLIS 0x04
void writeLED(bool state) {
Wire.beginTransmission(SLAVE_ADDR);
Wire.write(REG_LED);
Wire.write(state);
Wire.endTransmission();
}
void sendStr(const char* str) {
Wire.beginTransmission(SLAVE_ADDR);
Wire.write(REG_STRING);
Wire.print(str);
Wire.endTransmission();
}
uint8_t getCounter() {
Wire.beginTransmission(SLAVE_ADDR);
Wire.write(REG_COUNTER);
Wire.endTransmission(false);
Wire.requestFrom(SLAVE_ADDR, 1);
return Wire.read();
}
uint32_t getMillis() {
Wire.beginTransmission(SLAVE_ADDR);
Wire.write(REG_MILLIS);
Wire.endTransmission(false);
Wire.requestFrom(SLAVE_ADDR, 4);
uint32_t ms;
Wire.readBytes((uint8_t*)&ms, 4);
return ms;
}
void setup() {
Serial.begin(115200);
Wire.begin();
}
// вызываем все команды через паузу
void loop() {
writeLED(1);
delay(1000);
writeLED(0);
delay(1000);
sendStr("hello");
delay(1000);
sendStr("from master");
delay(1000);
Serial.println(getCounter());
delay(1000);
Serial.println(getMillis());
delay(1000);
}
Программа ведомого:
// slave
#include <Wire.h>
// адрес
#define SLAVE_ADDR 123
// команды
#define REG_LED 0x01
#define REG_STRING 0x02
#define REG_COUNTER 0x03
#define REG_MILLIS 0x04
// последняя выбранная команда
// в обработчике приёма
uint8_t cmd = 0;
// счётчик сообщений
uint8_t counter = 0;
// обработчик приёма
void receiveCb(int amount) {
cmd = Wire.read();
++counter;
switch (cmd) {
case REG_LED:
digitalWrite(LED_BUILTIN, Wire.read());
break;
case REG_STRING:
while (Wire.available()) {
Serial.print((char)Wire.read());
}
Serial.println();
break;
case REG_COUNTER: break;
case REG_MILLIS: break;
}
}
// обработчик запроса
void requestCb() {
switch (cmd) {
case REG_LED: break;
case REG_STRING: break;
case REG_COUNTER:
Wire.write(counter);
break;
case REG_MILLIS: {
uint32_t ms = millis();
Wire.write((uint8_t*)&ms, 4);
} break;
}
}
void setup() {
Serial.begin(115200);
Wire.begin(SLAVE_ADDR);
Wire.onReceive(receiveCb);
Wire.onRequest(requestCb);
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
}
Ведомая Ардуинка будет мигать светодиодом и выводить в порт hello
from master
, а ведущая будет получать и выводить с неё значения счётчика и таймера аптайма.
В этом примере ведомое устройство имеет список команд, на которые оно знает как реагировать - где нужно принять ещё данные, а где нужно отправить оговоренное количество байт. Мы сохраняем последнюю полученную команду в reg
, чтобы позже в обработчике запроса отправить соответствующие данные. Это простой пример, в нём нет обработки ошибок и таймаутов, чтобы не усложнять код - в реальной работе лучше добавить таймаут между получением команды и запросом данных.
Данный пример уже позволяет подключить гроздь ардуинок к одной главной, раздать им разные адреса и управлять по шине.
Дополнительно #
Дополнительный контент доступен владельцам набора GyverKIT и по подписке, подробнее читай здесь. Блок содержит:
- Пример с MPU6050
- 1 изображений
- 2 блоков кода
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками
