Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Y86-64: система команд и кодирование

senior~120 мин

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

Y86-64: система команд и кодирование

Одиннадцать уроков подряд ты читал чужой машинный код: доставал листинг компилятора, разбирал префиксы, считал смещения переходов, в конце сам порождал байты x86-64 и вызывал их как функцию. Теперь стрелка разворачивается до конца. Мы берём маленькую систему команд, Y86-64 из четвёртой главы CS:APP, и за одиннадцать уроков строим под неё всё: ассемблер, симулятор, а потом настоящий процессор на Verilog, сначала последовательный, потом конвейерный. Начинаем с контракта, на котором всё это держится: какое состояние машина показывает программисту и как каждая инструкция превращается в байты.

Цели урока

  • Назвать всё состояние Y86-64, видимое программисту: пятнадцать регистров, три флага, счётчик команд, память и код состояния.
  • Разобрать первый байт инструкции на icode и ifun и понять, почему код операции и функция разнесены по полубайтам.
  • Прочитать байт регистров rA и rB, объяснить, зачем нужен код 0xf, которого нет ни у одного регистра.
  • Знать, у каких инструкций есть байт регистров, у каких восьмибайтное поле valC, и считать длину инструкции по одному первому полубайту.
  • Закодировать любую инструкцию Y86-64 руками и раскодировать любой дамп обратно в ассемблер.
  • Объяснить каждое расхождение с x86-64 как инженерное решение, а не как упрощение ради упрощения.
  • Понимать четыре кода состояния AOK, HLT, ADR и INS и знать, что машина делает при каждом.
  • Спроектировать кодирование новой инструкции iaddq и посчитать, что она даёт по числу инструкций и по размеру кода.

Идея: система команд, которую можно построить целиком

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

Y86-64 решает ровно эту задачу. Это подмножество x86-64, урезанное до двенадцати инструкций и одного способа адресации, но урезанное честно: те же регистры, те же флаги, тот же little-endian, тот же стек, растущий вниз, то же соглашение о вызовах. Всё, что ты знаешь про машинный уровень из предыдущих уроков, здесь работает. Только набор стал таким, что его целиком видно на одном экране, и потому под него можно написать ассемблер за один урок, симулятор за второй и процессор за восемь.

Этот урок открывает y86lab, сквозную работу всего блока. За одиннадцать уроков ты напишешь три вещи.

  1. Ассемблер на Zig (урок 21). Лексер, парсер, два прохода с таблицей меток, кодировщик. На вход текст на языке ассемблера Y86, на выход байты и листинг, где каждая строка исходника стоит рядом со своими байтами.
  2. Симулятор на Zig (урок 22). Шесть этапов книжной последовательной машины как шесть функций над одной структурой состояния. На вход байты, на выход трасса: адрес каждой исполненной инструкции, её код, состояние машины после неё, конечные регистры и изменённые слова памяти.
  3. Процессор на SystemVerilog (уроки с 23 по 30). Сначала вентили и мультиплексоры, потом ALU и регистровый файл, потом последовательная машина SEQ целиком, потом конвейер PIPE с продвижением данных, остановами и пузырьками.

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

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

Состояние, видимое программисту

Состояние, видимое программисту, у Y86-64 умещается в пять строк.

ЧтоСколькоПодробности
Регистры15 штук по 64 бита%rax, %rcx, %rdx, %rbx, %rsp, %rbp, %rsi, %rdi, %r8 до %r14
Флаги3 битаZF (ноль), SF (знак), OF (переполнение)
Счётчик команд64 битаадрес текущей инструкции
Память4096 байтадреса от 0x000 до 0xfff, побайтовая адресация, little-endian
Код состояния4 значенияAOK, HLT, ADR, INS

Три строки в этой таблице разберём сразу.

Регистров пятнадцать, а не шестнадцать. В x86-64 их шестнадцать, от %rax до %r15, и номер укладывается ровно в четыре бита. Y86-64 отдаёт код 0xf под особое значение “регистра нет”, а %r15 выбрасывает. Зачем, станет понятно через два раздела, когда мы дойдём до байта регистров: инструкции вроде pushq используют только одно из двух полей, и второе надо чем-то заполнить.

Флагов три, а не шесть. У x86-64 есть ещё флаг переноса CF и флаг чётности PF, они нужны беззнаковой арифметике и работе с числами с плавающей точкой (ты видел их в уроке про флаги, переходы и cmov). Y86-64 это знаковый набор команд без беззнаковых сравнений и без плавающей точки, поэтому CF и PF ему не нужны. Флаги выставляют только арифметические инструкции, OPq и её родственница iaddq. Ни пересылка, ни обращение к памяти, ни переход флагов не трогают.

Память крошечная и целиком своя. Четыре килобайта, никакой виртуальной памяти, никакого разделения на код и данные: инструкции, данные и стек лежат в одном плоском массиве байт. Программа сама решает, где у неё что, директивой .pos. Стек, как и в x86-64, растёт вниз, поэтому его обычно ставят в конец памяти, а код с нуля. Обращение за границу это не “сегфолт”, а код состояния ADR, о нём ниже.

Начальное состояние машины после сброса тоже часть контракта: все регистры нулевые, ZF равен единице, SF и OF нулевые, счётчик команд равен нулю, код состояния AOK. Симулятор и Verilog обязаны стартовать одинаково, иначе трассы разойдутся на первой же инструкции.

Первый байт: icode и ifun

Инструкция Y86-64 начинается с одного байта, и этот байт всегда делится пополам. Старший полубайт это icode, младший это ifun.

Разделение не косметическое. icode отвечает на вопрос “что за инструкция”, ifun на вопрос “какая именно из семейства”. Для железа инструкции с одинаковым icode устроены одинаково: одна и та же длина, одни и те же поля, один и тот же путь по этапам. Отличается только число, которое уезжает в ALU или в блок проверки условия. Когда мы дойдём до процессора, ты увидишь, что почти вся управляющая логика смотрит на icode, а ifun идёт мимо неё прямо в исполнительный блок.

Тринадцать значений icode заняты, три семейства пользуются полем ifun.

icodeИнструкцияЧто делает
0haltостановить машину
1nopничего
2rrmovq и cmovXXпереслать регистр в регистр, может быть по условию
3irmovqположить константу в регистр
4rmmovqзаписать регистр в память
5mrmovqпрочитать память в регистр
6OPqарифметика и логика над двумя регистрами
7jXXпереход, может быть по условию
8callвызов
9retвозврат
apushqположить регистр на стек
bpopqснять со стека в регистр
ciaddqприбавить константу к регистру (расширение, о нём в конце урока)

Поле ifun расписано двумя таблицами. Для OPq оно выбирает операцию ALU.

ifunМнемоникаДействие
0addqсложить
1subqвычесть
2andqпобитовое И
3xorqпобитовое исключающее ИЛИ

Для переходов и условных пересылок то же поле выбирает условие, и таблица у них общая: jle и cmovle это один и тот же ifun, только при разном icode.

ifunПереходПересылкаУсловие по флагам
0jmprrmovqвсегда
1jlecmovleSF ^ OF или ZF
2jlcmovlSF ^ OF
3jecmoveZF
4jnecmovne!ZF
5jgecmovge!(SF ^ OF)
6jgcmovg!(SF ^ OF) & !ZF

Обрати внимание на две строки с ifun равным нулю. Безусловный переход jmp это jXX с условием “всегда”, а обычная пересылка регистра rrmovq это cmovXX с тем же условием. Отдельных кодов для них нет и не нужно: условие “всегда” это законное условие. Так набор экономит два icode, а железо не получает ни одной лишней ветки. Знаковую логику условий ты уже разбирал: SF ^ OF это и есть знаковое “меньше”, знак результата, исправленный на переполнение.

Второй байт: rA и rB

Если инструкции нужны регистры, за первым байтом идёт ровно один байт, и он тоже делится пополам: старший полубайт это rA, младший rB. Номера регистров те же, что в x86-64, и в том же порядке.

КодРегистрКодРегистрКодРегистр
0%rax6%rsic%r12
1%rcx7%rdid%r13
2%rdx8%r8e%r14
3%rbx9%r9fRNONE
4%rspa%r10
5%rbpb%r11

Код 0xf называется RNONE. Это не регистр, это признак “поле есть, регистра в нём нет”. Он нужен потому, что байт регистров неделим: он либо есть целиком, либо его нет вовсе. Полбайта не бывает.

Посмотри, как это работает на трёх формах.

  • addq %rax, %rbx использует оба поля: rA равно 0, rB равно 3. Байт регистров получается 03.
  • pushq %rbx использует только rA: rB заполняется RNONE. Байт получается 3f.
  • irmovq $8, %rbx, наоборот, использует только rB, потому что источник это константа, а не регистр. Байт получается f3.

И это уже не просто соглашение о записи, а правило проверки. Если в дампе встретится a0 31, то есть pushq с непустым rB, машина обязана назвать такую инструкцию недопустимой, а не “догадаться”. В процессоре RNONE делает ту же работу физически: регистровый файл при номере 0xf отдаёт ноль на чтение и игнорирует запись, поэтому ни один этап не нуждается в дополнительном условии “а есть ли тут регистр”.

valC: восемь байт little-endian

Третье и последнее поле кодировки это valC, восьмибайтная константа. Она идёт последней, всегда занимает ровно восемь байт и всегда записана в порядке little-endian, то есть младшим байтом вперёд.

У valC три роли, и все три уже знакомы.

  • Непосредственное значение у irmovq и iaddq. Число как оно есть.
  • Смещение у rmmovq и mrmovq. Адрес считается как значение регистра rB плюс valC, и valC здесь знаковое: mrmovq -8(%rbp), %rax кладёт в valC число минус восемь, то есть восемь байт f8 ff ff ff ff ff ff ff.
  • Адрес перехода у jXX и call. И вот тут первое заметное расхождение с x86-64: адрес абсолютный, а не относительный. В x86-64 переход кодируется полем rel32, смещением от конца инструкции, и ты считал его руками в уроке про циклы и switch. В Y86-64 в valC лежит сам адрес назначения. Это дороже по байтам, зато этап выборки инструкции получает готовый адрес и никакой арифметики над ним делать не надо.

Восемь байт под константу это много. Инструкция irmovq $1, %r9 занимает десять байт, из которых семь нули. У x86-64 ради экономии есть отдельные короткие формы для маленьких констант, и поэтому длина инструкции там неизвестна, пока её не разобрал декодер. Y86-64 меняет размер кода на простоту декодирования, и это осознанный размен, к которому мы вернёмся через раздел.

Полная таблица кодирования

Теперь всё вместе. Первый байт есть всегда, байт регистров и valC по надобности, и надобность определяется одним только icode.

ИнструкцияБайтыДлина
halt001
nop101
rrmovq rA, rB20 rA rB2
cmovXX rA, rB2n rA rB2
irmovq V, rB30 f rB плюс V10
rmmovq rA, D(rB)40 rA rB плюс D10
mrmovq D(rB), rA50 rA rB плюс D10
OPq rA, rB6n rA rB2
jXX Dest7n плюс Dest9
call Dest80 плюс Dest9
ret901
pushq rAa0 rA f2
popq rAb0 rA f2
iaddq V, rBc0 f rB плюс V10

В колонке байтов n это переменное поле ifun, а f это полубайт RNONE. Внимательно прочитай строки irmovq и iaddq: у них байт регистров равен f rB, потому что источник это константа, и место rA занимает RNONE.

Из таблицы следуют два правила, на которых потом держится весь процессор.

Длина инструкции известна из первого полубайта. Ни от чего другого она не зависит. Байт регистров есть у восьми кодов: 2, 3, 4, 5, 6, a, b и c. Поле valC есть у шести: 3, 4, 5, 7, 8 и c. Складываешь единицу с тем, что нужно, и получаешь длину: 1, 2, 9 или 10 байт, других вариантов нет. В процессоре это два комбинационных сигнала, need_regids и need_valC, каждый из которых читает только icode.

Позиции полей фиксированы. rA всегда старший полубайт второго байта, valC всегда начинается с третьего байта, если байт регистров есть, и со второго, если его нет. Декодеру не нужно разбирать байты по очереди, накапливая состояние: он режет первые десять байт по границам полубайт один раз, а дальше выбирает мультиплексором, что из нарезанного имеет смысл.

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

Покрути его на трёх вещах. Набери irmovq $-1, %rsi и посмотри на valC: восемь байт ff, знаковое значение в дополнительном коде. Смени rrmovq %rax, %rbx на cmovg %rax, %rbx и увидишь, что меняется ровно один полубайт, ifun, а всё остальное стоит на месте. И сравни длину addq %rax, %rbx с длиной iaddq $1, %rbx: два байта против десяти, вся разница в поле valC.

Собираем программу руками

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

        .pos 0
init:   irmovq $10, %rdx
        irmovq $-4, %rbx
        rrmovq %rdx, %rax
        addq %rbx, %rax
        pushq %rax
        popq %rcx
        cmovg %rcx, %rsi
        rmmovq %rsi, 16(%rdx)
        mrmovq 16(%rdx), %rdi
        jne done
        call done
done:   ret
        halt

Директива .pos 0 говорит, что код начинается с нулевого адреса. Ассемблер эталона, который ты напишешь в следующем уроке, печатает такой листинг: адрес слева, байты посередине, исходная строка справа.

0x000: 30f20a00000000000000 | init:   irmovq $10, %rdx
0x00a: 30f3fcffffffffffffff |         irmovq $-4, %rbx
0x014: 2020                 |         rrmovq %rdx, %rax
0x016: 6030                 |         addq %rbx, %rax
0x018: a00f                 |         pushq %rax
0x01a: b01f                 |         popq %rcx
0x01c: 2616                 |         cmovg %rcx, %rsi
0x01e: 40621000000000000000 |         rmmovq %rsi, 16(%rdx)
0x028: 50721000000000000000 |         mrmovq 16(%rdx), %rdi
0x032: 744400000000000000   |         jne done
0x03b: 804400000000000000   |         call done
0x044: 90                   | done:   ret
0x045: 00                   |         halt

Пройдись по строкам сам, это ровно та работа, которую делает кодировщик.

irmovq $10, %rdx это icode 3, ifun 0, значит первый байт 30. Байт регистров f2: источник константа, поэтому rA равно RNONE, приёмник %rdx с номером 2. Дальше восемь байт числа десять младшим вперёд: 0a и семь нулей. Итого десять байт, и следующая инструкция начинается с адреса 0x00a.

irmovq $-4, %rbx отличается только приёмником и значением. Байт регистров f3, а минус четыре в дополнительном коде это fc ff ff ff ff ff ff ff. Заметь, что fc стоит первым: младший байт числа лежит по меньшему адресу.

rrmovq %rdx, %rax это 20 плюс байт регистров 20: rA равно 2 (%rdx), rB равно 0 (%rax). Совпадение двух байт здесь случайное, не запутайся.

addq %rbx, %rax это icode 6, ifun 0, значит 60, и регистры 30: %rbx номер 3 в поле rA, %rax номер 0 в поле rB. Порядок операндов, как и в синтаксисе AT&T из прошлых уроков, “источник, приёмник”, и считается rB плюс rA с результатом в rB.

pushq %rax это a0 0f: rA равно 0, rB это RNONE. У popq %rcx то же устройство: b0 1f.

cmovg %rcx, %rsi это тот самый случай, где ifun не ноль. icode 2, ifun 6 (условие “больше”), значит первый байт 26, а регистры 16: %rcx в rA, %rsi в rB.

rmmovq %rsi, 16(%rdx) это 40, регистры 62 (источник %rsi в rA, база %rdx в rB) и восемь байт смещения: 10 и семь нулей, потому что шестнадцать это 0x10. Обратная инструкция mrmovq 16(%rdx), %rdi устроена так же, только icode 5 и rA стало приёмником: 50 72 и то же смещение.

jne done это icode 7, ifun 4, первый байт 74, дальше восемь байт абсолютного адреса метки done, то есть 0x044. В листинге это 44 00 00 00 00 00 00 00. У call done всё то же, только первый байт 80. И здесь видно, зачем ассемблеру два прохода: в момент кодирования jne метка done ещё не встретилась, её адрес известен только после того, как посчитаны длины всех инструкций до неё.

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

0x000: 30f40002000000000000 | init:   irmovq stack, %rsp
0x00a: 803800000000000000   |         call main
0x013: 00                   |         halt
0x018: 0d000d000d000000     | array:  .quad 0x000d000d000d
                            | ... ещё три слова массива и функция main
0x056: 30f80800000000000000 | sum:    irmovq $8, %r8
0x060: 30f90100000000000000 |         irmovq $1, %r9
0x06a: 6300                 |         xorq %rax, %rax
0x06c: 6266                 |         andq %rsi, %rsi
0x06e: 708700000000000000   |         jmp test
0x077: 50a70000000000000000 | loop:   mrmovq (%rdi), %r10
0x081: 60a0                 |         addq %r10, %rax
0x083: 6087                 |         addq %r8, %rdi
0x085: 6196                 |         subq %r9, %rsi
0x087: 747700000000000000   | test:   jne loop
0x090: 90                   |         ret

Две строки тут стоит прочитать особо. mrmovq (%rdi), %r10 записан без смещения, но кодировка от этого не меняется: смещения нет, значит valC равно нулю, и восемь нулевых байт всё равно занимают своё место. Инструкция xorq %rax, %rax с байтами 63 00 это знакомая идиома обнуления регистра, и заодно она сбрасывает флаги. А andq %rsi, %rsi с байтами 62 66 не меняет %rsi вообще: она нужна только ради флагов, потому что в Y86-64 нет отдельной инструкции сравнения. Ни cmp, ни test в наборе нет, и роль обеих играет любая арифметика над нужными регистрами.

Чем Y86-64 отличается от x86-64 и зачем

Каждое расхождение с x86-64 это решение, принятое ради того, чтобы процессор можно было построить за восемь уроков. Разберём шесть главных.

Длина инструкции определяется одним полубайтом. В x86-64, как ты помнишь по уроку про операнды, mov и стек, длина складывается из необязательных префиксов, опкода, байта ModRM, байта SIB, смещения и непосредственного значения, и узнать её можно только разобрав всё по порядку. Настоящий процессор держит для этого отдельный блок предекодирования, который только режет поток байт на инструкции. В Y86-64 этого блока нет вовсе: посмотрел на icode, узнал длину, посчитал адрес следующей инструкции сложением. Один сумматор вместо конечного автомата.

Позиции полей фиксированы. rA всегда в одном месте, rB всегда в одном месте, valC всегда одной ширины. В x86-64 номер регистра собирается из трёх бит поля ModRM плюс бита из префикса REX, и ты вручную собирал этот номер в уроке про переход от Zig к машинному коду. Фиксированные позиции означают, что декодирование это провода, а не логика: этап выборки ведёт нужные биты в нужные входы.

Один способ адресации. В x86-64 операнд в памяти это Imm(rb, ri, s), база плюс индекс, умноженный на масштаб, плюс смещение, четыре компоненты в разных сочетаниях. В Y86-64 остался только D(rB), база плюс смещение. Значит адресный тракт это один сумматор, и никакого умножителя на масштаб. За это платят программы: индексировать массив приходится явно, прибавляя восьмёрку к указателю, как в цикле sum выше.

Пересылка расщеплена на четыре инструкции. В x86-64 всё это одна мнемоника mov, а какая именно форма, решает байт ModRM. В Y86-64 у каждой формы свой icode: rrmovq регистр в регистр, irmovq константа в регистр, rmmovq регистр в память, mrmovq память в регистр. Причина в том, что этапы процессора должны знать своё поведение сразу после выборки, из одного поля. Читает ли эта инструкция память, пишет ли она в память, откуда берётся операнд ALU: всё это должно быть функцией от icode, а не от разбора операндов.

Слова только восьмибайтные. Суффиксов b, w, l, q нет, есть только q. В x86-64 ширина операнда это отдельная ось, со своими правилами обнуления старшей части регистра при записи в тридцатидвухбитную форму. В Y86-64 регистровый файл, ALU и порт памяти все шириной 64 бита, и ни одному блоку не нужно знать про ширину.

Переходы абсолютные. В x86-64 условный переход несёт rel32, смещение от конца инструкции, и потому код можно переместить в памяти целиком, не переписывая переходы. Y86-64 кладёт в valC готовый адрес. Программы становятся непереносимыми по адресам, зато этап выборки отдаёт адрес перехода дальше как есть, без сумматора.

Сложи всё вместе, и увидишь общую линию: каждое решение сдвигает сложность из железа в кодировку. Инструкции стали длиннее и однообразнее, зато у процессора почти вся управляющая логика превращается в таблицу “icode вот такой, значит сигналы вот такие”. Ровно эту таблицу мы и будем писать на Verilog, начиная с урока 26.

Заметь при этом, чего Y86-64 не потерял. Знаковая арифметика в дополнительном коде, little-endian, стек вниз, соглашение о вызовах с адресом возврата в стеке, флаги и условные переходы по ним, условная пересылка cmovXX без ветвления: всё это здесь настоящее и работает так же, как в большой архитектуре. Соглашение о вызовах ты разбирал в уроке про процедуры и кадры стека, и здесь оно работает точно так же. Именно поэтому программа absSum из следующего урока будет существовать в двух версиях, с переходом и с cmov, и в самом конце блока мы измерим разницу в тактах на конвейере, как измеряли цену промаха предсказателя в уроке про флаги и cmov.

Исключения ISA: AOK, HLT, ADR, INS

Часть контракта это то, что машина делает, когда сделать нельзя. У Y86-64 всего четыре кода состояния, и это тоже часть состояния, видимого программисту.

КодЗначениеКогда
1AOKвсё в порядке, машина продолжает
2HLTвстретился halt
3ADRобращение к памяти или выборка инструкции за пределами памяти
4INSнедопустимая инструкция

Пока код состояния равен AOK, машина исполняет следующую инструкцию. Как только он стал любым другим, она останавливается. В настоящем процессоре три из этих случаев были бы исключениями, то есть передачей управления обработчику в ядре: недопустимая инструкция это SIGILL, обращение по плохому адресу это SIGSEGV, а halt привилегированная инструкция, которую пользовательской программе исполнять нельзя. У нас ядра пока нет, поэтому машина встаёт. В уроке 50, когда Y86-64 обрастёт битом привилегий и таблицей исключений, эти же три кода станут настоящими прерываниями.

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

Посмотри на трассы. Вот программа из двух инструкций, где вторая читает по адресу 0x1000, а память кончается на 0xfff.

        irmovq $0x1000, %rax
        mrmovq (%rax), %rbx
        halt
0x000 30 AOK
0x00a 50 ADR
halt cycles=2
%rax 0x0000000000001000

Строка трассы это адрес инструкции, её icode и ifun одной парой, и код состояния после неё. После строки halt трасса печатает все пятнадцать регистров и изменённые слова памяти; здесь и в двух следующих примерах оставлены только те строки, ради которых пример написан. Видно, что irmovq отработала нормально, а mrmovq завершилась с ADR, и halt до исполнения не дожил. Регистр %rax при этом остался записанным: инструкция, которая упала, свою работу до падения сделать успела.

Теперь недопустимая инструкция. Байт d0 это icode d, а такого кода в наборе нет.

0x000 30 AOK
0x00a d0 INS
halt cycles=2

И третий случай, самый коварный: icode законный, а поля заполнены неправильно. Байты a0 31 это pushq с rA равным %rbx и rB равным %rcx, но у pushq второго регистра быть не может, там обязан стоять RNONE.

0x000 30 AOK
0x00a a0 INS
halt cycles=2

Машина отвечает INS, и это принципиально. Заманчиво было бы просто игнорировать лишнее поле, и такая реализация даже работала бы на всех правильных программах. Но тогда у одной и той же программы появилось бы несколько кодировок, и две реализации Y86-64 могли бы разойтись на мусорных входах. Контракт должен быть однозначным в обе стороны: у каждой инструкции ровно одна кодировка, и каждая последовательность байт либо законная инструкция, либо INS.

iaddq: как проектируют новую инструкцию

Базовый набор Y86-64 не умеет прибавлять константу к регистру. Чтобы сдвинуть указатель на восемь байт, приходится сначала завести константу в отдельный регистр через irmovq, а потом сложить через addq. В цикле sum выше на это уходят два регистра, %r8 и %r9, и две инструкции в прологе.

Упражнение 4.3 книги предлагает добавить в набор инструкцию iaddq V, rB, которая складывает константу с регистром напрямую. Это миниатюра на тему “как вообще расширяют систему команд”, и решают её в четыре шага.

Шаг первый: выбрать icode. Занято тринадцать значений, с 0 по c, если считать вместе с самой iaddq, свободны d, e, f. Мы берём c, следующее свободное после b. Никакой глубины тут нет, важно только, чтобы код не пересекался с существующими: тогда старые программы кодируются и декодируются ровно как раньше.

Шаг второй: выбрать форму операндов, а значит и поля. У iaddq источник константа, приёмник регистр. Это в точности форма irmovq, поэтому и кодировка та же: байт регистров с RNONE в rA и приёмником в rB, дальше восемь байт valC. Длина десять байт. Здесь и проявляется польза от фиксированных полей: новая инструкция не изобретает свой формат, а переиспользует существующий, и декодер не меняется вообще.

Шаг третий: решить, что с ifun. Логично было бы сделать iaddq семейством с ifun как у OPq, чтобы разом получить isubq, iandq и ixorq. Книга этого не делает, и мы тоже: iaddq берёт ifun равным нулю, а любое другое значение это INS. Причина: набору нужна ровно одна инструкция, а не четыре, и лишний ifun пришлось бы тащить через все этапы процессора.

Шаг четвёртый: описать поведение через существующие этапы. iaddq читает регистр rB, складывает его с valC в ALU, выставляет флаги и пишет результат обратно в rB. То есть она берёт операнд valB из регистра, как OPq, и операнд valC из инструкции, как irmovq. Ни одного нового блока в процессоре не появляется, добавляется только пара строк в мультиплексоры перед ALU. Это и есть признак хорошо спроектированного расширения.

Что оно даёт, видно на сумме массива. Вот тот же цикл, переписанный с iaddq.

sum:    xorq %rax, %rax
        andq %rsi, %rsi
        jmp itest
iloop:  mrmovq (%rdi), %r10
        addq %r10, %rax
        iaddq $8, %rdi          # сдвинуть указатель
        iaddq $-1, %rsi         # уменьшить счётчик, флаги
itest:  jne iloop
        ret

Два вводных irmovq исчезли, вместе с ними освободились регистры %r8 и %r9. Числа на массиве из четырёх элементов такие: исходная версия исполняет 34 инструкции, версия с iaddq 32, а сама функция sum ужимается с 59 байт до 55. Выигрыш небольшой и, что интереснее, неоднозначный. Тело цикла в байтах выросло, потому что каждая iaddq занимает десять байт против двух у addq, а общие минус четыре набрались целиком в прологе, где исчезли два десятибайтных irmovq. Инструкций в теле цикла не убавилось вовсе: addq с регистром сменилась на iaddq с числом, одна на одну. Поэтому на длинном массиве экономия так и останется теми же двумя инструкциями пролога. Держи это в голове: расширение системы команд почти никогда не бывает бесплатным улучшением по всем осям сразу.

В эталоне iaddq поддержана везде: ассемблером, симулятором, SEQ и PIPE. В уроке 27 ты добавишь её в Verilog своими руками.

Таблицы ISA на Zig

Всё, что разобрано выше, укладывается в один файл. Это src/isa.zig эталона y86lab, и он устроен так нарочно: здесь только таблицы и чистые функции над ними, ни одной строки про ассемблер и ни одной про симулятор. Ассемблер будет смотреть сюда, симулятор тоже, Verilog повторит эти же таблицы на своём языке, а сам файл не знает ни про кого из них. Когда в уроке 27 понадобится добавить инструкцию, править придётся здесь и только здесь.

Читай его как справочник, к которому мы будем возвращаться весь блок. Обрати внимание на четыре места: Icode и Reg как перечисления с явными числовыми значениями, функция cond с таблицей условий, тройка предикатов needRegids, needValC и length, и в самом низу таблица мнемоник, по которой ассемблер переводит слово в пару (icode, ifun), а дизассемблер обратно.

//! Система команд Y86-64.
//!
//! Подмножество x86-64 из четвёртой главы CS:APP плюс `iaddq` из упражнения 4.3.
//! Здесь только таблицы и чистые функции. Ассемблер и симулятор смотрят сюда,
//! а таблицы не знают ни про того, ни про другого.

const std = @import("std");

/// Память машины: адреса от 0x000 до 0xfff.
pub const mem_size = 4096;

/// Регистров пятнадцать: %r15 занял бы код 0xf, а он означает «регистра нет».
pub const reg_count = 15;

/// Код операции: старшая тетрада первого байта инструкции.
pub const Icode = enum(u4) {
    halt = 0x0,
    nop = 0x1,
    /// `rrmovq` при ifun = 0, `cmovXX` при ifun от 1 до 6.
    cmovxx = 0x2,
    irmovq = 0x3,
    rmmovq = 0x4,
    mrmovq = 0x5,
    /// `addq`, `subq`, `andq`, `xorq`: операцию выбирает ifun.
    opq = 0x6,
    /// `jmp` при ifun = 0, `jXX` при ifun от 1 до 6.
    jxx = 0x7,
    call = 0x8,
    ret = 0x9,
    pushq = 0xa,
    popq = 0xb,
    /// Расширение из упражнения 4.3: сложение с непосредственным значением.
    iaddq = 0xc,
    _,
};

/// Код, которого нет в наборе. Симулятор ставит его, когда прочитать байт
/// инструкции не удалось, и тогда в трассе видно `f0`.
pub const icode_none: Icode = @enumFromInt(0xf);

/// Номер регистра. 0xf это RNONE: поле есть, регистра в нём нет.
pub const Reg = enum(u4) {
    rax = 0x0,
    rcx = 0x1,
    rdx = 0x2,
    rbx = 0x3,
    rsp = 0x4,
    rbp = 0x5,
    rsi = 0x6,
    rdi = 0x7,
    r8 = 0x8,
    r9 = 0x9,
    r10 = 0xa,
    r11 = 0xb,
    r12 = 0xc,
    r13 = 0xd,
    r14 = 0xe,
    none = 0xf,
};

/// Имена регистров в том порядке, в котором их печатает трасса.
pub const reg_names = [reg_count][]const u8{
    "%rax", "%rcx", "%rdx", "%rbx", "%rsp",
    "%rbp", "%rsi", "%rdi", "%r8",  "%r9",
    "%r10", "%r11", "%r12", "%r13", "%r14",
};

pub fn regName(r: Reg) []const u8 {
    if (r == .none) return "%none";
    return reg_names[@intFromEnum(r)];
}

/// Разбирает `%rax`. Возвращает null на всём остальном, включая `%r15`.
pub fn parseReg(text: []const u8) ?Reg {
    for (reg_names, 0..) |name, i| {
        if (std.mem.eql(u8, name, text)) return @enumFromInt(@as(u4, @intCast(i)));
    }
    return null;
}

/// Функция АЛУ для OPq: младшая тетрада первого байта.
pub const Alu = enum(u4) {
    add = 0,
    sub = 1,
    @"and" = 2,
    xor = 3,
    _,
};

/// Условие для `jXX` и `cmovXX`: та же младшая тетрада.
pub const Cond = enum(u4) {
    always = 0,
    le = 1,
    l = 2,
    e = 3,
    ne = 4,
    ge = 5,
    g = 6,
    _,
};

/// Выполняется ли условие при таких флагах. Ровно таблица 4.3 книги.
pub fn cond(ifun: u4, zf: bool, sf: bool, of: bool) bool {
    return switch (@as(Cond, @enumFromInt(ifun))) {
        .always => true,
        // Знаковое «меньше» это SF^OF: знак результата, исправленный переполнением.
        .le => (sf != of) or zf,
        .l => sf != of,
        .e => zf,
        .ne => !zf,
        .ge => sf == of,
        .g => (sf == of) and !zf,
        _ => false,
    };
}

/// Результат АЛУ вместе с флагами, которые он выставляет.
pub const AluResult = struct {
    value: u64,
    zf: bool,
    sf: bool,
    of: bool,
};

/// valE = valB (операция) valA, порядок операндов как в таблицах SEQ.
/// Значит `subq %rax, %rbx` считает %rbx минус %rax.
pub fn alu(f: Alu, val_b: u64, val_a: u64) AluResult {
    const a: i64 = @bitCast(val_a);
    const b: i64 = @bitCast(val_b);
    var of = false;
    const value: u64 = switch (f) {
        .add => blk: {
            const r = b +% a;
            // Переполнение сложения: слагаемые одного знака, результат другого.
            of = ((a < 0) == (b < 0)) and ((r < 0) != (b < 0));
            break :blk @bitCast(r);
        },
        .sub => blk: {
            const r = b -% a;
            // Переполнение вычитания: знаки операндов разные, знак результата
            // не совпал со знаком уменьшаемого.
            of = ((a < 0) != (b < 0)) and ((r < 0) != (b < 0));
            break :blk @bitCast(r);
        },
        .@"and" => val_b & val_a,
        .xor => val_b ^ val_a,
        // Недопустимый ifun сюда не доходит: его ловит fetch и ставит INS.
        _ => 0,
    };
    return .{
        .value = value,
        .zf = value == 0,
        .sf = @as(i64, @bitCast(value)) < 0,
        .of = of,
    };
}

/// Код состояния машины после инструкции.
pub const Stat = enum(u8) {
    /// Всё в порядке.
    aok = 1,
    /// Встретился `halt`.
    hlt = 2,
    /// Обращение к памяти или выборка инструкции за границей памяти.
    adr = 3,
    /// Недопустимая инструкция.
    ins = 4,

    pub fn name(self: Stat) []const u8 {
        return switch (self) {
            .aok => "AOK",
            .hlt => "HLT",
            .adr => "ADR",
            .ins => "INS",
        };
    }
};

/// Нужен ли инструкции байт с номерами регистров.
pub fn needRegids(icode: Icode) bool {
    return switch (icode) {
        .cmovxx, .irmovq, .rmmovq, .mrmovq, .opq, .pushq, .popq, .iaddq => true,
        else => false,
    };
}

/// Нужно ли инструкции восьмибайтное поле valC.
pub fn needValC(icode: Icode) bool {
    return switch (icode) {
        .irmovq, .rmmovq, .mrmovq, .jxx, .call, .iaddq => true,
        else => false,
    };
}

/// Длина инструкции в байтах. У неизвестного кода длина 1: так делает и SEQ,
/// у которого need_regids и need_valC для чужого icode равны нулю.
pub fn length(icode: Icode) u8 {
    var len: u8 = 1;
    if (needRegids(icode)) len += 1;
    if (needValC(icode)) len += 8;
    return len;
}

/// Допустим ли такой ifun у такого icode.
pub fn validIfun(icode: Icode, ifun: u4) bool {
    return switch (icode) {
        .halt, .nop, .irmovq, .rmmovq, .mrmovq, .call, .ret, .pushq, .popq, .iaddq => ifun == 0,
        .cmovxx, .jxx => ifun <= 6,
        .opq => ifun <= 3,
        _ => false,
    };
}

/// Форма операндов: она же говорит ассемблеру, что разбирать после мнемоники.
pub const Form = enum {
    /// `halt`, `nop`, `ret`
    none,
    /// `rrmovq rA, rB`, `cmovXX rA, rB`, `OPq rA, rB`
    reg_reg,
    /// `irmovq V, rB`, `iaddq V, rB`
    imm_reg,
    /// `rmmovq rA, D(rB)`
    reg_mem,
    /// `mrmovq D(rB), rA`
    mem_reg,
    /// `pushq rA`, `popq rA`
    reg,
    /// `jXX Dest`, `call Dest`
    dest,
};

pub fn form(icode: Icode) Form {
    return switch (icode) {
        .cmovxx, .opq => .reg_reg,
        .irmovq, .iaddq => .imm_reg,
        .rmmovq => .reg_mem,
        .mrmovq => .mem_reg,
        .pushq, .popq => .reg,
        .jxx, .call => .dest,
        else => .none,
    };
}

/// Заполнены ли поля регистров так, как требует форма инструкции.
/// Лишний или пропущенный регистр это INS, а не «как получится».
pub fn regsValid(icode: Icode, ra: Reg, rb: Reg) bool {
    if (!needRegids(icode)) return ra == .none and rb == .none;
    return switch (form(icode)) {
        .reg_reg, .reg_mem, .mem_reg => ra != .none and rb != .none,
        .imm_reg => ra == .none and rb != .none,
        .reg => ra != .none and rb == .none,
        else => false,
    };
}

/// Мнемоника и то, во что она превращается.
pub const Mnemonic = struct {
    name: []const u8,
    icode: Icode,
    ifun: u4,
};

pub const mnemonics = [_]Mnemonic{
    .{ .name = "halt", .icode = .halt, .ifun = 0 },
    .{ .name = "nop", .icode = .nop, .ifun = 0 },
    .{ .name = "rrmovq", .icode = .cmovxx, .ifun = 0 },
    .{ .name = "cmovle", .icode = .cmovxx, .ifun = 1 },
    .{ .name = "cmovl", .icode = .cmovxx, .ifun = 2 },
    .{ .name = "cmove", .icode = .cmovxx, .ifun = 3 },
    .{ .name = "cmovne", .icode = .cmovxx, .ifun = 4 },
    .{ .name = "cmovge", .icode = .cmovxx, .ifun = 5 },
    .{ .name = "cmovg", .icode = .cmovxx, .ifun = 6 },
    .{ .name = "irmovq", .icode = .irmovq, .ifun = 0 },
    .{ .name = "rmmovq", .icode = .rmmovq, .ifun = 0 },
    .{ .name = "mrmovq", .icode = .mrmovq, .ifun = 0 },
    .{ .name = "addq", .icode = .opq, .ifun = 0 },
    .{ .name = "subq", .icode = .opq, .ifun = 1 },
    .{ .name = "andq", .icode = .opq, .ifun = 2 },
    .{ .name = "xorq", .icode = .opq, .ifun = 3 },
    .{ .name = "jmp", .icode = .jxx, .ifun = 0 },
    .{ .name = "jle", .icode = .jxx, .ifun = 1 },
    .{ .name = "jl", .icode = .jxx, .ifun = 2 },
    .{ .name = "je", .icode = .jxx, .ifun = 3 },
    .{ .name = "jne", .icode = .jxx, .ifun = 4 },
    .{ .name = "jge", .icode = .jxx, .ifun = 5 },
    .{ .name = "jg", .icode = .jxx, .ifun = 6 },
    .{ .name = "call", .icode = .call, .ifun = 0 },
    .{ .name = "ret", .icode = .ret, .ifun = 0 },
    .{ .name = "pushq", .icode = .pushq, .ifun = 0 },
    .{ .name = "popq", .icode = .popq, .ifun = 0 },
    .{ .name = "iaddq", .icode = .iaddq, .ifun = 0 },
};

pub fn lookupMnemonic(name: []const u8) ?Mnemonic {
    for (mnemonics) |m| {
        if (std.mem.eql(u8, m.name, name)) return m;
    }
    return null;
}

/// Обратный поиск: по паре (icode, ifun) вернуть мнемонику. Нужен дизассемблеру
/// и тестам, которые проверяют кодирование в обе стороны.
pub fn mnemonicOf(icode: Icode, ifun: u4) ?[]const u8 {
    for (mnemonics) |m| {
        if (m.icode == icode and m.ifun == ifun) return m.name;
    }
    return null;
}

test "условия совпадают с таблицей книги" {
    // ZF = 1, SF = 0, OF = 0: результат ноль.
    try std.testing.expect(cond(@intFromEnum(Cond.e), true, false, false));
    try std.testing.expect(cond(@intFromEnum(Cond.le), true, false, false));
    try std.testing.expect(cond(@intFromEnum(Cond.ge), true, false, false));
    try std.testing.expect(!cond(@intFromEnum(Cond.g), true, false, false));
    try std.testing.expect(!cond(@intFromEnum(Cond.l), true, false, false));
    // SF = 1, OF = 0: результат отрицательный и переполнения не было.
    try std.testing.expect(cond(@intFromEnum(Cond.l), false, true, false));
    try std.testing.expect(cond(@intFromEnum(Cond.le), false, true, false));
    try std.testing.expect(!cond(@intFromEnum(Cond.ge), false, true, false));
    // SF = 1, OF = 1: переполнение перевернуло знак, значит на самом деле больше.
    try std.testing.expect(!cond(@intFromEnum(Cond.l), false, true, true));
    try std.testing.expect(cond(@intFromEnum(Cond.g), false, true, true));
}

test "переполнение сложения и вычитания" {
    const min: u64 = @bitCast(@as(i64, std.math.minInt(i64)));
    const max: u64 = @bitCast(@as(i64, std.math.maxInt(i64)));

    const overflow_add = alu(.add, max, 1);
    try std.testing.expect(overflow_add.of);
    try std.testing.expect(overflow_add.sf);

    const quiet_add = alu(.add, 2, 3);
    try std.testing.expectEqual(@as(u64, 5), quiet_add.value);
    try std.testing.expect(!quiet_add.of and !quiet_add.sf and !quiet_add.zf);

    // 0 - minInt переполняется: противоположного к minInt числа нет.
    const overflow_sub = alu(.sub, 0, min);
    try std.testing.expect(overflow_sub.of);

    const zero = alu(.sub, 7, 7);
    try std.testing.expect(zero.zf and !zero.sf and !zero.of);

    // Логические операции переполнения не знают.
    try std.testing.expect(!alu(.xor, max, min).of);
}

test "длина инструкции и поля" {
    try std.testing.expectEqual(@as(u8, 1), length(.halt));
    try std.testing.expectEqual(@as(u8, 2), length(.opq));
    try std.testing.expectEqual(@as(u8, 9), length(.call));
    try std.testing.expectEqual(@as(u8, 10), length(.irmovq));
    try std.testing.expectEqual(@as(u8, 10), length(.iaddq));
    try std.testing.expectEqual(@as(u8, 1), length(icode_none));
    try std.testing.expect(regsValid(.pushq, .rsp, .none));
    try std.testing.expect(!regsValid(.pushq, .rsp, .rax));
    try std.testing.expect(!regsValid(.irmovq, .rax, .rbx));
}

test "имена регистров разбираются и печатаются" {
    try std.testing.expectEqual(Reg.r14, parseReg("%r14").?);
    try std.testing.expectEqual(@as(?Reg, null), parseReg("%r15"));
    try std.testing.expectEqualStrings("%rsp", regName(.rsp));
}

Пара мест здесь стоит пояснения.

Перечисление Icode объявлено с хвостовым _, то есть оно неисчерпывающее: значение 0xd, которого нет в наборе, всё равно можно положить в переменную типа Icode. Так и нужно, потому что байт из памяти может содержать что угодно, а решать, законна ли инструкция, должен не компилятор Zig, а функция validIfun вместе с regsValid. Отдельная константа icode_none со значением 0xf служит начальным значением до выборки: если такая пара попадёт в трассу, ты увидишь в ней f0 и сразу поймёшь, что до инструкции дело не дошло.

Функция alu возвращает не только результат, но и три флага, потому что в железе они появляются одновременно, одним и тем же блоком. Порядок операндов у неё valB операция valA, и это ровно тот порядок, что в таблицах этапов книги: subq %rax, %rbx считает %rbx минус %rax. Ошибиться здесь легко, а проявится ошибка только на вычитании и только на знаке результата, поэтому тест на переполнение стоит прямо в файле.

Функция regsValid это формализация того самого правила про RNONE. Она смотрит на форму инструкции и проверяет, заполнены ли поля именно так, как форма требует: у pushq должен быть rA и не должно быть rB, у irmovq наоборот, у OPq оба. Именно она превращает байты a0 31 в INS.

Кодирование одной инструкции

Осталось соединить таблицы с байтами. Вся функция кодирования умещается в пятнадцать строк, потому что вся сложность уже вынесена в предикаты. Это src/asm/encoder.zig эталона, начало файла.

/// Инструкция в виде полей, ещё не в виде байтов.
pub const Instr = struct {
    icode: Icode,
    ifun: u4 = 0,
    ra: Reg = .none,
    rb: Reg = .none,
    val_c: u64 = 0,
};

/// Максимальная длина инструкции: `irmovq V, rB` это байт кода, байт регистров
/// и восемь байтов значения.
pub const max_len = 10;

/// Кодирует инструкцию в `out` и возвращает занятую часть буфера.
pub fn encode(instr: Instr, out: *[max_len]u8) []u8 {
    var n: usize = 0;
    out[n] = (@as(u8, @intFromEnum(instr.icode)) << 4) | instr.ifun;
    n += 1;
    if (isa.needRegids(instr.icode)) {
        out[n] = (@as(u8, @intFromEnum(instr.ra)) << 4) | @intFromEnum(instr.rb);
        n += 1;
    }
    if (isa.needValC(instr.icode)) {
        // Little-endian, как у x86-64: младший байт первым.
        std.mem.writeInt(u64, out[n..][0..8], instr.val_c, .little);
        n += 8;
    }
    return out[0..n];
}

Прочитай тело encode ещё раз и сравни с таблицей кодирования из середины урока. Первый байт собирается сдвигом icode на четыре бита влево и наложением ifun. Байт регистров пишется, только если needRegids сказала “да”, и собирается тем же сдвигом. Восемь байт valC пишутся, только если сказала “да” needValC, и пишутся функцией writeInt с явным указанием порядка .little. Никакого switch по инструкциям в самой функции нет: переключается она через предикаты, а те переключаются через icode.

Обратная функция decode живёт в том же файле нарочно, чтобы было видно, что это ровно то же самое, прочитанное задом наперёд. Она пригодится в уроке 22, когда симулятор будет доставать инструкцию из памяти, и в уроке 21, когда ассемблер начнёт проверять сам себя: закодировал, раскодировал, сравнил.

Дальше в задаче ты напишешь encode сам, с нуля и по таблице.

Практика

Тебе даны перечисления Icode и Reg, структура Instr с полями icode, ifun, rA, rB и valC, и полная таблица кодирования в комментарии. Напиши encode(instr, out), которая раскладывает инструкцию по байтам буфера и возвращает число занятых байт. Решать надо по таблице: сначала первый байт из двух полубайт, потом байт регистров у тех кодов, у которых он есть, потом восьмибайтная константа у тех, у которых она есть, в порядке little-endian. Тесты сверяют байты каждой инструкции набора, включая cmovXX и jXX с ненулевым ifun, iaddq и отрицательное значение valC, а заодно возвращённые длины.

Упражнения

Итоги

  • Состояние Y86-64, видимое программисту, это пятнадцать регистров по 64 бита, три флага ZF, SF и OF, счётчик команд, четыре килобайта памяти с побайтовой адресацией и код состояния. Всё остальное это внутреннее устройство реализации.
  • Регистров пятнадцать потому, что код 0xf отдан под RNONE, признак “поле есть, регистра в нём нет”. Чтение RNONE даёт ноль, запись в него не происходит, и благодаря этому ни один этап процессора не нуждается в проверке “а есть ли здесь регистр”.
  • Инструкция это первый байт icode:ifun, необязательный байт rA:rB и необязательные восемь байт valC в порядке little-endian. Других полей нет, других порядков нет.
  • icode говорит, что это за инструкция и как она устроена, ifun уточняет операцию внутри семейства: действие ALU у OPq и условие у jXX и cmovXX. У переходов и условных пересылок таблица условий общая, а ifun равный нулю означает “всегда”, поэтому jmp и rrmovq не тратят отдельных кодов.
  • Длина инструкции целиком определяется первым полубайтом: 1, 2, 9 или 10 байт. Байт регистров есть у кодов 2, 3, 4, 5, 6, a, b, c, поле valC у кодов 3, 4, 5, 7, 8, c.
  • valC служит непосредственным значением, знаковым смещением в памяти и абсолютным адресом перехода. Адрес именно абсолютный, а не относительный, как rel32 в x86-64.
  • Отличия от x86-64 это не упрощения ради простоты, а перенос сложности из железа в кодировку: фиксированные поля вместо префиксов и ModRM, одна адресация D(rB) вместо базы с индексом и масштабом, четыре отдельных icode вместо одного mov, только восьмибайтные слова вместо четырёх ширин.
  • Кодов состояния четыре. AOK означает “продолжаем”, HLT встретился halt, ADR обращение или выборка за пределами памяти, INS недопустимая инструкция. Проверка выборки старше проверки памяти, а при любом коде кроме AOK машина останавливается.
  • INS даёт не только неизвестный icode, но и неподходящий ifun, и неверно заполненные поля регистров. Это нужно ради однозначности контракта: у каждой инструкции ровно одна кодировка.
  • Расширение iaddq получает свободный icode c, переиспользует формат irmovq и описывается через уже существующие этапы. На сумме массива оно снимает две инструкции и два регистра из пролога, но удлиняет тело цикла в байтах: выигрыш по одной оси, проигрыш по другой.
  • Кодирование одной инструкции это пятнадцать строк кода, если вся таблица уже вынесена в предикаты needRegids и needValC. Дальше на этих же предикатах будут стоять ассемблер, симулятор и оба процессора.

Дальше

Контракт зафиксирован: ты знаешь всё состояние машины, все её инструкции и то, во что превращается каждая из них. Кодировать руками ты умеешь, а значит умеешь и проверять любую реализацию. В следующем уроке, 32-systems/21 · Ассемблер Y86-64 на Zig, мы перестанем кодировать руками и напишем ассемблер: лексер, который режет строку на токены, парсер, который собирает из них инструкции и директивы, первый проход, который считает адреса и запоминает метки, и второй, который зовёт ту самую encode. На выходе получится листинг, ровно такой, какие ты читал сегодня, и образ памяти, который позже проглотит Verilog. А заодно появятся первые настоящие программы на Y86-64: сумма массива итеративная и рекурсивная, сумма модулей с переходом и с cmov, пузырьковая сортировка и switch через таблицу адресов. Именно на них будут сверяться все три реализации до конца блока.

домашка

Домашка