Зачем эмулировать то, что можно купить за копейки? Тратить месяцы на создание виртуальной модели микроконтроллера, если реальная отладочная плата на базе GD32F303 стоит как пара чашек кофе? Ответ прост: хороший физический стенд — это не просто плата. Это проектирование, сборка, место в стойке, электропитание, обжиг кабелей и поддержка в рабочем состоянии. А самое главное — его часто нельзя раздать каждому разработчику.
Но что, если можно запустить ту же прошивку, что и на реальном железе, в эмуляторе? Причем не в изолированной песочнице, а с возможностью полноценного взаимодействия с виртуальной периферией (GPIO, I2C, USB) через привычные Linux-инструменты: libgpiod, i2c-tools и minicom. Это открывает путь к раннему старту разработки, автоматизированному тестированию и отладке прошивок до появления даже первого прототипа.

В этой статье я представлю результат работы нашей команды из отдела разработки встраиваемого ПО в YADRO. Наиболее полную на сегодняшний день модель SoC GD32F30X в QEMU и набор интерфейсов (GPIODEV, remote-i2c-master, CDC-ACM Host), которые делают взаимодействие с эмулируемой периферией «прозрачным» для хостовой системы.
В рамках цикла статей и докладов на конференциях OS Dev Conf 2025 и System Level Meetup мы уже рассказывали о созданных нашей командой инструментах для «прозрачного» взаимодействия с периферией в QEMU:
→ GPIO — тоже интерфейс, а также QEMU GPIODEV и GUSE
→ QEMU: как организовать прозрачное взаимодействие с I2C-устройствами
В них мы добавили универсальный функционал для работы с GPIO и I2C через стандартные интерфейсы Linux. Но настоящая ценность таких решений раскрывается только в контексте конкретной системы.
Пришло время показать практический результат этой работы — полнофункциональную модель SoC GD32F30X в QEMU, где все эти технологии собраны воедино.
Многие знают, что линейки GD32 и STM32 во многом совместимы на уровне периферии. Сначала даже рассматривалась идея выбрать в качестве отправной точки наименования регистров и битов STM32, а затем расширить модели с учетом специфики GD32. В этом микроконтроллере не все регистры совпадают с точностью до битов, а в некоторых моделях GD32 регистров больше. Тем не менее в силу некоторых причин я отказался от этой идеи. И вот что в итоге получилось...
Общая архитектура
Цель — не просто эмулировать CPU и память, а создать полнофункциональную среду, где прошивка «видит» те же регистры и устройства, что и на реальном кристалле, а разработчик может управлять ими стандартными средствами Linux.
Ядро системы — модель SoC GD32F30X в QEMU:
Унаследовано и доработано из STM32: ADC (с DMA), USART (RX DMA), SPI (DMA), TIMER, EXTI.
Реализовано с нуля: GPIO, CRC, RTC, AFIO, I2C (полная поддержка Slave), FMC, RCU, USBD, CAN, FWDGT, DMA.
В работе: Ethernet (для F307), DAC, BKP.
Состояние на декабрь 2025: 99% периферии готово к использованию.
Мосты между виртуальным и реальным миром:
-
GPIODEV — унифицированный интерфейс для доступа к виртуальным GPIO. Он поддерживает три транспорта:
UNIX Socket — простые инструменты qemu-gpio-tools.
CUSE — модифицированный libgpiod, работает через /dev/cuse.
GUSE — экспериментальный модуль ядра, полная поддержка GPIO UAPI.
remote-i2c-master — предоставляет доступ к виртуальной I2C-шине гостя через CUSE. На хосте появляется /dev/i2c-N, с которым работают стандартные i2c-tools.
CDC-ACM Host — виртуальный USB-хост, который позволяет гостевой прошивке (в роли CDC-ACM гаджета) общаться с хостовым chardev (pty, socket, pipe).
Весь проект — это суперрепозиторий, включающий модифицированные QEMU, ядро Linux, libgpiod, libfuse и примеры прошивок: код на GitFlic.
Примечание
Поддержка pinmux реализована только в виде заглушек, и нет никакой проверки, куда выведен функционал периферии и выведен ли вообще, — как это, например, было сделано в qemu_stm32.
Zephyr RTOS
В качестве демонстрационной прошивки мы использовали не стандартные примеры из SDK GD32, а специально созданный минимальный проект zephyr-gpio-shell для RTOS Zephyr. Его история показательна: проект был полностью разработан и отлажен в QEMU и лишь затем перенесен на реальное железо — плату Olimex P405 с чипом GD32F303. Причем на реальной плате он заработал сразу, без каких-либо правок.
Этот проект представляет собой Zephyr с входящими в состав консолью (shell) и утилитой для управления GPIO (gpio_shell). Его задача — управление загрузкой другой платы через hwstrap-линии.
Интересно, что в основной ветке Zephyr на тот момент не оказалось готовой поддержки именно gd32f303x. Вероятно, из-за высокой степени совместимости с STM32F3, для которой поддержка уже была. Мы просто добавили недостающие описания устройств в zephyr/ и hal_gigadevice/, доказав тем самым, что наша модель корректно реализует периферию, ожидаемую реальным HAL.
Именно этот zephyr-gpio-shell мы и будем использовать в примерах ниже.
Примеры использования
Быстрый метод загрузки программы в эмулируемый микроконтроллер
Как и для большинства машин в QEMU, доступен общепринятый метод загрузки программы через параметр -kernel. В нашем случае он будет загружен по адресу 0x0 — то есть напрямую в флеш-память:
qemu-gd32-suite $ build-qemu/qemu-system-arm -M olimexino-gd32 -kernel ./build/zephyr-gpio-shell/zephyr/zephyr.bin -serial none -serial mon:stdio -nographic
*** Booting Zephyr OS build v4.1.0-3837-g48a491bb0578 ***
uart:~$ gpio devices
Device Other names
gpio@40010c00 gpiob
uart:~$
Здесь и далее используем -serial none -serial mon:stdio, так как консоль выведена на usart1:
chosen {
zephyr,console = &usart1;
zephyr,shell-uart = &usart1;
};
usart0 у этой платы выведен на «гребенку» (штыревой разъем) и представляет собой TTL, а usart1 — на разъем DB-9 через драйвер ST3232 и является полноценным RS-232.
Поэтому мы первой командой -serial none говорим QEMU, что usart0 мы ни к чему не подключаем, а командой -serial mon:stdio, что usart1 мы подключаем к stdio, где также будет командная строка QEMU. В QEMU исторически нет возможности обратиться к конкретному serial, поэтому нужно перечислить каждый, начиная с самого первого в системе.
Загрузка через файл-образ флеша
Этот подход требует дополнительных шагов, зато позволяет проверить образ, идентичный тому, что будет записываться в память реальной платы:
$ dd if=/dev/zero count=256 bs=1024 | tr '\0' '\377' > flash.bin
$ dd if=./build/zephyr-gpio-shell/zephyr/zephyr.bin of=flash.bin conv=notrunc
$ qemu-system-arm -M olimexino-gd32 -drive format=raw,if=pflash,id=mtd0,file=flash.bin -serial none -serial mon:stdio -nographic
Здесь просто вместо -kernel мы передаем файл в FMC-модель: действует тот же принцип что и с serial, но флешка для FMC у нас одна.
Загрузка через gdb
Это уже общий для всех машин метод, но работает и тут:
$ build-qemu/qemu-system-arm -M olimexino-gd32 -serial none -serial mon:stdio -nographic -s -S
Мы запускаем машину без прошивки с дополнительными ключами -s -S, где -s это сокращение для -gdb tcp::1234, а -S говорит QEMU, чтобы машина была на паузе после старта.
Тогда можно просто загрузить программу через gdb:
$ arm-none-eabi-gdb -ex 'target remote localhost:1234' ./build/zephyr-gpio-shell/zephyr/zephyr.elf
(gdb) load
(gdb) continue
Так же можно подключится с gdb к QEMU уже в процессе работы.
Загрузка через loader
Еще один общий метод, который позволяет напрямую загрузить elf-файл, и он сам увидит адреса и куда указывает entry-point:
$ build-qemu/qemu-system-arm -M olimexino-gd32 -serial none -serial mon:stdio -nographic -device loader,file=./build/zephyr-gpio-shell/zephyr/zephyr.elf
Можно также загрузить бинарный файл по определенному адресу, но доступность адреса придется проверять вручную:
$ build-qemu/qemu-system-arm -M olimexino-gd32 -serial none -serial mon:stdio -nographic -device loader,file=./build/zephyr-gpio-shell/zephyr/zephyr.elf
Если начало программы не совпадает с 0x0, то дополнительно можно выставить PC:
$ ... -device loader,addr=0x8000,cpu-num=0 ...
Краткое описание реализованной периферии
gpiodev
Сам gpiodev и его варианты подробно описаны в GPIO — тоже интерфейс, а также QEMU GPIODEV и GUSE, так что просто приведу список доступных портов: gpioA, gpioB, gpioC, gpioD, gpioE, gpioF, gpioG.
Их имена совпадают с документацией, и именно такое названия надо передавать в gpiodev:
$ ... -gpiodev <backend-name>,id=gpioA,...
$ ... -gpiodev <backend-name>,id=gpioB,...
...
Например, при использование UNIX Socket и модифицированных qemu-gpio-tools, для всех доступных на SoC портов:
./build-qemu/qemu-system-arm -machine olimexino-gd32 \
-kernel ./build/zephyr-gpio-shell/zephyr/zephyr.bin \
-gpiodev chardev,id=gpioA,chardev=gd32-gpioA -chardev socket,path=/tmp/gd32-gpioA,id=gd32-gpioA,server=on,wait=off \
-gpiodev chardev,id=gpioB,chardev=gd32-gpioB -chardev socket,path=/tmp/gd32-gpioB,id=gd32-gpioB,server=on,wait=off \
-gpiodev chardev,id=gpioC,chardev=gd32-gpioC -chardev socket,path=/tmp/gd32-gpioC,id=gd32-gpioC,server=on,wait=off \
-gpiodev chardev,id=gpioD,chardev=gd32-gpioD -chardev socket,path=/tmp/gd32-gpioD,id=gd32-gpioD,server=on,wait=off \
-gpiodev chardev,id=gpioE,chardev=gd32-gpioE -chardev socket,path=/tmp/gd32-gpioE,id=gd32-gpioE,server=on,wait=off \
-gpiodev chardev,id=gpioF,chardev=gd32-gpioF -chardev socket,path=/tmp/gd32-gpioF,id=gd32-gpioF,server=on,wait=off \
-gpiodev chardev,id=gpioG,chardev=gd32-gpioG -chardev socket,path=/tmp/gd32-gpioG,id=gd32-gpioG,server=on,wait=off \
-serial none -serial mon:stdio
В ветке с GD32 уже есть все транспорты из упомянутой выше статьи, а также возможность чтения записи через QMP.
remote-i2c-master
Для I2C реализована полноценная slave-функциональность с поддержкой удаленного доступа через механизм remote-i2c-master. Это решение, разработанное Ильей Чичковым, позволяет организовать прозрачное взаимодействие с виртуальной I2C-шиной из хостовой системы с использованием стандартных утилит i2c-tools.
Через CUSE-интерфейс создается виртуальное устройство /dev/i2c-* на хосте, что позволяет работать с эмулируемыми I2C-устройствами так же, как с реальными: использовать знакомые i2cget, i2cset и другие инструменты и библиотеки без необходимости модификации кода гостевой системы.
Slave-функциональность должна поддерживаться загруженной прошивкой:
$ ./build-qemu/qemu-system-arm -machine olimexino-gd32 \
-kernel ./build/zephyr-gpio-shell/zephyr/zephyr.bin \
-device remote-i2c-controller,i2cbus=i2c0.0,devname=i2c-33 \
-device remote-i2c-controller,i2cbus=i2c1.0,devname=i2c-34 \
-serial none -serial mon:stdio
CDC ACM Host Module
В модели GD32 реализована поддержка виртуального USB-хоста для случаев, когда прошивка выступает в роли устройства CDC-ACM. CDC-ACM Host — это мост между виртуальным USB-устройством в гостевой системе и символьными устройствами на хосте. Фактически мы эмулируем самый распространенный класс USB-последовательных адаптеров, что позволяет прозрачно связывать виртуальную машину с реальными портами или виртуальными терминалами.
Модуль выступает в роли USB-хоста, обрабатывая пакеты данных и преобразуя их в потоковый ввод-вывод через QEMU chardev. Ключевое преимущество подхода в том, что GD32 с загруженной прошивкой выступает в качестве гаджета CDC-ACM, подключенного к виртуальному USB-хосту в QEMU, при этом не требуя реализации USB-стека на стороне хоста. Это открывает привычные сценарии работы: от последовательных консолей до эмуляции модемов и других коммуникационных устройств.
В нашем случае его использование будет выглядеть вот так:
$ ./build-qemu/qemu-system-arm -machine olimexino-gd32 \
-kernel ./build/zephyr-gpio-shell/zephyr/zephyr.bin \
-usb -chardev pty,id=acm -device cdc-acm-host,chardev=acm \
-serial none -serial mon:stdio
Эту функциональность должна поддерживать прошивка, как это происходит на реальном устройстве.
Описание и технические подробности этого модуля пока не выкладывались, но я уверен, что мы найдем время для публикации.
ADC
Модель АЦП была реализована в минимально необходимой конфигурации: с поддержкой установки значений каналов через QMP-интерфейс. Хотя такой подход не охватывает всех аспектов работы реального АЦП, таких как эмуляция времени преобразования или шум, он полностью покрывает потребности базового тестирования.
Пример скрипта, который можно использовать для выставления значений каналов:
{ 'execute': 'qmp_capabilities' }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[0]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[0]" } }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[1]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[1]" } }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[2]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[2]" } }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[3]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[3]" } }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[4]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[4]" } }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[5]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[5]" } }
{ "execute": "qom-set", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[6]", "value": 1500 } }
{ "execute": "qom-get", "arguments": { "path": "/machine/unattached/device[0]/adc[0]/", "property": "input[6]" } }
Его можно загрузить через QMP:
socat UNIX:qmp.sock - < scripts/setup_adc_sensors
Отмечу, что задача организации подачи данных в виртуальный АЦП оказывается значительно сложнее, чем может показаться на первый взгляд. Здесь возникают вопросы синхронизации с виртуальным временем, обеспечения целостности данных при асинхронном обмене и корректного взаимодействия с регистровым интерфейсом. В перспективе интересно было бы рассмотреть более сложные сценарии — как предварительную загрузку данных с временными метками, так и динамическую корректировку параметров работы.
В текущей реализации ручного управления через QMP достаточно для решения большинства практических задач, однако эта тема остается перспективной для дальнейшего развития.
Заключение
Проделанная работа — это не просто академическое упражнение. Это практический инструмент, который уже используется внутри YADRO для разработки и тестирования встраиваемого ПО.
Что он дает разработчику:
Ранний старт: начинать разработку можно параллельно с проектированием железа или даже до его начала.
Скорость итераций: перезапуск теста в QEMU занимает секунды, а не минуты на перепрошивку.
Масштабируемость: можно запустить десятки виртуальных устройств в CI/CD для покрытия тестами.
Отладка: доступны все мощные инструменты QEMU — gdb, трассировка, проверка памяти и даже неинвазивный coverage.
Совместимость: тот же код, те же инструменты (libgpiod, i2c-tools), что и на реальном железе.
Наши следующие шаги — доведение до ума модуля Ethernet для GD32F307, создание транспорта GPIODEV для работы с реальными GPIO хоста и, конечно, привлечение сообщества к развитию проекта.
Полезные ссылки
Суперрепозиторий проекта — весь код для повторения.
-
Цикл статей:
-
Презентации с конференций:
«Вечный IOCTL: вызовы, требующие помощи ядра» (System Level Meetup, ноябрь 2025)
«QEMU: новые горизонты взаимодействия с гостевой системой» (OS Dev Conf, декабрь 2025)
Отдельное спасибо Илье Чичкову за реализацию половины моделей GD32 и фреймворка для тестирования, Павлу Бурову и Тимуру Максимову за ревью и ценные замечания, а также всем коллегам из YADRO, кто поддерживал этот проект.
Комментарии (8)

hardegor
19.12.2025 13:18"место в стойке, электропитание, обжиг кабелей" - только мне кажется это всё-таки из другой "оперы"? Или это галюники нейросети)

maquefel Автор
19.12.2025 13:18Это вас "обжиг кабелей" так возбудил или не верите, что платы занимают место в стойке и, о ужас, потребляют электричество ?
А если серьёзно, только пара человек заметила эту ошибку, она веселая и пусть будет как ОДПВ.

LinkToOS
19.12.2025 13:18Это когда отладочная плата присоединяется к GPIO-Ethernet и usb-over-ip, и разработчик дистанционно заливает прошивку и читает пины. Если нужен контроль аналоговых сигналов, то все еще сложнее.

hardegor
19.12.2025 13:18Пробовал-пробовал так работать в ковид, бррр... Ужас-ужас. Нужен человек на месте с камерой\смарфоном, который может потыкать в плате кнопочки и что-то понимающий в теме. А если, не дай бог, осциллографом нужно ткнуть, то еще нужна стационарная камера или/или осциллограф с езернет(но оно все равно подтормаживает) то всё, туши свет - камера всегда с некоторой задержкой показывает одно, осциллограф с некоторой задержкой другое, я вижу что сигнал пошёл, но камера тормозит, ещё и человек там пытается настроить осциллографа, но одновременно он загораживает вид, и тут интернет лагнул... Это можно медленно и печально, с расстановкой, поработать день, максимум два....
LinkToOS
Модель создавали стандартными средствами, или сначала создали среду разработки (или набор утилит для упрощения разработки)? Иными словами, были созданы инструменты, которые облегчат и ускорят разработку модели другого микроконтроллера, если это понадобится?
В таких историях, процесс разработки (методы, инструменты, и проблемы) не менее интересен чем результат.
maquefel Автор
Нет, к сожалению, никаких специальных инструментов, только расширение функционала QEMU.
Если порассуждать то в первую очередь пригодилась бы конвертация таблиц из pdf файлов в условно любой текстовый формат, чтобы генерировать код с описанием регистров.
Так же, в нашем случае уже был обширный набор реальных и тестировочных программ, но в случае разработки модели для нового SoC, особенно если у него плохая документация, надо начинать с набора тестов для проверки соответствия поведения моделей с реальным поведением устройств.