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

SEQ: этапы fetch и decode

senior~120 мин

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

SEQ: этапы fetch и decode

В 32-systems/22 · Симулятор Y86-64 на Zig ты написал шесть функций, и они по очереди трогали одну структуру состояния. Это работало, потому что программа умеет ждать: fetch закончил, вернул управление, decode начал. У железа такой роскоши нет. В схеме нет “сначала” и “потом”, есть провода, по которым сигнал течёт всё время, и один фронт такта, на котором результат защёлкивается. В этом уроке те же шесть этапов перестают быть функциями и становятся кусками схемы, а ты пишешь первые два из них на Verilog и проверяешь тестбенчем на всех шестнадцати кодах инструкций.

Цели урока

  • Понять, чем шесть этапов SEQ как схема отличаются от шести функций симулятора, и почему в железе они работают одновременно, а не по очереди.
  • Прочитать общую организацию SEQ: счётчик команд, память инструкций, регистровый файл, ALU, память данных и что происходит с ними за один такт.
  • Уметь построить таблицу вычислений для любого класса инструкций Y86-64 и объяснить, откуда взялась каждая строка.
  • Увидеть, что весь этап выборки это разбор десяти байт: первый байт делится на код и функцию, остальные выравниваются под константу, плюс два признака need_regids и need_valC.
  • Написать instr_valid и imem_error и понять, зачем машине два разных вида ошибки вместо одного.
  • Написать srcA, srcB, dstE и dstM как принадлежность кода инструкции множеству, а не как отдельные правила.
  • Проверить оба этапа тестбенчем, который перебирает все коды инструкций, и прочитать полученную таблицу как документацию к ISA.
  • Провести настоящие инструкции из программ урока 21 через оба этапа с конкретными числами.

Идея: шесть функций становятся шестью кусками схемы

Симулятор из урока про симулятор на Zig устроен так: есть структура Signals, есть шесть функций, каждая читает то, что записали предыдущие, и дописывает своё. Порядок вызовов задаёт смысл. Поменяй местами decode и execute, и всё сломается, потому что execute возьмёт мусор вместо valA.

В схеме порядка вызовов нет. Есть комбинационная логика, которая считает всё время, и есть тактируемые элементы, которые запоминают результат по фронту. Если нарисовать провода правильно, то “порядок” получается сам собой, из того, что выход одного блока подключён ко входу другого. Сигнал сначала физически доходит до decode, потом до execute, потому что путь именно такой, а не потому, что кто-то вызвал функцию.

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

За один такт вся комбинационная логика успевает устояться от счётчика команд до нового значения счётчика команд, и только потом по фронту такта записываются регистры, флаги и память.

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

Три вопроса, на которые отвечает урок. Что именно каждый этап вычисляет для каждой из тринадцати инструкций Y86-64. Как эти вычисления складываются в одну схему, общую для всех инструкций сразу. И как выглядят на Verilog первые два этапа, выборка и декодирование, где нет ни одного сложения кроме valP и ни одного обращения к памяти данных, зато сосредоточена почти вся управляющая логика.

Общая организация SEQ

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

Счётчик команд. Один 64-разрядный регистр. Хранит адрес инструкции, которая исполняется прямо сейчас. Меняется ровно раз в такт.

Память инструкций. Читается по адресу из счётчика команд и отдаёт десять байт подряд. Почему именно десять: самая длинная инструкция Y86-64 занимает десять байт (irmovq, rmmovq, mrmovq, iaddq), и мы не хотим два обращения к памяти за такт. Проще всегда прочитать максимум, а потом разобраться, сколько байт нам было нужно. Лишние байты остаются без дела.

Регистровый файл. Пятнадцать 64-разрядных регистров, два порта чтения и два порта записи. Чтение комбинационное: подал номер, тут же получил значение. Запись по фронту такта. Порты подробно разобраны в уроке про тактирование и регистровый файл.

ALU. Считает сложение, вычитание, “и”, “исключающее или”, выставляет флаги ZF, SF и OF. Оно у нас одно на всю машину и обслуживает не только арифметику: адрес в памяти это тоже сложение, а плюс восемь и минус восемь для стека тоже проходят через него.

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

Плюс три бита флагов, которые живут отдельным маленьким регистром.

Один такт целиком

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

  1. Счётчик команд всё время держит 0x077 на входе памяти инструкций. Память отдаёт десять байт начиная с этого адреса.
  2. Этап выборки разбирает эти байты на поля: код инструкции, функция, номера регистров, константа. Заодно считает valP, адрес следующей инструкции.
  3. Этап декодирования смотрит на код инструкции и выбирает номера регистров для двух портов чтения. Регистровый файл тут же отдаёт два значения, valA и valB.
  4. Этап исполнения подаёт нужные значения на ALU и получает valE, а для условных инструкций ещё и признак Cnd.
  5. Этап обращения к памяти выставляет адрес и признаки чтения или записи. При чтении память тут же отдаёт valM.
  6. Этап записи выбирает, в какие регистры пойдут valE и valM. Этап обновления счётчика команд выбирает новый адрес из valC, valM или valP.
  7. Приходит фронт такта. Регистровый файл записывает то, что стоит на портах записи. Память данных записывает то, что стоит на порту записи. Флаги записывают новое значение. Счётчик команд записывает новый адрес.

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

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

Виджет показывает тракт SEQ схемой и подсвечивает те провода, которые задействует текущая инструкция на этапах выборки и декодирования. Выбери программу в списке и шагай по ней кнопкой “шаг” или ползунком тактов, а рядом со схемой читай таблицу сигналов. Дойди в “Сумме массива циклом” до mrmovq и сравни с popq из программы “Тонкости pushq %rsp и popq %rsp”: у первой порт A погашен, у второй он читает указатель стека, хотя в самой инструкции указатель стека не написан ни разу.

Что делает каждый этап

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

ЭтапЧто вычисляетОткуда берёт
fetch, выборкаicode, ifun, rA, rB, valC, valPсчётчик команд и десять байт из памяти инструкций
decode, декодированиеvalA, valBномера srcA и srcB, которые сам же и выбрал
execute, исполнениеvalE, Cnd, новые флагиvalA, valB, valC, старые флаги
memory, обращение к памятиvalM, запись в памятьvalE или valA как адрес, valA или valP как данные
write back, записьничего, только выставляет портыvalE в порт E, valM в порт M
PC update, новый счётчикnew_pcvalC, valM или valP

Три имени стоит проговорить отдельно, потому что их путают чаще всего.

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

valC это константа из тела инструкции. Смысл у неё разный: непосредственное значение у irmovq, смещение у rmmovq и mrmovq, адрес перехода у jXX и call. Схеме на смысл всё равно, это просто восемь байт, вынутых из нужного места.

valE это то, что посчитало ALU, а valM это то, что прочитала память данных. Дальше они расходятся по разным портам записи, E и M, и именно поэтому у некоторых инструкций два порта записи заняты одновременно.

Таблицы вычислений по классам инструкций

Теперь главное упражнение урока. Для каждого класса инструкций расписываем, что делает каждый этап. Такая таблица это одновременно и спецификация, и черновик схемы: почти каждая строка потом превращается в одну строку Verilog.

Договоримся о записи. M1[a] это один байт по адресу a, M8[a] это восемь байт по адресу a в порядке little-endian. R[r] это регистр с номером r. Стрелка означает “получает значение”.

Инструкции, которые только считают

Сюда попадают OPq, rrmovq вместе с семейством cmovXX, irmovq и наше расширение iaddq. Общее у них одно: они не трогают память данных.

ЭтапOPq rA, rBrrmovq rA, rBirmovq V, rBiaddq V, rB
fetchicode:ifun ← M1[PC], rA:rB ← M1[PC+1], valP ← PC+2то же, valP ← PC+2rA:rB ← M1[PC+1], valC ← M8[PC+2], valP ← PC+10как у irmovq
decodevalA ← R[rA], valB ← R[rB]valA ← R[rA]ничегоvalB ← R[rB]
executevalE ← valB OP valA, флагиvalE ← 0 + valA, Cnd по флагамvalE ← 0 + valCvalE ← valB + valC, флаги
memoryничегоничегоничегоничего
write backR[rB] ← valEесли Cnd, то R[rB] ← valER[rB] ← valER[rB] ← valE
PC updatePC ← valPPC ← valPPC ← valPPC ← valP

Откуда берётся каждая строка.

Длина инструкции в valP это прямое следствие формата. У OPq и rrmovq два байта: код плюс номера регистров. У irmovq и iaddq десять: код, номера регистров, восемь байт константы. Никакого выбора здесь нет, формат задан в уроке про систему команд.

Строка decode у irmovq пустая, и это не оплошность. Инструкция irmovq $4, %rsi не читает ни одного регистра: значение приходит из тела инструкции. Поле rA в её втором байте заполнено кодом 0xF, RNONE, и порт A молчит.

Строка execute у rrmovq выглядит странно: зачем гонять значение через ALU, если его надо просто скопировать. Затем, что другого пути в порт записи E нет. ALU у нас одно, и все результаты, которые попадают в регистр через порт E, проходят через него. Ноль плюс valA это способ сказать “пропусти без изменений” на языке, который понимает ALU.

Строка write back у rrmovq единственная во всей таблице с условием. Условная пересылка cmovXX отличается от rrmovq только тем, что при невыполненном условии запись отменяется. В симуляторе это было одной строкой: если условие ложно, порт E гасится в RNONE. В схеме будет тем же самым.

Флаги ставят только OPq и iaddq. Пересылки флаги не трогают, поэтому rrmovq можно смело ставить между сравнением и переходом, и это не сломает условие. То же самое верно и в x86-64, и это не совпадение: Y86-64 списан с него.

Инструкции, которые ходят в память

Этапrmmovq rA, D(rB)mrmovq D(rB), rA
fetchrA:rB ← M1[PC+1], valC ← M8[PC+2], valP ← PC+10то же
decodevalA ← R[rA], valB ← R[rB]valB ← R[rB]
executevalE ← valB + valCvalE ← valB + valC
memoryM8[valE] ← valAvalM ← M8[valE]
write backничегоR[rA] ← valM
PC updatePC ← valPPC ← valP

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

Различаются они направлением. У rmmovq значение идёт из регистра в память, поэтому нужен порт A на чтение и порт записи в память. У mrmovq значение идёт из памяти в регистр, поэтому порт A на чтение не нужен вовсе, зато занят порт записи M.

Заметь несимметричность: у mrmovq результат в регистр приходит через порт M, а не через порт E, хотя порт E в этот момент свободен. Так сделано намеренно. Порт E жёстко привязан к выходу ALU, порт M жёстко привязан к выходу памяти. Мультиплексора между ними нет, потому что он был бы на критическом пути и удлинил бы такт.

Стек

Этапpushq rApopq rA
fetchrA:rB ← M1[PC+1], valP ← PC+2то же
decodevalA ← R[rA], valB ← R[%rsp]valA ← R[%rsp], valB ← R[%rsp]
executevalE ← valB - 8valE ← valB + 8
memoryM8[valE] ← valAvalM ← M8[valA]
write backR[%rsp] ← valER[%rsp] ← valE, R[rA] ← valM
PC updatePC ← valPPC ← valP

Здесь начинается интересное. В тексте инструкции pushq %rbx указатель стека не упомянут ни разу, а в таблице он появляется дважды: порт B читает его, порт E пишет. Это и есть неявный операнд: инструкция знает про %rsp не из своих байтов, а из своего кода. Ровно то же самое ты видел в x86-64 в уроке про вызовы и кадры стека, только там неявность была спрятана за мнемоникой, а здесь она видна проводом.

pushq кладёт по уменьшенному адресу: сначала valE равно valB - 8, потом по этому адресу ложится значение. Стек растёт вниз, поэтому свободное место находится ниже текущей вершины.

popq читает по старому адресу, а не по увеличенному. В строке memory стоит M8[valA], где valA это старый %rsp, а не M8[valE]. Так и должно быть: снимаемое слово лежит ровно на вершине, а valE это уже адрес новой вершины, где ничего интересного нет.

И самая заметная строка во всей таблице: у popq два порта записи заняты одновременно. Порт E пишет увеличенный указатель, порт M пишет прочитанное слово. Обычно они целятся в разные регистры и не мешают друг другу. Но если написать popq %rsp, оба порта целятся в %rsp, и надо решить, кто победит. Мы условились в уроке про регистровый файл, что побеждает порт M, и там же видно почему: два неблокирующих присваивания в одном always_ff применяются по порядку, последнее переписывает предыдущее.

Симметричная тонкость у pushq %rsp: в память уходит старое значение указателя, потому что valA читается на этапе декодирования, задолго до того, как порт E запишет уменьшенный. Обе тонкости проверяет программа push_pop_rsp.ys из 32-systems/21 · Ассемблер Y86-64 на Zig, и её трасса это подтверждает.

Управление

ЭтапjXX Destcall Destret
fetchvalC ← M8[PC+1], valP ← PC+9valC ← M8[PC+1], valP ← PC+9valP ← PC+1
decodeничегоvalB ← R[%rsp]valA ← R[%rsp], valB ← R[%rsp]
executeCnd по флагам и ifunvalE ← valB - 8valE ← valB + 8
memoryничегоM8[valE] ← valPvalM ← M8[valA]
write backничегоR[%rsp] ← valER[%rsp] ← valE
PC updatePC ← Cnd ? valC : valPPC ← valCPC ← valM

Заметь, что у переходов константа лежит начиная с PC+1, а не с PC+2. У них нет второго байта с номерами регистров, потому что называть нечего. Отсюда и длина девять вместо десяти.

call это в точности pushq от valP плюс переход. Тело таблицы совпадает с pushq слово в слово, кроме одной строки memory, где вместо valA стоит valP, и последней строки. Адрес возврата это адрес следующей инструкции, а он у нас уже посчитан.

ret это в точности popq в счётчик команд. Строки decode, execute и memory совпадают с popq буквально, а вместо записи в регистр прочитанное слово уходит в счётчик команд.

Единственная строка, где Cnd влияет на счётчик команд, это jXX. У cmovXX тот же самый Cnd влияет на порт записи. Один и тот же сигнал, два разных получателя, и это одна из тех вещей, которые в схеме видно, а в языке нет.

Что видно, когда таблицы стоят рядом

Разложи все четыре таблицы перед собой и посмотри на строку decode целиком. Получится вот что.

Порт A читает rA у rrmovq, rmmovq, OPq и pushq. Читает %rsp у popq и ret. Молчит у всех остальных.

Порт B читает rB у rmmovq, mrmovq, OPq и iaddq. Читает %rsp у pushq, popq, call и ret. Молчит у остальных.

Порт записи E пишет в rB у rrmovq, irmovq, OPq и iaddq. Пишет в %rsp у pushq, popq, call и ret. Молчит у остальных.

Порт записи M пишет в rA ровно у двух инструкций, mrmovq и popq. У остальных молчит.

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

Пакет констант

Прежде чем писать модули, договоримся об именах. В SystemVerilog для этого есть пакет: файл объявляет константы, а каждый модуль начинается со строки импорта.

Пакет нужен по одной причине: код инструкции 4'h6 встречается в трёх модулях, и если написать его числом, то первая же опечатка даст схему, которая собирается, проходит половину тестов и врёт на OPq. С именем IOPQ такая ошибка становится ошибкой компиляции.

// Общие константы Y86-64: коды инструкций, функции АЛУ, номера регистров,
// коды состояния. Каждый модуль начинается со строки import y86_pkg::*,
// поэтому имена icode и Stat читаются так же, как в главе 4 CS:APP.
// Пакет общий на весь проект, поэтому любой отдельно взятый модуль берёт
// из него лишь горстку констант. Verilator считает неиспользованной каждую
// остальную и на одном только alu выдаёт три десятка предупреждений, так что
// проверка на неиспользованный параметр здесь выключена целиком.
/* verilator lint_off UNUSEDPARAM */
package y86_pkg;

  // Разрядность машинного слова и размер памяти в байтах.
  // WORD ни в один модуль не подставляется: разрядность портов записана
  // числом [63:0], как её рисует книга. Константа стоит здесь как единственное
  // место, где разрядность машины названа словом.
  localparam int WORD      = 64;
  localparam int MEM_BYTES = 4096;

  // icode: старший полубайт первого байта инструкции.
  localparam logic [3:0] IHALT   = 4'h0;
  localparam logic [3:0] INOP    = 4'h1;
  localparam logic [3:0] IRRMOVQ = 4'h2;  // сюда же попадают cmovXX
  localparam logic [3:0] IIRMOVQ = 4'h3;
  localparam logic [3:0] IRMMOVQ = 4'h4;
  localparam logic [3:0] IMRMOVQ = 4'h5;
  localparam logic [3:0] IOPQ    = 4'h6;
  localparam logic [3:0] IJXX    = 4'h7;
  localparam logic [3:0] ICALL   = 4'h8;
  localparam logic [3:0] IRET    = 4'h9;
  localparam logic [3:0] IPUSHQ  = 4'hA;
  localparam logic [3:0] IPOPQ   = 4'hB;
  localparam logic [3:0] IIADDQ  = 4'hC;  // расширение из упражнения 4.3

  // ifun для OPq, он же код операции АЛУ.
  localparam logic [3:0] ALUADD = 4'h0;
  localparam logic [3:0] ALUSUB = 4'h1;
  localparam logic [3:0] ALUAND = 4'h2;
  localparam logic [3:0] ALUXOR = 4'h3;

  // ifun для jXX и cmovXX.
  localparam logic [3:0] CALWAYS = 4'h0;
  localparam logic [3:0] CLE     = 4'h1;
  localparam logic [3:0] CL      = 4'h2;
  localparam logic [3:0] CE      = 4'h3;
  localparam logic [3:0] CNE     = 4'h4;
  localparam logic [3:0] CGE     = 4'h5;
  localparam logic [3:0] CG      = 4'h6;

  // Регистры. Их пятнадцать, номер 4'hF означает «регистра нет».
  localparam logic [3:0] RRSP  = 4'h4;
  localparam logic [3:0] RNONE = 4'hF;

  // Коды состояния машины.
  localparam logic [2:0] SAOK = 3'h1;  // всё хорошо
  localparam logic [2:0] SHLT = 3'h2;  // выполнен halt
  localparam logic [2:0] SADR = 3'h3;  // обращение за границу памяти
  localparam logic [2:0] SINS = 3'h4;  // неизвестная инструкция

endpackage
/* verilator lint_on UNUSEDPARAM */

Пара строк с verilator lint_off вокруг пакета это не магия и не затыкание рта проверялке вообще. Комментарий над ними говорит, откуда берутся три десятка “неиспользованных” параметров на любом модуле. Сообщение верное по букве и бесполезное по смыслу, и глушить его надо ровно здесь, на объявлении, а не в модулях. Правило одно: выключать проверку можно только вместе с комментарием, который объясняет, почему в этом конкретном месте она врёт. Предупреждение про защёлку, о котором речь ниже, глушить нельзя никогда.

Про RNONE скажем отдельно. Регистров пятнадцать, а поле для номера четырёхразрядное, значит одно из шестнадцати значений остаётся свободным. Мы отдаём его под признак “регистра нет”. Благодаря ему в схеме не появляется отдельного бита “порт включён”: номер регистра сам себе признак.

Этап выборки на Verilog

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

Split: разбор первого байта

Первый байт делится пополам. Старшая тетрада это код инструкции, младшая это её функция. В Verilog это два среза:

always_comb icode = byte0[7:4];
always_comb ifun  = byte0[3:0];

Тонкость одна, и она про ошибки. Если байт прочитать не удалось (адрес за границей памяти), то byte0 это мусор, и разбирать его бессмысленно. Хуже того, опасно: мусорный код может оказаться кодом popq, и тогда декодирование включит порт записи в регистр. Поэтому при ошибке выборки мы подставляем вместо кода инструкции nop. Дальше по тракту пойдут безопасные сигналы, все порты записи погашены, а сам факт ошибки уже запомнил отдельный флаг.

Два признака формата

Дальше идут два сигнала, ради которых половина этапа и существует.

need_regids отвечает на вопрос “есть ли у этой инструкции второй байт с номерами регистров”. Он равен единице у восьми кодов: rrmovq, irmovq, rmmovq, mrmovq, OPq, pushq, popq, iaddq. У остальных нет: halt, nop и ret однобайтные, а jXX и call состоят из кода и константы.

need_valC отвечает на вопрос “есть ли восьмибайтная константа”. Он равен единице у шести кодов: irmovq, rmmovq, mrmovq, jXX, call, iaddq.

Оба сигнала это принадлежность множеству, и это самая частая форма управляющей логики во всей четвёртой главе CS:APP. В книге для неё есть специальная запись, в Verilog она пишется цепочкой сравнений через || или несколькими метками в одном пункте case. Выбор между ними чисто стилистический: цепочка короче для одного бита, case читается лучше для четырёх разрядов.

Про запись case ... inside из стандарта SystemVerilog придётся забыть: Verilator её понимает, а Icarus Verilog, которым мы собираем и в котором гоняет задачи песочница курса, нет. Поэтому во всех наших модулях принадлежность множеству это либо ||, либо перечисление меток через запятую.

Из этих двух признаков получается всё остальное. Номера регистров идут из второго байта, если он есть, а без него превращаются в RNONE:

always_comb rA = need_regids ? byte1[7:4] : RNONE;
always_comb rB = need_regids ? byte1[3:0] : RNONE;

Align: откуда берётся константа

Восьмибайтная константа лежит либо со второго байта, либо с третьего, в зависимости от того, был ли байт регистров. Это и есть выравнивание: одни и те же десять байт читаются с разным смещением.

always_comb valC = need_regids ? imem_bytes[79:16] : imem_bytes[71:8];

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

valP

Длина инструкции складывается из трёх слагаемых: один байт кода всегда, плюс один байт регистров, если он есть, плюс восемь байт константы, если она есть.

always_comb valP = pc + 64'd1 + (need_regids ? 64'd1 : 64'd0)
                              + (need_valC   ? 64'd8 : 64'd0);

Длин получается ровно четыре: один байт, два, девять и десять. Проверь по формату: halt, nop и ret дают единицу, OPq, rrmovq, pushq и popq двойку, jXX и call девятку, irmovq, rmmovq, mrmovq и iaddq десятку. Пятой комбинации признаков не бывает, потому что признаков всего два.

Два вида ошибки

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

imem_error означает, что байты прочитать не удалось: адрес за границей памяти, или инструкция начинается внутри памяти, но её последний байт уже снаружи. Второй случай ловит сравнение valP с размером памяти, и это единственное место, где valP работает не по прямому назначению.

instr_valid означает, что байты прочитаны, но такой инструкции в наборе нет. Проверка смотрит на пару из кода и функции: у OPq функция должна быть от нуля до трёх, у jXX и cmovXX от нуля до шести, у всех остальных ровно ноль. Байт 0x6f не проходит её не потому, что код 6 плохой, а потому, что функции 0xf у OPq не бывает.

Ошибка памяти старше неправильной инструкции. Логика такая: если байты не прочитаны, то и проверять нечего, мы не знаем, что там было. Этот порядок закреплён в модуле stat, который мы напишем в следующем уроке.

Модуль целиком

// Этап выборки, таблица 4.18 книги.
// Разбирает десять байт из памяти инструкций: первый байт даёт icode и ifun,
// второй (если он нужен) даёт rA и rB, остальные восемь дают константу valC.
// Заодно считает адрес следующей инструкции valP и признаки ошибок.
module fetch (
  input  logic [63:0] pc,
  input  logic [79:0] imem_bytes,
  input  logic        imem_error_in,

  output logic [3:0]  icode,
  output logic [3:0]  ifun,
  output logic [3:0]  rA,
  output logic [3:0]  rB,
  output logic [63:0] valC,
  output logic [63:0] valP,
  output logic        instr_valid,
  output logic        imem_error,
  output logic        need_regids,
  output logic        need_valC
);

  import y86_pkg::*;

  logic [7:0] byte0;
  logic [7:0] byte1;

  always_comb byte0 = imem_bytes[7:0];
  always_comb byte1 = imem_bytes[15:8];

  // Если байт прочитать не удалось, подставляем nop: дальше по тракту пойдут
  // безопасные сигналы, а ошибку уже запомнил флаг imem_error.
  always_comb icode = imem_error_in ? INOP : byte0[7:4];
  always_comb ifun  = imem_error_in ? 4'h0 : byte0[3:0];

  // Инструкция известна, когда известны и icode, и ifun при нём.
  always_comb begin
    case (icode)
      IHALT, INOP, IRET:              instr_valid = (ifun == 4'h0);
      IRRMOVQ:                        instr_valid = (ifun <= CG);
      IIRMOVQ, IRMMOVQ, IMRMOVQ:      instr_valid = (ifun == 4'h0);
      IOPQ:                           instr_valid = (ifun <= ALUXOR);
      IJXX:                           instr_valid = (ifun <= CG);
      ICALL, IPUSHQ, IPOPQ, IIADDQ:   instr_valid = (ifun == 4'h0);
      default:                        instr_valid = 1'b0;
    endcase
  end

  // Второй байт с номерами регистров нужен всем инструкциям, кроме однобайтных и переходов.
  always_comb need_regids = (icode == IRRMOVQ) || (icode == IIRMOVQ) ||
                            (icode == IRMMOVQ) || (icode == IMRMOVQ) ||
                            (icode == IOPQ)    || (icode == IPUSHQ)  ||
                            (icode == IPOPQ)   || (icode == IIADDQ);

  // Восьмибайтная константа нужна там, где есть непосредственное значение,
  // смещение в памяти или адрес перехода.
  always_comb need_valC = (icode == IIRMOVQ) || (icode == IRMMOVQ) ||
                          (icode == IMRMOVQ) || (icode == IJXX)    ||
                          (icode == ICALL)   || (icode == IIADDQ);

  always_comb rA = need_regids ? byte1[7:4] : RNONE;
  always_comb rB = need_regids ? byte1[3:0] : RNONE;

  // Константа начинается со второго байта, если байта регистров нет,
  // и с третьего, если он есть. Байты уже лежат в нужном порядке little-endian.
  always_comb valC = need_regids ? imem_bytes[79:16] : imem_bytes[71:8];

  always_comb valP = pc + 64'd1 + (need_regids ? 64'd1 : 64'd0)
                                + (need_valC   ? 64'd8 : 64'd0);

  // Ошибка выборки это либо адрес за границей памяти, либо инструкция,
  // последний байт которой уже вне памяти.
  always_comb imem_error = imem_error_in || (valP > 64'(MEM_BYTES));

endmodule

Обрати внимание на default: instr_valid = 1'b0; в конце case. Он тут не для красоты. В always_comb каждая ветка обязана присвоить сигналу значение, иначе синтезатор построит защёлку, чтобы “помнить” прежнее значение. Комбинационный по замыслу сигнал превратится в элемент с памятью, и схема станет зависеть от того, какая инструкция была раньше. Verilator ловит это предупреждением про latch, и игнорировать его нельзя никогда.

Тестбенч выборки

Проверять этап выборки удобнее всего на настоящих байтах. Возьмём куски программы sum.ys из урока про ассемблер прямо по адресам из её листинга.

// Тестбенч этапа выборки: кладём в память куски настоящей программы sum.ys
// и смотрим, что fetch вытащил из байтов по каждому адресу.
module tb_fetch;

  import y86_pkg::*;

  // Адреса печатаются младшими разрядами: памяти четыре килобайта, старшие
  // разряды всегда нулевые. Флаг imem_error здесь не смотрим вовсе:
  // на вход подан ноль, все адреса лежат внутри памяти.
  /* verilator lint_off UNUSEDSIGNAL */
  logic [63:0] pc;
  logic [79:0] imem_bytes;
  logic [3:0]  icode, ifun, rA, rB;
  logic [63:0] valC, valP;
  logic        instr_valid, imem_error, need_regids, need_valC;
  /* verilator lint_on UNUSEDSIGNAL */

  fetch dut (
      .pc           (pc),
      .imem_bytes   (imem_bytes),
      .imem_error_in(1'b0),
      .icode        (icode),
      .ifun         (ifun),
      .rA           (rA),
      .rB           (rB),
      .valC         (valC),
      .valP         (valP),
      .instr_valid  (instr_valid),
      .imem_error   (imem_error),
      .need_regids  (need_regids),
      .need_valC    (need_valC)
  );

  logic [7:0] mem [0:MEM_BYTES-1];

  // Порт инструкций отдаёт десять байт подряд, как память из прошлого урока.
  always_comb begin
    for (int i = 0; i < 10; i++) imem_bytes[8*i +: 8] = mem[pc[11:0] + 12'(i)];
  end

  task automatic show(input logic [11:0] at);
    pc = 64'(at);
    #1
    $display("0x%03h  icode=%h ifun=%h  rA=%h rB=%h  valC=0x%03h valP=0x%03h  regids=%b valC?=%b valid=%b",
             at, icode, ifun, rA, rB, valC[11:0], valP[11:0],
             need_regids, need_valC, instr_valid);
  endtask

  initial begin
    // Память нулевая, поэтому выписываем только ненулевые байты инструкций.
    // Адреса и байты взяты из листинга sum.yo урока 21.
    for (int i = 0; i < MEM_BYTES; i++) mem[i] = 8'h00;

    mem[12'h000] = 8'h30; mem[12'h001] = 8'hf4; mem[12'h003] = 8'h02;  // irmovq stack, %rsp
    mem[12'h00a] = 8'h80; mem[12'h00b] = 8'h38;                        // call main
                                                                       // halt по 0x013 это нулевой байт
    mem[12'h077] = 8'h50; mem[12'h078] = 8'ha7;                        // mrmovq (%rdi), %r10
    mem[12'h081] = 8'h60; mem[12'h082] = 8'ha0;                        // addq %r10, %rax
    mem[12'h087] = 8'h74; mem[12'h088] = 8'h77;                        // jne loop
    mem[12'h090] = 8'h90;                                              // ret
    mem[12'h100] = 8'hf0;                                              // такой инструкции нет

    show(12'h000);
    show(12'h00a);
    show(12'h013);
    show(12'h077);
    show(12'h081);
    show(12'h087);
    show(12'h090);
    show(12'h100);
    $finish(0);
  end

endmodule

Собираем и запускаем:

iverilog -g2012 -s tb_fetch -o /tmp/tb_fetch \
  hdl/lib/y86_pkg.sv hdl/seq/fetch.sv hdl/tb/tb_fetch.sv
vvp -n /tmp/tb_fetch

Вывод:

0x000  icode=3 ifun=0  rA=f rB=4  valC=0x200 valP=0x00a  regids=1 valC?=1 valid=1
0x00a  icode=8 ifun=0  rA=f rB=f  valC=0x038 valP=0x013  regids=0 valC?=1 valid=1
0x013  icode=0 ifun=0  rA=f rB=f  valC=0x000 valP=0x014  regids=0 valC?=0 valid=1
0x077  icode=5 ifun=0  rA=a rB=7  valC=0x000 valP=0x081  regids=1 valC?=1 valid=1
0x081  icode=6 ifun=0  rA=a rB=0  valC=0x000 valP=0x083  regids=1 valC?=0 valid=1
0x087  icode=7 ifun=4  rA=f rB=f  valC=0x077 valP=0x090  regids=0 valC?=1 valid=1
0x090  icode=9 ifun=0  rA=f rB=f  valC=0x000 valP=0x091  regids=0 valC?=0 valid=1
0x100  icode=f ifun=0  rA=f rB=f  valC=0x000 valP=0x101  regids=0 valC?=0 valid=0

Пройдись по строкам и сверь каждую с листингом.

Первая строка: irmovq stack, %rsp по адресу 0x000, байты 30 f4 00 02 .... Код 3, функция 0. Поле rA равно f, потому что у irmovq нет исходного регистра. Поле rB равно 4, это %rsp. Константа 0x200 это адрес метки stack. Следующая инструкция по адресу 0x00a, то есть десять байт спустя, что и написано в листинге.

Вторая строка: call main, байты 80 38 00 .... Код 8, регистров нет, поэтому оба поля f. Константа 0x038 это адрес метки main, и в листинге main действительно стоит по 0x038. Длина девять, следующий адрес 0x013, и там как раз halt.

Третья строка: halt, один нулевой байт. Всё пусто, длина единица.

Четвёртая: mrmovq (%rdi), %r10, байты 50 a7 00 .... Код 5. Поле rA равно a, это %r10, приёмник. Поле rB равно 7, это %rdi, база адреса. Константа ноль, потому что смещения в исходнике не было, но признак valC? всё равно единица: у mrmovq константа есть всегда, даже нулевая, и она занимает свои восемь байт. Отсюда длина десять.

Пятая: addq %r10, %rax, байты 60 a0. Код 6, функция 0 это сложение. Константы нет, длина два.

Шестая: jne loop, байты 74 77 00 .... Код 7, функция 4 это условие “не равно”. Регистров нет, константа 0x077 это адрес метки loop. Длина девять.

Седьмая: ret, один байт 90. Код 9, функция 0, длина единица.

Восьмая строка это проверка на мусор. Байт 0xf0 даёт код f, которого в наборе нет, и valid честно равен нулю. Заметь, что все остальные поля при этом выглядят “нормально”: длина посчитана, регистры RNONE. Схема не умеет остановиться посреди вычисления, она считает всё всегда, а решение принимает потом отдельный модуль по флагу.

Этап декодирования на Verilog

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

Модуль отвечает на четыре вопроса и не делает больше ничего. Само чтение регистров выполняет регистровый файл, ему нужны только номера.

// Этап декодирования, верхняя половина таблицы 4.19.
// Отвечает на четыре вопроса: какие регистры читать (srcA, srcB)
// и в какие писать через порт E и порт M (dstE, dstM).
// Само чтение делает регистровый файл, здесь только выбор номеров.
module decode (
  input  logic [3:0] icode,
  input  logic [3:0] rA,
  input  logic [3:0] rB,

  output logic [3:0] srcA,
  output logic [3:0] srcB,
  output logic [3:0] dstE,
  output logic [3:0] dstM
);

  import y86_pkg::*;

  // Порт A читает rA, а у ret и popq он читает указатель стека.
  always_comb begin
    case (icode)
      IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ: srcA = rA;
      IPOPQ, IRET:                    srcA = RRSP;
      default:                        srcA = RNONE;
    endcase
  end

  // Порт B читает rB, а у всех операций со стеком читает указатель стека.
  always_comb begin
    case (icode)
      IRMMOVQ, IMRMOVQ, IOPQ, IIADDQ:     srcB = rB;
      IPUSHQ, IPOPQ, ICALL, IRET:         srcB = RRSP;
      default:                            srcB = RNONE;
    endcase
  end

  // Порт записи E получает результат АЛУ.
  always_comb begin
    case (icode)
      IRRMOVQ, IIRMOVQ, IOPQ, IIADDQ:     dstE = rB;
      IPUSHQ, IPOPQ, ICALL, IRET:         dstE = RRSP;
      default:                            dstE = RNONE;
    endcase
  end

  // Порт записи M получает значение, прочитанное из памяти.
  always_comb begin
    case (icode)
      IMRMOVQ, IPOPQ: dstM = rA;
      default:        dstM = RNONE;
    endcase
  end

endmodule

Четыре блока always_comb, четыре case, и каждый в точности повторяет один из четырёх абзацев раздела “что видно, когда таблицы стоят рядом”. Это редкий случай, когда спецификация переписывается в код построчно.

Две вещи стоит заметить.

Ветка default в каждом блоке возвращает RNONE, и это не только защита от защёлки. Это ещё и правильное поведение для неизвестных кодов D, E и F. Мусорная инструкция обязана оставить все порты выключенными, иначе она успеет испортить регистр в том же такте, в котором машина решит остановиться. Машина обнаруживает ошибку параллельно с декодированием, а не до него.

Условной пересылки cmovXX в этой таблице нет, хотя у неё запись бывает отменена. Здесь она неотличима от rrmovq: порт E назначается всегда. Гасит его этап исполнения, когда Cnd окажется нулём. Так сделано потому, что Cnd считается по флагам, а флаги считает ALU, и знать их на этапе декодирования схема физически не может: сигнал туда ещё не дошёл.

Тестбенч по всем кодам инструкций

Раз выход зависит только от кода инструкции, то и проверить можно исчерпывающе: шестнадцать значений, все.

// Тестбенч этапа декодирования: перебирает все шестнадцать значений icode
// с одной и той же парой номеров регистров и печатает, что модуль decode
// выбрал для четырёх портов регистрового файла.
//
//   iverilog -g2012 -s tb_decode -o /tmp/tb_decode \
//     hdl/lib/y86_pkg.sv hdl/seq/decode.sv hdl/tb/tb_decode.sv && vvp -n /tmp/tb_decode
//
// rA равен %rdx (номер 2), rB равен %rbx (номер 3): оба видны в выводе
// невооружённым глазом, и ни один не совпадает с %rsp (номер 4), поэтому
// сразу понятно, откуда взялся указатель стека.
module tb_decode;

  import y86_pkg::*;

  logic [3:0] icode;
  logic [3:0] rA;
  logic [3:0] rB;
  logic [3:0] srcA, srcB, dstE, dstM;

  decode dut (
      .icode(icode),
      .rA   (rA),
      .rB   (rB),
      .srcA (srcA),
      .srcB (srcB),
      .dstE (dstE),
      .dstM (dstM)
  );

  // Имя регистра по номеру. Пятнадцать настоящих плюс RNONE.
  function automatic string reg_name(logic [3:0] r);
    case (r)
      4'h0: reg_name = "%rax";
      4'h1: reg_name = "%rcx";
      4'h2: reg_name = "%rdx";
      4'h3: reg_name = "%rbx";
      4'h4: reg_name = "%rsp";
      4'h5: reg_name = "%rbp";
      4'h6: reg_name = "%rsi";
      4'h7: reg_name = "%rdi";
      4'h8: reg_name = "%r8";
      4'h9: reg_name = "%r9";
      4'hA: reg_name = "%r10";
      4'hB: reg_name = "%r11";
      4'hC: reg_name = "%r12";
      4'hD: reg_name = "%r13";
      4'hE: reg_name = "%r14";
      default: reg_name = "%none";
    endcase
  endfunction

  // Мнемоника по icode, чтобы таблица читалась без сверки с пакетом.
  function automatic string icode_name(logic [3:0] c);
    case (c)
      IHALT:   icode_name = "halt";
      INOP:    icode_name = "nop";
      IRRMOVQ: icode_name = "rrmovq";
      IIRMOVQ: icode_name = "irmovq";
      IRMMOVQ: icode_name = "rmmovq";
      IMRMOVQ: icode_name = "mrmovq";
      IOPQ:    icode_name = "OPq";
      IJXX:    icode_name = "jXX";
      ICALL:   icode_name = "call";
      IRET:    icode_name = "ret";
      IPUSHQ:  icode_name = "pushq";
      IPOPQ:   icode_name = "popq";
      IIADDQ:  icode_name = "iaddq";
      default: icode_name = "????";
    endcase
  endfunction

  initial begin
    rA = 4'h2;  // %rdx
    rB = 4'h3;  // %rbx
    $display("icode  name    srcA  srcB  dstE  dstM");
    for (int i = 0; i < 16; i++) begin
      icode = i[3:0];
      #1
      $display("%h      %-7s %-5s %-5s %-5s %-5s", icode, icode_name(icode),
               reg_name(srcA), reg_name(srcB), reg_name(dstE), reg_name(dstM));
    end
    $finish(0);
  end

endmodule

Вывод:

icode  name    srcA  srcB  dstE  dstM
0      halt    %none %none %none %none
1      nop     %none %none %none %none
2      rrmovq  %rdx  %none %rbx  %none
3      irmovq  %none %none %rbx  %none
4      rmmovq  %rdx  %rbx  %none %none
5      mrmovq  %none %rbx  %none %rdx 
6      OPq     %rdx  %rbx  %rbx  %none
7      jXX     %none %none %none %none
8      call    %none %rsp  %rsp  %none
9      ret     %rsp  %rsp  %rsp  %none
a      pushq   %rdx  %rsp  %rsp  %none
b      popq    %rsp  %rsp  %rsp  %rdx 
c      iaddq   %none %rbx  %rbx  %none
d      ????    %none %none %none %none
e      ????    %none %none %none %none
f      ????    %none %none %none %none

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

Строки halt, nop и jXX пустые целиком. Ни одна из этих инструкций не трогает регистры. У jXX есть операнд, но это адрес, а не регистр.

Строка rrmovq: читает %rdx, пишет в %rbx. Порт B молчит, потому что складывать не с чем, ALU получит ноль.

Строка irmovq: не читает ничего, пишет в %rbx. Значение приходит из константы.

Строка rmmovq: читает оба регистра, не пишет никуда. Единственная инструкция в таблице с двумя чтениями и без записи.

Строка mrmovq зеркальна: читает только базу через порт B, пишет через порт M. Порт E молчит, хотя ALU в этот момент как раз считает адрес. Результат ALU нужен памяти, а не регистру.

Строка OPq самая плотная из “обычных”: два чтения и запись, причём rB работает и как источник, и как приёмник. Ровно так же устроен addq в x86-64.

Строки call и ret показывают неявный операнд во всей красе. В байтах call нет ни одного номера регистра, а в таблице %rsp стоит дважды. Схема знает про указатель стека не из инструкции, а из её кода.

Строка pushq: порт A читает названный регистр, порт B читает указатель стека, порт E пишет новый указатель. Три занятых порта из четырёх.

Строка popq единственная, где заняты все четыре. Порт A даёт адрес, по которому лежит слово. Порт B даёт значение для ALU. Порт E пишет увеличенный указатель. Порт M пишет прочитанное слово. Если бы в исходнике стоял popq %rsp, то dstE и dstM совпали бы, и вопрос “кто победит” перестал бы быть теоретическим.

Строка iaddq: читает rB через порт B, пишет в него же через порт E, порт A свободен, потому что второе слагаемое это константа. Сравни со строкой OPq и увидишь ровно одно отличие.

Строки d, e, f пустые, как и договаривались.

Пять инструкций живьём

Теперь проведём настоящие инструкции из программ 32-systems/21 · Ассемблер Y86-64 на Zig через оба этапа с конкретными числами. Это самое полезное упражнение во всех уроках про SEQ: пока не проделаешь его руками несколько раз, таблицы остаются таблицами.

call main по адресу 0x00a в sum.ys

Байты 80 38 00 00 00 00 00 00 00. Указатель стека к этому моменту равен 0x200, его поставила предыдущая инструкция.

ЭтапЗначения
fetchicode = 8, ifun = 0, rA = rB = RNONE, valC = 0x038, valP = 0x013
decodesrcA = RNONE, srcB = %rsp, значит valA = 0, valB = 0x200
executevalE = 0x200 - 8 = 0x1f8
memoryM8[0x1f8] ← valP = 0x013
write backR[%rsp] ← 0x1f8
PC updatePC ← valC = 0x038

Проверить можно прямо по финальному состоянию машины: трасса sum.ys заканчивается строкой mem 0x1f8 0x0000000000000013. Это тот самый адрес возврата, который сюда положил call, и после ret его никто не затирал.

mrmovq (%rdi), %r10 по адресу 0x077 в sum.ys

Байты 50 a7 00 00 00 00 00 00 00 00. Первая итерация цикла, %rdi равен 0x018, это адрес метки array.

ЭтапЗначения
fetchicode = 5, rA = %r10, rB = %rdi, valC = 0, valP = 0x081
decodesrcA = RNONE, srcB = %rdi, значит valB = 0x018
executevalE = 0x018 + 0 = 0x018
memoryvalM ← M8[0x018] = 0x0000000d000d000d
write backR[%r10] ← 0x0000000d000d000d
PC updatePC ← 0x081

Значение 0x0000000d000d000d это первый элемент массива, он записан в исходнике как .quad 0x000d000d000d. В памяти его байты лежат как 0d 00 0d 00 0d 00 00 00, потому что порядок little-endian, и обратно в число их собирает память данных.

rmmovq %r11, (%rdx) по адресу 0x0c0 в bubble.ys

Байты 40 b2 00 00 00 00 00 00 00 00. Это первый обмен в пузырьковой сортировке: массив начинается с 5, 3, значит %rdx равен 0x018, %r10 равен 5, %r11 равен 3.

ЭтапЗначения
fetchicode = 4, rA = %r11, rB = %rdx, valC = 0, valP = 0x0ca
decodesrcA = %r11, srcB = %rdx, значит valA = 3, valB = 0x018
executevalE = 0x018 + 0 = 0x018
memoryM8[0x018] ← valA = 3
write backничего, оба порта RNONE
PC updatePC ← 0x0ca

Обрати внимание, насколько похожи эта таблица и предыдущая на этапах fetch, decode и execute. Различаются они одной строкой memory и одной строкой write back. В схеме это буквально так: одни и те же провода, разные признаки чтения и записи.

popq %rax по адресу 0x00c в push_pop_rsp.ys

Байты b0 0f. К этому моменту предыдущая инструкция pushq %rsp уже положила по адресу 0x1f8 значение 0x200 и оставила %rsp равным 0x1f8.

ЭтапЗначения
fetchicode = B, rA = %rax, rB = RNONE, valP = 0x00e
decodesrcA = %rsp, srcB = %rsp, значит valA = valB = 0x1f8
executevalE = 0x1f8 + 8 = 0x200
memoryvalM ← M8[0x1f8] = 0x200
write backR[%rsp] ← 0x200, R[%rax] ← 0x200
PC updatePC ← 0x00e

Вот и ответ на вопрос, почему pushq %rsp кладёт в память старое значение. В память ушло 0x200, а не 0x1f8, потому что на этапе декодирования порт A прочитал %rsp до того, как порт E успел его изменить. Финальное состояние машины подтверждает: %rax равен 0x200.

ret по адресу 0x090 в sum.ys

Байт 90. Внутренний вызов call sum положил адрес возврата 0x055 по адресу 0x1f0, значит сейчас %rsp равен 0x1f0.

ЭтапЗначения
fetchicode = 9, регистров нет, valP = 0x091
decodesrcA = srcB = %rsp, значит valA = valB = 0x1f0
executevalE = 0x1f0 + 8 = 0x1f8
memoryvalM ← M8[0x1f0] = 0x055
write backR[%rsp] ← 0x1f8
PC updatePC ← valM = 0x055

И правда, в трассе sum.ys сразу после строки 0x090 90 AOK идёт строка 0x055 90 AOK. Машина вернулась именно туда, а valP со значением 0x091 был посчитан и выброшен, как и обещано.

Практика

Задача собирает оба этапа в один модуль управляющей логики. На входе код инструкции и два номера регистров, на выходе шесть сигналов: need_regids, need_valC, srcA, srcB, dstE и dstM. Пиши по таблицам урока, iaddq не забудь, а на неизвестных кодах всё должно молчать. Грейдер перебирает все шестнадцать кодов на двух разных парах номеров регистров, чтобы отличить настоящий выбор rA и rB от константы, вписанной вручную.

Упражнения

Итоги

  • Шесть этапов SEQ это не шесть шагов во времени, а шесть кусков одной комбинационной схемы. Внутри такта сигнал течёт от счётчика команд до нового значения счётчика команд, и только фронт такта меняет состояние машины.
  • Состояние машины внутри такта неподвижно: регистровый файл, память и флаги всё время отдают старые значения. Новое приезжает целиком, одним фронтом.
  • Таблица вычислений для класса инструкций это одновременно спецификация и черновик схемы. Почти каждая её строка превращается в одну строку Verilog.
  • valP считается всегда, даже когда заведомо не понадобится. В комбинационной логике нет “не считать”, зато посчитанное дёшево выбросить мультиплексором.
  • ALU в машине одно и обслуживает всё: арифметику OPq, копирование в rrmovq через прибавление нуля, адрес в rmmovq и mrmovq, плюс восемь и минус восемь для стека.
  • Порт записи E жёстко привязан к выходу ALU, порт M к выходу памяти данных. Мультиплексора между ними нет, потому что он лёг бы на критический путь.
  • Этап выборки это разбор десяти байт: первый байт делится на код и функцию, остальные выравниваются под константу, плюс два признака формата need_regids и need_valC. Из этих двух признаков получаются и номера регистров, и смещение константы, и длина инструкции.
  • Ошибок две, и они разные. imem_error означает, что байты не прочитаны, instr_valid означает, что прочитанные байты не складываются в известную инструкцию. Первая старше второй.
  • При ошибке выборки вместо кода инструкции подставляется nop, чтобы мусор не успел включить порт записи в том же такте, в котором машина решит остановиться.
  • Весь этап декодирования это четыре функции от кода инструкции, каждая возвращает номер регистра или RNONE. Ни одного вычисления, только выбор.
  • RNONE это не регистр номер пятнадцать, а признак “порт не используется”. Благодаря ему отдельного бита включения порта в схеме не нужно.
  • Ветка default в always_comb обязательна. Без неё синтезатор строит защёлку, комбинационный сигнал начинает помнить прошлое, и схема зависит от предыдущей инструкции.
  • Указатель стека у pushq, popq, call и ret это неявный операнд: он не написан в байтах инструкции, схема знает про него из кода инструкции.
  • У popq заняты все четыре порта регистрового файла сразу, и только у неё. Отсюда и вся странность popq %rsp: два порта записи целятся в один регистр, побеждает M.
  • call это pushq от valP плюс переход, ret это popq в счётчик команд. Совпадение строк в таблицах буквальное, и в схеме это те же провода.
  • Тестбенч, который перебирает все шестнадцать кодов инструкций, короче любого текста про декодирование и говорит ровно то же самое. Такую таблицу стоит держать под рукой весь блок.

Дальше

Первые два этапа готовы: байты разобраны на поля, номера регистров выбраны, значения valA и valB лежат на выходах регистрового файла. Дальше по тракту идут четыре этапа, которые превращают эти значения в результат. В 32-systems/27 · SEQ: execute, memory, write back и новый PC появится ALU со своими мультиплексорами входов и признаком Cnd, обращение к памяти с четырьмя сигналами, модуль состояния Stat, который решает, доводить ли инструкцию до конца, и выбор нового счётчика команд. После этого SEQ соберётся целиком, и мы прогоним через неё все программы урока 21, сверив трассу Verilog с трассой симулятора байт в байт. Заодно добавим iaddq, и станет видно, насколько мало нужно менять в готовой машине, чтобы вырастить ей новую инструкцию.

домашка

Домашка