Большинство статей про 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, &reg, 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   = &reg;

    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, и уметь проверить функциональность адаптера перед тем как удивляться, почему составная транзакция не работает, всё это отделяет работающий инструмент от скрипта, который сломается на первом нестандартном устройстве.