Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
SEQ: этапы fetch и decode
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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.
- Счётчик команд всё время держит
0x077на входе памяти инструкций. Память отдаёт десять байт начиная с этого адреса. - Этап выборки разбирает эти байты на поля: код инструкции, функция, номера регистров, константа. Заодно считает
valP, адрес следующей инструкции. - Этап декодирования смотрит на код инструкции и выбирает номера регистров для двух портов чтения. Регистровый файл тут же отдаёт два значения,
valAиvalB. - Этап исполнения подаёт нужные значения на ALU и получает
valE, а для условных инструкций ещё и признакCnd. - Этап обращения к памяти выставляет адрес и признаки чтения или записи. При чтении память тут же отдаёт
valM. - Этап записи выбирает, в какие регистры пойдут
valEиvalM. Этап обновления счётчика команд выбирает новый адрес изvalC,valMилиvalP. - Приходит фронт такта. Регистровый файл записывает то, что стоит на портах записи. Память данных записывает то, что стоит на порту записи. Флаги записывают новое значение. Счётчик команд записывает новый адрес.
Пункты с первого по шестой это одна большая комбинационная схема, в которой ничего не “происходит по шагам”. Просто сигнал доходит от входа до выхода за какое-то время. Период такта выбирают так, чтобы этого времени хватило с запасом. Пункт седьмой это единственный момент, когда состояние машины меняется.
Отсюда, кстати, и главная слабость 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_pc | valC, 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, rB | rrmovq rA, rB | irmovq V, rB | iaddq V, rB |
|---|---|---|---|---|
| fetch | icode:ifun ← M1[PC], rA:rB ← M1[PC+1], valP ← PC+2 | то же, valP ← PC+2 | rA:rB ← M1[PC+1], valC ← M8[PC+2], valP ← PC+10 | как у irmovq |
| decode | valA ← R[rA], valB ← R[rB] | valA ← R[rA] | ничего | valB ← R[rB] |
| execute | valE ← valB OP valA, флаги | valE ← 0 + valA, Cnd по флагам | valE ← 0 + valC | valE ← valB + valC, флаги |
| memory | ничего | ничего | ничего | ничего |
| write back | R[rB] ← valE | если Cnd, то R[rB] ← valE | R[rB] ← valE | R[rB] ← valE |
| PC update | PC ← valP | PC ← valP | PC ← valP | PC ← 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 |
|---|---|---|
| fetch | rA:rB ← M1[PC+1], valC ← M8[PC+2], valP ← PC+10 | то же |
| decode | valA ← R[rA], valB ← R[rB] | valB ← R[rB] |
| execute | valE ← valB + valC | valE ← valB + valC |
| memory | M8[valE] ← valA | valM ← M8[valE] |
| write back | ничего | R[rA] ← valM |
| PC update | PC ← valP | PC ← valP |
Обе инструкции считают адрес одинаково: база из регистра плюс смещение из константы. Это то же самое ALU, что складывает числа в OPq. Отдельного сумматора адресов в машине нет, и это важная мысль: адрес в памяти для процессора обычное число.
Различаются они направлением. У rmmovq значение идёт из регистра в память, поэтому нужен порт A на чтение и порт записи в память. У mrmovq значение идёт из памяти в регистр, поэтому порт A на чтение не нужен вовсе, зато занят порт записи M.
Заметь несимметричность: у mrmovq результат в регистр приходит через порт M, а не через порт E, хотя порт E в этот момент свободен. Так сделано намеренно. Порт E жёстко привязан к выходу ALU, порт M жёстко привязан к выходу памяти. Мультиплексора между ними нет, потому что он был бы на критическом пути и удлинил бы такт.
Стек
| Этап | pushq rA | popq rA |
|---|---|---|
| fetch | rA:rB ← M1[PC+1], valP ← PC+2 | то же |
| decode | valA ← R[rA], valB ← R[%rsp] | valA ← R[%rsp], valB ← R[%rsp] |
| execute | valE ← valB - 8 | valE ← valB + 8 |
| memory | M8[valE] ← valA | valM ← M8[valA] |
| write back | R[%rsp] ← valE | R[%rsp] ← valE, R[rA] ← valM |
| PC update | PC ← valP | PC ← 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 Dest | call Dest | ret |
|---|---|---|---|
| fetch | valC ← M8[PC+1], valP ← PC+9 | valC ← M8[PC+1], valP ← PC+9 | valP ← PC+1 |
| decode | ничего | valB ← R[%rsp] | valA ← R[%rsp], valB ← R[%rsp] |
| execute | Cnd по флагам и ifun | valE ← valB - 8 | valE ← valB + 8 |
| memory | ничего | M8[valE] ← valP | valM ← M8[valA] |
| write back | ничего | R[%rsp] ← valE | R[%rsp] ← valE |
| PC update | PC ← Cnd ? valC : valP | PC ← valC | PC ← 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, его поставила предыдущая инструкция.
| Этап | Значения |
|---|---|
| fetch | icode = 8, ifun = 0, rA = rB = RNONE, valC = 0x038, valP = 0x013 |
| decode | srcA = RNONE, srcB = %rsp, значит valA = 0, valB = 0x200 |
| execute | valE = 0x200 - 8 = 0x1f8 |
| memory | M8[0x1f8] ← valP = 0x013 |
| write back | R[%rsp] ← 0x1f8 |
| PC update | PC ← 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.
| Этап | Значения |
|---|---|
| fetch | icode = 5, rA = %r10, rB = %rdi, valC = 0, valP = 0x081 |
| decode | srcA = RNONE, srcB = %rdi, значит valB = 0x018 |
| execute | valE = 0x018 + 0 = 0x018 |
| memory | valM ← M8[0x018] = 0x0000000d000d000d |
| write back | R[%r10] ← 0x0000000d000d000d |
| PC update | PC ← 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.
| Этап | Значения |
|---|---|
| fetch | icode = 4, rA = %r11, rB = %rdx, valC = 0, valP = 0x0ca |
| decode | srcA = %r11, srcB = %rdx, значит valA = 3, valB = 0x018 |
| execute | valE = 0x018 + 0 = 0x018 |
| memory | M8[0x018] ← valA = 3 |
| write back | ничего, оба порта RNONE |
| PC update | PC ← 0x0ca |
Обрати внимание, насколько похожи эта таблица и предыдущая на этапах fetch, decode и execute. Различаются они одной строкой memory и одной строкой write back. В схеме это буквально так: одни и те же провода, разные признаки чтения и записи.
popq %rax по адресу 0x00c в push_pop_rsp.ys
Байты b0 0f. К этому моменту предыдущая инструкция pushq %rsp уже положила по адресу 0x1f8 значение 0x200 и оставила %rsp равным 0x1f8.
| Этап | Значения |
|---|---|
| fetch | icode = B, rA = %rax, rB = RNONE, valP = 0x00e |
| decode | srcA = %rsp, srcB = %rsp, значит valA = valB = 0x1f8 |
| execute | valE = 0x1f8 + 8 = 0x200 |
| memory | valM ← M8[0x1f8] = 0x200 |
| write back | R[%rsp] ← 0x200, R[%rax] ← 0x200 |
| PC update | PC ← 0x00e |
Вот и ответ на вопрос, почему pushq %rsp кладёт в память старое значение. В память ушло 0x200, а не 0x1f8, потому что на этапе декодирования порт A прочитал %rsp до того, как порт E успел его изменить. Финальное состояние машины подтверждает: %rax равен 0x200.
ret по адресу 0x090 в sum.ys
Байт 90. Внутренний вызов call sum положил адрес возврата 0x055 по адресу 0x1f0, значит сейчас %rsp равен 0x1f0.
| Этап | Значения |
|---|---|
| fetch | icode = 9, регистров нет, valP = 0x091 |
| decode | srcA = srcB = %rsp, значит valA = valB = 0x1f0 |
| execute | valE = 0x1f0 + 8 = 0x1f8 |
| memory | valM ← M8[0x1f0] = 0x055 |
| write back | R[%rsp] ← 0x1f8 |
| PC update | PC ← 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, и станет видно, насколько мало нужно менять в готовой машине, чтобы вырастить ей новую инструкцию.
домашка