Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
SEQ: execute, memory, write back и новый PC
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
SEQ: execute, memory, write back и новый PC
В прошлом уроке ты довёл сигнал до середины тракта: байты разобраны, номера регистров выбраны, значения прочитаны. Сегодня достраиваем вторую половину и замыкаем кольцо. Четыре маленьких модуля решают, что подать на ALU, по какому адресу пойти в память, жива ли ещё машина и откуда взять адрес следующей инструкции. Потом всё это собирается в один топ-модуль, где тактируются ровно четыре вещи, а остальное чистые провода. В конце процессор запускает те же программы, что и симулятор из урока 22, и трасса совпадает строка в строку.
Цели урока
- Выбрать входы ALU по коду инструкции и понять, почему пересылка регистра тоже идёт через сумматор.
- Отделить вычисление флагов от их записи и увидеть, что сигнал
set_ccэто единственная защита кодов условия от посторонних инструкций. - Понять, почему условие
Cndсчитается по старым флагам, а не по тем, что ALU выдал в этом же такте. - Собрать этап памяти из четырёх сигналов и объяснить, почему у
popqиretадрес берётся не из результата ALU. - Расставить проверки в модуле
statв правильном порядке и сказать, что сломается от перестановки. - Понять write back как два независимых порта записи и разобрать
popq %rspчерез порядок неблокирующих присваиваний. - Выбрать новый PC из четырёх источников и увидеть, что
retзаставляет процессор ждать чтения памяти. - Собрать топ-модуль SEQ, где состояние хранят только PC, флаги, регистровый файл и память, а всё остальное комбинационная логика.
- Прогнать программы прошлых уроков на своей машине и сверить трассу с трассой симулятора через
tools/compare-trace.sh. - Провести расширение
iaddqчерез все этапы и показать пальцем, какие строки каждого модуля за него отвечают.
Идея: вторая половина тракта это те же таблицы
Урок 26 закончился на середине. Из этапов fetch и decode вышли восемь значений: icode, ifun, valA, valB, valC, valP, номера регистров записи dstE и dstM, плюс признаки instr_valid и imem_error. Дальше по книге идут execute, memory, write back и pc update, и все четыре устроены одинаково: строка таблицы на класс инструкций, столбец на сигнал, а сам сигнал это мультиплексор, который по icode выбирает одну из нескольких величин.
Ничего принципиально нового по сравнению с прошлым уроком тут нет, и это хорошая новость. Плохая новость в том, что мест, где легко ошибиться, стало больше, а ошибка теперь видна не сразу: неправильный srcA ломает программу на первом же такте, а неправильный set_cc тихо работает на трёх программах из четырёх и разваливается на четвёртой. Поэтому урок заканчивается не словами “готово”, а прогоном восьми программ и посимвольной сверкой трасс.
Три вопроса, вокруг которых крутится вся вторая половина.
Первый. У Y86-64 одно арифметико-логическое устройство на весь процессор, и через него проходят все инструкции без исключения: сложение, пересылка регистра, вычисление адреса в памяти, изменение указателя стека. Значит, вопрос не в том, что умеет ALU, а в том, что подать ему на входы и как не дать ему испортить флаги.
Второй. Память в SEQ одна на всё, но обращений к ней два: порт инструкций читает всегда, а порт данных только тогда, когда инструкция этого просит. Кто просит, по какому адресу и что кладёт, решают четыре сигнала.
Третий. Кольцо надо замкнуть. PC это единственное место, где выход тракта возвращается на его вход, и именно там процессор решает, куда идти дальше: на следующую инструкцию, на цель перехода, на адрес вызова или на адрес возврата, вынутый из памяти.
Пакет констант, к которому мы возвращаемся весь урок
Все модули начинаются со строки import y86_pkg::*, и дальше в коде стоят имена, а не голые полубайты. Пакет целиком стоит привести здесь: сегодня в дело идёт каждая его строка, включая IIADDQ, до которой мы доберёмся в конце урока.
// Общие константы 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 */
Обрати внимание на две мелочи, которые дальше сыграют. IRRMOVQ покрывает и обычную пересылку, и все шесть условных: у них один код инструкции и разный ifun. И RNONE, номер несуществующего регистра, это не заглушка для красоты, а рабочий сигнал: именно им write back отключает запись, когда условная пересылка не сработала.
Execute: два мультиплексора, ALU и условие
Этап исполнения отвечает на четыре вопроса. Что подать на первый вход ALU, что на второй, какую операцию заказать и разрешать ли запись флагов. Вот таблица по классам инструкций, и её стоит прочитать глазами, прежде чем смотреть в код.
| Инструкция | aluA | aluB | alufun | set_cc |
|---|---|---|---|---|
halt, nop | 0 | 0 | ADD | нет |
rrmovq, cmovXX | valA | 0 | ADD | нет |
irmovq | valC | 0 | ADD | нет |
rmmovq, mrmovq | valC | valB | ADD | нет |
OPq | valA | valB | ifun | да |
jXX | 0 | 0 | ADD | нет |
call, pushq | минус 8 | valB | ADD | нет |
ret, popq | 8 | valB | ADD | нет |
iaddq | valC | valB | ADD | да |
Из таблицы видно главное. Через ALU проходит всё. Пересылка регистра это сложение с нулём, загрузка константы это сложение константы с нулём, вычисление адреса это сложение смещения с базой, а работа со стеком это сложение указателя с плюс восемью или минус восемью. Отдельного сумматора для адресов и отдельного пути для пересылок в Y86-64 нет, и это не упрощение ради учебника: настоящие процессоры экономят на том же самом, просто у них таких блоков несколько.
Второе. Собственная операция есть ровно у одного класса, у OPq, и берётся она из ifun. У всех остальных ifun либо ноль, либо код условия, и к ALU он отношения не имеет. Отсюда одна строка кода вместо таблицы.
Третье, самое коварное. Флаги ALU выставляет всегда, при любой инструкции: он же комбинационная схема, он не знает, что происходит вокруг. Записывать ли их в регистр кодов условия, решает отдельный сигнал set_cc снаружи. Если этот сигнал забыть и разрешить запись всем подряд, любой jmp или mrmovq между сравнением и переходом затрёт флаги, и программа поедет по не той ветке. Это ровно тот случай, когда ошибка не ломает машину, а делает её тихо неправильной.
Начнём с блока, который считает. Он написан в уроке про слова, мультиплексоры и ALU, и мы приводим его здесь целиком по двум причинам: execute подключает его портами, и именно его флаги мы сейчас будем защищать.
// Арифметико-логическое устройство Y86-64.
// Важно: операция считается как aluB OP aluA, поэтому subq rA, rB даёт rB - rA.
// Флаги выставляются всегда, а запишет ли их регистр состояния, решает сигнал set_cc снаружи.
module alu (
input logic [3:0] alufun,
input logic [63:0] aluA,
input logic [63:0] aluB,
output logic [63:0] valE,
output logic ZF,
output logic SF,
output logic OF
);
import y86_pkg::*;
always_comb begin
case (alufun)
ALUSUB: valE = aluB - aluA;
ALUAND: valE = aluB & aluA;
ALUXOR: valE = aluB ^ aluA;
default: valE = aluB + aluA; // ALUADD
endcase
end
always_comb ZF = (valE == 64'd0);
always_comb SF = valE[63];
// Переполнение знакового сложения: слагаемые одного знака, а сумма другого.
// Для вычитания знаки операндов должны различаться, а результат уйти от знака уменьшаемого.
always_comb begin
case (alufun)
ALUSUB: OF = (aluA[63] != aluB[63]) && (valE[63] != aluB[63]);
ALUAND: OF = 1'b0;
ALUXOR: OF = 1'b0;
default: OF = (aluA[63] == aluB[63]) && (valE[63] != aluA[63]);
endcase
end
endmodule
Порядок операндов тут не косметика. ALU считает aluB OP aluA, поэтому subq %rdx, %rbx даёт rbx - rdx, как и положено ассемблеру AT&T. Если перепутать входы местами, сложение и логические операции переживут подмену, а вычитание молча поменяет знак, и первая же программа с циклом уйдёт в бесконечность.
Второй блок этапа исполнения считает условие. Он маленький, но обслуживает сразу два разных дела: условные переходы jXX и условные пересылки cmovXX. Обе группы задают условие одинаково, кодом в ifun, и обеим нужен один бит ответа.
// Блок вычисления условия. По коду ifun и трём флагам говорит, сработало ли условие.
// Один и тот же блок обслуживает и переходы jXX, и условные пересылки cmovXX.
module cond (
input logic [3:0] ifun,
input logic ZF,
input logic SF,
input logic OF,
output logic Cnd
);
import y86_pkg::*;
always_comb begin
case (ifun)
CALWAYS: Cnd = 1'b1;
CLE: Cnd = (SF ^ OF) | ZF;
CL: Cnd = SF ^ OF;
CE: Cnd = ZF;
CNE: Cnd = ~ZF;
CGE: Cnd = ~(SF ^ OF);
CG: Cnd = ~(SF ^ OF) & ~ZF;
default: Cnd = 1'b0;
endcase
end
endmodule
Выражения тут те самые, что ты выводил для x86-64 в уроке про флаги, переходы и cmov, только записанные вентилями. Знаковое “меньше” это SF ^ OF: результат вычитания отрицательный, и переполнения не было, либо результат положительный, но переполнение случилось. Знаковое “меньше или равно” добавляет к этому ZF. Беззнаковых условий у Y86-64 нет вообще, флага переноса тоже, и это одно из мест, где учебная система команд честно проще настоящей.
Теперь сам этап. Три мультиплексора, два экземпляра модулей и ни одной строки, которая бы что-то считала своими руками.
// Этап исполнения, таблица 4.20.
// Два мультиплексора подают операнды в АЛУ, третий выбирает операцию.
// Отдельно считается условие Cnd: оно нужно и переходам, и условным пересылкам.
module execute (
input logic [3:0] icode,
input logic [3:0] ifun,
input logic [63:0] valA,
input logic [63:0] valB,
input logic [63:0] valC,
input logic ZF,
input logic SF,
input logic OF,
output logic [63:0] aluA,
output logic [63:0] aluB,
output logic [3:0] alufun,
output logic set_cc,
output logic [63:0] valE,
output logic Cnd,
output logic new_ZF,
output logic new_SF,
output logic new_OF
);
import y86_pkg::*;
// Первый операнд: значение регистра, константа или шаг указателя стека.
always_comb begin
case (icode)
IRRMOVQ, IOPQ: aluA = valA;
IIRMOVQ, IRMMOVQ, IMRMOVQ, IIADDQ: aluA = valC;
ICALL, IPUSHQ: aluA = -64'd8;
IRET, IPOPQ: aluA = 64'd8;
default: aluA = 64'd0;
endcase
end
// Второй операнд: либо значение из порта B, либо ноль для чистой пересылки.
always_comb begin
case (icode)
IRMMOVQ, IMRMOVQ, IOPQ, IIADDQ,
ICALL, IPUSHQ, IRET, IPOPQ: aluB = valB;
default: aluB = 64'd0; // IRRMOVQ, IIRMOVQ и прочие
endcase
end
// Своя операция есть только у OPq, все остальные складывают.
always_comb alufun = (icode == IOPQ) ? ifun : ALUADD;
// Флаги меняют только арифметические инструкции.
always_comb set_cc = (icode == IOPQ) || (icode == IIADDQ);
alu u_alu (
.alufun (alufun),
.aluA (aluA),
.aluB (aluB),
.valE (valE),
.ZF (new_ZF),
.SF (new_SF),
.OF (new_OF)
);
// Условие проверяется по флагам, которые лежат в регистре состояния СЕЙЧАС,
// то есть по результату предыдущей арифметической инструкции.
cond u_cond (
.ifun (ifun),
.ZF (ZF),
.SF (SF),
.OF (OF),
.Cnd (Cnd)
);
endmodule
Разбери подключение флагов внимательно, это единственное место урока, где легко ошибиться, не заметив. У модуля два комплекта флаговых сигналов, и они смотрят в разные стороны. Входы ZF, SF, OF это то, что лежит в регистре кодов условия прямо сейчас, то есть результат предыдущей арифметической инструкции. Выходы new_ZF, new_SF, new_OF это то, что ALU насчитал в этом такте. В блок cond уходят входы, а не выходы.
Так и должно быть. Инструкция jne loop сама ничего не сравнивает, она читает флаги, оставленные предыдущим subq. Если подключить к cond свежие флаги ALU, переход начнёт смотреть на результат своего собственного сложения нуля с нулём, и любой условный переход превратится в проверку “ноль равен нулю”. Заметить это по коду почти невозможно, а по трассе видно сразу: цикл либо не начинается, либо не кончается.
Тот же вход Cnd работает и на условную пересылку, но там он гасит не переход, а запись в регистр. Про это будет отдельный разговор в write back.
На схеме тракта видно, чем execute отличается от соседей по расходу проводов: два широких мультиплексора на входах ALU это самая толстая часть комбинационной логики во всей машине. Выбери программу, прошагай её кнопкой “шаг” и посмотри, как меняется набор подсвеченных блоков. У jne погашены и регистровый файл, и память данных, зато горит регистр флагов: переход только читает его. У call наоборот светится почти всё, включая память данных и обратную запись: он и кладёт адрес возврата на стек, и двигает указатель. А у OPq загорается регистр флагов с другой стороны, на запись, и разрешает её ровно set_cc.
Memory: адрес, данные, чтение и запись
Этап памяти это четыре сигнала, и ни одного больше. Куда идём, что кладём, читаем ли, пишем ли.
| Инструкция | mem_addr | mem_data | mem_read | mem_write |
|---|---|---|---|---|
rmmovq | valE | valA | нет | да |
mrmovq | valE | нет | да | нет |
pushq | valE | valA | нет | да |
call | valE | valP | нет | да |
popq | valA | нет | да | нет |
ret | valA | нет | да | нет |
| остальные | 0 | 0 | нет | нет |
Две строки таблицы стоят объяснения: они выглядят непоследовательно, а подчиняются одному правилу.
Первая. У четырёх инструкций адрес это valE, результат ALU, а у двух это valA. Разница в том, куда двигается указатель стека. Когда мы кладём на стек, указатель сначала уменьшается, и класть надо уже по новому адресу, а он как раз и есть valE. Когда снимаем со стека, читать надо по старому адресу, до увеличения, и старое значение указателя лежит в valA, потому что этап декодирования специально прочитал %rsp через порт A. Результат ALU у popq и ret тоже нужен, но не памяти, а регистровому файлу: туда уходит увеличенный указатель.
Вторая. У call в память уходит valP, адрес следующей инструкции. Это единственное место во всём тракте, где значение с этапа выборки проезжает мимо ALU прямо в память. Так и работает вызов: адрес возврата это просто адрес инструкции, следующей за call, и считать его отдельно не надо, он уже посчитан выборкой.
// Этап обращения к памяти, таблица 4.21.
// Решает три вещи: по какому адресу идём, что туда пишем и читаем мы или пишем.
// Модуль называется memory_stage, потому что memory это уже сама память.
module memory_stage (
input logic [3:0] icode,
input logic [63:0] valA,
input logic [63:0] valE,
input logic [63:0] valP,
output logic [63:0] mem_addr,
output logic [63:0] mem_data,
output logic mem_read,
output logic mem_write
);
import y86_pkg::*;
// Запись и чтение по вычисленному адресу valE, а снятие со стека берёт адрес
// из valA, потому что там лежит старое значение указателя стека.
always_comb begin
case (icode)
IRMMOVQ, IPUSHQ, ICALL, IMRMOVQ: mem_addr = valE;
IPOPQ, IRET: mem_addr = valA;
default: mem_addr = 64'd0;
endcase
end
// call кладёт на стек адрес возврата, то есть valP.
always_comb begin
case (icode)
IRMMOVQ, IPUSHQ: mem_data = valA;
ICALL: mem_data = valP;
default: mem_data = 64'd0;
endcase
end
always_comb mem_read = (icode == IMRMOVQ) || (icode == IPOPQ) || (icode == IRET);
always_comb mem_write = (icode == IRMMOVQ) || (icode == IPUSHQ) || (icode == ICALL);
endmodule
Имя модуля выбрано так, чтобы не спорить с именем самой памяти. memory это блок из урока про тактирование, регистры и память, у него внутри массив байтов; memory_stage это управляющая логика вокруг него, у неё внутри только мультиплексоры. Когда в шестом уроке подряд читаешь слово “memory”, такое различие экономит минут двадцать отладки.
Чтение здесь комбинационное, а запись идёт по фронту. Это дисциплина из урока 25, и в SEQ она даёт важное следствие: valM, прочитанное из памяти, доступно в том же такте, в котором инструкция началась. Именно поэтому ret в SEQ успевает и прочитать адрес возврата, и подставить его в PC за один такт. В конвейере это перестанет работать, и оттуда возьмутся три пузырька, но об этом в уроке про риски и продвижение.
Stat: четыре кода и порядок проверок
Машина должна уметь останавливаться, и не одним способом, а четырьмя. Инструкция halt останавливает её штатно. Байт с неизвестным кодом инструкции останавливает её как ошибку. Обращение за границу памяти тоже, и таких обращений два: за инструкцией и за данными.
// Код состояния машины. Порядок проверок важен: сначала ошибки обращения
// к памяти, потом неизвестная инструкция, и только потом штатный halt.
module stat (
input logic imem_error,
input logic instr_valid,
input logic dmem_error,
input logic [3:0] icode,
output logic [2:0] Stat
);
import y86_pkg::*;
always_comb begin
if (imem_error || dmem_error) Stat = SADR;
else if (!instr_valid) Stat = SINS;
else if (icode == IHALT) Stat = SHLT;
else Stat = SAOK;
end
endmodule
Модуль в семь строк, и все семь про приоритет. Цепочка if тут не стилистический выбор, а сама суть: несколько условий могут быть истинными одновременно, и порядок решает, какой код увидит трасса.
Смотри, как они пересекаются. Первый случай это байт 0x0f. Старший полубайт нулевой, значит icode совпал с кодом halt. Младший полубайт равен 0xf, а у halt функция обязана быть нулевой, значит instr_valid равен нулю. Оба условия истинны разом. С нашим порядком трасса скажет INS, и это правда: 0x0f не остановка машины, а мусор. Подними проверку halt на первое место, и тот же байт отчитается штатным завершением, а отладка превратится в гадание.
Второй случай это инструкция, которая начинается внутри памяти и заканчивается снаружи. Положи байт 0x6f по адресу 0x0fff и прыгни туда. Байты выборка прочитала, icode это OPq, ifun равен 0xf, такой функции у OPq нет, и instr_valid снова ноль. При этом valP уехал за 0x1000, так что поднят и imem_error. Наш порядок даёт ADR, и снова правда: судить о законности инструкции по байтам, часть которых лежит вне памяти, бессмысленно. Ошибка обращения к памяти старше именно поэтому: при ней недостоверно всё, что посчитано по байтам.
А привычного рассуждения “за границей памяти читаются нули, а нуль это halt” здесь не будет, и это заслуга этапа выборки. При поднятом imem_error_in он подменяет код инструкции на nop, поэтому мусор не может притвориться ни halt, ни чем-то ещё. Проверь сам: прыжок на 0x1000 даёт в трассе строку с кодом 10, а не 00.
Есть и обратная сторона. Ошибка данных dmem_error приходит с этапа памяти, то есть позже всех, и она тоже участвует в Stat. В SEQ это ничего не стоит, потому что весь такт комбинационный и порядок вычислений внутри такта неважен. В конвейере ошибка догоняет инструкцию через несколько ступеней, и там придётся тащить Stat по конвейерным регистрам вместе с ней.
Ещё одна деталь, ради которой стоит вернуться к таблице кодов: SAOK это единица, а не ноль. Так задумано. Регистр, сброшенный в ноль, даёт код, которого нет в таблице, и такое состояние сразу заметно, а если бы “всё хорошо” было нулём, любая неинициализированная защёлка выглядела бы как здоровая машина.
Write back: два порта и знаменитый popq %rsp
Регистровый файл Y86-64 умеет писать в два регистра за такт: порт E принимает результат ALU, порт M принимает слово из памяти. Номера регистров выбрал ещё этап декодирования, здесь остаётся одно решение.
// Этап записи, нижняя половина таблицы 4.19.
// Здесь единственное решение: отключить порт E, если условная пересылка
// не выполнилась. Порт M отдаётся регистровому файлу как есть.
module writeback (
input logic [3:0] icode,
input logic Cnd,
input logic [3:0] dstE,
input logic [3:0] dstM,
output logic [3:0] wb_dstE,
output logic [3:0] wb_dstM
);
import y86_pkg::*;
// cmovXX с несработавшим условием ведёт себя как nop: номер регистра
// подменяется на RNONE, и регистровый файл ничего не пишет.
always_comb wb_dstE = ((icode == IRRMOVQ) && !Cnd) ? RNONE : dstE;
always_comb wb_dstM = dstM;
endmodule
Условная пересылка гасится не значением, а адресом. Это красивее и дешевле, чем кажется на первый взгляд. Можно было бы обнулять valE или заводить в регистровый файл отдельный сигнал разрешения, но подмена номера регистра на RNONE использует механизм, который в файле уже есть: порт с номером несуществующего регистра просто ничего не делает. Одна строка вместо новой шины и нового провода.
Заодно эта строка отвечает на вопрос, почему безусловный rrmovq тоже проходит через cond. У него ifun равен нулю, то есть CALWAYS, и Cnd всегда единица, так что подмена не срабатывает. Обычная пересылка это частный случай условной с условием “всегда”, и отдельной ветки в тракте ей не нужно.
Теперь самое интересное место всего SEQ. Что будет, если оба порта записи укажут на один регистр? Такое возможно ровно у одной инструкции: popq %rsp пишет в %rsp через порт E увеличенный указатель и через порт M значение, вынутое из памяти. Ответ спрятан не в этом модуле, а в регистровом файле.
// Оба порта пишут на одном фронте. Если они указывают на один регистр,
// побеждает порт M: неблокирующие присваивания применяются по порядку,
// и последнее переписывает предыдущее. Именно поэтому popq %rsp
// оставляет в %rsp значение, прочитанное из памяти, а не rsp + 8.
always_ff @(posedge clk) begin
if (rst) begin
for (int i = 0; i < 15; i++) regs[i] <= 64'd0;
end else if (write_en) begin
if (dstE != RNONE) regs[dstE] <= valE;
if (dstM != RNONE) regs[dstM] <= valM;
end
end
Побеждает порт M, потому что его присваивание стоит вторым. Это не случайность и не деталь реализации Icarus: неблокирующие присваивания внутри одного блока применяются в порядке следования, и последнее переписывает предыдущее. Порядок двух строк в этом файле определяет семантику инструкции.
Проверить это можно программой push_pop_rsp.ys из урока 22, в которой собраны оба спорных случая.
.pos 0
init: irmovq stack, %rsp
pushq %rsp # в память уходит 0x200
popq %rax # %rax равен 0x200, а не 0x1f8
irmovq $0x123, %rbx
pushq %rbx
popq %rsp # %rsp равен 0x123, а не 0x200
halt
.pos 0x200
stack:
Машина отвечает так.
0x000 30 AOK
0x00a a0 AOK
0x00c b0 AOK
0x00e 30 AOK
0x018 a0 AOK
0x01a b0 AOK
0x01c 00 HLT
halt cycles=7
%rax 0x0000000000000200
%rcx 0x0000000000000000
%rdx 0x0000000000000000
%rbx 0x0000000000000123
%rsp 0x0000000000000123
%rbp 0x0000000000000000
%rsi 0x0000000000000000
%rdi 0x0000000000000000
%r8 0x0000000000000000
%r9 0x0000000000000000
%r10 0x0000000000000000
%r11 0x0000000000000000
%r12 0x0000000000000000
%r13 0x0000000000000000
%r14 0x0000000000000000
mem 0x1f8 0x0000000000000123
Оба ответа сошлись с симулятором из урока про симулятор на Zig. В %rax лежит 0x200, значит pushq %rsp положил в память старое значение указателя: valA прочитан на этапе декодирования, до того как порт E успел записать уменьшенный указатель. В %rsp лежит 0x123, значит у popq %rsp победил порт M. Две строчки железа, два предложения в книге и один тест, который ловит обе ошибки сразу.
Новый PC: четыре источника и одно кольцо
Последний мультиплексор тракта самый короткий и самый важный: без него машина выполнит одну инструкцию и остановится.
// Этап обновления счётчика команд, последняя строка таблицы 4.21.
// Обычная инструкция идёт на valP, call прыгает на valC, ret берёт адрес
// из памяти (valM), а условный переход выбирает по Cnd.
module pc_update (
input logic [3:0] icode,
input logic Cnd,
input logic [63:0] valC,
input logic [63:0] valM,
input logic [63:0] valP,
output logic [63:0] new_pc
);
import y86_pkg::*;
always_comb begin
case (icode)
ICALL: new_pc = valC;
IRET: new_pc = valM;
IJXX: new_pc = Cnd ? valC : valP;
default: new_pc = valP;
endcase
end
endmodule
Четыре источника, и каждый берётся с разной глубины тракта. Обычная инструкция идёт на valP, а он посчитан ещё на выборке, из длины инструкции. Вызов идёт на valC, тоже с выборки, это адрес прямо в байтах инструкции. Условный переход выбирает между двумя значениями с выборки по одному биту с этапа исполнения. А ret берёт valM, и это значение приходит с этапа памяти, то есть с самого дна тракта.
Отсюда видно, где у SEQ длинный путь. Чтобы посчитать новый PC для ret, сигналу надо пройти выборку, декодирование, ALU, память и вернуться назад к регистру PC, и всё это внутри одного такта. Такие цепочки и задают период такта в SEQ. В уроке про принципы конвейера мы посчитаем, сколько это стоит, и увидим, почему разрезать такой путь регистрами выгодно даже с учётом накладных расходов.
Ещё одна мелочь про безусловный переход. Отдельной ветки для jmp в мультиплексоре нет: у безусловного перехода ifun равен нулю, блок cond даёт единицу, и общая ветка IJXX сама выбирает valC. Тот же приём, что и с rrmovq в write back: условие “всегда” это не исключение из правила, а его частный случай.
Топ-модуль: три регистра и много проводов
Теперь всё соединяется. Топ-модуль стоит читать как ответ на один вопрос: что в этой машине помнит своё состояние между тактами, а что не помнит ничего.
Помнят четыре вещи: счётчик команд, регистр кодов условия, регистровый файл и память. Всё остальное, включая семь модулей этапов, это комбинационная логика, которая за такт успевает досчитать от PC до нового PC и погаснуть.
// Последовательная реализация Y86-64: одна инструкция за такт.
// За такт сигнал успевает пройти все шесть этапов насквозь, а по фронту
// сохраняются только три вещи: счётчик команд, флаги и содержимое
// регистрового файла и памяти.
module seq (
input logic clk,
input logic rst,
// Наблюдательные выходы для тестбенча: что за инструкция выполняется
// прямо сейчас и каким будет состояние машины после неё.
output logic [63:0] pc_out,
output logic [3:0] icode_out,
output logic [3:0] ifun_out,
output logic [2:0] stat_out
);
import y86_pkg::*;
// ------------------------------------------------------------------
// Состояние машины
// ------------------------------------------------------------------
logic [63:0] pc;
logic cc_ZF, cc_SF, cc_OF;
// ------------------------------------------------------------------
// Выборка
// ------------------------------------------------------------------
logic [79:0] imem_bytes;
logic imem_error_raw, imem_error;
logic [3:0] icode, ifun, rA, rB;
logic [63:0] valC, valP;
logic instr_valid, need_regids, need_valC;
fetch u_fetch (
.pc (pc),
.imem_bytes (imem_bytes),
.imem_error_in (imem_error_raw),
.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 [3:0] srcA, srcB, dstE, dstM;
logic [63:0] valA, valB;
decode u_decode (
.icode (icode),
.rA (rA),
.rB (rB),
.srcA (srcA),
.srcB (srcB),
.dstE (dstE),
.dstM (dstM)
);
// ------------------------------------------------------------------
// Исполнение
// ------------------------------------------------------------------
logic [63:0] aluA, aluB, valE;
logic [3:0] alufun;
logic set_cc, Cnd, new_ZF, new_SF, new_OF;
execute u_execute (
.icode (icode),
.ifun (ifun),
.valA (valA),
.valB (valB),
.valC (valC),
.ZF (cc_ZF),
.SF (cc_SF),
.OF (cc_OF),
.aluA (aluA),
.aluB (aluB),
.alufun (alufun),
.set_cc (set_cc),
.valE (valE),
.Cnd (Cnd),
.new_ZF (new_ZF),
.new_SF (new_SF),
.new_OF (new_OF)
);
// ------------------------------------------------------------------
// Обращение к памяти
// ------------------------------------------------------------------
logic [63:0] mem_addr, mem_data, valM;
logic mem_read, mem_write, dmem_error;
memory_stage u_memory_stage (
.icode (icode),
.valA (valA),
.valE (valE),
.valP (valP),
.mem_addr (mem_addr),
.mem_data (mem_data),
.mem_read (mem_read),
.mem_write (mem_write)
);
// ------------------------------------------------------------------
// Код состояния
// ------------------------------------------------------------------
logic [2:0] Stat;
stat u_stat (
.imem_error (imem_error),
.instr_valid (instr_valid),
.dmem_error (dmem_error),
.icode (icode),
.Stat (Stat)
);
// Инструкция доводится до конца, только если с ней всё в порядке.
// При ошибке или halt состояние машины больше не меняется.
logic run;
always_comb run = (Stat == SAOK);
// Разрешение обратиться к памяти строится на выборке, а не на Stat.
// Иначе выходит петля: dmem_error попадает в Stat, Stat гасит запись,
// без записи dmem_error пропадает, и Stat снова разрешает запись.
// Ранних условий достаточно: для инструкции записи Stat = AOK это ровно
// «выборка удалась, инструкция законна и адрес в границах памяти»,
// а последнее memory проверяет у себя и само гасит запись.
logic instr_ok;
always_comb instr_ok = !imem_error && instr_valid;
// ------------------------------------------------------------------
// Запись результатов
// ------------------------------------------------------------------
logic [3:0] wb_dstE, wb_dstM;
writeback u_writeback (
.icode (icode),
.Cnd (Cnd),
.dstE (dstE),
.dstM (dstM),
.wb_dstE (wb_dstE),
.wb_dstM (wb_dstM)
);
regfile u_regs (
.clk (clk),
.rst (rst),
.srcA (srcA),
.srcB (srcB),
.valA (valA),
.valB (valB),
.write_en (run),
.dstE (wb_dstE),
.valE (valE),
.dstM (wb_dstM),
.valM (valM)
);
memory u_mem (
.clk (clk),
.imem_addr (pc),
.imem_bytes (imem_bytes),
.imem_error (imem_error_raw),
.dmem_addr (mem_addr),
.dmem_read (mem_read),
.dmem_write (mem_write && instr_ok),
.dmem_wdata (mem_data),
.dmem_rdata (valM),
.dmem_error (dmem_error)
);
// ------------------------------------------------------------------
// Обновление счётчика команд
// ------------------------------------------------------------------
logic [63:0] new_pc;
pc_update u_pc_update (
.icode (icode),
.Cnd (Cnd),
.valC (valC),
.valM (valM),
.valP (valP),
.new_pc (new_pc)
);
// ------------------------------------------------------------------
// Тактируемое состояние
// ------------------------------------------------------------------
always_ff @(posedge clk) begin
if (rst) begin
pc <= 64'd0;
// Начальные флаги как в симуляторе yis: ноль равен нулю, знака нет.
cc_ZF <= 1'b1;
cc_SF <= 1'b0;
cc_OF <= 1'b0;
end else if (run) begin
pc <= new_pc;
if (set_cc) begin
cc_ZF <= new_ZF;
cc_SF <= new_SF;
cc_OF <= new_OF;
end
end
end
always_comb pc_out = pc;
always_comb icode_out = icode;
always_comb ifun_out = ifun;
always_comb stat_out = Stat;
// Эти сигналы существуют ради наглядности схемы: этап выборки и этап
// исполнения выставляют их наружу, но верхнему уровню они не нужны.
/* verilator lint_off UNUSEDSIGNAL */
logic unused_ok;
always_comb unused_ok = &{1'b0, need_regids, need_valC, aluA, aluB, alufun};
/* verilator lint_on UNUSEDSIGNAL */
endmodule
Четыре наблюдения по этому файлу, каждое стоит отдельной минуты.
Сигнал run это тормоз машины. Он равен единице, только когда Stat равен SAOK. От него зависят запись в регистровый файл и обновление PC с флагами. Как только код состояния перестал быть SAOK, машина замирает: такты идут, комбинационная логика считает те же значения, но ни один регистр больше не меняется. Никакого отдельного механизма остановки в SEQ нет, и это правильно: остановка процессора это не действие, а отсутствие действий.
Порядок инстанцирования модулей в файле ничего не значит. Модуль execute подключён раньше memory_stage, а pc_update в самом низу, хотя valM для него приходит из памяти, объявленной выше. В Verilog это просто список соединений, а не последовательность шагов. Тот же файл с переставленными блоками даст ту же схему.
Единственная обратная связь тут нарисована явно. Выход new_pc идёт в always_ff и возвращается в pc, а pc уходит в память инструкций, откуда начинается новый круг. Разрыв кольца проходит по регистру, и это обязательное условие: комбинационное кольцо без регистра не устоялось бы никогда.
Запись в память разрешает не run, а отдельный сигнал instr_ok, и вот это стоит разобрать медленно. Регистровый файл получает run портом write_en, а память в строке .dmem_write (mem_write && instr_ok) получает другой сигнал, собранный только из признаков выборки: !imem_error && instr_valid.
Разница не косметическая. Попробуй подставить туда run и проследи цепочку. Сигнал dmem_error рождается в памяти, входит в stat и делает Stat равным SADR. Тогда run становится нулём, гасит dmem_write, а модуль memory считает ошибку только при поднятом dmem_read или dmem_write, так что dmem_error пропадает. Раз ошибки нет, Stat снова равен SAOK, run снова единица, запись снова разрешена, и ошибка возвращается. Схема начинает решать уравнение, у которого нет решения.
Это комбинационная петля, и в симуляции она проявляется по-разному: iverilog может тихо остановиться на первом попавшемся значении, а verilator --lint-only честно ругается предупреждением UNOPTFLAT. Лечится она тем же способом, что и любая петля в логике: разорвать её, убрав из условия то, что от него же и зависит. Признаки выборки посчитаны до всякого обращения к памяти, зависеть от dmem_error они не могут, и петли с ними не возникает. Проверять границы адреса при этом никто не перестал: этим занимается сам модуль memory, который при dmem_error запись не выполняет.
Общее правило, которое стоит унести из этого места: сигнал остановки машины нельзя заводить обратно в то, что эту остановку порождает. Как только заметишь, что условие разрешения зависит от ошибки, а ошибка от разрешения, ищи, какую половину условия можно взять с более раннего этапа.
Тестбенч: где именно снимать трассу
Модуль машины ничего не печатает, печатает тестбенч. Он загружает образ памяти, гоняет такты и на каждом снимает строку трассы того самого формата, который завела программа-симулятор из урока 22.
// Тестбенч последовательной машины.
// Загружает образ памяти, гоняет такты и печатает трассу версии 1:
// строка на каждую выполненную инструкцию, потом число тактов,
// потом пятнадцать регистров и все изменившиеся слова памяти.
module tb_seq;
import y86_pkg::*;
localparam int CYCLE_LIMIT = 100000;
logic clk;
logic rst;
// Адрес приходит полным словом, а печатаются младшие двенадцать разрядов:
// памяти всего четыре килобайта, старшие разряды всегда нулевые.
/* verilator lint_off UNUSEDSIGNAL */
logic [63:0] pc_out;
/* verilator lint_on UNUSEDSIGNAL */
logic [3:0] icode_out;
logic [3:0] ifun_out;
logic [2:0] stat_out;
seq dut (
.clk (clk),
.rst (rst),
.pc_out (pc_out),
.icode_out (icode_out),
.ifun_out (ifun_out),
.stat_out (stat_out)
);
// Копия загруженного образа: по ней в конце ищем изменившиеся слова.
logic [7:0] image [0:MEM_BYTES-1];
string prog;
int cycles;
initial clk = 1'b0;
always #5 clk = ~clk;
function automatic string stat_name(logic [2:0] s);
case (s)
SAOK: stat_name = "AOK";
SHLT: stat_name = "HLT";
SADR: stat_name = "ADR";
SINS: stat_name = "INS";
default: stat_name = "???";
endcase
endfunction
task automatic dump_state(int n);
logic [63:0] word;
logic [63:0] orig;
$display("halt cycles=%0d", n);
$display("%%rax 0x%016h", dut.u_regs.regs[0]);
$display("%%rcx 0x%016h", dut.u_regs.regs[1]);
$display("%%rdx 0x%016h", dut.u_regs.regs[2]);
$display("%%rbx 0x%016h", dut.u_regs.regs[3]);
$display("%%rsp 0x%016h", dut.u_regs.regs[4]);
$display("%%rbp 0x%016h", dut.u_regs.regs[5]);
$display("%%rsi 0x%016h", dut.u_regs.regs[6]);
$display("%%rdi 0x%016h", dut.u_regs.regs[7]);
$display("%%r8 0x%016h", dut.u_regs.regs[8]);
$display("%%r9 0x%016h", dut.u_regs.regs[9]);
$display("%%r10 0x%016h", dut.u_regs.regs[10]);
$display("%%r11 0x%016h", dut.u_regs.regs[11]);
$display("%%r12 0x%016h", dut.u_regs.regs[12]);
$display("%%r13 0x%016h", dut.u_regs.regs[13]);
$display("%%r14 0x%016h", dut.u_regs.regs[14]);
for (int a = 0; a < MEM_BYTES; a += 8) begin
for (int i = 0; i < 8; i++) begin
word[8*i +: 8] = dut.u_mem.mem[a + i];
orig[8*i +: 8] = image[a + i];
end
if (word !== orig) $display("mem 0x%03h 0x%016h", a[11:0], word);
end
endtask
initial begin
if (!$value$plusargs("prog=%s", prog)) begin
$display("ERROR: expected +prog=<file.hex>");
$finish(0);
end
for (int i = 0; i < MEM_BYTES; i++) begin
image[i] = 8'h00;
dut.u_mem.mem[i] = 8'h00;
end
$readmemh(prog, image);
$readmemh(prog, dut.u_mem.mem);
cycles = 0;
rst = 1'b1;
@(posedge clk);
#1 rst = 1'b0;
forever begin
// Спад такта: состояние обновилось на прошлом фронте, комбинационная
// логика уже устоялась, значит выходы описывают текущую инструкцию.
@(negedge clk);
cycles = cycles + 1;
$display("0x%03h %h%h %s", pc_out[11:0], icode_out, ifun_out, stat_name(stat_out));
if (stat_out != SAOK) begin
dump_state(cycles);
$finish(0);
end
if (cycles >= CYCLE_LIMIT) begin
$display("ERROR: cycle limit %0d reached", CYCLE_LIMIT);
$finish(0);
end
end
end
endmodule
Ключевая строка тут @(negedge clk), и выбор спада вместо фронта не вкусовщина. На фронте такта состояние только начинает обновляться, а комбинационная логика ещё не устоялась, и снятая в этот момент строка показала бы кашу из старых и новых значений. К середине такта, то есть к спаду, регистры уже приняли новое значение, а провода уже досчитали, и выходы описывают ровно ту инструкцию, которая выполняется прямо сейчас. Это общее правило работы с тактируемыми схемами, а не хитрость этого тестбенча.
Дальше три технические детали, которые сэкономят время.
Тестбенч читает образ памяти дважды: один раз в саму память машины, второй раз в свой массив image. Копия нужна, чтобы в конце показать только изменившиеся слова, а не все 4096 байт. Сравнение идёт через !==, а не !=, потому что четырёхзначная логика Verilog умеет отвечать “неизвестно”, и обычное сравнение с неизвестным дало бы неизвестный результат вместо честного “отличается”.
Обращения вида dut.u_regs.regs[0] это иерархические имена: тестбенч лезет внутрь машины и читает содержимое регистрового файла напрямую, минуя порты. В синтезируемом коде так делать нельзя, а в тестбенче это стандартный приём, иначе пришлось бы выводить наружу пятнадцать шин ради одной печати в конце.
Ограничение CYCLE_LIMIT спасает от зависшей программы. Ошибка в pc_update легко даёт бесконечный цикл, и без счётчика симулятор молотил бы вечно.
Как это запускается
Скрипт сборки один на обе машины, последовательную и конвейерную. Он же следит за порядком файлов.
#!/usr/bin/env bash
# Собирает и запускает Verilog-модель Y86-64, печатает трассу версии 1.
# ./hdl/run.sh seq programs/sum.hex
# ./hdl/run.sh pipe programs/sum.hex
#
# Третий режим гоняет отдельный тестбенч из hdl/tb, которому образ памяти
# не нужен: он сам подаёт векторы и печатает их через $display.
# ./hdl/run.sh tb tb_alu
set -euo pipefail
usage() {
echo "usage: $0 <seq|pipe> <prog.hex>" >&2
echo " $0 tb <tb_name>" >&2
exit 2
}
[ $# -eq 2 ] || usage
kind="$1"
prog="$2"
hdl="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
command -v iverilog >/dev/null || { echo "iverilog not found in PATH" >&2; exit 127; }
# Пакет с константами обязан идти первым: и iverilog, и verilator читают
# файлы по порядку, а импорт пакета работает только после его объявления.
lib=("$hdl/lib/y86_pkg.sv" "$hdl"/lib/bit_eq.sv "$hdl"/lib/bit_mux.sv
"$hdl"/lib/word_eq.sv "$hdl"/lib/word_mux.sv "$hdl"/lib/alu.sv
"$hdl"/lib/cond.sv "$hdl"/lib/regfile.sv "$hdl"/lib/memory.sv)
case "$kind" in
seq)
[ -f "$prog" ] || { echo "no such file: $prog" >&2; exit 2; }
top=tb_seq
sources=("${lib[@]}" "$hdl"/seq/*.sv "$hdl"/tb/tb_seq.sv)
;;
pipe)
[ -f "$prog" ] || { echo "no such file: $prog" >&2; exit 2; }
top=tb_pipe
sources=("${lib[@]}" "$hdl"/seq/*.sv "$hdl"/pipe/*.sv "$hdl"/tb/tb_pipe.sv)
;;
tb)
# Модульный тестбенч. Модулей этапов и конвейера подаём весь набор:
# неиспользованные iverilog просто не разворачивает, зато один и тот же
# вызов годится и для tb_lib, и для tb_forward.
[ -f "$hdl/tb/$prog.sv" ] || { echo "no such testbench: hdl/tb/$prog.sv" >&2; exit 2; }
top="$prog"
sources=("${lib[@]}" "$hdl"/seq/*.sv "$hdl"/pipe/*.sv "$hdl/tb/$prog.sv")
;;
*) usage ;;
esac
out="$(mktemp -t y86-"$kind"-XXXXXX)"
log="$(mktemp -t y86-log-XXXXXX)"
trap 'rm -f "$out" "$log"' EXIT
if ! iverilog -g2012 -s "$top" -o "$out" "${sources[@]}" 2> "$log"; then
cat "$log" >&2
exit 1
fi
# iverilog на каждый разряд, взятый внутри always_comb, печатает заметку про
# список чувствительности. Для always_comb список и так полный, заметка безвредна,
# поэтому её убираем, а всё остальное показываем как есть.
grep -v 'sorry: constant selects in always_\* processes' "$log" >&2 || true
if [ "$kind" = "tb" ]; then
vvp -n "$out"
else
vvp -n "$out" "+prog=$prog"
fi
Самая содержательная строка тут комментарий про порядок. Пакет y86_pkg.sv стоит первым в списке не для красоты: iverilog читает файлы по порядку, и import y86_pkg::* в модуле, скомпилированном раньше пакета, даёт ошибку про неизвестный пакет. Если однажды соберёшь проект через hdl/*.sv с раскрытием шаблона и получишь странную ошибку про имена, вспомни эту строку.
Флаг -s "$top" явно называет корневой модуль. Без него iverilog попробует сделать корнями все модули, которые никто не инстанцирует, и получит несколько верхних уровней сразу.
Режимов у скрипта три, а не два. Кроме seq и pipe, которым нужен образ памяти, есть tb: он берёт из hdl/tb тестбенч по имени и запускает его без всякой программы. Такому тестбенчу образ не нужен, он сам подаёт векторы и печатает их через $display, поэтому проверку существования файла и передачу +prog= скрипт для него пропускает. Пользуйся этим режимом, когда отлаживаешь один модуль: гонять всю машину ради одного мультиплексора долго и неудобно, а bash hdl/run.sh tb tb_alu отвечает за секунду.
Фильтр grep -v в конце убирает одну конкретную заметку симулятора. Она появляется на каждой строке вроде SF = valE[63] внутри always_comb: Icarus предупреждает, что сделает блок чувствительным ко всем разрядам сигнала, а не только к взятому. Для always_comb это ровно то, что нужно, поэтому заметка шумит и ничего не значит. Всё остальное с потока ошибок проходит наружу как есть.
Прогон: тот же результат, что у симулятора
Теперь то, ради чего писались шесть уроков. Собираем программу своим ассемблером из урока 21, гоняем её на своей машине и сверяем с трассой симулятора.
$ zig build run -- asm programs/sum.ys --hex sum.hex -o sum.yo
$ zig build run -- trace programs/sum.ys > sum.trace
$ bash hdl/run.sh seq sum.hex > sum.seq.trace
$ head -12 sum.seq.trace
0x000 30 AOK
0x00a 80 AOK
0x038 30 AOK
0x042 30 AOK
0x04c 80 AOK
0x056 30 AOK
0x060 30 AOK
0x06a 63 AOK
0x06c 62 AOK
0x06e 70 AOK
0x087 74 AOK
0x077 50 AOK
Строка трассы это адрес инструкции, её байт icode с ifun и код состояния. По первым строкам уже читается программа: irmovq с кодом 30 заводит указатель стека, call с кодом 80 уходит в main, там ещё два irmovq, ещё один call в sum, дальше xorq (63), andq (62), безусловный jmp (70), условный jne (74) и загрузка из памяти mrmovq (50). Дальше цикл повторяется четыре раза.
Сверку делает скрипт, потому что глазами сравнивать тридцать четыре строки плюс пятнадцать регистров плюс память быстро надоедает.
$ bash tools/compare-trace.sh sum.trace sum.seq.trace
cycles: sum.trace=34 sum.seq.trace=34
traces match
Строку с числом тактов скрипт печатает отдельно и из сравнения исключает. Для SEQ это лишняя предосторожность: такт равен инструкции, и числа совпадают. Она понадобится в уроке про PIPE на Verilog, где PIPE даст тот же результат за другое число тактов, и сравнивать построчно уже будет нельзя.
Одна программа ничего не доказывает, поэтому гоняем все.
$ for p in sum rsum abs_sum abs_sum_cmov bubble switchv push_pop_rsp iaddq_sum; do
zig build run -- asm programs/$p.ys --hex $p.hex -o $p.yo
zig build run -- trace programs/$p.ys > $p.trace
bash hdl/run.sh seq $p.hex > $p.seq.trace
printf '%-14s %s\n' "$p" "$(bash tools/compare-trace.sh $p.trace $p.seq.trace | tr '\n' ' ')"
done
sum cycles: sum.trace=34 sum.seq.trace=34 traces match
rsum cycles: rsum.trace=65 rsum.seq.trace=65 traces match
abs_sum cycles: abs_sum.trace=64 abs_sum.seq.trace=64 traces match
abs_sum_cmov cycles: abs_sum_cmov.trace=61 abs_sum_cmov.seq.trace=61 traces match
bubble cycles: bubble.trace=173 bubble.seq.trace=173 traces match
switchv cycles: switchv.trace=23 switchv.seq.trace=23 traces match
push_pop_rsp cycles: push_pop_rsp.trace=7 push_pop_rsp.seq.trace=7 traces match
iaddq_sum cycles: iaddq_sum.trace=32 iaddq_sum.seq.trace=32 traces match
Восемь программ, восемь совпадений, и вместе они трогают каждый класс инструкций, кроме nop: арифметика, обе пересылки в память и из памяти, условные переходы, условные пересылки, рекурсия через стек, переход по таблице адресов, оба спорных случая с указателем стека и расширение iaddq.
Что тут доказано и что нет. Доказано, что схема и программа сходятся на этом наборе программ: два независимых описания одной системы команд дали одинаковый результат, и вероятность одинаковой ошибки в обоих мала. Не доказано, что схема правильна вообще: набор программ не покрывает, например, всех комбинаций флагов у всех шести условий. Это нормальное состояние дел для железа, и настоящие процессоры проверяют так же, только программ у них миллионы и часть из них порождается случайно.
Число тактов тут равно числу выполненных инструкций, считая и сам halt: в SEQ инструкция целиком укладывается в такт. Запомни эти числа: в уроке 30 те же программы пройдут через конвейер за большее число тактов, и разница между “больше тактов” и “быстрее работает” окажется главным сюжетом блока.
iaddq: одно расширение через все этапы
Инструкция iaddq V, rB складывает константу с регистром и меняет флаги. В базовой системе команд её нет, и упражнение 4.3 книги предлагает её придумать, а домашнее задание 4.51 провести через всю реализацию SEQ. В нашем эталоне она уже проведена, и это удобно: вместо того чтобы писать её с нуля, посмотрим, какие именно строки за неё отвечают. Если ты понимаешь каждую из них, значит таблицы этапов ты не выучил, а понял.
Формат инструкции повторяет irmovq: код 0xC, ifun равен нулю, байт регистров с rA равным RNONE и rB, дальше восемь байт константы. Итого десять байт. Теперь по этапам.
Выборка. Инструкция считается известной и требует и байта регистров, и константы. В fetch.sv это три строки: IIADDQ попадает в список кодов с instr_valid при нулевом ifun, в список need_regids и в список need_valC. Из последних двух автоматически получается правильный valP, потому что длина считается формулой, а не таблицей.
Декодирование. Читать надо только rB, писать тоже только в rB. В decode.sv IIADDQ стоит в ветке srcB = rB и в ветке dstE = rB, и не стоит нигде больше. В srcA его нет: константа приходит из инструкции, а не из регистра, и порт A этой инструкции не нужен.
Исполнение. Две строки, обе в execute.sv. В мультиплексоре aluA код IIADDQ стоит рядом с IIRMOVQ, IRMMOVQ и IMRMOVQ, то есть на первый вход ALU идёт константа valC. В мультиплексоре aluB он стоит рядом с IOPQ, то есть на второй вход идёт значение регистра. Операция остаётся сложением, потому что своя операция есть только у OPq. И самая содержательная строка: set_cc включает IIADDQ наравне с IOPQ, иначе после iaddq $-1, %rsi условный переход не увидел бы нуля и цикл не кончился бы никогда.
Память. Ни одной строки. Инструкция к памяти не обращается, и все четыре мультиплексора этапа оставляют её в ветке default.
Запись и новый PC. Тоже ни одной строки. Порт E пишет valE в регистр, номер которого выбрал decode, а PC идёт на valP, как у всякой инструкции без перехода.
Итого: три строки в выборке, две в декодировании, три в исполнении, ноль в остальных трёх этапах. Плюс одна строка константы в пакете. Это и есть цена новой инструкции в SEQ, и она честно мала, потому что тракт устроен как набор таблиц, а не как набор особых случаев.
Стоило ли оно того, видно из программы iaddq_sum.ys. Это та же сумма массива, что в sum.ys, но без двух регистров под константы.
# long sum(long *start, long count)
sum: xorq %rax, %rax
andq %rsi, %rsi
jmp itest
iloop: mrmovq (%rdi), %r10
addq %r10, %rax
iaddq $8, %rdi # сдвинуть указатель
iaddq $-1, %rsi # уменьшить счётчик, флаги
itest: jne iloop
ret
Тело цикла осталось той же длины, четыре инструкции, а вот подготовка стала короче на два irmovq: константы 8 и 1 больше не занимают ни регистров, ни инструкций. По трассе это ровно два такта: 34 у sum против 32 у iaddq_sum. Освобождённые %r8 и %r9 в этой маленькой программе никому не нужны, но в bubble или в любой функции с несколькими живыми значениями два лишних регистра это уже заметно.
Практика
Собери управляющую логику этапов execute и memory одним модулем. Входы это icode, ifun и три значения с этапа декодирования, выходы это восемь сигналов: оба входа ALU, код операции, разрешение флагов, чтение и запись памяти, адрес и данные. Считать модуль почти ничего не считает, он только выбирает, и все семь мультиплексоров пишутся по таблицам этого урока. Грейдер подаёт по вектору на каждый код инструкции, включая iaddq, и печатает все выходы, так что по выводу сразу видно, какую строку таблицы ты пропустил.
Упражнения
Итоги
- Через ALU проходят все инструкции без исключения: пересылка это сложение с нулём, адрес это сложение смещения с базой, работа со стеком это сложение с плюс восемью или минус восемью. Отдельных сумматоров в Y86-64 нет.
- Флаги ALU выставляет всегда, а записывает их в регистр кодов условия только сигнал
set_cc, разрешённый двум инструкциям из тринадцати. Без этой защиты любой переход между сравнением и ветвлением затрёт флаги. - Блок
condчитает старые флаги, а не свежие выходы ALU. Условный переход опирается на результат предыдущей арифметики, и подключение сюда новых флагов ломает всякое ветвление. - Адрес обращения к памяти у
rmmovq,mrmovq,pushqиcallэто результат ALU, а уpopqиretэтоvalA, старое значение указателя стека. Разница в том, до или после сдвига указателя происходит обращение. - Инструкция
callкладёт в памятьvalPс этапа выборки. Это единственное значение, которое едет в память мимо ALU. - Порядок проверок в модуле
statопределяет, что покажет трасса при нескольких одновременных бедах. Ошибки памяти идут первыми, потому что при них недостоверно всё, что посчитано по байтам, а штатныйhaltпроверяется последним, иначе битый байт0x0fотчитался бы чистой остановкой. - Условная пересылка гасится подменой номера регистра назначения на
RNONE. Механизм “несуществующий регистр не пишется” в файле уже есть, и новых проводов не требуется. - У
popq %rspоба порта записи целятся в один регистр, и побеждает порт M, потому что его неблокирующее присваивание стоит в файле вторым. Порядок двух строк задаёт семантику инструкции. - В SEQ тактируются только PC, регистр кодов условия, регистровый файл и память. Всё остальное комбинационная логика, успевающая за такт пройти путь от PC до нового PC.
- Сигнал
run, равныйStat == SAOK, это тормоз машины: он запрещает запись в регистры и обновление PC с флагами. Остановка процессора это отсутствие действий, а не отдельное действие. - Запись в память разрешает не
run, аinstr_ok, собранный из одних признаков выборки. Иначе получается комбинационная петля: ошибка адреса гасит запись, без записи ошибка исчезает, и схема начинает решать уравнение без решения. Условие разрешения нельзя строить на том, что от этого разрешения зависит. - Самая длинная цепочка такта проходит через
ret: выборка, декодирование, ALU, память и обратно в PC. Из длины таких цепочек берётся период такта, и именно её конвейер будет резать. - Трасса схемы совпала с трассой симулятора на всех программах блока. Два независимых описания одной системы команд, сошедшиеся построчно, это лучшее доказательство, доступное на этом этапе.
- Расширение
iaddqстоит восьми строк в трёх модулях и ни одной в остальных. Это мера того, насколько тракт устроен таблицами, а не особыми случаями.
Дальше
Процессор готов и считает правильно. Осталось понять, почему он медленный. В SEQ такт обязан вместить всю дорогу от чтения байтов до записи нового PC, и длина этой дороги задана самой длинной цепочкой, той, что проходит через ret. Пока инструкция едет по тракту, вся остальная логика простаивает: пока работает память, ALU уже отработал и ждёт следующего такта. Следующий урок разбирает, как это чинят: где резать длинный путь регистрами и что при этом происходит с задержкой одной инструкции и с пропускной способностью машины. Там же станет видно, почему неравномерные этапы и накладные расходы регистров ограничивают выигрыш, а обратная связь между соседними инструкциями делает конвейер процессора куда труднее конвейера на заводе. Схем и формул там будет больше, чем Verilog, зато после него станет понятно, зачем в уроках 29 и 30 появятся пузырьки, остановки и продвижение данных.
домашка