Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Verilog: слова, мультиплексоры и ALU
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Verilog: слова, мультиплексоры и ALU
В прошлом уроке ты собирал схемы из отдельных битов: вентиль, равенство двух битов, мультиплексор на один разряд. Процессор так не устроен. У него всё широкое: регистр это 64 провода, шина данных это 64 провода, результат ALU это тоже 64 провода. В этом уроке мы переходим от бита к слову. Разберём, как объявить шину, откуда у литерала ширина, что делают срез и конкатенация. Соберём мультиплексор не из вентилей, а из
caseвнутриalways_comb, и по дороге поймаем защёлку, которая появляется от одной забытой строчки. А потом напишем главную комбинационную схему всего процессора: арифметико-логическое устройство Y86-64 с флагами ZF, SF и OF, и блок вычисления условия, который эти флаги читает.
Цели урока
- Объявлять и читать шины:
logic [63:0], порядок разрядов, срез, конкатенация, повтор. - Всегда писать литералы с шириной и понимать, что делает разрядность в арифметике.
- Собирать мультиплексор на много входов через
caseвнутриalways_comb. - Знать, откуда в комбинационной схеме берётся защёлка, и что про неё говорит verilator.
- Написать ALU Y86-64 целиком: четыре операции, выбранные кодом
alufun. - Вывести правило знакового переполнения для сложения и для вычитания и проверить его на векторах, где оно срабатывает.
- Прочитать блок
cond, который превращает три флага и код условия в один битCnd. - Записывать принадлежность множеству кодов так, чтобы это собиралось Icarus Verilog.
- Параметризовать модуль по ширине и понимать, почему один и тот же исходник даёт схемы разного размера.
Идея: слово это одно значение, а выбор это мультиплексор
В прошлом уроке ты убедился, что модуль на Verilog это не программа, а схема: assign это провод, а не присваивание, и порядок строк ничего не значит. Всё это осталось в силе. Меняется только одно: вместо одного провода у нас пучок из 64 проводов, и язык позволяет обращаться с ним как с одним значением.
Из этого следует вся глава про тракт данных. Возьми схему процессора из книги и посмотри, что на ней нарисовано. Толстые линии это шины по 64 провода. Прямоугольники с наклонным входом сбоку это мультиплексоры: несколько шин входят, одна выходит, а тонкая линия сбоку говорит, какую выбрать. И один блок побольше, в который входят две шины и код операции, а выходят шина результата и три отдельных провода. Это ALU. Больше на схеме почти ничего и нет.
Значит, чтобы описать процессор, достаточно уметь три вещи. Объявить шину. Выбрать одну шину из нескольких по коду. И посчитать над двумя шинами арифметику вместе с признаками результата. Первые две мы разберём на маленьких примерах, третью напишем целиком и прогоним на настоящем симуляторе.
Одна оговорка про HDL, которую придётся держать в голове весь урок. Всё, что ты пишешь, существует одновременно. Когда ниже появится case и он будет выглядеть как оператор ветвления из обычного языка, помни: никто не проверяет условия по очереди. Схема из мультиплексоров считает все ветки сразу, а выбирает уже готовые значения.
Шина: одно имя на 64 провода
Объявление шины отличается от объявления бита указанием диапазона разрядов:
logic cnd; // один провод
logic [63:0] valE; // шестьдесят четыре провода
logic [3:0] icode; // четыре провода
logic [79:0] bytes10; // десять байт одним куском
Запись [63:0] читается как “с 63 по 0”, и это не украшение, а порядок. Слева старший разряд, справа младший, ровно как ты привык записывать двоичное число. Разряд valE[63] это старший бит, он же знаковый бит, а valE[0] это младший. Обратный порядок [0:63] язык тоже разрешает, но в этом проекте его нигде нет, и тебе он не понадобится: смешивать два порядка в одном исходнике: верный способ потерять полдня.
Литерал всегда с шириной
Число в Verilog пишется в три части: ширина, основание, цифры.
64'd0 // ноль в шестидесяти четырёх разрядах, десятичная запись
64'h8000000000000000 // то же слово, шестнадцатеричная запись
4'h6 // код операции OPq, четыре разряда
1'b0 // один ноль
2'b01 // два разряда, значение один
Ширину можно не писать, и тогда язык подставит разрядность машинного целого симулятора, обычно 32. Иногда это безобидно, иногда нет. Правило одно: в этом проекте у каждого литерала есть ширина. Причина не в педантизме, а в том, что несовпадение ширин молча меняет результат, и заметить это по симуляции трудно.
Посмотри, что происходит с числами разной записи. Модуль ниже ничего не считает, он только печатает:
module tb_words;
logic [63:0] w;
logic [79:0] bytes10;
initial begin
w = 64'h0123456789abcdef;
bytes10 = {16'hbeef, w};
$display("w = %016h", w);
$display("w[7:0] = %02h", w[7:0]);
$display("w[63:60] = %01h", w[63:60]);
$display("all_ones = %016h", {64{1'b1}});
$display("bytes10 = %020h", bytes10);
$display("{4'h6, 4'h1} = %02h", {4'h6, 4'h1});
w = -1;
$display("w = -1 даёт %016h", w);
$finish(0);
end
endmodule
$ iverilog -g2012 -o wsim words.sv && vvp -n wsim
w = 0123456789abcdef
w[7:0] = ef
w[63:60] = 0
all_ones = ffffffffffffffff
bytes10 = beef0123456789abcdef
{4'h6, 4'h1} = 61
w = -1 даёт ffffffffffffffff
Здесь сразу три приёма, которые дальше встречаются в каждом модуле.
Срез w[7:0] берёт кусок шины и сам работает как шина нужной ширины. Именно так этап выборки достаёт из десяти прочитанных байт первый байт инструкции, второй байт с номерами регистров и восьмибайтную константу: imem_bytes[7:0], imem_bytes[15:8], imem_bytes[79:16]. Границы среза обязаны быть константами, потому что срез это выбор конкретных проводов, а не вычисление адреса.
Конкатенация в фигурных скобках склеивает несколько значений в одно, старшее слева. Строка {4'h6, 4'h1} даёт байт 0x61, и это ровно то, как кодируется первый байт инструкции addq: старший полубайт icode, младший полубайт ifun. Ты собирал такие байты руками в уроке про кодирование Y86-64, теперь видишь ту же операцию проводами.
Повтор {64{1'b1}} это конкатенация одного значения само с собой заданное число раз. Удобно, когда нужна константа из одних единиц любой ширины.
И последняя строка вывода стоит отдельного внимания. Присваивание w = -1 дало ffffffffffffffff. Минус единица легла в шину как дополнительный код, потому что другого способа записать отрицательное число в 64 разряда просто нет. Шина хранит биты, а не смысл.
Шина не знает, знаковая она или нет
Вот ловушка, из-за которой мы потом будем считать переполнение вручную. Тип logic [63:0] беззнаковый. Сравнение двух таких шин это беззнаковое сравнение, даже если ты держишь в них числа со знаком.
module tb_signs;
logic [63:0] a;
logic [63:0] b;
initial begin
a = 64'hffffffffffffffff; // это минус один в дополнительном коде
b = 64'h0000000000000001;
$display("a < b как logic : %b", a < b);
$display("a < b со знаком : %b", $signed(a) < $signed(b));
$display("a[63] : %b", a[63]);
$finish(0);
end
endmodule
$ iverilog -g2012 -o ssim signs.sv && vvp -n ssim
a < b как logic : 0
a < b со знаком : 1
a[63] : 1
Одни и те же биты, два разных ответа. Как беззнаковое число ffffffffffffffff это максимум, и оно больше единицы. Как знаковое это минус один, и оно меньше. Функция $signed переключает интерпретацию, но пользоваться ей мы не будем: в схеме процессора знаковость живёт не в типе, а в том, какой флаг читает управляющая логика. Ровно та же история, что и с флагами CF и OF в уроке про сравнения и переходы: процессор считает одну сумму и выставляет оба признака, а какой из них правильный, решает компилятор по типам исходной программы.
Сложение и вычитание при этом писать можно спокойно, оператором + и -. В дополнительном коде схема сумматора одна и та же для знаковых и беззнаковых, разница только в том, как читать результат. А вот флаг переполнения придётся собрать руками из старших битов, и в этом весь смысл второй половины урока.
case как мультиплексор
Мультиплексор на один бит ты собирал из вентилей: две ветки and под управлением сигнала и его инверсии, результаты складывает or. Для слов так писать нельзя. Мультиплексор на четыре входа по 64 разряда это 256 вентилей and, и никто не станет выписывать их руками.
Вместо этого есть case внутри always_comb.
module pick4 (
input logic [1:0] sel,
input logic [63:0] a,
input logic [63:0] b,
input logic [63:0] c,
input logic [63:0] d,
output logic [63:0] out
);
always_comb begin
case (sel)
2'b00: out = a;
2'b01: out = b;
2'b10: out = c;
default: out = d;
endcase
end
endmodule
Читается это как выбор, а собирается как схема. Синтезатор видит здесь четыре шины, приходящие на входы одного широкого мультиплексора, и два провода управления. Никакой очерёдности проверки нет: значения a, b, c и d присутствуют на входах всегда, sel только решает, какое из них дойдёт до выхода.
Блок always_comb это обещание, которое ты даёшь инструментам: внутри описана чисто комбинационная схема, у которой выход зависит только от текущих значений входов и ни от какой памяти. Три вещи он делает за тебя. Собирает список чувствительности сам, так что забыть входной сигнал невозможно. Запускается один раз в самом начале, чтобы схема не оставалась неопределённой до первого изменения входов. И разрешает инструменту проверить, что обещание выполнено.
Для одной строки скобки begin/end не нужны, и в проекте полно такой записи:
always_comb ZF = (valE == 64'd0);
always_comb SF = valE[63];
Это тот же always_comb, только с одним присваиванием внутри. Разницы со старым assign в поведении почти нет, но always_comb честнее говорит о намерении и ловит ошибки, которые assign пропустит.
Забытая ветка превращается в защёлку
Теперь главная ловушка урока, из-за которой у case в этом проекте всегда есть default. Убери из pick4 последнюю ветку:
module pick3 (
input logic [1:0] sel,
input logic [63:0] a,
input logic [63:0] b,
input logic [63:0] c,
output logic [63:0] out
);
always_comb begin
case (sel)
2'b00: out = a;
2'b01: out = b;
2'b10: out = c;
endcase
end
endmodule
Сигнал sel имеет два разряда, значит у него четыре значения, а веток три. Что должно оказаться на выходе при sel равном 2'b11? В обычном языке ответ был бы “ничего, пропускаем”. В схеме пропустить нельзя: провод всегда чем-то нагружен. Язык обязан сказать, какое значение на нём будет, и единственный ответ, который у него остаётся, это “то же, что было раньше”.
А “то же, что было раньше” означает память. Схема, которая помнит предыдущее значение, пока управляющий сигнал её не отпустит, называется защёлкой. Ты хотел комбинационную схему, а получил элемент памяти, о котором не просил. В симуляции это выглядит как случайно залипшее старое значение, в синтезе как схема, которая не проходит анализ времени.
Именно за этим и нужен verilator. Запусти его на pick3:
$ verilator --lint-only -Wall latch.sv --top-module pick3
%Warning-CASEINCOMPLETE: latch.sv:9:5: Case values incompletely covered (example pattern 0x3)
9 | case (sel)
| ^~~~
%Warning-LATCH: latch.sv:8:3: Latch inferred for signal 'out' (not all control paths of combinational always assign a value)
: ... Suggest use of always_latch for intentional latches
8 | always_comb begin
| ^~~~~~~~~~~
%Error: Exiting due to 3 warning(s)
Две жалобы про одну ошибку (третью в счётчике даёт придирка к имени файла, latch.sv не совпадает с именем модуля, её я вырезал), и вторая называет её прямо: выведена защёлка, потому что не все пути через комбинационный блок присваивают значение. Icarus Verilog при этом соберёт pick3 молча и даже отсимулирует. Поэтому в проекте два инструмента: iverilog гоняет схему, verilator --lint-only читает её глазами придирчивого рецензента.
Лечится одной строкой. Либо добавить default, либо перечислить все значения управляющего сигнала. В этом проекте принято прятать в default самую частую ветку, и ты сейчас увидишь, как это выглядит в настоящем ALU.
У виджета две вкладки, и открывается он на первой. Переключись пока на вторую, “мультиплексор”: она про защёлку. Там четыре входа по байту, ползунок sel и листинг рядом, который подсвечивает сработавшую ветку case. Доведи sel до 2'b11 и увидишь, что явной ветки для него нет, работает default.
Первая вкладка это ALU, к которому мы переходим дальше. Введи два операнда (в шестнадцатеричной или в десятичной записи, есть готовые пары) и выбери операцию, и он покажет результат вместе с тремя флагами и объяснением каждого. Ползунок подстройки двигает операнд на восемь единиц в каждую сторону: поводи им у границ диапазона, 0x7fffffffffffffff и 0x8000000000000000, и посмотри, когда загорается флаг переполнения. Ниже на той же вкладке стоит таблица блока cond: на текущих флагах видно, какие условные переходы и пересылки сработали бы. К ней мы вернёмся ближе к концу урока. А переполнение ты научишься предсказывать раньше, чем отпустишь ползунок.
ALU Y86-64
Пора собрать настоящую схему. Арифметико-логическое устройство Y86-64 умеет четыре операции, и код операции приходит на отдельный вход. Коды эти не выдуманы: это поле ifun инструкции OPq, то самое, которое ты кодировал руками.
Все константы проекта живут в одном пакете. Пакет это набор имён, доступных модулям, которые его импортируют. Файл целиком:
// Общие константы 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 */
Больше половины этих имён понадобится только в уроках про SEQ. Сейчас работают четыре кода операции, от ALUADD до ALUXOR, и семь кодов условия, от CALWAYS до CG. Слово localparam означает константу, которую нельзя переопределить снаружи, и в схеме она не занимает ни одного провода: синтезатор подставляет значение прямо в логику.
Теперь сам ALU, файл целиком:
// Арифметико-логическое устройство 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
Разберём по частям, потому что здесь спрятаны три решения, каждое из которых стоит понять.
Порядок операндов перевёрнут. Результат считается как aluB минус aluA, а не наоборот. Причина в кодировании: инструкция subq rA, rB вычитает rA из rB, приёмник в Y86-64 стоит вторым. На этапе decode значение rA попадёт на вход aluA, значение rB на вход aluB, и схема посчитает то, что нужно, без единого дополнительного провода. Такие мелочи и есть работа архитектора: перевернуть один раз в определении входов вместо мультиплексора на каждой инструкции.
Сложение спрятано в default. Веток у case четыре, а выписаны три: ALUADD не упомянут нигде, зато default подписан комментарием. Это не лень. Сложение самая частая операция ALU, и оно же должно происходить, когда через ALU идёт что-то кроме OPq: прибавление смещения к адресу, прибавление восьмёрки к указателю стека при pushq, прибавление нуля при обычной пересылке. Все эти случаи приходят с alufun равным нулю и попадают в default естественным образом. Заодно закрыты все двенадцать оставшихся четырёхразрядных кодов, и защёлки нет.
Флаги считаются всегда. ALU не спрашивает, нужны ли кому-то ZF, SF и OF. Он выставляет их на каждую операцию, а решение записать их в регистр состояния принимается снаружи отдельным сигналом. Это прямое следствие того, как устроен x86: инструкция addq меняет флаги, mrmovq нет, хотя обе проходят через один и тот же сумматор.
Обрати внимание, что ZF и SF читают уже готовый valE, то есть выход первого мультиплексора. Никакого цикла тут нет: сигнал идёт по схеме слева направо, от case к сравнению с нулём и к отводу старшего разряда. Комбинационная логика запрещает только настоящую петлю, когда сигнал через цепочку возвращается сам в себя.
Три флага и правило переполнения
Два флага из трёх считаются в одну строку, и проговорить их стоит только затем, чтобы не ошибиться в мелочи.
ZF это признак нулевого результата. Пишется как сравнение всего слова с нулём: valE == 64'd0. Оператор сравнения сворачивает 64 разряда в один бит, а схема за ним это дерево из вентилей or с инверсией на выходе. Частая ошибка новичка: проверить valE[0]. Это признак чётности, а не нуля.
SF это знак результата, то есть его старший разряд. Никакого вычисления, отвод одного провода. Он честно отвечает на вопрос “как выглядит знак того слова, которое получилось”, и именно поэтому одного его мало.
OF это признак того, что настоящий математический ответ не поместился в 64 разряда со знаком. Вот здесь и начинается работа.
Откуда берётся правило
Диапазон знакового 64-разрядного числа это от 0x8000000000000000 до 0x7fffffffffffffff, в десятичной записи примерно от минус девяти квинтиллионов до плюс девяти квинтиллионов. Сложение двух чисел из этого диапазона может дать сумму, которой в диапазоне нет. Схема при этом ничего не заметит: сумматор выдаст 64 младших разряда правильной суммы, а старший бит окажется чужим.
Ключевое наблюдение такое. Складывать числа разных знаков без переполнения безопасно всегда: сумма по модулю не превосходит большего из слагаемых, значит она гарантированно внутри диапазона. Переполнение возможно только у слагаемых одного знака, и распознаётся оно по одному признаку: сумма двух положительных обязана быть положительной, сумма двух отрицательных обязана быть отрицательной. Если знак ушёл, значит не поместилось.
Отсюда и строчка в листинге:
OF = (aluA[63] == aluB[63]) && (valE[63] != aluA[63]);
Слева проверка “знаки слагаемых совпали”, справа проверка “знак суммы отличается от них”. Три бита, два сравнения, ни одного лишнего провода.
С вычитанием зеркально. Разность b минус a это то же самое, что b плюс минус a, значит опасны те случаи, когда у b и у минус a знаки совпадают, то есть у b и у a они различаются. А результат обязан сохранить знак уменьшаемого: из положительного вычитаем отрицательное, ответ положительный; из отрицательного вычитаем положительное, ответ отрицательный.
OF = (aluA[63] != aluB[63]) && (valE[63] != aluB[63]);
Разница с первой формулой ровно в двух местах: знак сравнения в первой скобке перевернулся, а во второй скобке вместо aluA стоит aluB, потому что уменьшаемое теперь оно. Держи эту пару строк рядом, они путаются легче всего.
Сведём в таблицу:
| операция | знаки операндов | знак результата | OF |
|---|---|---|---|
| сложение | совпадают | тот же | 0 |
| сложение | совпадают | другой | 1 |
| сложение | различаются | любой | 0 |
| вычитание | различаются | как у уменьшаемого | 0 |
| вычитание | различаются | не как у уменьшаемого | 1 |
| вычитание | совпадают | любой | 0 |
| and, xor | любые | любой | 0 |
Последняя строка про логические операции. Побитовое И и исключающее ИЛИ не могут вынести результат за диапазон: каждый разряд ответа выбран из разрядов операндов, ничего нового не появляется. Флаг там прибит к нулю, и это ровно то же поведение, что у инструкций andq и xorq в x86, которые ты видел в уроке про флаги: они обнуляют OF и CF, а ZF и SF выставляют по результату.
Где флаги живут между инструкциями
ALU считает флаги на каждую операцию, но сам он ничего не помнит: это чистая комбинационная схема, у которой на выходе всегда признаки того, что она посчитала прямо сейчас. Помнить их будет отдельный регистр состояния, и появится он в следующем уроке вместе с тактом.
Записывать туда флаги разрешено не всякой инструкции. В Y86-64 регистр состояния меняют только OPq и её расширение iaddq. Всё остальное проходит через ALU, не трогая флаги: mrmovq прибавляет смещение к адресу, pushq вычитает восьмёрку из указателя стека, rrmovq прибавляет ноль, и ни одна из них не имеет права затереть результат прошлого сравнения. Иначе jle после subq читал бы флаги от вычисления адреса. Решает это отдельный управляющий сигнал set_cc, поднятый только для OPq, и потому в шапке листинга ALU стоит про него оговорка.
Отсюда же берётся неочевидная мелочь, которая понадобится, когда ты начнёшь сверять свои трассы с эталонными. Машина стартует не с нулевыми флагами: начальное состояние это ZF равен единице, SF и OF равны нулю, как в симуляторе yis из книги и в твоём симуляторе на Zig. Логика такая: до первой арифметики результата ещё не было, а “результата не было” удобнее считать нулём, чем чем-то отрицательным. Регистры при этом обнулены, PC равен нулю, Stat равен AOK.
Тестбенч ALU
Схему нельзя считать готовой, пока она не прогнана на векторах, где каждый флаг хотя бы раз встаёт в единицу. Напишем тестбенч.
// Тестбенч арифметико-логического устройства: гоняет ALU на наборе пар,
// в котором каждая операция и каждый флаг хотя бы раз встают в единицу.
// Печатает по строке на вектор, отдельная колонка на ZF, SF и OF.
// iverilog -g2012 -s tb_alu -o /tmp/tb_alu hdl/lib/y86_pkg.sv hdl/lib/alu.sv hdl/tb/tb_alu.sv
// vvp -n /tmp/tb_alu
module tb_alu;
import y86_pkg::*;
logic [3:0] alufun;
logic [63:0] aluA;
logic [63:0] aluB;
logic [63:0] valE;
logic ZF;
logic SF;
logic OF;
alu dut (
.alufun(alufun),
.aluA(aluA),
.aluB(aluB),
.valE(valE),
.ZF(ZF),
.SF(SF),
.OF(OF)
);
// Одна строка отчёта. Имя операции печатаем словом, чтобы таблицу
// можно было читать, не держа в голове коды ifun.
task automatic check(input logic [3:0] fun, input logic [63:0] b, a, input string note);
begin
alufun = fun;
aluB = b;
aluA = a;
#1;
$display("%-4s B=%016h A=%016h -> %016h Z=%b S=%b O=%b %s",
name_of(fun), aluB, aluA, valE, ZF, SF, OF, note);
end
endtask
function automatic string name_of(input logic [3:0] fun);
case (fun)
ALUSUB: name_of = "sub";
ALUAND: name_of = "and";
ALUXOR: name_of = "xor";
default: name_of = "add";
endcase
endfunction
initial begin
check(ALUADD, 64'd5, 64'd3, "обычное сложение");
check(ALUADD, 64'h7fffffffffffffff, 64'd1, "плюс один за верхнюю границу");
check(ALUADD, 64'h8000000000000000, 64'h8000000000000000, "два минимума дают ноль");
check(ALUADD, 64'hffffffffffffffff, 64'd1, "минус один плюс один, переполнения нет");
check(ALUSUB, 64'd3, 64'd5, "3 минус 5, знак есть, переполнения нет");
check(ALUSUB, 64'd5, 64'd5, "равные операнды дают ноль");
check(ALUSUB, 64'h8000000000000000, 64'd1, "минимум минус один");
check(ALUSUB, 64'h7fffffffffffffff, 64'hffffffffffffffff, "максимум минус минус один");
check(ALUAND, 64'hff00ff00ff00ff00, 64'h0f0f0f0f0f0f0f0f, "and никогда не переполняется");
check(ALUAND, 64'hff00ff00ff00ff00, 64'h00ff00ff00ff00ff, "and дал ноль");
check(ALUXOR, 64'hffffffffffffffff, 64'h0000000000000001, "xor поднял знак");
check(ALUXOR, 64'h0123456789abcdef, 64'h0123456789abcdef, "xor слова с собой");
$finish(0);
end
endmodule
Три вещи в этом файле стоит забрать себе на будущее.
Конструкция task automatic это подпрограмма тестбенча. Она умеет то, чего не умеет функция: внутри стоит задержка #1, то есть ожидание модельного времени. Без неё комбинационная схема не успела бы пересчитаться, и $display напечатал бы значения от предыдущего вектора. Слово automatic означает, что аргументы и локальные переменные создаются на каждый вызов заново, и это то, чего почти всегда хочется.
Инстанцирование модуля записано по именам портов: .alufun(alufun) связывает порт alufun модуля с сигналом alufun тестбенча. Совпадение имён тут случайное и ни на что не влияет. Позиционная форма, где порты перечисляются по порядку, языком разрешена, но в проекте её нет: перепутанные местами aluA и aluB дадут схему, которая собирается, симулируется и молча вычитает не в ту сторону.
Функция name_of печатает имя операции словом. Она нужна не схеме, а тебе: таблица из двенадцати строк с колонкой 1 вместо sub читается вдвое хуже.
Запускаем. Пакет обязан идти первым, потому что и iverilog, и verilator читают файлы по порядку, а импорт работает только после объявления:
$ iverilog -g2012 -s tb_alu -o /tmp/tb_alu \
hdl/lib/y86_pkg.sv hdl/lib/alu.sv hdl/tb/tb_alu.sv
$ vvp -n /tmp/tb_alu
add B=0000000000000005 A=0000000000000003 -> 0000000000000008 Z=0 S=0 O=0 обычное сложение
add B=7fffffffffffffff A=0000000000000001 -> 8000000000000000 Z=0 S=1 O=1 плюс один за верхнюю границу
add B=8000000000000000 A=8000000000000000 -> 0000000000000000 Z=1 S=0 O=1 два минимума дают ноль
add B=ffffffffffffffff A=0000000000000001 -> 0000000000000000 Z=1 S=0 O=0 минус один плюс один, переполнения нет
sub B=0000000000000003 A=0000000000000005 -> fffffffffffffffe Z=0 S=1 O=0 3 минус 5, знак есть, переполнения нет
sub B=0000000000000005 A=0000000000000005 -> 0000000000000000 Z=1 S=0 O=0 равные операнды дают ноль
sub B=8000000000000000 A=0000000000000001 -> 7fffffffffffffff Z=0 S=0 O=1 минимум минус один
sub B=7fffffffffffffff A=ffffffffffffffff -> 8000000000000000 Z=0 S=1 O=1 максимум минус минус один
and B=ff00ff00ff00ff00 A=0f0f0f0f0f0f0f0f -> 0f000f000f000f00 Z=0 S=0 O=0 and никогда не переполняется
and B=ff00ff00ff00ff00 A=00ff00ff00ff00ff -> 0000000000000000 Z=1 S=0 O=0 and дал ноль
xor B=ffffffffffffffff A=0000000000000001 -> fffffffffffffffe Z=0 S=1 O=0 xor поднял знак
xor B=0123456789abcdef A=0123456789abcdef -> 0000000000000000 Z=1 S=0 O=0 xor слова с собой
Пройдись по таблице глазами, она вся про правило из прошлого раздела.
Вторая строка это классика: к максимальному положительному прибавили единицу и получили самое отрицательное. Знаки слагаемых совпали (оба нули), знак суммы другой, O=1. Третья строка ещё нагляднее: два минимума сложились в ноль, потому что настоящий ответ вдвое больше диапазона. Флаг Z тут единица, и это честно: результат действительно нулевой, просто он неправильный. Четвёртая строка ловушка для тех, кто путает знаковое переполнение с беззнаковым: ffffffffffffffff плюс единица тоже даёт ноль, перенос из старшего разряда был, но знаки слагаемых различались, и O=0. Как знаковые числа это минус один плюс один, ответ ноль, всё поместилось.
Седьмая и восьмая строки то же самое для вычитания. Из минимума вычли единицу и уехали на максимум. Из максимума вычли минус единицу и уехали на минимум. В обоих случаях знаки операндов различались, а результат не сохранил знак уменьшаемого.
Про iverilog осталось сказать одно. На каждый взятый разряд внутри always_comb он печатает в поток ошибок заметку sorry: constant selects in always_* processes are not fully supported. Она означает, что список чувствительности блока сделан по всей шине, а не по одному разряду. Для always_comb список и так полный, так что заметка безвредна, и скрипт hdl/run.sh отфильтровывает её, чтобы она не тонула в реальных ошибках.
Блок cond: флаги превращаются в один бит
ALU выставил три флага. Кто-то должен по ним решить, сработало условие или нет. Этим занимается отдельный крошечный модуль, и он один обслуживает и условные переходы jXX, и условные пересылки cmovXX, потому что коды условия у них общие.
// Блок вычисления условия. По коду 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: jl это SF ^ OF, jge это ~(SF ^ OF), jle это (SF ^ OF) | ZF. Здесь ровно те же формулы, но теперь они не описание поведения процессора, а сам процессор: восемь строк логики, которые физически стоят проводами между регистром флагов и мультиплексором выбора следующего адреса.
Проговорим ещё раз, почему знаковое “меньше” это SF ^ OF, а не просто SF. Сравнение это вычитание, у которого выбрасывают результат и оставляют флаги. Если разность переполнилась, знак результата врёт ровно наоборот, и OF отмечает ровно этот случай. Исключающее ИЛИ читается как “знак результата, исправленный на переполнение”. Возьми восьмую строку вывода тестбенча: максимум минус минус единица дал S=1 O=1, то есть SF ^ OF равно нулю, и CL не сработает. Правильно: максимум больше минус единицы.
Ветка default здесь закрывает девять оставшихся кодов ifun, отвечая на них нулём. Эти коды означают битую инструкцию, и переход по ним не должен случиться. Заметить саму битую инструкцию это дело этапа выборки, а не блока условия.
Проверяем cond полным перебором
У блока cond вход крошечный: четыре разряда ifun и три флага, всего семь бит. Пространство настолько мало, что тестбенч может перебрать его целиком и напечатать таблицу истинности всего модуля. Это редкая роскошь, и ей стоит пользоваться каждый раз, когда она есть.
// Тестбенч блока вычисления условия. Перебирает восемь значений ifun на всех
// восьми сочетаниях флагов и печатает таблицу целиком: по ней видно, что
// jle и jg дополняют друг друга, а ifun = 7 в наборе не значит ничего.
// iverilog -g2012 -s tb_cond -o /tmp/tb_cond \
// hdl/lib/y86_pkg.sv hdl/lib/cond.sv hdl/tb/tb_cond.sv
// vvp -n /tmp/tb_cond
module tb_cond;
import y86_pkg::*;
logic [3:0] ifun;
logic ZF, SF, OF;
logic Cnd;
cond dut (
.ifun (ifun),
.ZF (ZF),
.SF (SF),
.OF (OF),
.Cnd (Cnd)
);
// Суффикс условия по ifun. Нулевой это безусловный переход jmp,
// а всё, что больше шести, набором не определено.
function automatic string cond_name(logic [3:0] f);
case (f)
CALWAYS: cond_name = "always";
CLE: cond_name = "le";
CL: cond_name = "l";
CE: cond_name = "e";
CNE: cond_name = "ne";
CGE: cond_name = "ge";
CG: cond_name = "g";
default: cond_name = "????";
endcase
endfunction
initial begin
$display("ifun name ZF SF OF -> Cnd");
for (int f = 0; f < 8; f++) begin
for (int flags = 0; flags < 8; flags++) begin
ifun = f[3:0];
{ZF, SF, OF} = flags[2:0];
#1
$display("%h %-7s %b %b %b -> %b", ifun, cond_name(ifun), ZF, SF, OF, Cnd);
end
end
$finish(0);
end
endmodule
Внутренний цикл собирает три флага одной конкатенацией: {ZF, SF, OF} = flags[2:0] раскладывает счётчик по трём проводам сразу. Приём тот же, что в разделе про шины, только в обратную сторону.
Вывод получается на 65 строк, поэтому смотреть его целиком незачем. Полезнее вытащить пары, которые обязаны быть противоположны. Знаковое “меньше” и знаковое “не меньше” покрывают все случаи и не пересекаются:
$ iverilog -g2012 -s tb_cond -o /tmp/tb_cond \
hdl/lib/y86_pkg.sv hdl/lib/cond.sv hdl/tb/tb_cond.sv
$ vvp -n /tmp/tb_cond | grep -E '^[25] '
2 l 0 0 0 -> 0
2 l 0 0 1 -> 1
2 l 0 1 0 -> 1
2 l 0 1 1 -> 0
2 l 1 0 0 -> 0
2 l 1 0 1 -> 1
2 l 1 1 0 -> 1
2 l 1 1 1 -> 0
5 ge 0 0 0 -> 1
5 ge 0 0 1 -> 0
5 ge 0 1 0 -> 0
5 ge 0 1 1 -> 1
5 ge 1 0 0 -> 1
5 ge 1 0 1 -> 0
5 ge 1 1 0 -> 0
5 ge 1 1 1 -> 1
Сравни две восьмёрки построчно: в каждой строке значения Cnd противоположны. Это и есть проверка того, что ~(SF ^ OF) действительно отрицание SF ^ OF, снятая с работающей схемы, а не с бумаги. Обрати внимание, что ZF в обеих группах не влияет ни на что: верхняя половина каждой восьмёрки повторяет нижнюю. Правильно, ведь в формулах этих двух условий ZF не участвует.
С парой le и g та же история, но ZF там уже работает:
$ vvp -n /tmp/tb_cond | grep -E '^[16] '
1 le 0 0 0 -> 0
1 le 0 0 1 -> 1
1 le 0 1 0 -> 1
1 le 0 1 1 -> 0
1 le 1 0 0 -> 1
1 le 1 0 1 -> 1
1 le 1 1 0 -> 1
1 le 1 1 1 -> 1
6 g 0 0 0 -> 1
6 g 0 0 1 -> 0
6 g 0 1 0 -> 0
6 g 0 1 1 -> 1
6 g 1 0 0 -> 0
6 g 1 0 1 -> 0
6 g 1 1 0 -> 0
6 g 1 1 1 -> 0
Первые четыре строки каждой группы, где ZF равен нулю, противоположны как и раньше. А в нижних четырёх, где результат нулевой, le отвечает единицей всегда, а g нулём всегда: равенство это частный случай “не больше” и никак не случай “больше”. Один прогон, и обе формулы проверены на всех входах разом.
Принадлежность множеству
В таблицах главы 4 книги на каждом шагу встречается вопрос “входит ли icode в такой-то набор”. Нужен ли второй байт с номерами регистров. Нужна ли восьмибайтная константа. Читает ли инструкция память. В книге это записано как icode in {IRRMOVQ, IOPQ, IPUSHQ, IPOPQ} и в HCL работает прямо так.
В Verilog есть три способа записать то же самое, и выбирать между ними приходится не по красоте.
Первый способ это цепочка сравнений через ||. Так написано в этапе выборки нашего процессора:
// Второй байт с номерами регистров нужен всем инструкциям, кроме однобайтных и переходов.
always_comb need_regids = (icode == IRRMOVQ) || (icode == IIRMOVQ) ||
(icode == IRMMOVQ) || (icode == IMRMOVQ) ||
(icode == IOPQ) || (icode == IPUSHQ) ||
(icode == IPOPQ) || (icode == IIADDQ);
Многословно, зато собирается везде и читается однозначно.
Второй способ это case, у которого в одном пункте перечислено несколько меток через запятую. Тот же этап выборки использует его для проверки, что инструкция вообще существует:
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
Вот это и есть рабочая форма принадлежности множеству. Строка IHALT, INOP, IRET: читается ровно как “если icode входит в этот набор”. Схема получается одна и та же, что при цепочке ||, синтезатор в обоих случаях строит дерево из вентилей сравнения и одно широкое ИЛИ. Разница только в том, что case заодно заставляет тебя написать default и подумать про все остальные коды.
Третий способ это оператор inside из SystemVerilog, самый близкий к записи книги:
// Так писать красивее всего, но в этом проекте нельзя, см. текст ниже.
always_comb need_regids = icode inside {IRRMOVQ, IIRMOVQ, IRMMOVQ, IMRMOVQ,
IOPQ, IPUSHQ, IPOPQ, IIADDQ};
always_comb begin
case (icode) inside
IRRMOVQ, IOPQ, IPUSHQ, IPOPQ: need_regids = 1'b1;
default: need_regids = 1'b0;
endcase
end
Пользоваться им мы не будем, и вот почему. Icarus Verilog версии 13 обе формы не принимает:
$ iverilog -g2012 -o sim inside.sv
inside.sv:3: syntax error
inside.sv:4: error: Incomprehensible case expression.
$ iverilog -g2012 -o sim2 inside2.sv
inside2.sv:2: sorry: "inside" expressions not supported yet.
Verilator при этом обе формы разбирает молча. Получается расхождение: линтер доволен, симулятор падает. Поскольку и твои задачи в песочнице, и все тестбенчи проекта гоняет именно Icarus, inside в этом проекте не используется нигде. Знать про него стоит: в промышленных проектах на SystemVerilog он встречается постоянно, и в чужом коде ты его увидишь.
Мораль шире одного оператора. Verilog это не один язык, а несколько стандартов, и каждый инструмент поддерживает свой кусок. Прежде чем опереться на красивую конструкцию, проверь её тем симулятором, которым будешь пользоваться. Двадцать секунд на маленький файл экономят вечер отладки.
Параметры модулей
Осталась последняя конструкция, без которой не обойтись. Мультиплексор на слово нужен и шириной 64, и шириной 4 для номеров регистров, и шириной 3 для кода состояния. Писать три почти одинаковых модуля глупо. Вместо этого модуль объявляется с параметром.
Сначала однобитная деталь из прошлого урока, она понадобится целиком:
// Мультиплексор на один бит: при s = 0 выбирает a, при s = 1 выбирает b.
// Тоже собран из вентилей, а не из тернарного оператора.
module bit_mux (
input logic s,
input logic a,
input logic b,
output logic out
);
always_comb out = (~s & a) | (s & b);
endmodule
А теперь широкий мультиплексор поверх неё:
// Мультиплексор на слово: W однобитных мультиплексоров с общим сигналом выбора.
// Именно так в книге рисуют широкие мультиплексоры на схеме тракта данных.
module word_mux #(
parameter int W = 64
) (
input logic s,
input logic [W-1:0] a,
input logic [W-1:0] b,
output logic [W-1:0] out
);
genvar i;
generate
for (i = 0; i < W; i++) begin : g_bits
bit_mux u_bit (.s(s), .a(a[i]), .b(b[i]), .out(out[i]));
end
endgenerate
endmodule
Блок #(parameter int W = 64) объявляет параметр со значением по умолчанию. Дальше W работает как константа: ширина портов записана как [W-1:0], а не числом.
Конструкция generate с genvar это цикл, который разворачивается при сборке, а не при работе схемы. Инструмент читает for (i = 0; i < W; i++) и создаёт 64 отдельных экземпляра модуля bit_mux, каждый со своим разрядом. Никакого цикла в железе нет: там просто стоят 64 одинаковые детали в ряд. Метка g_bits после двоеточия даёт этой группе имя, чтобы в сообщениях симулятора экземпляры назывались осмысленно.
Использовать это можно так:
word_mux u_data (.s(sel), .a(x), .b(y), .out(z)); // 64 разряда
word_mux #(.W(4)) u_reg (.s(sel), .a(rA), .b(rB), .out(dstE)); // 4 разряда
word_mux #(.W(3)) u_stat (.s(sel), .a(s1), .b(s2), .out(stat)); // 3 разряда
Один исходник, три разные схемы. Первый экземпляр возьмёт значение по умолчанию, второй и третий получат своё через #(.W(...)). В тракте SEQ, который ты соберёшь через два урока, таких экземпляров будет десяток, и все они из этого одного файла.
Практика
Собери ALU сам, с нуля и целиком. Заготовка даёт только заголовок модуля: код операции на два разряда, два операнда по 64 бита, слово результата и три флага. Тело прибито к нулю, и грейдер это видит.
Тебе нужно написать три части. Мультиплексор на четыре операции через case с обязательным default. Флаги zf и sf, которые читаются прямо с готового результата. И флаг of со своим case, потому что правило переполнения у сложения и у вычитания разное, а у логических операций его нет вовсе.
Одно отличие от листинга урока: в задаче уменьшаемое это первый операнд, out при вычитании равен a минус b. Перевёрнутый порядок настоящего Y86-64 нужен для кодирования инструкций, а здесь он только сбивал бы с толку.
Грейдер гоняет двенадцать векторов и печатает результат вместе с тремя флагами. Набор подобран так, что каждый флаг хотя бы раз встаёт в единицу и хотя бы раз остаётся нулём, а переполнение ловится и на сложении, и на вычитании в обе стороны.
Упражнения
Итоги
- Шина это одно имя на много проводов:
logic [63:0]объявляет 64 разряда, слева старший, справа младший. Срезw[7:0]берёт кусок и сам работает как шина, конкатенация в фигурных скобках склеивает куски обратно, повтор{64{1'b1}}размножает значение. - У литерала всегда пиши ширину:
64'd0,4'h6,1'b0. Без неё язык подставит разрядность машинного целого, и несовпадение ширин молча изменит результат. - Тип
logic [63:0]беззнаковый. Одни и те же биты дают разный ответ при беззнаковом и знаковом сравнении, и схема сама не знает, что в ней лежит. Знаковость живёт не в типе, а в том, какой флаг читает управляющая логика. caseвнутриalways_combэто широкий мультиплексор, а не ветвление. Все входы посчитаны всегда, код операции только выбирает, какой из них дойдёт до выхода.- У
caseв комбинационной схеме обязана быть веткаdefaultили полный перебор значений. Иначе для незакрытых кодов язык обязан сохранить старое значение, а это память: выводится защёлка. - Про защёлку говорит
verilator --lint-only, двумя предупреждениями сразу, CASEINCOMPLETE и LATCH. Icarus Verilog соберёт такую схему молча, поэтому линтер в этом проекте обязателен, а не по желанию. - ALU Y86-64 это один мультиплексор на четыре операции плюс три флага. Сложение спрятано в
default, потому что оно самое частое и приходит с нулевым кодом при вычислении адресов и при работе со стеком. - Порядок операндов в ALU перевёрнут: результат это
aluBOPaluA, потому чтоsubq rA, rBвычитает первый операнд из второго. Один переворот в определении входов экономит мультиплексор на каждой инструкции. - ZF это сравнение всего слова с нулём, SF это отвод старшего разряда. OF считается по трём знакам: у сложения слагаемые одного знака и другой знак суммы, у вычитания разные знаки операндов и уход от знака уменьшаемого. У
andиxorон всегда ноль. - ALU ничего не помнит: флаги на его выходе относятся к текущей операции. Хранит их отдельный регистр состояния, и записывают туда только
OPqиiaddq, потому что через ALU идут ещё и вычисления адресов, которым затирать флаги нельзя. Стартовое состояние машины это ZF равен единице, SF и OF равны нулю. - Блок
condпревращает код условия и три флага в один битCnd, и это та же таблица условных кодов x86, которую ты читал в уроке про переходы. Знаковое “меньше” этоSF ^ OF, то есть знак результата, исправленный на переполнение. - Принадлежность множеству пиши списком меток в одном пункте
caseили цепочкой сравнений. Операторinsideиз SystemVerilog красивее, но Icarus Verilog его не поддерживает, а гоняем мы именно им. - Параметр
#(parameter int W = 64)делает один исходник источником схем разной ширины.generateсgenvarразворачивается при сборке и создаёт W независимых экземпляров, никакого цикла в железе нет.
Дальше
Ты написал первую настоящую схему процессора. ALU считает, флаги описывают результат, cond превращает их в решение, а мультиплексоры выбирают, какое слово поедет дальше. Всё это чистая комбинационная логика: подал входы, через задержку получил выходы, и ничего не помнится.
Процессор так работать не может. Ему нужно помнить содержимое регистров между инструкциями, помнить адрес следующей инструкции, помнить флаги от прошлой арифметики. Следующий урок про память в железе: тактовый сигнал, always_ff @(posedge clk), регистр как элемент, который меняется только по фронту, регистровый файл с двумя портами чтения и двумя портами записи и память с байтовой адресацией. Там же разберём дисциплину, на которой держится весь тракт: в такте читаем, по фронту пишем. И научимся смотреть на сигналы глазами, снимая waveform в файл VCD.
домашка