Кратко: встроенный ассемблер языка Odin, по мнению его создателя, на данный момент — лучшая реализация подобного механизма среди всех языков программирования.

Наиболее важные особенности этого решения перечислены ниже. Ни один другой инлайн-ассемблер (GCC/Clang/Rust/Go…) не сочетает в себе все эти черты сразу:

  • Инлайн-ассемблер организован в виде asm-«шаблонов», похожих на процедуры и вызываемых аналогичным образом.
  • Шаблоны asm интегрируются с остальным кодом через привязки, задающие затираемые (clobber), закреплённые, связанные и вспомогательные (scratch) регистры.
  • Синтаксис ассемблера унифицирован для разных архитектур и согласован с синтаксисом самого Odin.
  • Ассемблерный код полностью проходит проверку типов, как и остальной код на Odin.
  • В основе лежит понимание того, что ассемблер на самом деле типизирован.
  • Реальная семантическая диагностика на основе таблиц кодирования core:rexcode.
  • Всё это было реализовано примерно за 7 дней.

Часто задают вопрос, зачем Odin вообще понадобился собственный встроенный ассемблер. Разве инлайн-ассемблер — не решённая задача? Берётся строка, передаётся ассемблеру, а дальше внешний инструмент сам разбирается с остальным. Именно так, более или менее, поступают GCC, Clang и Rust Инлайн-ассемблер Rust немного сложнее из-за системы макросов, но не сильно.. Колесо давно изобретено, не так ли?

Именно такой подход не устраивал изначально, и именно на нём остановилось большинство языков. Цель с самого начала состояла в том, чтобы получить инлайн-ассемблер, по-настоящему интегрированный в остальной язык, а не приделанный сбоку. И, честно говоря, то, что получилось в итоге у Odin — лучшая система инлайн-ассемблера среди всех современных языков. Это утверждение не голословно, и к концу статьи должно стать понятно, почему.

Строковый бардак

Начнём с того, против чего был направлен весь дизайн. Вот как выглядит тривиальное «прибавить единицу» в стиле расширенного asm GCC с использованием синтаксиса x86 AT&T/GAS:

int dst;
asm("movl %1, %0\n\t"
    "addl $1, %0"
    : "=r" (dst) // outputs
    : "r"  (src) // inputs
    : // clobbers
);

Взгляните на этот пример и задайте себе вопрос: что понимает здесь компилятор (в отличие от ассемблера)? Ответ — «почти ничего». Тело этого ассемблерного блока — обычная строка. "=r" и "r" — это явные строки-ограничения, по сути ещё один маленький DSL, приклеенный сбоку к настоящему DSL. %0 и %1 — позиционные ссылки в список, который приходится считать вручную. А если где-то ошибиться, ошибку вернёт не компилятор, знающий типы и семантику, а ассемблер, который работает намного позже в цепочке компиляции и указывает на сгенерированный текст, написанный не вами.

Так происходит, когда функция изначально проектируется как лазейка, а не как часть языка. Похоже, никто всерьёз не задавался вопросом «как будет выглядеть инлайн-ассемблер, если он будет уважать систему типов, соглашения о вызовах, систему констант и прочие особенности (например, семантику множественных возвращаемых значений) языка-хозяина?». Вместо этого задавали вопрос «как впихнуть немного ассемблера в эту функцию с минимумом работы для компилятора?» — и ответом стала строка.

Практически все инлайн-ассемблеры игнорируют особенности языка-хозяина и просто приделывают ассемблер сбоку. В случае с Odin выбран другой путь — ассемблер спроектирован с нуля.

Краткая история «приделывания сбоку»

Строки — не единственный способ реализации инлайн-ассемблера, и стоит взглянуть, что делали предыдущие языки и компиляторы: некоторые из этих подходов гораздо лучше того, что сделали GCC и Clang, но, к сожалению, развитие в этом направлении в мире компиляторов остановилось.

MSVC

У компиляторов Microsoft был по-настоящему иной подход, отличный от передачи строк. __asm в MSVC был построен на основе инструкций, а не строк. Программист писал блок реальных инструкций как идентификаторы, и — это самое приятное — мог напрямую по имени ссылаться на переменные и метки языка C, а компилятор сам их разрешал:

int add_one(int x) {
    __asm {
        mov eax, x // 'x' is the C parameter, resolved by the compiler
        inc eax
    }
    // For the calling convention, the value in eax is the return value
}

Никаких строк-ограничений, никаких %0, никакого подсчёта операндов для ссылок на них. По сравнению с конструкцией GCC это действительно приятно читать и писать. Долгое время именно так писалась значительная часть системного кода Windows. Так почему же этот подход исчез?

Во-первых, он был только для x86. Когда Microsoft перешла на x64/amd64 (а позже и на arm64), этот механизм не перенесли. Официальной рекомендацией стало «используйте компиляторные интринсики или пишите отдельный .asm-файл и прогоняйте его через MASM». Одним из заявленных принципов компилятора для x64/amd64 было полное отсутствие инлайн-ассемблера. Вместо обобщения подхода на новую архитектуру от него просто отказались на границе набора инструкций.

Во-вторых, даже там, где механизм существовал, компилятор по-настоящему не понимал этот блок. Он разрешал имена символов, но не позволял явно указать, что затирается (clobber). Оптимизатор в основном обращался с такими участками как с непрозрачными барьерами, действуя вокруг них максимально консервативно. Он знал, что такое x (поскольку был компилятором C/C++), но никак не сообщал пользователю, что именно делают инструкции.

Turbo Pascal

Если заглянуть ещё дальше в прошлое, можно вспомнить Turbo Pascal, к которому, как и к семейству Pascal в целом, сохраняется определённая симпатия. Для инлайн-ассемблера там существовало два механизма.

Первый — директива inline, чистейший пример подхода «компилятор не понимает ничего». Машинный код задавался последовательностью числовых констант (фактических опкодов в виде байтов):

procedure Cli;  inline($FA);     { $FA = the CLI instruction }
procedure Nops; inline($90/$90); { two NOP bytes }

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

Второй механизм, добавленный в Turbo Pascal 6.0, — встроенный ассемблер: блок asm ... end и директива процедуры assembler. Этот подход гораздо лучше, поскольку использует настоящие мнемоники, и (как позже в MSVC) позволяет напрямую ссылаться на переменные и параметры Pascal по имени:

function AddOne(X: Word): Word; assembler;
asm
    mov ax, X { 'X' is the Pascal parameter }
    inc ax    { result returned in AX }
end;

Для 1990 года это выглядит по-настоящему красиво Это было ещё до рождения автора. и, пожалуй, опережает то, к чему в итоге пришли современные компиляторы C. Но из-за своего времени встроенный ассемблер понимал инструкции только до уровня 80286, поэтому когда требовался 386 с его 32-битными регистрами, всё равно приходилось прибегать к внешнему ассемблеру.

Приделали сбоку, а затем заколотили намертво.

По интуитивному дизайну MSVC и Turbo Pascal были лучше подхода GCC, особенно на фоне бестолковых строк-ограничений. Однако оба остановились ровно в одном и том же месте: они разрешали идентификаторы, но никогда не моделировали сами инструкции (не типы операндов), ограничивались лишь проверкой диапазонов непосредственных значений, не давая контроля над тем, что затирается и что нужно закрепить. GCC же вовсе отбросил этот подход и забыл о том, чтобы дать ассемблеру говорить на собственном языке.

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

Ассемблер не является нетипизированным

Широко распространено мнение, что ассемблер «нетипизирован», а значит инлайн-ассемблер по своей природе — территория вседозволенности. Это неверно, и преодоление этого заблуждения — главная идея всего дизайна универсального инлайн-ассемблера.

Ранее уже была статья о «нетипизированных типах» в контексте Odin, но там речь шла о экзистенциальных типах. Многие плохо понимают, что означает «нетипизированный» — на самом деле это просто означает, что тип один-единственный: например, всё является целым числом, или всё является строкой. Это не означает отсутствие «типов» вовсе или «отсутствие типов в привычном смысле». У большинства людей довольно слабое представление о том, что вообще такое тип, и они, возможно, просто считают, что всё «непрозрачно» и очень слабо типизировано. Ассемблер обычно считается идеальным примером такого «нетипизированного» языка — и это утверждение здесь оспаривается полностью.

На деле же у каждой инструкции есть набор допустимых форм. Каждая форма определяет вид каждого операнда (регистр, память, непосредственное значение, метка), класс каждого регистра (общего назначения, векторный, маска), разрядность каждого операнда, диапазон возможных непосредственных значений и то, что инструкция затирает (флаги, память, конкретные регистры). В x86 mulps требует 128-битный векторный регистр; crc32 в одной из форм требует 32-битный приёмник и 8-битный источник в памяти; div читает и записывает rdx и rax независимо от того, просили вы об этом или нет.

Это не отсутствие системы типов — это и есть система типов, причём довольно богатая, зависимая, индивидуальная для каждой инструкции. Ассемблер по сути представляет собой полиадическую типизированную алгебру, которую все договорились считать похлёбкой из байтов Не такой вкусной, как может показаться.. Как только это осознаётся, вопрос дизайна перестаёт звучать как «как протащить строку мимо компилятора?» и превращается в «как выразить эту алгебру средствами самого языка?». К счастью, у Odin уже были почти все нужные для этого детали.

Один синтаксис для многих архитектур

Первое решение, которое нужно было принять — по поводу синтаксиса. Не мнемоник — очевидно, что mov в AMD64 не имеет ничего общего с ldr в ARM64 — а всего того, что окружает мнемоники: как объявлять операнды, как ссылаться на регистры, как записывать адрес в памяти, как оформлять метку и так далее.

Здесь применён тот же подход, что и у Plan 9 (а позже и у Go): выбрать один синтаксис и придерживаться его для каждой целевой платформы. Тулчейн Кена Томпсона, унаследованный Go, поступил именно так, и действительно приятно один раз выучить форму записи, а не переучиваться под каждую платформу. У ассемблера Go есть свои проблемы и несостыковки, но сама идея блестящая.

У самого Odin контекстно-свободная грамматика, поэтому инлайн-ассемблер Odin тоже должен быть контекстно-свободной грамматикой. Была поставлена задача добиться следующей общей формы:

instruction [operand{, operand}]

Инструкция должна быть валидным идентификатором или ключевым словом Odin В x86/AMD64 in — валидная инструкция, но одновременно и ключевое слово в Odin. К сожалению, из-за правил автоматической вставки точки с запятой в Odin, при использовании мнемоники in без операндов приходится добавлять ;, чтобы компилятор не решил, что следующая строка — часть операндов инструкции. Компромисс при таком синтаксисе, но, по мнению автора, полностью оправданный.. Явные физические регистры всегда получают сигил % (%rax, %xmm0, %al), что не даёт им конфликтовать с именами параметров пользователя и глобальными константами из родительской области видимости. Имена параметров и вспомогательных регистров всегда пишутся без сигила (потому что компилятор понимает семантику). Операнды памяти всегда записываются как эффективные адреса в стиле Intel ([base + index*scale + disp]). Метки всегда начинаются с .name. Эту форму нужно выучить один раз, и она переносится на любую архитектуру, которая когда-либо будет добавлена, даже если сами инструкции под ней совершенно разные. Используется тот же набор токенов, что и в Odin: числовые литералы, комментарии, даже правила вставки точки с запятой.

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

Порядок операндов как в Intel, а не AT&T

Есть одно синтаксическое решение, вокруг которого спорят чаще всего, когда речь заходит об ассемблере: использовать порядок Intel или AT&T/GAS (по крайней мере применительно к x86/AMD64). Тела asm в Odin используют порядок операндов Intel (приёмник первым, dst, src) вместе с адресацией памяти в стиле Intel (хотя и слегка отличающейся), а не соглашения AT&T/GAS.

Здесь произошло отступление от Plan 9 и Go, даже при заимствовании их лучшей общей идеи. Ассемблеры Plan 9 и Go записывают операнды в порядке источник-первым, слева направо, по ходу потока данных Go не вполне последователен и в других соглашениях. Порядок некоторых операндов просто не согласуется с другими ассемблерами AT&T. Прелестно, не правда ли? /s, поэтому MOVQ $0, AX обнуляет AX, а приёмник находится справа. Это тот же порядок операндов, что и в AT&T (противоположный Intel). Общая философия «одна грамматика для каждой архитектуры» была заимствована у них целиком, а вот порядок операндов — нет. Может показаться, что это произвольный выбор, но это не так.

Первая причина — сохранить согласованность с остальным Odin. mov dst, src читается как dst = src. Приёмник находится слева, ровно там же, где находится цель присваивания в любой другой строке Odin, которую когда-либо придётся написать: x = y, x := y, name: type = value Тот же аргумент приводился и в отношении синтаксиса приведения типов, где тип стоит слева, потому что именно так читаются объявления.. Запись movl %src, %dst в AT&T прогоняет поток данных в обратную сторону относительно любого присваивания в окружающем языке. Читая шаблон, встроенный в обычный код Odin, не должно быть нужды переключать мысленную модель того, куда указывает стрелка, посреди процедуры.

Вторая причина важнее всего именно для универсального синтаксиса: порядок «приёмник первым» — не особенность Intel, а преобладающее соглашение среди архитектур. ARM пишет add r0, r1, r2 (приёмник первым). RISC-V пишет add rd, rs1, rs2 (приёмник первым). Документация MIPS RISC-V по сути просто вариант MIPS. Можно поспорить. поступает так же. Именно порядок «источник первым» — узкоспециальный частный случай. Традиция x86/GAS унаследована от линейки DEC и PDP-11, и немногие другие соглашения об архитектурах последовали за ней.

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

К счастью, остальной багаж AT&T отпадает по схожим причинам:

Операнды памяти

AT&T записывает disp(base, index, scale) — позиционные слоты, которые приходится просто заучивать. Intel записывает [base + index*scale + disp], что читается как арифметика адреса, которой оно на самом деле и является. Odin использует второй вариант, и он аккуратно расширяется на формы, нужные другим платформам, например [base + index< или [base + index>>scale] на arm64.

Размер операнда

AT&T зашивает разрядность в саму мнемонику (movb, movw, movl, movq). Odin в этом не нуждается, поскольку операнды типизированы: размер берётся из типа параметра, а там, где регистр его не фиксирует — из явной аннотации вроде [%rax]:u8 В синтаксисе Intel принято ставить перед операндом памяти byte, word, dword или qword, но Odin напрямую использует собственную систему типов.. Система типов уже несёт информацию, которую AT&T размазывает по многочисленным разным написаниям mov.

Сигилы

AT&T безоговорочно украшает каждый регистр знаком %, а каждое непосредственное значение — знаком $. Сигил % в Odin выглядит похоже, но выполняет совершенно иную работу: он появляется только у явных физических регистров и только для того, чтобы не конфликтовать с пространством имён пользовательских параметров и вспомогательных регистров. В идиоматичном шаблоне пишутся обычные имена без сигила (например, foo, acc, i), а к %rax обращаются только тогда, когда действительно нужно закрепить конкретный регистр или сослаться на него напрямую. Сигил отмечает исключение из правила, а не является сплошным украшением, размазанным повсюду.

Сначала форма записи в AT&T/GAS:

movl %eax, %ebx             # ebx = eax   (source is on the LEFT)
addl $1, %ebx               # ebx += 1
movl 8(%rdi,%rsi,4), %ecx   # ecx = *(rdi + rsi*4 + 8)

а теперь те же три инструкции в порядке Intel, принятом в Odin:

mov  %ebx, %eax              // ebx = eax   (destination is on the LEFT)
add  %ebx, 1                 // ebx += 1
mov  %ecx, [%rdi + %rsi*4 + 8]

Стоит отметить, что это худший случай для Odin, специально написанный целиком через физические регистры, чтобы синтаксическое сравнение было честным. В реальном шаблоне использовались бы имена, а не регистры с сигилом %, и правая колонка избавилась бы почти от всех оставшихся сигилов. Именно такое, более здравое, прочтение и было целью: одна грамматика, приёмник первым, как в большинстве мира, никаких искажённых суффиксами мнемоник, операнды памяти, выглядящие как арифметика, и знаки препинания только там, где они действительно нужны. Более реалистичный пример — с именованными параметрами:

mov  x, y
add  x, 1
mov  z, [base + index*4 + disp]

Синтаксис шаблонов

Вот общая форма шаблона asm:

name :: asm(params) -> (results) [bindings] {
    body
}

params — это входные данные, обычные имена с типами Odin. results — выходные данные, тоже обычные имена с типами, использующие тот же синтаксис сигнатуры, что и обычная процедура Odin. Блок [bindings] хранит всё, что не является простым входом или выходом: связи, закрепления, вспомогательные регистры, представления с изменённой разрядностью, затирания и эффекты. body — это поток инструкций. Параметры и результаты необязательны, как и у обычного типа процедуры, а блок привязок полностью необязателен, если в нём нет нужды.

Типы параметров — это настоящие типы Odin: целые числа, числа с плавающей точкой, булевы значения, указатели, мульти-указатели или #simd[N]T. Они не для украшения. Компилятор использует тип, чтобы определить класс регистра, разрядность операнда и то, примет ли вообще данная форма инструкции такой операнд. #simd[4]f32 — векторный операнд, и проверяющий это понимает.

Как и в обычных процедурах Odin, можно объявлять параметрические полиморфные константные параметры. Параметр вида $name — это непосредственное значение времени компиляции ($ctrl: u8), с проверкой диапазона в момент инстанцирования, точно как любая другая константа Odin.

Вот один из простейших примеров синтаксиса asm:

add_one :: asm(x: u64) -> (r: u64) [
    x -> r,
] {
    inc r
}

В блоке привязок запись x -> r означает, что вход x и выход r связаны с одним и тем же регистром, что понижается до операнда с доступом на чтение и запись. Никаких дурацких %0, никакого инлайнового "+r", никакого подсчёта вручную. Написанные имена означают именно то, что написано.

Множественные возвращаемые значения достаются бесплатно

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

Ассемблерные инструкции по своей природе полиадичны. rdtsc выдаёт два результата в edx и eax. cpuid выдаёт четыре. div одновременно выдаёт частное и остаток. В языке с единственным возвращаемым значением всё это приходится моделировать через выходные параметры, упаковку в структуру/кортеж или иные ухищрения. В Odin их просто… возвращают.

rdtsc :: asm() -> (lo, hi: u32) [
    lo = %eax,
    hi = %edx,
] {
    rdtsc
}

cpuid :: asm(leaf: u32) -> (a, b, c, d: u32) [
    leaf -> a = %eax,
    b = %ebx,
    c = %ecx,
    d = %edx,
] {
    cpuid
}

divmod_u64 :: asm(n: u64, d: u64) -> (quo, rem: u64) [
    n -> quo = %rax,
    rem      = %rdx,
    #clobber flags, // this is inferred and thus not necessary,
                    // but it's to show you can make it explicit
] {
    xor %rdx, %rdx   // clear the high half of the dividend
    div d            // rax = rdx:rax / d ; rdx = remainder
}

А в месте вызова они деструктурируются точно так же, как любая другая процедура Odin с несколькими возвращаемыми значениями:

lo, hi := rdtsc()
quo, rem := divmod_u64(100, 7)
ea, eb, ec, ed := cpuid(0)

Если результат не привязан, компилятор просто игнорирует неиспользуемое значение, поскольку оно явно не запрашивалось. Шаблон, чьи выходы — просто артефакты ABI, не вынуждает их деструктурировать. Такие вещи кажутся очевидными только после того, как уже реализованы. Ассемблер и есть полиадическая типизированная алгебра, и в тот момент, когда язык бегло говорит на языке полиадических типизированных значений, несоответствие импедансов, преследующее любой строковый ассемблер (или ассемблерные блоки), просто исчезает.

Связи, закрепления, вспомогательные регистры и представления разрядности

Блок привязок — аспект, потребовавший немало проектной работы, и для многих может показаться, что его вообще не должно существовать, как будто это искусственный пролог. Но именно здесь живёт значительная часть явного закрепления регистров — того, что GCC запихивает в свои загадочные строки-ограничения (технический термин). Цель была в том, чтобы почти всё затирание выводилось автоматически там, где это возможно, а там, где нужна конкретика — делать её явной, читаемой и именованной.

В блоке привязок живёт лишь несколько сущностей, и они аккуратно комбинируются:

Затирание

Можно явно указать затирание регистров — #clobber %rax, флагов/кодов условий — #clobber flags, и памяти — #clobber memory, тоже в блоке привязок.

Эффекты

При необходимости также можно указать «эффекты», которые должны произойти, такие как #volatile или #align_stack.

Связь

in -> out связывает вход и выход с одним регистром (операнд для чтения и записи). Вместе с закреплением фиксируется конкретный регистр; без него распределитель выбрал бы разные регистры.

Закрепление

name = %reg принудительно закрепляет конкретный физический регистр.

Вспомогательный регистр/параметр

name: T — рабочий регистр, класс которого определяется его типом: i64 даёт регистр общего назначения, #simd[4]f32 — векторный. Незакреплённый вспомогательный регистр помечается как затираемый заранее, поэтому никогда случайно не пересечётся с входными данными.

Представление разрядности

view: T = src — второе имя для регистра src, видимое с меньшей разрядностью. Один регистр, но с разными представлениями по ширине. Классический пример в x86 — setcc, идиома, когда нужны младшие 8 бит под одним именем и полные 64 бита под другим. По сути, это тоже форма закрепления.

Использование синтаксиса блока привязок

Правая часть = и определяет разницу между последними двумя случаями: = %reg — это регистр, значит закрепление; = src — это имя, значит представление разрядности.

В качестве примера — векторное ядро, использующее вспомогательные регистры векторного типа, и вот ровно та причина, по которой важны типизированные параметры: проверяющий знает, что acc и tmp — регистры xmm, потому что это указано через #simd[4]f32:

dot_f32x4 :: asm(a, b: [^]f32, n: i64) -> (result: f32) [
    acc: #simd[4]f32,
    tmp: #simd[4]f32,
    i:   i64,
    #clobber flags,  // the cmp/jl sets flags
    #clobber memory, // we read memory the compiler can't see
                     //
                     // NOTE: neither of these `#clobber` things are
                     // necessary as the compiler infers them from
                     // the usage of the instructions
] {
    xorps  acc, acc
    xor    i, i
.loop:
    movups tmp, [a + i*4]   // scale 4 = sizeof(f32)
    mulps  tmp, [b + i*4]
    addps  acc, tmp
    add    i, 4
    cmp    i, n
    jl     .loop
    haddps acc, acc
    haddps acc, acc
    movss  result, acc
}

Стоит отметить одну деталь про метки вроде .loop: они локальны для шаблона и переименовываются при каждом инстанцировании, поэтому один и тот же шаблон можно встраивать сколько угодно раз и никогда не получить конфликт символов. Глобальных меток нет по замыслу. Это ровно то, что даёт гигиеничная система макросов.

Префиксы и другие синтаксические особенности

При проектировании этого универсального синтаксиса для шаблонов инлайн-ассемблера пришлось принять ряд небольших синтаксических решений. Каждое из них способно сбить с толку тех, кто провёл годы в NASM или GAS. Ни одно из них не произвольно. Почти каждое — одно и то же правило в другом обличье: тело ассемблерного блока токенизируется и разбирается тем же механизмом, что и остальной код Odin, поэтому всё, что выглядит особенностью, на деле обычно означает лишь отсутствие специального исключения из правил.

Самый наглядный пример — префиксы.

Префикс занимает отдельную строку

Префиксы инструкций вроде lock, rep и repne в x86 не стоят перед мнемоникой, как это принято повсюду. Они выносятся на отдельную строку:

atomic_fetch_add :: asm(p: ^i64, delta: i64) -> (old: i64) [
    delta -> old,
] {
    lock
    xadd [p], old
}

memcpy_rep :: asm(dst, src: rawptr, len: uint) -> (end_dst, end_src: rawptr, rem: uint) [
    dst -> end_dst = %rdi,
    src -> end_src = %rsi,
    len -> rem     = %rcx,
] {
    rep
    movsb
}

Ветерану ассемблера это покажется неправильным: разве lock xadd не единое целое? Но стоит задуматься, что на самом деле говорит грамматика. Каждая строка в шаблоне имеет вид instruction [operand{, operand}], а в Odin (как в Go или Python) действует автоматическая вставка точки с запятой, поэтому перевод строки завершает инструкцию. Если бы префикс делил строку с мнемоникой, потребовалось бы специальное исключение в токенизаторе: «эти конкретные идентификаторы на самом деле не инструкции, а модификаторы, поэтому не завершайте после них инструкцию». Такого исключения делать не хотелось. Префикс — это просто инструкция, которая не принимает операндов и стоит на собственной строке. Грамматика остаётся единообразной, и правил становится на одно меньше.

Очевидное опасение — что отделение префикса от инструкции позволит им «разъехаться». Этого не происходит, потому что проверяющий держит их связанными, даже пока синтаксис их разносит. Префикс обязан сразу сопровождаться настоящей инструкцией (не меткой, не другим префиксом), и его допустимость проверяется относительно формы этой следующей инструкции: lock требует приёмник в памяти; rep/repne требуют строковую инструкцию. Если написать lock перед чем-то без приёмника в памяти, компилятор отклонит это, указав именно на данный токен. В этом и вся философия дизайна: держать синтаксис простым и единообразным, а семантический проверяющий делать умным.

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

Комментарии — // и /**/, а не ; или #

Практически в любом традиционном ассемблере комментарий начинается с ; (или # в GAS). Здесь не так. ; — разделитель инструкций, потому что именно им он является в Odin, а комментарии — // и /**/, потому что именно так они оформляются в Odin. Это, пожалуй, единственная особенность, способная больнее всего укусить того, кто пишет свой первый шаблон asm: моторная память набирает ; для начала комментария и вместо этого получает синтаксическую ошибку. У Odin уже есть собственный синтаксис комментариев — зачем ассемблерному синтаксису заводить свой? Лучше сохранить согласованность с окружающим языком.

Числовые литералы — как в Odin

Никаких 0FAh, никаких $FA, никакой похлёбки с буквами системы счисления в конце. Шестнадцатеричный литерал — это 0xFA, двоичный — 0b1010, работают разделители разрядов, поэтому можно написать rol_imm(0x0000_00FF, 8), и это читается чисто. Непосредственные значения в ассемблерном коде токенизируются тем же кодом, что и целые числа во всей остальной программе, а значит ведут себя идентично: те же основания систем счисления, те же разделители, те же правила переполнения.

Директивы используют #

Директивы данных и разметки — это #byte, #skip, #nop и #align, а не .byte, .skip, .nops и .p2align. #byte 0x90, 0x90 выводит сырые байты; #align 16 выравнивает следующую инструкцию по границе 16 байт.

# — не украшение ради украшения, а сигил директив Odin, тот же самый, что и у #simd, #clobber, #volatile и любой другой директивы в языке. Читатель, знающий, что означает # везде в языке, уже знает, что оно означает и здесь. Это согласовано с остальными синтаксическими решениями Odin.

Метки начинаются с точки

Метка объявляется как .name:, а используется как .name. Ведущая точка обозначает её как локальную для шаблона, и она переименовывается при каждом инстанцировании, поэтому один и тот же шаблон можно встроить сотню раз без коллизий. Глобальных меток внутри шаблона нет по замыслу — заблудившемуся jmp просто некуда сбежать. Компилятор гипотетически мог бы разбирать метки и без ведущей точки, но она также позволяет держать метки в собственном пространстве имён и сразу давать понять, что это метки, оставаясь при этом узнаваемой для тех, кто знаком с другими синтаксисами ассемблера, пусть и с некоторыми отличиями в поведении.

Ни одно из этих решений не назовёшь хитроумным, и в этом весь смысл. Каждое — просто применение существующего лексического правила Odin внутри ассемблерного блока, а не переопределение его какой-то традицией, унаследованной от другого инструмента. Суть в том, что тело asm читается похоже на язык, в который оно встроено, и по-настоящему новое, что приходится учить — только сами инструкции.

Компилятор действительно всё понимает

Это самая важная часть, и та часть, которую практически любой другой [инлайн-]ассемблер десятилетиями полностью игнорировал: семантическая проверка.

Шаблоны не передаются ассемблеру дословно. Фронтенд семантически проверяет каждую инструкцию по собственным таблицам кодирования целевой платформы В частичной подготовке к этому инлайн-ассемблеру была создана собственная высокопроизводительная многоархитектурная библиотека кодирования/декодирования/вывода инструкций на Odin: rexcode. В ней есть все таблицы кодирования для множества архитектур и промежуточных представлений. (те же данные, из которых кодирует бэкенд), поэтому подавляющее большинство ошибок отлавливается на этапе компиляции, на проблемном токене в исходном коде, а не всплывает позже в виде непрозрачной ошибки ассемблера, указывающей на текст, написанный не вами.

Для каждой инструкции проверяется мнемоника, количество операндов, вид операнда (регистр, память, непосредственное значение, метка), размер и класс операнда, диапазоны непосредственных значений и полная корректность операндов памяти. Когда у мнемоники есть несколько форм кодирования и непонятно, что именно имелось в виду, сообщение об ошибке указывает на ближайшую форму (ту, которой операнды соответствовали больше всего), так что подсказка указывает на реально подразумевавшееся кодирование:

movsss ...   // did you mean `movss`, `movsd`?
...          // operand 2 expected a register, got an immediate
...          // 36893488147419103232 does not fit a 32-bit immediate

Многие ассемблеры умеют исправлять опечатки по принципу «может, вы имели в виду?» — это минимум минимума. Но настоящая претензия к остальным [инлайн-]ассемблерам в другом: если компилятор понимает все допустимые формы, все требуемые виды операндов и всю информацию о затирании, почему так мало инлайн-ассемблеров предлагают сообщения об ошибках и подсказки, выходящие за рамки простого исправления опечаток? Вся информация уже есть. Почему её не используют?

Именно этого хотелось добиться для инлайн-ассемблера Odin. Он может пометить избыточное использование #align_stack, если в теле шаблона ничто не требует выровненного стека, потому что понимает инструкции. Он сообщает об отсутствующем #volatile, если шаблон явно должен считаться volatile, потому что понимает инструкции. Если шаблон помечен как расходящийся (-> !), а на практике он однозначно никогда не расходится, компилятор сообщит об этом — тоже потому что понимает инструкции.

Большинство затираний вообще не нужно прописывать явно — они выводятся из использованных инструкций. Фактически, главная причина существования явных форм #clobber и #volatile — эффекты, которые таблицы действительно не могут вывести самостоятельно, вроде зависящего от времени выполнения маскирования AVX-512. Компилятор — не пассивный проводник к ассемблеру. Он понимает алгебру и даёт хорошие сообщения об ошибках, когда что-то сделано неправильно.

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

Как компилятор это понимает: rexcode

Когда говорится, что компилятор понимает инструкцию, это не фигура речи и не магия. В основе лежит библиотека.

И проверяющий во фронтенде, и бэкенд — когда он понижает код до внутреннего ассемблера — обращаются к одному и тому же: core:rexcode, высокопроизводительному многоархитектурному кодировщику/декодировщику/принтеру инструкций, который поставляется в коллекции core Odin отчасти как раз в качестве подготовки к подобным инструментам core:rexcode спроектирован и изначально написан Брендоном Пански (dotbmp).. Если спросить, является ли crc32 crc, [p + i]:u8 допустимой формой — какие виды и разрядности операндов нужны, что затирается — ответ приходит из таблиц кодирования, а не из написанных вручную условных операторов, зарытых в компиляторе.

Кодирование и декодирование управляются таблицами из единого источника истины: для каждой архитектуры существует одна написанная вручную таблица, а метапрограмма разворачивает её в скомпилированные бинарные блобы, загружаемые директивой #load в секцию @(rodata) на этапе компиляции, поэтому поиск выполняется за O(1) по статическим данным без единой аллокации на горячем пути Компилятор Odin использует ещё один этап метапрограммирования, чтобы преобразовать эти таблицы поиска Odin в таблицы для C++, поскольку сам компилятор написан на C++.. Таблицы не просто заявлены как корректные, а действительно проверены: сверены в обе стороны с llvm-mc, а ретро- и встраиваемые архитектуры — с da65, ca65, armips и binutils. Именно эта проверка даёт праву проверяющему быть строгим.

rexcode уже покрывает многое:

  • x86 — x86-64 и i386, вплоть до SSE/AVX/AVX-512/BMI/FMA/AES-NI
  • arm32 и arm64 — AArch32 (A32/T32/Thumb/VFP/NEON) и AArch64
  • mips, riscv, ppc — включая Power ISA 3.1 с более чем 3000 записей
  • ppc_vle, mos6502, mos65816, rsp — встраиваемые, ретро-архитектуры и векторный блок N64
  • слой ir/, уже содержащий wasm и spirv

Всё это скрыто за единым API-контрактом: смени импорт — и код сохранит свою форму.

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

Почему этого не существовало десятилетиями?

Кодирование x86 — фиксированная, познаваемая, конечная вещь, обновляемая лишь периодически. То же верно для arm64, то же для RISC-V. И тем не менее каждый ассемблер, дизассемблер, JIT, отладчик, эмулятор и фаззер заново выводит те же самые знания с нуля — обычно плохо, обычно намертво приваренные к одному инструменту на одном языке. У LLVM есть TableGen, но это LLVM, на C++, и никогда не задумывался как импортируемая библиотека. У binutils есть таблицы опкодов, но это внутренние детали конкретных инструментов на C. Никогда не существовало чистого, проверенного, импортируемого «вот все формы инструкций для дюжины архитектур», которое компилятор мог бы просто подключить.

Отсутствие такой библиотеки, пожалуй, и есть настоящая причина того, что инлайн-ассемблеры настолько плохи, как и общие инструменты кодогенерации компиляторов в целом. Семантическая проверка ассемблера — не сложная идея; проблема в том, что без машиночитаемой модели набора инструкций под рукой проверять попросту не по чему, поэтому приходится сдаваться и передавать строку нижестоящему ассемблеру, позволяя ему самому «жаловаться». Строковый дизайн — прямое следствие проблемы отсутствующих таблиц, и именно поэтому все просто лепят как получится.

Насколько известно, Odin — один из первых языков, поставляющих такую библиотеку в своей стандартной поставке, причём сразу с таким количеством архитектур и промежуточных представлений в одной согласованной библиотеке. И поскольку она уже существовала до начала работы над шаблонами инлайн asm, реализация оказалась исключительно лёгкой Проектирование синтаксиса, реализация разбора, интеграция таблиц rexcode, семантическая проверка ассемблера и понижение до LLVM IR для инлайн-ассемблера заняли в сумме около 7 дней. Продуктивность на высоте :D.: сложная, кропотливая, чреватая ошибками часть работы — те самые девяносто процентов — уже была сделана и уже проверена относительно LLVM. Осталось лишь построить сверху приятную часть.

Шаблоны, а не интринсики

Постоянные читатели помнят статью под названием Если бы у Odin были макросы, ответом в которой было знаменитое Нет. Так что стоит сразу обратиться к очевидному вопросу: эти шаблоны asm и есть гигиеничные макросы. Получается противоречие с самим собой?

Противоречия здесь нет, даже если различие похоже на то, что проводилось в той статье применительно к итераторам. Возражение никогда не было направлено против гигиеничных макросов как таковых — оно было направлено против универсальной системы макросов, потому что это скользкий путь без принципиальной точки остановки. Ограниченный гигиеничный макрос, привязанный к одной чётко понятной области, — совсем другое дело. Шаблон asm способен лишь на одно: развернуть на месте типизированный, проверенный поток инструкций, подобно принудительно инлайнируемой процедуре. Он не может переписать поток управления, изобрести новый синтаксис или распространиться на остальной язык. Он гигиеничен ровно там, где это нужно (переименование меток при каждом инстанцировании, область видимости регистров), и ограничен по построению.

И знаете что? Это по-настоящему приятно работает. А поскольку это шаблоны, они во многом устраняют потребность в выделенных компиляторных интринсиках. Многое из того, что иначе пришлось бы реализовывать как встроенную функцию — mfence, атомарное сложение с выборкой, tzcnt, который заодно сообщает, был ли вход нулевым — можно просто построить напрямую из шаблонов:

mfence :: asm() [ #volatile ] { mfence }

atomic_fetch_add :: asm(p: ^i64, delta: i64) -> (old: i64) [
    delta -> old,
] {
    lock
    xadd [p], old   // [p] += old; old = previous [p]
}

tzcnt :: asm(x: u64) -> (count: u64, was_zero: bool) [
    was_zero = %flags.z,
] {
    tzcnt count, x
}

Небольшое отступление: запись was_zero = %flags.z означает доступ к флагу псевдорегистра %flags. Флаг нуля превращается в типизированный булев результат шаблона, код условия становится обычным значением Odin, которое деструктурируется, как любое другое. Это согласованность и единообразие вплоть до регистра флагов.

В ближайшее время часть платформозависимых интринсиков будет заменена именно такими инлайн-шаблонами asm, и туда им и дорога. Интринсик — это чёрный ящик, жёстко зашитый в компилятор, что порой может быть уместным; но применительно к платформозависимым вещам шаблон — это то, что можно прочитать, проверить и написать самостоятельно.

Лучший инлайн-ассемблер

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

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

Ничего из этого не проистекает из «великой теории типов». Всё это идёт из того же места, откуда происходит весь дизайн Odin: делать то, что людям действительно нужно, а не то, что все считают неизбежным злом, и спрашивать, как это выглядело бы, если бы уважало язык, в котором живёт.

Ассемблер был типизирован всё это время. Просто нужно было перестать притворяться, что это не так, и принять его истинную природу.