Новый год — новый порт. Портируем самый «крошечный» эмулятор RV32IMA в BetterC

В этой статье мы расскажем вам немного про порт самого «крошечного» в мире эмулятора архитектуры RV32IMA, оригинальный исходный код которого написан на C99. Расскажем, это конечно, сильно сказано, поскольку мы затронем лишь некоторые моменты, которые касаются портирования в BetterC и почему все-таки это не так просто, как описано во многих восторженных статьях.

Итак, мы портировали эмулятор архитектуры RV32IMA, но что это за архитектура-то такая?

RV32IMA — это одна из архитектур семейства (?) RISCV-V.

RISC-V — это расширяемая и свободная система команд, а также основанная на ней процессорная архитектура, созданная на базе применения концепции RISC (Reduced Instruction Set Computer — Сокращенный набор команд). Сам RISC-V неплохо описан в различных стандартах, но помимо базового набор команд, одинакового для всех реализующих его ядер, есть и набор документированных расширений. С учетом того, что архитектура и набор инструкций (ISA, Instruction Set Architecture) свободны, то их может делать кто-угодно без всяких отчислений и патентных преследований. Сама система команд, по мнению многих, проста и очевидна, а наличие разного рода расширений определенно делают RISC-V привлекательной и перспективной архитектурой на ближайшие годы.

Возвращаясь назад, несложно объяснить что скрывается под сокращением RV32IMA, префикс RV32 обозначает, что перед нами 32-битный RISC-V, буквы после префикса обозначают расширения базовой архитектуры. Буква I (Integer) обозначает, что архитектура поддерживает базовые операции целочисленной арифметики, буква M (Multiply) обозначает наличие аппаратного умножения и деления, а буква A (Atomic) соответствует наличию атомарных операций (т.е. операций, выполняющихся целиком, или работающих с блоками памяти).

Эмулятор RV32IMA за автором Чарльза Лора (Charles Lohr), который написан на чистом Си и имеет зависимости только от стандартной библиотеки C (за исключением POSIX и Windows специфичных блоков), мы и решили портировать еще в конце декабря 2022 года. Заинтересовал нас этот эмулятор тем, что его автор проделал просто фантастическую работу — он создал самостоятельно практически целую виртуальную машину (VM — Virtual Machine) размером всего 18кБ, внутри которой можно запустить вполне рабочий Linux и даже можно запустить Doom!

Посмотрите сами «промо-ролик» автора (осторожно, английский язык):

Одним словом, это невероятно!

Эмулятор от Чарльза представляет собой по сути дела один заголовочный файл .h, в котором описана структура MiniRV32IMAState, сохраняющая в себе состояние виртуального процессора с архитектурой RV32IMA и некоторую другую информацию (в частности, тайминги процессора). Также в этом заголовочном файле описана функция MiniRV32IMAStep, которая позволяет запустить один такт эмулятора, исполнив на процессоре некоторую программу по заданеному адресу. Но, сам заголовочный файл очень беден и не описывает того, что нужно для взаимодействия процессора с внешним миром, а именно набора операций I/O (Input/Output — ввод/вывод). Набор операций I/O для VM, а также базовый функционал для взаимодействия с командной строкой (эмулятор — консольный) реализуется уже в отдельном файле с исходным кодом на C, который использует .h файл как библиотеку.

Использование заголовочного файла как библиотеки — это стандартная практика языка C, также как и повсеместное использование в таких файлах макросов препроцессора.

Как вы думаете, что является наиболее неудобным при портировании C кода в D? Правильно, использование макроподстановок.

Обычно, раскрытие макросов — это не так сложно и тут обычно помогает либо интегрированная среда разработки, либо текстовый редактор, так как в обоих средствах есть инструментарий массовой замены. Но, порой такого функционала недостаточно, поскольку в некоторых случаях бывает использование макросов для расширения функционала и (внезапно) макрос можно переопределить. В случае эмулятора RV32IMA макросы используются для переопределения базовой функциональности и добавления новых обработчиков взаимодействия со статусными и контрольными регистрами CSR (CSR, Control and Status Registers), и такое использование приводит к циклическим зависимостям в раскрытии макросов и поэтому разделить реализации в .c и .h файле не получится.

И потому, наш порт эмулятора — это один файл с исходным кодом на BetterC. Также из-за некоторых ограничений в возможности тестирования сборки, наш порт работает только в POSIX-совместимых системах, т.е. разных дистрибутивах Linux (в Termux работать не будет, нужны дополнительные изменения исходного кода для адаптации под отсутствующие заголовки).

Для решения проблем с макросами и макроподстановками, мы воспользовались средствами компилятора gcc (кстати, если кто-то знает адекватный инструмент для развертывания макросов, то отпишитесь, пожалуйста, в комментариях — этим вы посодействуете как минимум портированию нескольких библиотек с C). При использовании флага компилятора -E произойдет раскрытие всех макросов (всех, без преувеличения) и получится большой файл, в котором для текущей архитектуры и операционной системы будут выполнены все замены.

Делается это командой:

gcc -E <исходный файл> | grep "^#" > <результирующий файл>

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

После того, как нужное вынесли из отработанного файла, обычно требуется автозамена для наименования типа и исправления преобразования типов в стиле C на преобразования с использованием cast. Автозамену по наименованиям типов можно не делать, если воспользоваться alias или просто подключить в импортах core.stdc.stdint:

alias int8_t   = byte;
alias uint8_t  = ubyte;
alias int16_t  = short;
alias uint16_t = ushort;
alias int32_t  = int;
alias uint32_t = uint;
alias int64_t  = long;
alias uint64_t = ulong;

От исправления выражений типа (uint16_t) var на cast(uint16_t) var это все равно не спасает, и в любом случае необходимо делать правки. К счастью, здесь помогает обычная массовая замена.

Первоначально, в нашей версии было сделано именно через псевдонимы, но потом, мы просто выполнили автоматическую замену типов из C на типы из D, про core.stdc.stdint мы узнали уже на моменте написания этой статьи и по чистой случайности.

В целом, дальнейшее портирование проблем не вызывало, однако, на ряде функций из C мы встретились с неожиданностью. Дело в том, что мы столкнулись с двумя функциями atexit и signal, которые (внезапно) могут принимать в качестве аргумента другие функции и подача в качестве аргумента функции, как это принято в D, не сработает.

Смотрите, есть функция ResetKeyboardInput и ее надо передать в функцию atexit, и вот такой код:

atexit(ResetKeyboardInput);

не сработает, поскольку функция из C и функция из D — это две большие разницы, а также потому что D думает, что идет передача не функции, а результата ее исполнения.

Мы долго думали, как это поправить, но ничего иного кроме как костыля с typeof не придумали:

extern (C) void function() func_0;

atexit(cast(typeof(func_0)) &ResetKeyboardInput);

Работает это примерно так: определяем указатель на функцию func_0 в стиле D, но с соглашением о вызове в стиле C (ключевое слово extern(C)), дальше берем адрес указателя на функцию (кстати, если его просто взять то получится void) и приводим его с помощью хака c typeof к нужному виду.

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

ldc2 -betterC -release -Os minirv32ima.d && strip -s minirv32ima

Запускается полученный эмулятор из командной строки, но запустив его без всяких аргументов, вы получите подсказку об использовании программы и толком ничего интересного не увидите. Именно поэтому, в репозитории нашей версии эмулятора RV32IMA мы выложили также файл с наименованием linux-5.18.0-rv32nommu-cnl-1.zip, который содержит в себе сырой образ минимального Linux с несколькими программами и подготовленной файловой системой. Архив следует распаковать в папку с эмулятором и после чего запустить сам эмулятор командой:

./minirv32ima -f linux-5.18.0-rv32nommu-cnl-1

После чего можно увидеть стандартный пуск Linux (который собран с помощью инструментария buildroot):

Кстати, имя пользователя — root, а пароля нет. Полученный эмулятор вместе с образом Linux мы протестировали на Raspberry Pi 4 и на ноутбуке от Asus — в обоих случаях размер эмулятора 20 кБ (мы честно без понятия почему размер тут больше чем у оригинала, хотя изменений почти никаких) и скорость работы примерно одинакова.

Что действительно удивляет, так это то, что тут реально запускается работоспособный Linux и под него можно реально что-нибудь написать и запустить, если скачать с git полностью весь репозиторий оригинального исходного кода и выполнить команду make everything. Данный шаг не является необходимым, но благодаря ему у вас будет полностью готовый компилятор gcc под RV32IMA, все исходники для сборки ядра Linux 5.19.0 и части пользовательского окружения, а также сам свежесобранный образ для эмулятора (ну и конечно сам эмулятор).

Напоследок, небольшой лайфхак от нашей команды…

Допустим, хотите вы скомпилировать собственное приложение на C под данный эмулятор и положить его в тот самый образ Linux. Тогда, после успешно отработавшей команды make everything для файла приложения запускаем следующую команду:

<путь до клонированного репозитория эмулятора>/buildroot/output/host/bin/riscv32-buildroot-linux-uclibc-gcc -O4 -funroll-loops -s -march=rv32ima -mabi=ilp32 -fPIC <исходный файл на C> -Wl,-elf2flt=-r -o <результирующий файл>

Полученный результат переносим в папку <путь до клонированного репозитория эмулятора>/buildroot/output/target/root, после чего запускаем опять make everything, из папки <путь до клонированного репозитория эмулятора>/buildroot/output/images переносим файл Image в папку эмулятора, и запускаем его как раньше.

И теперь просто можно запускать написанное приложение прямо из текущей папки в эмуляторе!

aquaratixc

Программист-самоучка и программист-любитель

Добавить комментарий