В разработке ПО для встраиваемых систем все большее значение приобретает возможность эффективной виртуализации оборудования, которая кардинально повышает скорость и гибкость разработки. Не нужно паять платы, ждать поставки железа или бегать между стендами с осциллографом для каждого чипа. Достаточно просто запустить виртуальную машину на ноутбуке. 

Виртуализация позволяет отлаживать драйверы и приложения в идеально воспроизводимых условиях, параллельно работать над разными фичами и начинать писать код еще до того, как готово физическое устройство. Особенно это актуально при разработке и тестировании embedded-решений, где часто требуется работа с периферией — например, I2C-устройствами: датчиками температуры, давления, влажности, EEPROM-памятью и другими компонентами.

Как правильно организовать взаимодействие с этими устройствами в виртуальной среде? Как и в случае с GPIO, для I2C в QEMU возникли похожие сложности: нужен способ максимально прозрачно организовать связь host-устройства с модулем периферии виртуальной модели. Обычно для этого разработчики используют протокол QMP или специальные скрипты, что неудобно и неинтуитивно. В идеале хочется использовать знакомые утилиты, такие как i2cget, i2cset, и библиотеки вроде libi2c.

Так в отделе разработки встраиваемого ПО в YADRO зародилась идея, которая получила реализацию через библиотеку libcuse. Она позволяет гостевой операционной системе напрямую взаимодействовать с виртуальными I2C-устройствами на виртуальной машине QEMU. Такой подход позволяет сохранить все преимущества виртуализации, но при этом получить прямой доступ к реальным данным с устройств.

CUSE: когда «устройство» живет вне ядра

CUSE — это расширение инфраструктуры FUSE для реализации драйверов символьных устройств прямо из пользовательского пространства. Вместо того чтобы писать код, который работает в привилегированном режиме ядра, разработчик может создать обычное приложение. Это приложение, используя библиотеку libfuse и модуль cuse в ядре, регистрирует новое символьное устройство (например, /dev/my_custom_device).

Как это применяется на практике? Рассмотрим работу виртуального I2C-контроллера в QEMU.

  1. Модуль remote-i2c-controller в гипервизоре QEMU эмулирует для гостевой ОС оборудование — I2C-контроллер.

  2. Внутри гостевой ОС драйвер этого виртуального контроллера использует CUSE для создания в хостовой системе символьного устройства (например, /dev/i2c-33). Теперь программы на хостовой машине (например, утилиты i2-ctools, такие как i2cget или i2cset) могут открывать этот файл устройства и взаимодействовать с ним.

  3. Все системные вызовы (open(), read(), write()) и команды управления ioctl(), которые отправляют хостовые утилиты, перенаправляются через механизм CUSE виртуальному I2C-контроллеру в QEMU.

  4. QEMU, в свою очередь, транслирует эти команды в обращения к виртуальной I2C-шине гостевой ОС.

Взаимодействие компонентов с CUSE
Взаимодействие компонентов с CUSE

Архитектура: как это работает «под капотом»

Исходная конфигурация включает виртуальную машину QEMU, виртуальный датчик с I2C-интерфейсом внутри нее, хост-устройство и набор утилит i2c-tools на хосте.

Работа начинается с добавления в QEMU нового виртуального устройства. В рамках объектной модели QEMU мы создаем устройство базового типа (TYPE_DEVICE). Это устройство будет выполнять роль прослойки, отвечая за весь функционал — от инициализации виртуального I2C-узла в гостевой системе до обработки последующих системных вызовов.

Схема работы remote-i2c-master
Схема работы remote-i2c-master

На схеме выше показана следующая архитектура.

  • Интерфейс CUSE используется для создания и управления виртуальным символьным устройством /dev/i2c-* в пользовательском пространстве.

  • Блок управления I2C-операциями отвечает за обработку различных типов команд SMBus и транзакций I2C.

  • Таймеры и механизмы bottom-half обеспечивают асинхронную обработку событий, что позволяет не блокировать работу системы на время выполнения операций.

Модуль открывает ряд ключевых возможностей для разработки:

  • тестировать свои драйверы для I2C-устройств без физического доступа к оборудованию,

  • создавать более точные модели виртуальных окружений с проработанной I2C-периферией,

  • экономить время и ресурсы при разработке embedded-приложений,

  • использовать существующие решения по взаимодействию с I2C-устройствами, такие как i2c-tools,

  • тестировать компоненты виртуальных машин QEMU и модули I2C-шин.

Пример использования модуля

Для использования нужно добавить модуль в параметры запуска QEMU:

./qemu-system-arm \
	-M virt \
	-cpu cortex-a53 \
	-nographic \
	-device tmp105,id=temp_sensor,address=0x40,bus=i2c0.0 \
	-device remote-i2c-controller,i2cbus=i2c0.0,devname=i2c-33 \
<...>

В качестве имени устройства указываем: remote-i2c-controller, затем I2C-шину, для которой создаем виртуальный узел и указываем его имя в devname (/dev/i2c-33).

После запуска в системе должно появиться специальное устройство, доступное через FUSE. Проверим, что устройство появилось:

ls /dev/i2c-33

Попробуем воспользоваться утилитами i2c-tools:

# Читаем температуру
i2cget -y 0 0x40 0x00
 
# Записываем значение в регистр
i2cset -y 0 0x40 0x01 0xAB

В YADRO мы работаем над множеством собственных продуктов, которые невозможно сделать лучше без embedded-разработчиков. Присоединяйтесь к нашей команде: 

Разработчик утилит для встраиваемых систем.
Инженер-программист FPGA (Middle).

Реализация

Инициализация FUSE-интерфейса

Наша следующая задача после создания нового устройства в структуре QEMU — интегрировать в него FUSE-сессию.

static int i2c_fuse_export(RemoteI2CControllerState *i2c, Error **errp)
{
	struct fuse_session *session = NULL;
	char fuse_opt_dummy[] = FUSE_OPT_DUMMY;
	char fuse_opt_fore[] = FUSE_OPT_FORE;
	char fuse_opt_debug[] = FUSE_OPT_DEBUG;
	char *fuse_argv[] = { fuse_opt_dummy, fuse_opt_fore, fuse_opt_debug };
	char dev_name[128];
	struct cuse_info ci = { 0 };
    char *curdir = get_current_dir_name();
	int ret;
 
	/* Set device name for CUSE dev info */
	sprintf(dev_name, "DEVNAME=%s", i2c->devname);
	const char *dev_info_argv[] = { dev_name };
 
	memset(&ci, 0, sizeof(ci));
	ci.dev_major = 0;
	ci.dev_minor = 0;
	ci.dev_info_argc = 1;
	ci.dev_info_argv = dev_info_argv;
	ci.flags = CUSE_UNRESTRICTED_IOCTL;
 
	int multithreaded;
	session = cuse_lowlevel_setup(ARRAY_SIZE(fuse_argv), fuse_argv, &ci,
      	                        &i2cdev_ops, &multithreaded, i2c);
	if (session == NULL) {
    	error_setg(errp, "cuse_lowlevel_setup() failed");
    	errno = EINVAL;
    	return -1;
	}
 
	/* FIXME: fuse_daemonize() calls chdir("/") */
	ret = chdir(curdir);
	if (ret == -1) {
    	error_setg(errp, "chdir() failed");
    	return -1;
	}
 
	i2c->ctx = iohandler_get_aio_context();
 
	aio_set_fd_handler(i2c->ctx, fuse_session_fd(session),
                   	read_from_fuse_export, NULL,
                   	NULL, NULL, i2c);
 
	i2c->fuse_session = session;
 
	trace_remote_i2c_master_fuse_export();
	return 0;
}

cuse_lowlevel_setup() создает FUSE-сессию, но не запускает цикл. Мы сами будем управлять событиями через aio_set_fd_handler().

Обработчики операций FUSE

Следующая задача после реализации FUSE-сессии — подготовить набор обработчиков, которые будут отвечать на стандартные операции, доступные через FUSE: ioctl(), open(), release() и другие.

static const struct cuse_lowlevel_ops i2cdev_ops = {
	.init    	= i2cdev_init,
	.open    	= i2cdev_open,
	.release 	= i2cdev_release,
	.read    	= i2cdev_read,
	.ioctl   	= i2cdev_ioctl,
	.poll    	= i2cdev_poll,
};

Основные обработчики:

  • open() — инициализирует контекст устройства, проверяет права доступа и выделяет ресурсы. Например, при открытии /dev/i2c-33 создается структура i2cdev_state, которая хранит состояние шины, адрес целевого устройства и ссылку на FUSE-сессию.

  • release() — освобождает ресурсы, сбрасывает внутреннее состояние (например, сбрасывает адрес I2C_SLAVE) и завершает активные операции.

  • ioctl() — системный вызов, который позволяет нам обрабатывать все стандартные I2C-команды:

    • I2C_FUNCS — возвращает поддерживаемые функции: все, что есть в i2c-dev).

    • I2C_SLAVE — устанавливает адрес целевого устройства: валидация от 0x00 до 0x7F.

    • I2C_SMBUS — обрабатывает все типы SMBus-операций: BYTE, BYTE_DATA, WORD_DATA, BLOCK_DATA, I2C_BLOCK.

В модуле за обработку системных вызовов отвечает функция, в которой через switch определяется тип ioctl и соответствующий обработчик:

static void i2cdev_ioctl(fuse_req_t req, int cmd, void *arg,
                      	struct fuse_file_info *fi, unsigned flags,
                      	const void *in_buf, size_t in_bufsz, size_t out_bufsz)
{
	RemoteI2CControllerState *s = fuse_req_userdata(req);
	<...>
 
	switch (ctl) {
	case I2C_SLAVE_FORCE:
    	fuse_reply_ioctl(req, 0, NULL, 0);
    	break;
	case I2C_FUNCS:
    	i2cdev_functional(s, req, arg, in_buf);
	break;
	case I2C_SLAVE:
    	i2cdev_address(s, req, arg, in_buf);
    	break;
	case I2C_SMBUS: {
    	i2cdev_cmd_smbus(s, req, arg, in_buf, in_bufsz, out_bufsz);
	}
	break;
	default:
    	fuse_reply_err(req, EINVAL);
	break;
	}
}

В обработчиках происходит анализ полученных данных и формируется структура ответа. Теперь при получении системного вызова будет выбран нужный обработчик, от которого хост получит ответ.

Адаптеры для работы с виртуальной I2C-шиной

На первый взгляд, все просто: гостевая ОС делает системный вызов и мы передаем данные в виртуальную I2C-шину. На практике все немного сложнее. Проблема в том, что формат данных, ожидаемый гостевой ОС, и формат, понятный реальной I2C-шине, не совпадают.

Linux использует struct i2c_smbus_ioctl_data:

union i2c_smbus_data {
	__u8 byte;
	__u16 word;
	__u8 block[I2C_SMBUS_BLOCK_MAX + 2]; /* block[0] is used for length */
               	/* and one more for user-space compatibility */
};

Виртуальная I2C-шина в QEMU для взаимодействия использует i2c_start_send() и i2c_send():

int i2c_send(I2CBus *bus, uint8_t data

Мы написали функции-адаптеры, которые преобразовывают полученные через ioctl данные в I2C-пакеты и отправляют их на виртуальную I2C шину и наоборот.

Основные адаптеры:

send_data_to_slave() — принимает struct i2c_smbus_ioctl_data, адрес и тип операции. Функционал адаптера:

  • извлекать команду (cmd) и данные (data),

  • формировать байтовый пакет: [cmd, data[0], data[1], ...],

  • вызывать i2c_smbus_write_*() с правильными параметрами,

  • обрабатывать ошибки — например, EIO, если устройство не отвечает.

static void send_data_to_slave(RemoteI2CControllerState *i2c,
                           	fuse_req_t req,
                           	const struct i2c_smbus_ioctl_data *in_val,
                           	const void *in_buf)
{
	union i2c_smbus_data data;
	uint8_t buf[64] = { 0 };
	size_t i = 0;
 
	/* Get SMBus data structure */
	<...>
	/* Parse data from SMBus struct */
	<...>
 
	/* Send data to I2C bus */
	i2c_start_send(i2c->i2c_bus, i2c->address);
	for (i = 0; i < buf[2]; i++) {
    	i2c_send(i2c->i2c_bus, buf[3 + i]);
	}
 
	i2c->address = 0x0;
	i2c->ioctl_state = I2C_IOCTL_FINISHED;
	fuse_reply_ioctl(req, 0, NULL, 0);
 
	trace_remote_i2c_master_i2cdev_send(in_val->size);
}

recv_data_from_slave() — принимает адрес, команду, буфер для ответа. Функционал адаптера:

  • вызвать i2c_smbus_read_*() с нужным типом,

  • копировать результат в data->byte, data->word или data->block,

  • возвращать длину прочитанных байт (или отрицательный код ошибки).

static void recv_data_from_slave(RemoteI2CControllerState *i2c,
                             	fuse_req_t req,
                             	const struct i2c_smbus_ioctl_data *in_val,
                             	const void *in_buf)
{
	union i2c_smbus_data *smbus_data = (union i2c_smbus_data *)(
    	in_buf + sizeof(struct i2c_smbus_ioctl_data)
	);
	uint8_t receive_byte = 0;
	size_t i = 0;
 
	/* Send command to slave */
	i2c_start_send(i2c->i2c_bus, i2c->address);
	i2c_send(i2c->i2c_bus, in_val->command);
	i2c_start_recv(i2c->i2c_bus, i2c->address);
 
	/* Receive data from slave */
	switch (in_val->size) {
	case I2C_SMBUS_BYTE_DATA:
    	smbus_data->byte = i2c_recv(i2c->i2c_bus);
	break;
	case I2C_SMBUS_WORD_DATA:
    	receive_byte = i2c_recv(i2c->i2c_bus);
    	smbus_data->word = ((uint16_t)receive_byte) & 0xFF;
    	receive_byte = i2c_recv(i2c->i2c_bus);
    	smbus_data->word |= (((uint16_t)receive_byte) << 8) & 0xFF00;
	break;
	case I2C_SMBUS_I2C_BLOCK_BROKEN:
	case I2C_SMBUS_BLOCK_DATA:
	case I2C_SMBUS_I2C_BLOCK_DATA:
	{
    	uint8_t len = smbus_data->block[0];
    	for (i = 0; i < len; i++) {
        	smbus_data->block[1 + i] = i2c_recv(i2c->i2c_bus);
    	}
	}
	break;
	}
 
	i2c->ioctl_state = I2C_IOCTL_FINISHED;
	fuse_reply_ioctl(req, 0, smbus_data, sizeof(union i2c_smbus_data *));
}

Асинхронность выполнения операций

Если гостевая ОС отправляет запрос на чтение с датчика, а в этот момент шина уже используется другим устройством (например, EEPROM), то операция не может быть выполнена немедленно, поэтому мы используем:

  • таймеры QEMU: remote_i2c_timer_cb(),

  • обработчики bottom-half: remote_i2c_bh(),

  • планирование операций через i2c_schedule_pending_master().

В QEMU есть облегченный механизм обратного вызова bottom-half для отложенного выполнения работы, которая должна обрабатываться асинхронно. Он не блокируют основной поток, но может быть вызван запланировано и потокобезопасным способом.

Исполнение i2c_schedule_pending_master() при условии, что никто не контролирует I2C-шину, поставит remote-i2c-master в очередь мастеров и вызовет обработчик remote_i2c_bh() для проведения нужных операций с шиной:

static void remote_i2c_bh(void *opaque)
{
	RemoteI2CControllerState *s = opaque;
 
	if (s->is_recv) {
    	recv_data_from_slave(s, s->req, s->in_val, s->in_buf);
	} else {
    	send_data_to_slave(s, s->req, s->in_val, s->in_buf);
	}
	i2c_end_transfer(s->i2c_bus);
	i2c_bus_release(s->i2c_bus);
 
	if (s->ioctl_state == I2C_IOCTL_FINISHED) {
    	s->ioctl_state = I2C_IOCTL_START;
    	s->last_ioctl = 0;
	}
}

Если шина занята, то будет запущен таймер, который проверяет состояние шины:

static void remote_i2c_timer_cb(void *opaque)
{
	RemoteI2CControllerState *s = opaque;
	s->is_recv = (s->ioctl_state == I2C_IOCTL_RECV);
	if (i2c_bus_busy(s->i2c_bus)) {
    	timer_mod(s->timer,
              	qemu_clock_get_ms(QEMU_CLOCK_VIRTUAL) + 5);
	} else {
    	i2c_bus_master(s->i2c_bus, s->bh);
    	i2c_schedule_pending_master(s->i2c_bus);
	}
}

Вызов из хоста в любом случае будет ждать ответа на отправленный ioctl-вызов.

Пример транзакций с I2C-устройством

Для примера попробуем прочитать температуру с датчика TMP105 по адресу 0x48 через привычные i2cget.

Общая схема транзакции с системным вызовом в remote-i2c-master:

После того как виртуальный узел был открыт, следует запрос на поддерживаемые функции I2C:

i2cget.c

if (ioctl(file, I2C_FUNCS, &funcs) < 0) { <...> }

Список поддерживаемых функций для I2C находится в i2c.h:

  • I2C_FUNC_I2C

  • I2C_FUNC_SMBUS_QUICK

  • I2C_FUNC_SMBUS_BYTE

  • I2C_FUNC_SMBUS_BYTE_DATA

  • I2C_FUNC_SMBUS_BLOCK_DATA

  • I2C_FUNC_SMBUS_WORD_DATA

  • I2C_FUNC_SMBUS_I2C_BLOCK

В ответе наш модуль заявляет о поддержке всех стандартных I2C-операций. Далее гостевая система отправляет ioctl-вызов для установки адреса целевого I2C-устройства:

i2cget.c

if (ioctl(file, I2C_SLAVE, address) < 0) { <...> }

Модуль remote-i2c-master принимает адрес, проверяет его корректность (валидация от 0 до 127) и сохраняет его во внутреннем состоянии устройства.

Если адрес установлен успешно, то система готова к работе с этим устройством, а все операции будут направлены на указанный адрес. Адрес сохраняется до следующего вызова I2C_SLAVE или до завершения сессии, поэтому можно использовать тот же адрес для нескольких операций без его повторной установки.

На этом этапе модуль готов принимать вызовы чтения или записи данных. Например, код для чтения байта данных будет выглядеть следующим образом:

i2cget.c

struct i2c_smbus_ioctl_data args;
 
args.read_write = I2C_SMBUS_READ;
args.command = 0; // SMBus commands
args.size = I2C_SMBUS_BYTE;
args.data = data;
 
ioctl(file, I2C_SMBUS, &args);

Гостевая система использует ioctl-вызов I2C_SMBUS, который может обрабатывать различные типы данных:

  • I2C_SMBUS_BYTE_DATA для передачи одного байта,

  • I2C_SMBUS_WORD_DATA для 16-битных значений,

  • I2C_SMBUS_BLOCK_DATA для блоков данных переменной длины.

Для определения типа операции (чтение или запись) используется флаг read_write.

Когда система хочет отправить данные, remote-i2c-master анализирует структуру i2c_smbus_ioctl_data, извлекает команду и данные, а затем преобразует их в формат, понятный для реальной I2C-шины.

При запросе данных из хоста происходит обратный процесс: система отправляет команду на устройство, а затем, когда виртуальная I2C-шина отправила данные, считывает ответ.

Ошибки в модуле обрабатываются через возврат кодов ошибок. Они передаются обратно в гостевую ОС, что позволяет ей корректно реагировать на проблемы с устройствами.

Как мы уже выяснили в главе с описанием реализации модуля, взаимодействие с виртуальной I2C-шиной происходит через асинхронную модель с использованием обработчиков bottom-half

Когда система запрашивает передачу или получение данных, remote-i2c-master проверяет состояние шины. Если шина занята, используется таймер remote_i2c_timer_cb() для повторной попытки через определенный интервал. После получения статуса мастера на шине вызывается bottom-half обработчик remote_i2c_bh(). В нем в зависимости от типа операции вызывается recv_data_from_slave() или send_data_to_slave(), которые завершают системный вызов и возвращают результат обратно в гостевую ОС.

Заключение

Разработка модуля remote-i2c-master в QEMU позволила нам достичь такого уровня интеграции с системой, что гостевая ОС не замечает разницы между реальным и виртуальным I2C-устройством.

Разработчики могут использовать привычные инструменты и методы для работы с виртуальными датчиками, EEPROM и прочим. Это открывает возможности для более удобного тестирования и отладки встраиваемых систем, особенно при работе с микроконтроллерами и датчиками.

Модуль универсален, его можно применять в разных виртуальных машинах и не изобретать обходные пути, чтобы работать с виртуальной I2C-шиной.

Полезные ссылки

Комментарии (0)