Железо и архитектуры Система команд: контракт между железом и программой
0%

Система команд: контракт между железом и программой

Система команд: контракт между железом и программой

Двоичный файл, собранный в 1995 году под x86, запускается на процессоре, спроектированном тридцать лет спустя, где нет ни одного общего транзистора с оригиналом, где внутри одновременно в полёте сотни операций, где регистров физически в десять раз больше, чем в документации, и где почти каждая команда исполняется не в том порядке, в котором записана. Он всё равно даёт правильный ответ.

Это работает не по счастливой случайности и не потому, что «архитектуру не меняли». Это работает, потому что между программой и железом проложен явно записанный контракт: система команд, ISA (instruction set architecture). Контракт перечисляет, что именно программа имеет право видеть и на что имеет право рассчитывать. Всё, чего в нём нет, производитель волен менять как угодно — и меняет.

В треке Основы Computer Science процессор разобран обзорно: цикл «выбрать команду — декодировать — выполнить», регистры, АЛУ. В Системном программировании вы писали на ассемблере: регистры, стек, соглашения о вызовах. Здесь начинается следующий уровень — не «как выполняется команда», а как устроен сам набор команд как инженерный документ: что в него кладут, что намеренно не кладут, какие решения оказались дорогими через двадцать лет и почему три живых сегодня семейства команд выглядят так по-разному.

Эта глава — фундамент для всего дальнейшего трека. Конвейер, спекуляция, суперскалярность, векторные расширения — всё это способы исполнить контракт быстрее, не нарушив его. Без понимания контракта они выглядят набором фокусов.

Архитектура и микроархитектура: линия, которую нельзя размывать

Два слова звучат похоже и означают принципиально разное.

Архитектура (ISA) — спецификация. Документ. Что такое команда add, какие у неё операнды, что произойдёт при переполнении, сколько регистров, что случится при обращении по неотображённому адресу. Это интерфейс.

Микроархитектура — конкретная реализация этой спецификации в кремнии. Сколько ступеней в конвейере, сколько команд декодируется за такт, какого размера кэш первого уровня, есть ли предсказатель переходов и какой. Это внутренности.

Одну ISA реализуют десятки разных микроархитектур — от однотактного учебного ядра на ПЛИС до серверного монстра. Все они исполняют один и тот же машинный код. Различаются скоростью, энергией и ценой — но не результатом.

Где проходит линия ISA

Практическое следствие этой линии — то, ради чего её и провели:

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

Правая колонка — содержание глав про конвейер, внеочередное исполнение и подсистему памяти. Левая — содержание этой.

Типичная путаница в разговорах: «этот код быстрее на x86». Такого утверждения не существует. x86 — контракт, а не скорость. Быстрее или медленнее бывает конкретная реализация конкретного поколения, и разброс внутри одного контракта запросто достигает порядка величины — от энергоэффективного ядра в планшете до серверного ядра с большим кэшем.

Архитектурное состояние: то, что программа имеет право видеть

Самая точная формулировка контракта звучит так: ISA определяет архитектурное состояние машины и то, как каждая команда его меняет.

Архитектурное состояние — это:

  • регистры общего назначения (их число и ширина; у x86-64 их 16, у AArch64 — 31 плюс отдельный указатель стека, у RISC-V — 32, причём нулевой всегда читается как ноль и не записывается);
  • счётчик команд — адрес следующей команды;
  • память — то, что видно по адресам;
  • флаги состояния, если они в этой ISA есть (у x86 и ARM есть, у RISC-V нет вовсе — и это осознанное решение, к которому вернёмся);
  • системные регистры: режим привилегий, указатели на таблицы страниц, маски прерываний.

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

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

Требование точности стоит дорого: именно оно заставляет добавлять буфер переупорядочивания и откладывать фиксацию результатов до момента, когда команда точно «выжила». Без него не работала бы виртуальная память — а значит, ни отображение файлов в память, ни ленивое выделение, ни подкачка. Как этим пользуется операционная система, подробно разобрано в главе про управление памятью.

Обратите внимание на асимметрию: контракт обещает результат, но не обещает путь. Именно в этом зазоре живут все ускоряющие трюки — и, как мы увидим ближе к концу главы, все связанные с ними проблемы безопасности.

Из чего состоит система команд

Спецификация ISA — это не список мнемоник. Список команд занимает в ней меньшую часть. Вот из чего она реально собрана.

Модель операндов: откуда команда берёт данные

Исторически это главная развилка, и она объясняет, почему разные ISA выглядят по-разному.

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

Стековая модель. Операнды берутся с вершины стека, результат кладётся туда же. add не имеет операндов вообще — она однобайтовая. Максимальная плотность кода, простейший компилятор. Проблема: стек — узкое место, соседние операции жёстко сериализованы, а внеочередное исполнение почти невозможно. Поэтому в железе стековая модель практически вымерла — но выжила в виртуальных машинах, где важнее компактность и простота интерпретатора: байткод JVM и .NET именно стековые.

Регистр-память. Арифметическая команда может взять один операнд прямо из памяти: add eax, [rdi+rdx*4]. Меньше команд на ту же работу, плотнее код. Так устроен x86. Цена: команда делает две принципиально разные вещи — обращение к памяти (которое может дать исключение и занять сотни тактов) и арифметику. Реализовать это конвейером сложнее.

Load/store (регистр-регистр). Арифметика работает только с регистрами; в память ходят отдельные команды load и store. Так устроены ARM, RISC-V, MIPS, SPARC, Power. Команд на ту же работу больше, зато каждая делает ровно одно и легко раскладывается по ступеням конвейера.

Причина перехода к load/store — не эстетика, а изменившиеся физические пропорции. Когда память была сравнима по скорости с процессором, а её объём измерялся килобайтами, экономить каждый байт кода было критично. Когда процессор стал в сотни раз быстрее памяти, а сама память подешевела, оптимизировать стало нужно другое: возможность держать много операций в полёте одновременно. Эту физическую подоплёку разбирают главы про историю и про транзистор; спор о том, где здесь оптимум, — тема следующей главы.

Режимы адресации

Как команда вычисляет адрес в памяти — отдельный набор решений с прямыми последствиями.

Режим Пример Кто поддерживает
база + константа lw t0, 8(a0) практически все
база + регистр ldr w0, [x1, x2] ARM, x86
база + регистр со сдвигом ldr w0, [x1, x2, lsl #2] ARM, x86 (масштаб 1/2/4/8)
база + индекс + константа mov eax, [rdi+rdx*4+16] x86
с автоинкрементом ldr w0, [x1], #4 ARM, многие DSP
относительно счётчика команд auipc, lea rax, [rip+off] все современные, нужно для позиционно-независимого кода

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

Управление потоком: флаги против сравнения в переходе

Есть две школы.

С флагами. Команда сравнения (или любая арифметическая) выставляет биты состояния — ноль, знак, перенос, переполнение. Условный переход читает эти биты. Так у x86 и ARM.

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

Без флагов. Переход сам сравнивает два регистра: blt t1, a1, .loop — «перейти, если t1 меньше a1». Так у RISC-V и MIPS.

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

Отдельный сюжет — условное исполнение. В 32-битном ARM почти каждая команда имела условный суффикс: addne, moveq. Идея была в том, чтобы избавиться от коротких переходов, которые дорого обходятся конвейеру. При переходе к 64-битному A64 от этого отказались почти полностью, оставив несколько команд условного выбора вроде csel. Причина инженерная: команда, которая может ничего не сделать, всё равно занимает место в конвейере и держит зависимость по регистру-приёмнику, а с хорошим предсказателем переходов выигрыш от неё исчезает. Про цену ошибки предсказания — отдельная глава.

Системный уровень: привилегии, исключения, атомарность

Пользовательская часть ISA — только половина контракта. Вторая половина описывает, как работает ядро операционной системы.

У RISC-V три уровня — M, S, U; машинный уровень нужен встроенному программному обеспечению, супервизорный — ядру ОС. У ARM это уровни исключений EL0–EL3, где EL2 отведён гипервизору, а EL3 — доверенной среде. У x86 исторически четыре «кольца», из которых реально используются два, плюс отдельная ось для виртуализации. Разные названия, одна идея: непривилегированный код не может выполнить команду, меняющую правила игры, а попытка приводит к исключению. Как этим пользуется ядро при загрузке и при обработке системных вызовов — в главах про загрузку и про системные вызовы.

Атомарные операции — тоже часть контракта, и здесь тоже две школы. x86 даёт готовые команды вроде lock cmpxchg. RISC-V и ARM дают пару «загрузить с резервированием — записать условно» (lr/sc, ldxr/stxr), из которой любая атомарная операция собирается циклом; сверх того RISC-V добавляет расширение A с готовыми командами вида «прибавить атомарно». Первый подход компактнее, второй — гибче и проще для железа.

И наконец, модель памяти — правила о том, в каком порядке записи одного ядра становятся видны другим. Это самая контринтуитивная часть любой ISA, и она полностью относится к контракту: код, корректный на x86 с его довольно строгой моделью, может ломаться на ARM или RISC-V с более слабой. Подробно — в главе про многоядерность.

Кодирование: биты, из которых состоит команда

Как только решено, что команда делает, остаётся вопрос, какими битами это записать. Здесь два принципиально разных подхода.

Кодирование команд: фиксированная и переменная длина

Фиксированная длина. Каждая команда занимает одинаковое число байт (у A64 и базового RISC-V — четыре). Декодер знает границы команд заранее: чтобы разобрать восемь команд параллельно, достаточно взять 32 байта и раздать их восьми декодерам. Поля стоят на фиксированных местах, поэтому номера регистров можно начать читать, ещё не поняв, что это за команда.

Цена — теснота. В 32 бита нужно уместить и код операции, и до трёх номеров регистров, и константу. Отсюда странные на вид решения RISC-V: биты непосредственного операнда в форматах S, B и J перемешаны. Это не небрежность — так сделано, чтобы одни и те же биты команды всегда попадали на одни и те же провода в схеме расширения знака, независимо от формата. Экономия проводов и задержки, оплаченная неудобством чтения для человека.

Переменная длина. Команда x86-64 занимает от 1 до 15 байт: префиксы, опциональный REX, код операции, байт ModRM, байт SIB, смещение, константа. Плотность кода выше — частые операции кодируются коротко. Но чтобы узнать, где кончается команда, надо разобрать её начало, и границу следующей нельзя узнать раньше. Параллельный декодер приходится строить как отдельный блок поиска длин, который каждый такт сканирует байты, — это дополнительная площадь и дополнительная энергия на каждый такт работы.

Компромисс между этими крайностями оба лагеря нашли в одном и том же месте: сжатые команды. ARM сделал Thumb и Thumb-2 с 16- и 32-битными командами, RISC-V — расширение C, где часто встречающиеся операции с популярными регистрами кодируются двумя байтами. Отличить сжатую команду от полной в RISC-V можно по двум младшим битам, так что декодер остаётся простым. Выигрыш в размере кода на реальных программах — величина порядка десятков процентов, но конкретное число сильно зависит от компилятора, набора тестов и того, насколько код насыщен вызовами; любую цифру здесь стоит воспринимать как порядок, а не как константу.

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

Одна функция, три системы команд

Разговор становится конкретным, когда видишь один и тот же код. Возьмём предельно простую функцию:

int sum(const int *a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++)
        s += a[i];
    return s;
}

x86-64 (синтаксис Intel; аргументы в rdi и esi, результат в eax):

sum:
        xor     eax, eax             ; s = 0
        test    esi, esi
        jle     .done                ; n <= 0 — сразу выходим
        mov     ecx, esi             ; сколько итераций
        xor     edx, edx             ; i = 0
.loop:
        add     eax, [rdi + rdx*4]   ; чтение из памяти прямо внутри арифметики
        inc     rdx
        cmp     rdx, rcx
        jb      .loop                ; переход по флагам от cmp
.done:
        ret

AArch64 (аргументы в x0 и w1, результат в w0):

sum:
        mov     w2, wzr              // s = 0, wzr  нулевой регистр
        cmp     w1, #0
        b.le    .done
        mov     x3, #0               // i = 0
.loop:
        ldr     w4, [x0, x3, lsl #2] // загрузка отдельной командой, но адрес со сдвигом
        add     w2, w2, w4
        add     x3, x3, #1
        cmp     w3, w1
        b.lt    .loop                // тоже по флагам
.done:
        mov     w0, w2
        ret

RV64I (аргументы в a0 и a1, результат в a0):

sum:
        li      t0, 0                # s = 0
        blez    a1, .done            # сравнение и переход — одна команда
        li      t1, 0                # i = 0
.loop:
        slli    t2, t1, 2            # i * 4 — отдельной командой
        add     t2, a0, t2           # адрес элемента — тоже отдельной
        lw      t3, 0(t2)            # единственный режим адресации: база + смещение
        addw    t0, t0, t3
        addi    t1, t1, 1
        blt     t1, a1, .loop        # сравнение прямо в переходе, флагов нет
.done:
        mv      a0, t0
        ret

Что здесь видно, помимо разного синтаксиса:

  1. Границы команды проходят в разных местах. Одна команда x86 add eax, [rdi+rdx*4] делает то, на что у RISC-V уходит четыре: сдвиг, сложение адреса, загрузка, сложение. Это не «x86 быстрее» — это разное место, где проведена линия между командой и микрооперацией.
  2. Флаги против сравнения в переходе. У x86 и ARM состояние передаётся неявно через регистр флагов, у RISC-V — явно в операндах перехода.
  3. Нулевой регистр. wzr у ARM и x0 у RISC-V позволяют не заводить отдельные команды для «обнулить», «сравнить с нулём», «просто скопировать»: mv a0, t0 — это на самом деле addi a0, t0, 0. Экономия кодового пространства без экономии на выразительности.
  4. Псевдокоманды. li, mv, blez в RISC-V не существуют как биты — ассемблер разворачивает их в реальные команды. Разделение между тем, что удобно писать, и тем, что закодировано в железе, — обычная практика, и при чтении дизассемблера её надо держать в голове.
  5. Компилятор перепишет это иначе. Реальный -O2 вынесет вычисление адреса из цикла и будет двигать указатель: lw t3, 0(a0) / addi a0, a0, 4. А при включённых векторных расширениях цикл вообще превратится в набор SIMD-операций. Ассемблерные листинги выше — иллюстрация модели, а не то, что вы увидите на выходе оптимизирующего компилятора.

Почему число команд ничего не говорит о скорости

Соблазн посчитать команды в трёх листингах и сделать вывод очень велик. Он ошибочен, и вот почему.

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

Дальше — два механизма, которые окончательно ломают наивный счёт:

  • Разложение на микрооперации. Внутри современного процессора команда — не единица работы. add eax, [rdi+rdx*4] превращается в загрузку и сложение, которые дальше живут своей жизнью, планируются независимо и завершаются в удобном железу порядке. Формально это делают все высокопроизводительные реализации, независимо от того, «CISC» у них контракт или «RISC».
  • Сращивание команд (macro-op fusion). Обратный процесс: декодер видит подряд идущие сравнение и переход — и превращает их в одну внутреннюю операцию. Или, в случае RISC-V, видит slli + add и делает из них одну адресную операцию. То есть недостаток «много мелких команд» частично компенсируется прямо в декодере, а разработчики ISA специально подбирают идиомы так, чтобы их было удобно сращивать.

Итог: контракт задаёт, что должно получиться, а сколько внутренних шагов на это уйдёт — вопрос реализации. Именно поэтому спор «RISC против CISC» в его исходной формулировке закончился ничем — об этом следующая глава. А как измерять производительность честно, вместо счёта команд глазами, — тема трека Производительность.

Контракт во времени: совместимость и расширения

ISA — редкий инженерный артефакт, который переживает всё, что вокруг него построено. Причина проста: за контрактом стоят миллиарды строк уже собранного кода, часть которого некому пересобрать.

Здесь видны две стратегии расширения контракта.

Наращивать поверх старого. AMD64 добавил 64-битные регистры, не убрав ни одной старой команды: 16-битный режим, сегменты, стековый сопроцессор для плавающей точки — всё осталось. Выигрыш: старые программы работают. Цена: декодер вынужден разбирать все исторические слои, кодовое пространство забито legacy-вариантами, а описание архитектуры разрослось до тысяч страниц. Часть этого груза постепенно снимают — но снятие любой возможности требует отдельного длинного согласования с индустрией, потому что где-то на неё опирается работающий код.

Сделать рядом новый контракт. ARMv8 ввёл A64 как отдельный набор команд с другим кодированием: 31 регистр вместо 16, нет условного исполнения, нет переменной длины. Старый 32-битный набор какое-то время поддерживался параллельно, а затем в новых ядрах старших классов его перестали реализовывать. Выигрыш: чистый контракт без наследства. Цена: разовый разрыв, который пришлось пережить всей экосистеме.

RISC-V пошёл третьим путём: маленькое неизменяемое ядро плюс модульные расширения. Базовый целочисленный набор RV32I/RV64I зафиксирован навсегда — порядка сорока команд (точный счёт зависит от версии спецификации и от того, считать ли системные команды и барьеры). Всё остальное — буквы: M (умножение и деление), A (атомарные), F и D (плавающая точка), C (сжатые команды), V (векторные) и десятки более узких. Устройство может реализовать любую комбинацию. Подробно устройство и последствия — в отдельной главе.

Как программа узнаёт, что доступно

Модульность создаёт обратную проблему: собранный двоичный файл не знает заранее, какие расширения есть на машине, где он запустится. Решений три, и на практике комбинируются все.

Механизм опроса у каждой ISA свой: на x86 это команда cpuid, на ARM под Linux — вектор вспомогательных значений с битами возможностей, на RISC-V — регистр misa для привилегированного кода и сведения от ОС для пользовательского. Поверх этого выстроены практические соглашения: у x86-64 определены уровни v2, v3 и v4, каждый из которых означает фиксированный набор расширений, и системы вроде glibc умеют подгружать разные варианты библиотек под разный уровень. У RISC-V ту же роль играют профили — RVA22, RVA23: набор букв, который поставщик обязуется реализовать целиком, чтобы дистрибутив мог собираться под один целевой уровень, а не под комбинаторику из десятков флагов.

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

ISA и ABI: два разных контракта

Очень частая путаница. ISA говорит, что делает команда. Она ничего не говорит о том, в каком регистре передавать первый аргумент функции, кто отвечает за сохранение регистров при вызове, как выровнен стек, как раскладывается структура в памяти и как устроена таблица виртуальных функций. Всё это — ABI (application binary interface), отдельное соглашение, поверх одной ISA их может быть несколько.

Практические последствия:

  • Один и тот же процессор x86-64 под Linux и под Windows использует разные соглашения о вызовах: разный порядок регистров для аргументов, разный набор сохраняемых регистров, разные правила для «красной зоны» под стеком.
  • Объектные файлы, собранные под разные ABI, не соберутся вместе — а если соберутся из-за совпадения имён, поведение будет неопределённым.
  • Смена ABI больнее смены ISA: перекомпиляции недостаточно, ломается совместимость библиотек. Поэтому ABI фиксируют документом и потом почти не трогают.

Детали соглашений о вызовах для x86-64 разобраны в Системном программировании, а то, как компилятор выбирает команды и распределяет регистры под конкретную ISA, — в Генерации кода.

Что ISA не обещает и где это протекает

Контракт описывает результат. Он не описывает время. Нигде в спецификации x86, ARM или RISC-V не сказано, сколько тактов занимает загрузка из памяти, — и не случайно: это свойство реализации, оно меняется каждое поколение.

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

Что Порядок величины О чём речь
попадание в кэш первого уровня единицы тактов высокопроизводительные ядра 2020-х
попадание в кэш последнего уровня десятки тактов там же; сильно зависит от размера кристалла
промах до оперативной памяти сотни тактов там же; растёт с ростом частоты ядра
ошибка предсказания перехода десятки тактов тем больше, чем глубже конвейер
длина команды 1–15 байт у x86, 4 у A64, 2 или 4 у RISC-V с расширением C свойство контракта, не поколения
декодирование за такт единицы команд у настольных ядер, одна-две у микроконтроллерных 2020-е
архитектурных регистров общего назначения 16 у x86-64, 31 у A64, 32 у RISC-V свойство контракта
физических регистров сотни высокопроизводительные ядра 2020-х

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

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

Ответ индустрии оказался двойным: заплатки в микрокоде и ядрах ОС — и постепенное расширение самого контракта. В ISA стали добавлять то, чего там раньше принципиально не было: команды-барьеры против спекуляции, средства защиты потока управления (теневой стек и проверка целей косвенных переходов у x86, аутентификация указателей и метки ветвлений у ARM). Иными словами, «безопасность» переехала из свойств реализации в текст контракта. Подробнее про спекуляцию и её цену — в главе про предсказание переходов; про эксплуатацию побочных каналов в прикладном коде — в треке Безопасность.

Отдельно стоит сказать, почему это вообще стало актуально именно в последние два десятилетия. Пока росла тактовая частота, производительность приходила «даром». Когда рост частоты упёрся в бюджет мощности, единственным источником ускорения остались параллелизм и специализация — а это значит агрессивную спекуляцию, много ядер и специализированные команды. То есть давление физики напрямую превратилось в давление на контракт: в ISA стали добавлять векторные расширения, атомарные примитивы, криптографические команды и подсказки для предвыборки. Физическая сторона разбирается в главе про мощность и пределы, а специализированные исполнители — в главе про ускорители.

Виртуальные системы команд

Идея контракта оказалась настолько полезной, что её воспроизвели без всякого железа.

Байткод JVM и CIL — стековые ISA для несуществующих машин. Компактны, легко проверяются на корректность, транслируются в машинный код на лету. Про устройство виртуальных машин и JIT-компиляцию — отдельные главы трека компиляторов.

WebAssembly — тоже стековая виртуальная ISA, спроектированная так, чтобы проверка безопасности была дешёвой, а трансляция в машинный код — почти линейной.

Бинарная трансляция — обратная задача: исполнить код одной ISA на железе другой. Rosetta 2 при переходе Apple с x86-64 на ARM переводила программы заранее, при установке, а не при каждом запуске; QEMU транслирует динамически. Здесь всплывает важная деталь: транслировать надо не только команды, но и модель памяти. Программа, написанная под строгую модель x86, на слабой модели ARM может сломаться, поэтому при трансляции либо расставляют барьеры (дорого), либо переключают ядро в режим более строгой модели, если такая возможность в железе предусмотрена. Хороший пример того, что контракт — это не только список команд.

Стоит отметить и то, что LLVM IR, вопреки распространённому мнению, не является виртуальной ISA: он не задаёт архитектурное состояние машины и намеренно оставляет часть поведения неопределённой, чтобы оптимизатор мог им пользоваться.

Практика: как посмотреть на контракт своими глазами

Всё вышеописанное проверяется за десять минут на своей машине.

# что реально сгенерировал компилятор — не догадки, а листинг
gcc -O2 -S -masm=intel -o - sum.c

# дизассемблировать готовый бинарник и посмотреть только нужную функцию
objdump -d --no-show-raw-insn ./a.out | sed -n '/<sum>:/,/^$/p'

# с байтами команд — видно, что у x86 они разной длины
objdump -d ./a.out | head -40

# собрать под совсем другую ISA и запустить без соответствующего железа
riscv64-linux-gnu-gcc -O2 -march=rv64gc -o sum-rv sum.c
qemu-riscv64 -L /usr/riscv64-linux-gnu ./sum-rv

# какие расширения поддерживает процессор под вами
lscpu                      # на x86 — длинный список флагов
cat /proc/cpuinfo          # на RISC-V здесь строка вида isa: rv64imafdc

Полезные привычки при чтении чужой ISA:

  1. Начинать не со списка команд, а с главы про архитектурное состояние и модель памяти — там лежит смысл.
  2. Смотреть на кодирование: оно объясняет, почему команды именно такие. Ограничение «12 бит на константу» породило половину идиом RISC-V.
  3. Отличать псевдокоманды ассемблера от реальных.
  4. Проверять, что относится к пользовательскому уровню, а что к привилегированному: это обычно разные тома спецификации.
  5. Использовать Compiler Explorer — переключение целевой архитектуры в один клик показывает разницу моделей нагляднее любого текста.

Типичные заблуждения

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

«У меня x86, значит поведение такое-то». Уточните, что именно: контракт или реализация. Тайминги, размеры кэшей и агрессивность спекуляции — реализация, они разные даже внутри одного поколения.

«Собралось под ту же архитектуру — значит, слинкуется». ABI — отдельный контракт. Разные соглашения о вызовах ломают совместимость при одинаковой ISA.

«Невыровненный доступ работает». На x86 работает почти всегда. На ARM — для большинства обычных обращений, но не для всех. На RISC-V базовая спецификация допускает и аппаратную поддержку, и исключение с программной эмуляцией, которая может быть на порядки медленнее. Код, который «работал», внезапно становится в сто раз медленнее при переносе.

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

«Зарезервированные биты равны нулю». Резерв означает «поведение не определено». Код, полагающийся на текущее значение, ломается при появлении расширения, которое эти биты займёт.

«Спецификация описывает всё наблюдаемое поведение». Она описывает результат. Время, энергия и следы в кэше наблюдаемы, но не описаны — на этом стоят атаки по побочным каналам.

Мини-итог

  • ISA — это контракт: описание архитектурного состояния и того, как каждая команда его меняет. Всё, чего в контракте нет, реализация меняет свободно.
  • Линия между архитектурой и микроархитектурой — не педантизм, а причина, по которой двоичный код живёт десятилетиями, а процессоры под ним меняются целиком.
  • Ключевые решения любой ISA: модель операндов, режимы адресации, наличие флагов, кодирование, системный уровень и модель памяти. Все они — компромиссы между размером кода, длиной критического пути и сложностью декодера.
  • Точные исключения — требование контракта, из которого вырастает почти вся сложность современного конвейера.
  • Число команд не измеряет скорость: разложение на микрооперации и сращивание команд размывают счёт в обе стороны.
  • Контракт расширяют тремя способами: наращивая наследие, вводя новый набор рядом или собирая систему из модулей. У каждого своя цена, и цену платит экосистема, а не спецификация.
  • ABI — отдельный контракт поверх ISA, и ломается он больнее.
  • То, что контракт не обещает, всё равно наблюдаемо — отсюда атаки по времени и постепенное переползание вопросов безопасности в текст самой спецификации.

Источники

  • David Patterson, John Hennessy. Computer Organization and Design, RISC-V Edition — базовый учебник, где ISA вводится вместе с реализацией.
  • John Hennessy, David Patterson. Computer Architecture: A Quantitative Approach — приложения по системам команд и количественный подход к компромиссам.
  • The RISC-V Instruction Set Manual — тома для непривилегированного и привилегированного уровней; редкий пример спецификации, которую можно прочитать целиком.
  • Intel 64 and IA-32 Architectures Software Developer Manuals — полное описание x86-64 со всеми историческими слоями.
  • Arm Architecture Reference Manual — описание A64 и уровней исключений.
  • felixcloutier.com/x86 — удобный справочник по командам x86.
  • System V AMD64 ABI — пример того, как выглядит ABI как документ.
  • RISC-V Profiles — как индустрия договаривается о базовом наборе расширений.
  • Vijay Nagarajan et al. A Primer on Memory Consistency and Cache Coherence — если нужна серьёзная работа с моделями памяти.
  • Compiler Explorer — практический инструмент для сравнения того, что генерируется под разные ISA.

Что дальше

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

RISC и CISC: спор, который закончился не победой, а смешением

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов