Search
Write a publication
Pull to refresh

AsmX G3: От высокоуровневого ассемблера к нативному коду. Разбираем компилятор ZGEN

Level of difficultyHard
Reading time14 min
Views2.2K

Введение

AsmX G3 — это наш ответ на избыточную сложность современного системного программирования. Это язык ассемблерного уровня, созданный для тех, кто требует полного контроля над железом, но не хочет жертвовать читаемостью и современными синтаксическими удобствами. Мы взяли мощь ассемблера и обернули её в синтаксис, который не вызывает желания выстрелить себе в ногу.

Долгое время AsmX полагался на JIT-компиляцию — быстро, удобно, но не всегда достаточно для задач, где каждый такт процессора на счету. Для создания по-настояшему производительных, нативных приложений мы разработали ZGEN (Zero-latency Code Generation). Это наш новый AOT (Ahead-Of-Time) компилятор, который преобразует код AsmX G3 напрямую в исполняемые файлы формата ELF64 для архитектуры x86_64. Никаких виртуальных машин, никакого оверхеда. Только чистый металл.

В этой статье мы заглянем под капот ZGEN. Я покажу, как он работает, из каких компонентов состоит, и как мы решаем фундаментальные задачи — от парсинга нашего собственного диалекта AT&T до финальной компоновки исполняемого файла с помощью встроенного линкера. Это не просто рассказ о компиляторе. Это демонстрация того, как мы строим инструменты будущего. Добро пожаловать в будущее ассемблера.

Режимы компиляции: Два пути к одной цели

Мой компилятор AsmX G3 предлагает два кардинально разных режима: JITC (Just-In-Time Compilation) и ZCC/ZGEN. JITC был стандартом до G3 — быстрый, интерпретируемый подход.

Как работает JITC:

asmx main.asmx

Это просто. Быстро. Идеально для скриптов и прототипирования.

Но настоящая мощь G3 раскрывается в режиме ZCC/ZGEN. Он генерирует нативный исполняемый файл ELF64 для архитектуры x86_64. Чтобы его активировать, нужен флаг --release и, что обязательно, --march, который указывает на целевой бэкенд. Сейчас мы сфокусированы на x86_64 под Linux, но это только начало.

asmx main --release --march x86_64 -o index

Вы можете задать имя выходного файла с помощью флага -o или --objname. По умолчанию, как и завещали предки, будет a.out.

Важный момент: JITC понимает синтаксис Intel, в то время как ZCC/ZGEN работает с нашим собственным, улучшенным диалектом AT&T. Да, мы взяли за основу классику и исправили её недостатки.

О поддержке x86 (32-bit)

Сразу проясню: мы не планируем создавать отдельный бэкенд для x86 (32-bit). Эта архитектура — наследие прошлого. Наш фокус — на современных 64-битных системах, где производительность и возможности на порядок выше. Мы строим инструменты для будущего, а не для поддержки легаси. Однако это не значит, что дверь закрыта навсегда. В будущем не исключена поддержка 32-битной версии, сгенерированной из x86_64 бэкенда. Время и события неочевидны.

Версионирование и Ревизии: Физические обновления в коде

С AsmX G3 мы вводим новую систему версионирования. Теперь у нас есть не только версии, но и ревизии. Формат выглядит так: v28.0.0-rev-1.0.

  • Версия (v28.0.0): Отражает изменения в ядре компилятора — в парсере, ядре kerneltapi. Это стандартное семантическое версионирование для базовой инфраструктуры.

  • Ревизия (rev-1.0): Это самое интересное. Ревизия, следуя принципу major.patch, обозначает обновление набора инструкций x86_64, которые ZGEN компилятор способен понимать и компилировать.

Это как физические обновления, но в компиляторе.

Думайте об этом как об апгрейде кремния для вашего компилятора.

  • Major-версия ревизии (1.0) — это целевая версия, обозначающая крупное расширение набора инструкций. Например, rev-1.0 включает базовый набор команд. Добавление поддержки SSE или AVX будет означать переход к rev-2.0, так как это фундаментальное расширение возможностей.

  • Patch-версия ревизии (1.1) будет означать исправление ошибок или незначительные уточнения в уже существующем наборе инструкций x86_64 ревизии 1.0.

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

Компоненты: Строительные блоки

  • kernel: Мозг всей операции. В G2 это было 83 строки кода. В G3 — 500 строк. Объём вырос на 502.41%, и это не просто код, это интеллект.

  • llvm.js: Наш фронтенд-партнер. Вся грязная работа с лексемами, токенами и базовыми выражениями — на нём.

  • tapi: Интерфейс командной строки. Парсит ваши команды и превращает их в понятный для ядра формат JSONC.

  • parser: Рекурсивный нисходящий (RD) парсер. Он берет поток токенов и строит из них осмысленную структуру — AST (Abstract Syntax Tree).

  • compiler_driver: Диспетчер компиляции для бэкендов (targets/x86_64). Он дирижирует всем оркестром, от парсинга (разбор типа инструкций) до генерации кода.

  • hwm: Hardware Machine. Наша база данных инструкций x86_64. Он знает, как превратить ассемблерную мнемонику в байты.

  • hwc: Hardware Compiler. Финальный сборщик. Он берет закодированные инструкции, данные, добавляет заголовки, секции, проводит линковку и выдает готовый к запуску файл ELF64.

  • assembly: Специализированный сборщик для таких вещей, как генерация пролога и эпилога функции main.

Kernel: Центр управления полетами

Ядро — это не просто точка входа. Это мозг всей операции, который запускает и контролирует каждый последующий процесс. В G2 это был простой скрипт на 83 строки. В G3 ядро разрослось до 500 строк кода — это рост на 502.41%. Этот рост отражает не раздувание, а увеличение интеллекта системы.

Что делает ядро:

  1. Запускает tapi: Первым делом ядро передает управление нашему парсеру аргументов командной строки. Оно должно понять, чего вы хотите: запустить JITC, скомпилировать релизный бинарник, или просто спросить версию.

  2. Проверяет окружение: Ядро проводит серию проверок: существует ли необходимый бэкенд для выбранной архитектуры (--march x86_64), корректны ли флаги, не противоречат ли они друг другу.

  3. Выбирает и запускает компилятор: На основе вашего запроса ядро принимает стратегическое решение. Если вы работаете в режиме JITC, запускается один конвейер. Если вы компилируете релиз (--release), ядро инициирует совершенно другой, более сложный процесс с участием ZCC/ZGEN. Это не просто if-else, это выбор целой производственной линии.

  4. Управляет флагами: Ядро передает все необходимые параметры (имя выходного файла, архитектуру, флаги отладки) в выбранный компилятор, настраивая его для выполнения конкретной задачи.

Ядро — это фундамент, на котором строится все остальное. Его надежность и логика определяют успех всей компиляции.

Парсер: Фундаментальное разделение для максимальной эффективности

С появлением ZCC/ZGEN мы столкнулись с проблемой. Наш старый рекурсивный парсер генерировал единое AST, оптимизированное исключительно для JITC. Оно содержало специфичные структуры, например, объекты MEAN (Mini Extend At Node), которые были удобны для быстрой интерпретации, но абсолютно непригодны для бэкенда, генерирующего машинный код. Пытаться "научить" ZGEN понимать это унаследованное AST было бы путем к провалу — это костыли, которые замедляют и усложняют систему.

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

  • jcc_parser: Генерирует AST для JITC. Быстрое, легковесное, идеально для интерпретации.

  • zcc_parser: Генерирует AST для ZCC/ZGEN. Структурированное, строгое, содержащее всю необходимую информацию для генерации нативного кода под конкретную архитектуру.

Это решение позволяет каждому компилятору получать данные в идеально подходящем для него формате. Это чистая инженерия. Если завтра мы решим добавить поддержку RISC-V или ARM, мы просто создадим riscv_parser или новое ответвление в zcc_parser, не затрагивая и не ломая существующие бэкенды. Это основа для масштабирования.

hwm: Фабрика машинного кода

hwm (Hardware Machine) — это наш конвейер по производству бинарного кода. Он работает в паре с компонентом tbl, который является, по сути, библиотекой чертежей для каждой инструкции x86_64.

  • tbl: Хранит все возможные варианты инструкций: форматы операндов, их размеры, порядок, опкоды. Это наша энциклопедия процессорной архитектуры.

  • hwm: Это производственная линия. Он получает на вход ассемблерную мнемонику и набор операндов. Далее он:

    1. Находит в tbl соответствующий "чертеж".

    2. Анализирует операнды, чтобы определить, нужны ли специальные префиксы, такие как REX (для 64-битных операций или расширенных регистров).

    3. Вычисляет и формирует байты ModR/M и SIB (Scale-Index-Base) для адресации памяти.

    4. Собирает все это в единый буфер — последовательность байт, которую процессор сможет выполнить.

hwm не занимается линковкой или форматом файла. Его задача одна — безупречно и эффективно переводить ассемблерные команды в машинный код.

HWC: Сборочный конвейер

hwc (Hardware Compiler) выполняет задачи на порядок сложнее. Он отвечает за правильную компоновку файла ELF64. Это включает создание секций (.text.rodata), таблиц релокаций, PLT/GOT для динамической линковки и всех необходимых заголовков. Да, в него встроен полноценный компоновщик.

Динамическая линковка и вызовы библиотечных функций: На пути к интеграции

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

Сейчас мы тестируем вызовы библиотечных функций. В идеальном будущем, к которому мы стремимся, код будет выглядеть так:

@include libc;

@section rodata {
  message: str("Hello World")
}

@fn main {
  @call libc::printf("sent %s", $message);
}

Это интуитивно, чисто и эффективно. Но чтобы достичь этой простоты, под капотом должна работать невероятно сложная система. На данный момент наша тестовая реализация выглядит более "низкоуровневой":

@include libc;

@section rodata {
  format: str("sent %s")
  message: str("Hello World")
}

@fn main {
  ;; программист сам кладет аргументы в регистры по ABI
  @call printf;
}

Почему существует эта разница и что нужно для перехода?

  1. Разбор сложных выраженийzcc_parser должен научиться понимать конструкцию libc::printf, распознавать ее как вызов функции из внешнего модуля и передавать эту информацию в hwc.

  2. Генерация PLT/GOT: Когда hwc видит вызов внешней функции (printf), он не знает ее точный адрес. Поэтому он генерирует специальный "трамплин" в секции PLT (Procedure Linkage Table). Первый вызов printf перенаправляется на динамический загрузчик системы, который находит реальный адрес функции в libc.so и записывает его в таблицу GOT (Global Offset Table). Все последующие вызовы printf уже будут идти напрямую по адресу из GOT, что практически не сказывается на производительности. hwc полностью управляет созданием этих таблиц.

  3. Сборка вызовов по ABI: Компонент assembly должен быть обучен правилам ABI (Application Binary Interface) для x86_64. Он должен знать, что первые шесть целочисленных аргументов функции передаются через регистры  %rax, %rdi%rsi%rdx%rcx, etc. Именно assembly должен будет генерировать mov инструкции для загрузки аргументов в правильные регистры перед инструкцией call.

Этот функционал находится в активной разработке. Ваше мнение здесь критически важно: стоит ли нам выпустить текущую "ручную" реализацию или подождать полностью автоматизированной? Напишите в комментариях, это поможет нам определить приоритеты.

Управление секциями и переменными: Проблема переопределения

hwc отвечает за создание секций .rodata (read-only data). Когда вы объявляете переменную, compiler_driver передает ее в hwc, который размещает данные в соответствующей секции и создает запись в таблице символов.

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

@section rodata {
  message: str("Hello World")
}

@section rodata {
  message: str("This is not overriden value")
}

В compiler_driver таблица vartable просто перезапишет старое значение новым. При генерации кода, который ссылается на &message, он возьмет адрес последнего определения. В результате выведется "This is not overriden value". Однако в самой секции .rodata внутри ELF-файла останутся обе строки, что приводит к раздуванию бинарника. В будущих версиях мы введем строгую проверку на переопределение символов в пределах одной секции, чтобы такие казусы были исключены на этапе компиляции.

Будущее системных вызовов

Сейчас системные вызовы в ZGEN выглядят так: вы вручную загружаете номер вызова и аргументы в регистры, а затем вызываете @syscall.

@mov $1, %rax       ; sys_write
@mov $1, %rdi       ; fd (stdout)
@mov &message, %rsi ; buf
@mov $13, %rdx      ; count
@syscall

Мы планируем (но пока не обещаем в ближайших релизах) упростить этот процесс, переложив работу по соблюдению ABI на assembly. В будущем это могло бы выглядеть так:

@syscall(1, 1, &message, $13);

Компилятор сам бы сгенерировал необходимые инструкции mov/lea для загрузки аргументов по ABI. Это сделает код чище и менее подверженным ошибкам.

Компиляция функции main: Практический разбор

Функция main — это не просто еще одна функция. Это точка входа. Это контракт между вашей программой и операционной системой. То, как компилятор обрабатывает main, является показателем его зрелости и интеллекта. В AsmX G3 мы подошли к этому вопросу с точки зрения инженерных принципов: main должна быть предсказуемой, эффективной и безопасной по умолчанию.

Давайте рассмотрим два сценария, которые в полной мере раскрывают нашу философию.

Сценарий 1: Явный "Hello, World!"

Давайте посмотрим, как это работает на практике. Возьмем классический "Hello World".

@section rodata {
 message: str("Hello World\n")
}

@fn pub main {
  @mov $1, %rax
  @mov $1, %rdi
  @mov &message, %rsi
  @mov $21, %rdx
  @syscall
}

Компилируем его в нативный бинарник:

asmx main.asmx --release --march x86_64 -o hello

Что происходит под капотом во время компиляции asmx main.asmx --release --march x86_64 -o hello?

  1. Драйвер компилятора (compiler_driver) видит объявление @fn pub main. Он идентифицирует main как особую, корневую функцию программы.

  2. Он немедленно передает управление нашему новому компоненту assembly, а именно классу impl_assembly_fn_main, который был добавлен в компилятор. Этот класс — специалист по сборке точки входа.

  3. Драйвер рекурсивно обрабатывает тело функции. Каждая инструкция (@mov@syscall) передается в hwm/assembly, который превращает их в точные последовательности байт (машинный код).

  4. impl_assembly_fn_main получает этот сгенерированный бинарный код тела функции. Теперь его задача — "обернуть" его в правильную структуру. Он вызывает свои методы gen_prologue() и gen_epilogue(), добавляя стандартный код для создания стекового фрейма в начале и его уничтожения в конце.

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

  6. hwc получает готовую бинарную последовательность для функции main и размещает ее в секции .text финального ELF-файла.

Исполняемый файл hello будет выглядеть так при дизассемблировании:

При запуске мы получим:

Hello World
Segmentation fault (core dumped)

Сегфолт — это логично. Программа выполнила системный вызов write и просто "упала", так как не знает, что делать дальше. Мы не дали ей команду на завершение.

Именно поэтому программа падает с ошибкой Segmentation fault. Она честно выполнила все ваши инструкции, дошла до ret, вернула управление... в никуда. Операционная система, не получив от программы сигнала о завершении через системный вызов exit, принудительно ее уничтожает. Это не ошибка компилятора. Это прямое следствие вашего кода. Предсказуемость — это главное.

Сценарий 2: Пустая main и интеллект компилятора

Теперь рассмотрим самый интересный случай — пустую функцию:

@fn pub main {}

Наивный компилятор (gcc, clang, g++, etc) сгенерировал бы просто пролог и эпилог, что привело бы к тому же самому сегфолту. Но AsmX G3 умнее.

Вот что происходит на самом деле:

  1. Шаги 1-3 идентичны: драйвер видит main и вызывает impl_assembly_fn_main.

  2. Драйвер обрабатывает тело функции и обнаруживает, что оно пустое. Он передает в impl_assembly_fn_main пустой буфер.

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

  4. impl_assembly_fn_main вызывает свой внутренний метод gen_exit_syscall(), который генерирует бинарный код для системного вызова exit(0).

  5. Этот сгенерированный код становится новым телом функции.

  6. Далее, как и в первом сценарии, тело оборачивается в стандартный пролог и эпилог.

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

За пределами main: Фундамент для будущего

Генерация стекового фрейма — это не просто традиция. Это необходимость для любого серьезного системного языка. Фиксированный указатель базы стека (%rbp) — это точка опоры, относительно которой компилятор может адресовать локальные переменные и аргументы функции.

Именно для этого в компиляторе появились методы gen_allocate_local_variables и gen_free_local_variables. Наша архитектура уже готова к следующему шагу. Когда вы объявите локальные переменные, compiler_driver вычислит их суммарный размер, автоматически управляя стеком.

Более того, введение класса impl_assembly_fn_base — это архитектурное решение с прицелом на будущее. impl_assembly_fn_main теперь является всего лишь его специализированным потомком. Это означает, что для добавления поддержки пользовательских функций нам не придется изобретать велосипед. Мы просто создадим новый класс impl_assembly_fn_user, который унаследует всю базовую логику работы со стеком, и добавим в него специфику обработки аргументов и возвращаемых значений.

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

Заключение

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

Это только начало. Архитектурные решения, заложенные сегодня, позволят нам в будущем с легкостью добавлять поддержку новых платформ, расширять синтаксис и повышать уровень абстракции, не теряя контроля над каждым битом исполняемого кода. Мы строим инструменты, которые хотели бы использовать сами.

Присоединяйтесь к нам в этом путешествии.

Официальный репозиторий AsmX G3: https://github.com/AsmXFoundation/AsmX-G3

Документация и Release Notes (v28.0.0-rev-1.0): https://github.com/AsmXFoundation/AsmX-G3/releases/tag/v28.0.0-rev-1.0

Справочник: Ключевые концепции

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

  • Syscall (Системный вызов)

    • Что это? Это основной механизм, с помощью которого программа в пользовательском режиме запрашивает у ядра операционной системы выполнение привилегированной операции. Примеры: чтение файла, отправка данных по сети, завершение процесса.

    • Почему это важно для AsmX? В ZGEN-режиме @syscall является прямым способом взаимодействия с ОС. Планируемое улучшение до @syscall(...) упростит этот процесс, автоматизируя передачу аргументов согласно ABI.

    • Для дополнительного чтения: Системный вызов — Википедия

  • x86-64

    • Что это? 64-битное расширение архитектуры набора команд x86. Является доминирующей архитектурой в большинстве современных настольных компьютеров, ноутбуков и серверов.

    • Почему это важно для AsmX? x86-64 — это первая целевая архитектура для компилятора ZGEN. Весь конвейер hwm и hwc в настоящее время настроен на генерацию машинного кода именно для этого семейства процессоров.

    • Для дополнительного чтения: x86-64 — Википедия

  • ELF (Executable and Linkable Format)

    • Что это? Стандартный формат файлов для исполняемых программ, объектного кода, разделяемых библиотек и дампов памяти в Unix-подобных системах, включая Linux.

    • Почему это важно для AsmX? Компонент hwc в ZGEN-режиме является, по сути, сборщиком ELF-файлов. Он создает все необходимые заголовки и секции для того, чтобы операционная система могла правильно загрузить и выполнить скомпилированную программу.

    • Для дополнительного чтения: Executable and Linkable Format — Википедия

  • ABI (Application Binary Interface)

    • Что это? Соглашение о том, как программы взаимодействуют на бинарном уровне. Оно определяет такие вещи, как порядок передачи аргументов в функции (через стек или регистры), как возвращаются значения, и как организуются системные вызовы.

    • Почему это важно для AsmX? Для вызова внешних функций (например, из libc) или корректных системных вызовов компилятор AsmX должен генерировать код, строго соответствующий ABI целевой платформы (в нашем случае, System V AMD64 ABI).

    • Для дополнительного чтения: Двоичный интерфейс приложений — Википедия

  • Стековый фрейм (Stack Frame)

    • Что это? Область на стеке, выделяемая для одной функции на время ее выполнения. Она содержит локальные переменные, сохраненные регистры и аргументы функции. Управляется указателями стека (%rsp) и базы (%rbp).

    • Почему это важно для AsmX? Компонент assembly автоматически генерирует код для создания и уничтожения стекового фрейма, что является основой для поддержки локальных переменных и вложенных вызовов функций в будущем.

  • PLT (Procedure Linkage Table) и GOT (Global Offset Table)

    • Что это? Два механизма, используемые при динамической линковке. PLT — это набор "трамплинов" для вызова внешних функций. GOT — это таблица, в которую динамический загрузчик записывает реальные адреса этих функций во время выполнения.

    • Почему это важно для AsmX? Компонент hwc самостоятельно генерирует эти таблицы, что позволяет программам на AsmX использовать разделяемые библиотеки (.so), такие как libc (libc.so.6), без необходимости статической линковки.

Tags:
Hubs:
-6
Comments13

Articles