Виртуальная машина Bismuth — это современный экспериментальный проект, который предлагает свежий взгляд на разработку и исполнение программ. Создавая свой собственный язык промежуточного представления и удобные инструменты разработки, авторы предлагают путь к развитию более надёжной, гибкой и безопасной среды исполнения. Для того чтобы понять, как работает Bismuth и как программировать в этой системе, рассмотрим классическую программу Hello World — идеальный пример, чтобы проследить весь жизненный цикл программы от начального кода до запуска в виртуальной машине. Исходный код пишется на языке Bronze — легковесном, удобочитаемом языке, ставящем своей целью максимально близко отображать промежуточное представление Bismuth с приятным синтаксисом, напоминающим C. В приведённом примере создаётся глобальная переменная hello, которая содержит UTF-8 строку «Hello world! ».
Это строка, которая будет выведена при запуске. Функция main — основной вход в программу. Все функции в Bismuth возвращают 32-битное целочисленное значение, или, как альтернатива, указатель. В примере main возвращает 0, что считается успешным завершением. Для вывода строки используется системный вызов с кодом 0x10 — PrintStr.
Системные вызовы в Bismuth — это нечто вроде встроенной стандартной библиотеки, они предоставляют доступ к функциям, которые изолированная виртуальная машина сама по себе не способна выполнить напрямую: от выделения и обнуления памяти до формирования строк и их вывода. Внутренне компилятор преобразует написанный код на Bronze в промежуточное представление (IR), которое по сути является текстовым описанием структуры программы с элементами, похожими на s-выражения. Это IR содержит декларации глобальных переменных, данные для инициализации и определения функций с вызовами системных функций и инструкциями возврата. Далее начинается волшебство трансляции. Промежуточный язык может быть преобразован двумя основными способами.
Первый — это транслирование в С. Такой подход обладает преимуществом максимально широкой портируемости: сгенерированный С-код можно скомпилировать под любые платформы, включая встроенные устройства или даже веб-окружение через WebAssembly. Кроме того, компиляция С-кода с помощью оптимизирующих компиляторов, вроде GCC или Clang, позволяет добиться высокой производительности. Важно убедиться, что функции запуска и системные вызовы включены в итоговый бинарный файл, чтобы программа могла полноценно работать без необходимости виртуальной машины. Сгенерированный С-код сам по себе представляет определённую сложность, так как исходная модель Bismuth требует строгого порядка вычислений, а С - язык, который не гарантирует порядок вычислений в аргументах функций.
Транспилятор решает эту проблему, выделяя промежуточные переменные и выполняя отдельные вычисления вне мест вызовов функций, чтобы избежать непредсказуемого порядка выполнения. Для обработки исключений и ошибок в коде используют специальные структуры данных EXCEPTION и CONTEXT, позволяющие реализовать блоки try/catch/finally, чего в обычном С нет. Благодаря этому весь транслированный код получает дополнительный уровень защиты и надёжности. Глобальные переменные в C-транспиляции — это особые идентификаторы, которые в рантайме не хранят сами данные, а выступают в роли индексов в структуре контекста программы. Для их корректного инициализирования используется модульный инициализатор, который выделяет память под данные, регистрирует их и копирует значения с помощью стандартных функций, таких как memcpy.
Такая организация позволяет изолировать каждую программу в собственной области памяти, что делает систему более надёжной и предотвращает случайные повреждения данных. Второй способ обработки IR — это компиляция в бинарный формат промежуточного языка. Этот формат более компактный и удобный для быстрого чтения и обработки виртуальной машиной. Среди важных элементов находятся секции данных, сигнатур функций и собственно кода программы. В секции данных хранится инициализационная информация о глобальных переменных, выровненная по 32-битным словам для удобства интерпретации.
Секции сигнатур и функций позволяют идентифицировать типы и параметры функций, что создаёт основу для возможной реализации вызовов через указатели на функции и их типобезопасности. Промежуточный бинарный формат можно считать более машиночитаемым представлением программы, что значительно упрощает для интерпретатора задачу трансформации абстрактного синтаксического дерева во внутренние байт-коды. При этом используется собственный формат байт-кода, где команды снабжены небольшим количеством общих регистров и параметров. Важно отметить, что системные вызовы в байт-коде реализованы более лёгким способом по сравнению с обычными функциями: им не нужны полные стековые кадры, благодаря чему выполнение системных вызовов более эффективно и не создаёт лишних накладных расходов. Запуск программы начинается с инициализации глобального контекста, который присваивает уникальные индексы всем глобальным переменным.
Затем инициализируется контекст конкретной программы, в котором выделяется память и копируются данные глобальных переменных. После этого вызывается интерпретатор, исполняющий байт-код, сформированный из бинарного IR, начиная со стартовой точки, выполняя первичный системный вызов, затем вызывая функцию main и завершаясь вызовом выхода. На выходе мы видим ожидаемый текст Hello world! — подтверждение того, что весь цикл работы от кода до вывода завершён успешно. Такая многоступенчатая архитектура позволяет создавать расширяемые и высоконадёжные программы, защищённые от распространённых ошибок управления памятью и небезопасного доступа к данным. Безусловно, подход Bismuth уникален и предлагает широкий простор для развития.