Большинство статей про I2C в Linux начинаются с описания протокола: два провода, мастер, слейв, ACK, NACK. Это полезно один раз, когда тема совсем новая. Но инженер, который уже подключил датчик к плате и хочет прочитать его регистры прямо сейчас, без написания kernel-модуля, нуждается в другом. Linux предоставляет для этого полноценный механизм userspace-доступа через символьные устройства /dev/i2c-N и набор утилит i2c-tools. Вся цепочка от физического устройства на шине до байта данных в терминале доступна без единой строчки кода ядра, и это не компромисс, а осознанная архитектурная возможность, встроенная в ядро через драйвер i2c-dev.
Понимать, как именно это устроено изнутри, важно: не для того, чтобы щеголять знанием деталей, а потому что неправильная работа с I2C из userspace реально способна сломать устройство или нарушить транзакцию, которую в этот момент выполняет kernel-драйвер. Граница между диагностикой и разрушением здесь тоньше, чем кажется.
Что происходит внутри ядра, когда userspace открывает /dev/i2c-N
Драйвер i2c-dev регистрирует символьное устройство для каждого I2C-адаптера, обнаруженного ядром. Это не прокси и не обёртка в тривиальном смысле: i2c-dev ведёт себя как обобщённый I2C-клиент, которого можно программировать из пользовательского пространства. Когда программа вызывает open("/dev/i2c-1", O_RDWR), ядро создаёт файловый дескриптор, связанный с конкретным адаптером. Дальше ioctl() на этом дескрипторе переводится ядром в вызовы тех же функций, которые использовали бы kernel-драйверы устройств, подключённых к этой шине. Никакой магии, никакого обходного пути, просто чистый механизм делегирования.
Прежде чем что-либо делать, нужно убедиться, что модули загружены:
# Загрузка i2c-dev если его нет
modprobe i2c-dev
# Проверить что модуль активен
lsmod | grep i2c_dev
# Какие I2C-адаптеры видит система
i2cdetect -l
# Пример вывода на Raspberry Pi
# i2c-1 i2c bcm2835 (i2c@7e804000) I2C adapter
# i2c-2 i2c bcm2835 (i2c@7e805000) I2C adapter
# Символьные устройства в /dev
ls -la /dev/i2c-*
Номера адаптеров назначаются динамически при загрузке и могут меняться между перезагрузками. Полагаться на конкретный номер в скриптах, которые будут работать на разном железе, ненадёжно. Правильный подход, определять нужный адаптер по имени из вывода i2cdetect -l или через sysfs: /sys/class/i2c-dev/ содержит по одной директории на каждый адаптер с именем, физическим путём и другими атрибутами.
Как найти устройства на шине, не сломав их при этом
i2cdetect с номером шины сканирует I2C-адрес за адресом и выводит таблицу. Звучит просто, но за этим скрывается важная деталь: метод сканирования имеет значение, и выбор неправильного метода способен нарушить работу некоторых устройств.
По умолчанию i2cdetect использует SMBus Quick Write для части адресного пространства и Read Byte для другой. SMBus Quick Write посылает START, адрес, STOP без данных: это самая короткая возможная транзакция. Большинство устройств на неё не реагируют. Но отдельные чипы, например некоторые модели SMBus Power Management IC, интерпретируют Quick Write как команду и могут изменить своё состояние. Это не теоретический риск: именно поэтому i2cdetect выводит предупреждение "WARNING! This program can confuse your I2C bus" перед сканированием.
# Безопасное сканирование шины 1 (без подтверждения предупреждения)
i2cdetect -y 1
# Пример вывода: устройство найдено на адресе 0x53
# 0 1 2 3 4 5 6 7 8 9 a b c d e f
# 00: -- -- -- -- -- -- -- -- -- -- --
# 10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
# 50: -- -- -- 53 -- -- -- -- -- -- -- -- -- -- -- --
# Расширенное сканирование включая "unsafe" адреса 0x00-0x07 и 0x78-0x7f
# Флаг -a нужен если устройство сидит в зарезервированном диапазоне
i2cdetect -y -a 1
# Принудительно использовать Read Byte вместо Quick Write (флаг -r)
# Безопаснее для большинства обычных датчиков
i2cdetect -y -r 1
# Ограничить диапазон сканирования: только 0x40-0x4f
i2cdetect -y 1 0x40 0x4f
В выводе i2cdetect три вида ответа заслуживают внимания. Прочерк -- означает отсутствие устройства. Шестнадцатеричный адрес вроде 53 означает найденное устройство без kernel-драйвера. UU означает найденное устройство, которое уже управляется kernel-драйвером: ядро видит его, зарегистрировало клиент, и i2c-tools вежливо сообщает, что трогать его напрямую из userspace нужно с осторожностью, так как kernel-драйвер может в это время выполнять собственную транзакцию.
Именно значение UU стоит воспринимать как сигнал остановиться и подумать. Если устройство уже обслуживается драйвером, прямой доступ через i2c-tools без остановки драйвера способен нарушить атомарность его операций read-modify-write и привести к непредсказуемому поведению.
Когда устройство найдено, i2c-tools не может сказать, что это за чип. Для идентификации нужны схема платы, device tree или sysfs:
# Посмотреть устройства, зарегистрированные ядром на шине 1
ls /sys/bus/i2c/devices/
# Информация о конкретном устройстве на адресе 0x53 шины i2c-1
cat /sys/bus/i2c/devices/1-0053/name 2>/dev/null || echo "Kernel driver не назначен"
# Функциональность адаптера (что умеет данная шина)
i2cdetect -F 1
# I2C yes, SMBus Quick Command yes, SMBus Send Byte yes...
Чтение регистров через i2cget и i2cdump
Когда устройство найдено и идентифицировано, начинается работа с регистрами. i2cget читает один регистр, i2cdump снимает полную карту регистров устройства. На примере датчика температуры/влажности HTS221 с адресом 0x5f на шине i2c-1 весь workflow выглядит так:
# Читать регистр WHO_AM_I (0x0f) датчика: должен вернуть 0xbc
# Синтаксис: i2cget [-y] ШИНА АДРЕС РЕГИСТР [РЕЖИМ]
i2cget -y 1 0x5f 0x0f b
# 0xbc
# Режимы чтения:
# b - read byte data (по умолчанию)
# w - read word data (2 байта, little-endian)
# c - write byte / read byte (send then receive)
# i - I2C block read (до 32 байт)
# Читать регистр CTRL_REG1 (0x20) для проверки конфигурации
i2cget -y 1 0x5f 0x20
# Читать 16-битный результат температуры (регистры 0x2a-0x2b)
# Word read возвращает bytes в little-endian порядке
i2cget -y 1 0x5f 0x2a w
i2cdump снимает весь регистровый блок за один вызов, что незаменимо для первичной диагностики незнакомого чипа или проверки начального состояния после включения:
# Полный дамп всех регистров 0x00-0xff в байтовом режиме
i2cdump -y 1 0x5f b
# Дамп только диапазона регистров 0x00-0x3f (для HTS221 этого достаточно)
i2cdump -y -r 0x00-0x3f 1 0x5f b
# Пример вывода (реальные данные с датчика):
# 0 1 2 3 4 5 6 7 8 9 a b c d e f 0123456789abcdef
# 00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 bc ...............?
# 10: 3f 00 76 32 97 be 62 a2 9e b2 fc 00 d0 01 80 9a ?.v2??b????.????
# 20: 00 00 00 00 00 00 00 00 c3 d6 31 00 5b d7 61 00 ........??1.[?a.
# 30: 3b 89 a7 14 00 c4 f4 ff 99 03 be cd fd ff ea 02 ;???.??.?????.??
# Слово WHO_AM_I = 0xbc в позиции 0x0f -- устройство правильно идентифицировано
Правый столбец с ASCII-символами часто помогает при работе с EEPROM и конфигурационными чипами, которые хранят текстовые строки или заголовки. Большинство датчиков там выдают нечитаемый мусор, но это само по себе информативно: видно, какие регистры нулевые (зарезервированы), какие имеют ненулевые значения по умолчанию, и где расположены калибровочные данные.
Запись регистров через i2cset и верификация результата
i2cset записывает значение в регистр устройства. Флаг -r выполняет верификацию: после записи инструмент немедленно читает тот же регистр и сравнивает результат с написанным. Не все регистры допускают обратное чтение, и не все возвращают ровно то, что было записано (некоторые регистры команд самоочищаются), но там, где это работает, -r экономит отдельный вызов i2cget:
# Записать 0x3f в CTRL_REG1 (0x20) датчика HTS221
# Включить питание (PD=1), ODR=12.5Hz
i2cset -y 1 0x5f 0x20 0x3f
# То же самое с верификацией через обратное чтение
i2cset -r -y 1 0x5f 0x20 0x3f
# Value 0x3f written, readback matched
# Запись с маской: изменить только указанные биты, остальные сохранить
# -m 0x03 означает что меняются только биты 0 и 1
i2cset -y -m 0x03 1 0x5f 0x20 0x01
# Запись двух байт (word mode) в little-endian порядке
i2cset -y 1 0x5f 0x22 0x1234 w
# I2C block write: записать несколько байт подряд
i2cset -y 1 0x5f 0x20 0x3f 0x01 0x00 i
Флаг -m MASK особенно ценен при работе с регистрами управления, где каждый бит отвечает за отдельную функцию. Без маски приходится сначала читать регистр, модифицировать нужные биты в голове и записывать результат целиком: три шага вместо одного, и при этом между чтением и записью состояние устройства может успеть измениться.
i2ctransfer для сложных протоколов и 16-битной адресации
i2cget и i2cdump хорошо работают с устройствами, у которых регистровое пространство не превышает 256 адресов (один байт адреса). Как только речь заходит об EEPROM с 16-битной адресацией или о датчиках с нестандартным протоколом, эти инструменты упираются в свои ограничения. i2ctransfer закрывает этот пробел: он позволяет составить произвольную последовательность I2C-сообщений как единую транзакцию.
Синтаксис i2ctransfer на первый взгляд пугает, но логика простая: каждое сообщение описывается как {r|w}ДЛИНА[@АДРЕС], где r это чтение, w это запись. Несколько дескрипторов через пробел образуют composite transfer, который ядро выполнит как одну атомарную операцию с REPEATED START между сообщениями вместо STOP/START:
# Читать 1 байт из регистра с 16-битным адресом 0x010f
# Устройство VL53L1X на адресе 0x29, шина i2c-2
# Сначала пишем 2 байта адреса регистра, потом читаем 1 байт ответа
i2ctransfer -y 2 w2@0x29 0x01 0x0f r1
# 0xea
# Читать 3 последовательных регистра начиная с 0x010f
i2ctransfer -y 2 w2@0x29 0x01 0x0f r3
# Запись в EEPROM с 16-битной адресацией (24C256, адрес 0x50, шина 1)
# Записать байт 0xAB по адресу страницы 0x0100
i2ctransfer -y 1 w3@0x50 0x01 0x00 0xAB
# Верификация: прочитать обратно
# Установить адрес без чтения, затем отдельным сообщением прочитать
i2ctransfer -y 1 w2@0x50 0x01 0x00 r1
Есть существенный нюанс: i2ctransfer поддерживает только те операции, которые разрешает конкретный I2C-адаптер. i2cdetect -F покажет, что умеет адаптер. Если I2C_FUNC_I2C не выставлен, составные транзакции недоступны, и придётся работать только через SMBus-подмножество.
Userspace-доступ через /dev/i2c-N напрямую из C
Для серьёзных применений, где нужна скорость, обработка ошибок и интеграция в собственное приложение, работа через командные утилиты уступает место прямым системным вызовам через linux/i2c-dev.h. Принцип тот же: open(), ioctl() для установки адреса, затем read()/write() или SMBus-функции:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c.h>
#include <linux/i2c-dev.h>
#define I2C_BUS "/dev/i2c-1"
#define DEVICE_ADDR 0x5f /* HTS221 */
#define REG_WHO_AM_I 0x0f
#define REG_CTRL1 0x20
int i2c_open(const char *bus, int addr) {
int fd = open(bus, O_RDWR);
if (fd < 0) { perror("open"); return -1; }
/* I2C_SLAVE_FORCE если устройство занято kernel-драйвером */
if (ioctl(fd, I2C_SLAVE, addr) < 0) {
perror("ioctl I2C_SLAVE");
close(fd);
return -1;
}
return fd;
}
/* Чтение регистра через raw write+read (без SMBus) */
int i2c_read_reg(int fd, uint8_t reg, uint8_t *val) {
if (write(fd, ®, 1) != 1) return -1;
if (read(fd, val, 1) != 1) return -1;
return 0;
}
/* Чтение через SMBus byte data (предпочтительно если адаптер поддерживает) */
int i2c_smbus_read(int fd, uint8_t reg) {
return i2c_smbus_read_byte_data(fd, reg);
}
/* Composite transfer через I2C_RDWR: запись адреса + чтение за одну транзакцию */
int i2c_read_reg_rdwr(int fd, int slave_addr, uint8_t reg, uint8_t *val) {
struct i2c_msg msgs[2];
struct i2c_rdwr_ioctl_data data;
msgs[0].addr = slave_addr;
msgs[0].flags = 0; /* write */
msgs[0].len = 1;
msgs[0].buf = ®
msgs[1].addr = slave_addr;
msgs[1].flags = I2C_M_RD; /* read */
msgs[1].len = 1;
msgs[1].buf = val;
data.msgs = msgs;
data.nmsgs = 2;
return ioctl(fd, I2C_RDWR, &data);
}
int main(void) {
int fd = i2c_open(I2C_BUS, DEVICE_ADDR);
if (fd < 0) return 1;
uint8_t who_am_i = 0;
if (i2c_read_reg(fd, REG_WHO_AM_I, &who_am_i) < 0) {
fprintf(stderr, "Read failed\n");
close(fd);
return 1;
}
printf("WHO_AM_I = 0x%02x (ожидаем 0xbc)\n", who_am_i);
/* Включить датчик: CTRL_REG1 = 0x3f (PD=1, ODR=12.5Hz) */
uint8_t buf[2] = { REG_CTRL1, 0x3f };
if (write(fd, buf, 2) != 2) {
perror("write CTRL_REG1");
}
close(fd);
return 0;
}
# Компиляция
gcc -o i2c_example i2c_example.c
# Запуск
./i2c_example
# WHO_AM_I = 0xbc (ожидаем 0xbc)
Разница между i2c_read_reg через простые write/read и i2c_read_reg_rdwr через I2C_RDWR принципиальная. Первый вариант генерирует два отдельных I2C-сообщения с STOP между ними. Некоторые устройства требуют именно REPEATED START без STOP между записью адреса регистра и чтением данных, и для них работает только I2C_RDWR. Это одна из тех тонкостей, которую не видно в утилитах командной строки, но которая обнаруживается при работе с нестандартными чипами.
Тестирование без физического железа через i2c-stub
Разрабатывать и отлаживать код для I2C-устройств без самого устройства под рукой, что звучит как парадокс, на самом деле вполне реально. Модуль ядра i2c-stub создаёт виртуальный SMBus-адаптер с виртуальным устройством по заданному адресу. Все записи сохраняются в памяти, все чтения возвращают то, что было записано ранее:
# Загрузить стаб с виртуальным устройством на адресе 0x5f
modprobe i2c-stub chip_addr=0x5f
# Найти номер виртуальной шины
i2cdetect -l
# i2c-11 smbus SMBus stub driver SMBus adapter
# Записать ожидаемое значение WHO_AM_I (HTS221 возвращает 0xbc)
# 0x8f = 0x0f | 0x80 (бит 7 у HTS221 означает auto-increment адреса)
i2cset -r -y 11 0x5f 0x8f 0xbc
# Value 0xbc written, readback matched
# Теперь можно проверить, что наш код корректно читает WHO_AM_I
./i2c_example # но предварительно поменяв /dev/i2c-1 на /dev/i2c-11
# Снять дамп всего состояния стаба
i2cdump -y 11 0x5f
Это особенно ценно при разработке kernel-драйверов: можно написать driver probe-функцию, зарегистрировать устройство через sysfs и убедиться, что драйвер корректно обрабатывает каждый регистр, не имея физического чипа и не рискуя сжечь прототип неправильной командой записи.
Userspace-доступ к I2C в Linux устроен так, что порог входа намеренно низкий: четыре утилиты, и можно начинать работать с реальными устройствами. Но за этой простотой стоит та же инфраструктура, которой пользуются kernel-драйверы, со всеми вытекающими. Знать, что UU в выводе i2cdetect означает не просто "занято", а "в этот момент может выполняться транзакция ядра", знать, чем raw write/read отличается от I2C_RDWR, и уметь проверить функциональность адаптера перед тем как удивляться, почему составная транзакция не работает, всё это отделяет работающий инструмент от скрипта, который сломается на первом нестандартном устройстве.