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

Вентили и комбинационная логика на Verilog

senior~80 мин

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

Вентили и комбинационная логика на Verilog

Три урока подряд ты писал софт: ассемблер превращал текст в байты, симулятор гонял эти байты через шесть функций и печатал трассу. Всё это время процессор оставался метафорой, набором шагов в твоей голове. Начиная с этого урока он становится проводами. Мы берём тот же Y86-64 и описываем его на Verilog: сначала вентили, потом мультиплексоры и компараторы, дальше ALU, регистры и память, и в конце SEQ и PIPE, чья трасса совпадёт с трассой твоего Zig-симулятора байт в байт. Сегодня первый шаг и самый непривычный: научиться не писать программу, а описывать схему.

Цели урока

  • Понять главное отличие языка описания аппаратуры от языка программирования: всё вычисляется одновременно, порядок строк не значит ничего, а logic это не переменная, а провод.
  • Написать первый модуль на SystemVerilog: порты, сигналы, assign, логические операции как вентили.
  • Написать тестбенч с initial, $display и $finish, собрать всё через iverilog -g2012 и запустить через vvp.
  • Разобраться, почему в современном коде пишут logic, а не wire и reg, и когда нужен always_comb.
  • Перевести на Verilog учебный HCL из главы 4 книги: bit_eq, битовый мультиплексор, 64-битный компаратор и мультиплексор на слово.
  • Увидеть, что широкий блок это не новая сущность, а generate из 64 одинаковых узких.
  • Поставить verilator --lint-only -Wall рядом с симулятором и понять, какой класс ошибок он ловит там, где Icarus молчит.
  • Собрать и проверить два настоящих модуля в песочнице курса.

Идея: ты не пишешь программу, ты описываешь схему

Всё, что ты писал до сих пор, исполнялось по шагам. У программы есть счётчик команд, он идёт сверху вниз, и если поменять две строки местами, поведение изменится. Именно эту модель твой Zig-симулятор и воспроизводил: fetch, потом decode, потом execute, шесть функций одна за другой.

Verilog устроен иначе, и в этом вся сложность первого дня. Verilog это HDL, язык описания аппаратуры. Файл на Verilog это не сценарий, а чертёж. Каждая строка добавляет на чертёж кусок схемы, и когда схема готова, она вся работает сразу и всё время.

Три следствия, которые надо принять сразу.

Порядок строк не значит ничего. Провод не “выполняется раньше” другого провода. Все связи существуют одновременно. Ниже мы проверим это прямым экспериментом: один и тот же модуль с перетасованными строками даёт побайтово тот же вывод.

Нет присваивания, есть связь. Строка assign y = a & b; не значит “положи в y результат”. Она значит “выход вентиля and, на входах которого висят a и b, соединён с проводом y, навсегда”. Поэтому написать два assign на один и тот же y это не “переприсвоить”, а физически соединить выходы двух вентилей одним проводом. Так делать нельзя, и симулятор покажет на этом проводе x.

Времени в привычном смысле нет. Комбинационная схема не ждёт. Как только меняется вход, через задержку вентилей меняется выход, и это происходит без всякого такта. Комбинационная логика это ровно такая схема: выход целиком определён входами, памяти нет. Такт, регистры и память появятся в 32-systems/25 · Verilog: такт, регистры и память, а сегодня и в 32-systems/24 · Verilog: слова, мультиплексоры и ALU всё, что мы строим, комбинационное.

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

Инструменты: что поставить и что стоит в песочнице

Нужны два инструмента, и оба бесплатные.

Icarus Verilog это компилятор и симулятор. Он читает .sv, собирает исполняемый образ схемы и гоняет его. Им мы и будем запускать всё в уроке.

Verilator мы используем только как линтер. Он умеет и симулировать, причём быстрее Icarus, но нам от него нужна строгость: он ловит целый класс ошибок, на которые Icarus не скажет ни слова.

На macOS:

brew install icarus-verilog verilator

На Debian и Ubuntu:

sudo apt install iverilog verilator

Проверь, что оба на месте:

iverilog -V | head -1
verilator --version

На машине, где писался этот урок, вывод такой:

Icarus Verilog version 13.0 (stable) (v13_0)
Verilator 5.052 2026-09-05 rev vUNKNOWN-built20260905

Версии у тебя будут другие, и это нормально. В песочнице курса, где исполняются код-задачи этого блока, стоит Icarus Verilog из пакетов Debian bookworm, версии 11.0, плюс Verilator оттуда же. Всё подмножество SystemVerilog, которое используют уроки про Verilog в этом разделе (logic, always_comb, always_ff, case, параметры, generate, $display, $readmemh, $finish), работает и в 11.0, и в 13.0 одинаково. Мы сознательно не заходим за границу этого подмножества: тогда неважно, где ты запускаешь, локально или кнопкой в уроке.

Ключ -g2012 в каждой команде компиляции включает диалект SystemVerilog-2012. Без него Icarus работает по правилам Verilog-2005, где нет ни logic, ни always_comb, и первый же модуль урока не соберётся.

Первый модуль: четыре вентиля

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

// Четыре вентиля в одном модуле. Каждый assign это не шаг программы, а провод:
// пока схема живёт, выход держит значение, которое диктуют входы.
module gates (
    input  logic a,
    input  logic b,
    output logic and_out,
    output logic or_out,
    output logic not_a,
    output logic xor_out
);

  assign and_out = a & b;
  assign or_out  = a | b;
  assign not_a   = ~a;
  assign xor_out = a ^ b;

endmodule

Разберём построчно, потому что здесь заложено всё остальное.

module gates ( ... ); открывает описание блока с именем gates. Имя модуля важно: по нему на этот блок будут ссылаться другие. Файл принято называть так же, как модуль, gates.sv. Verilator даже ругается, если имена разошлись.

Список в скобках это порты. У каждого порта есть направление и тип. input logic a означает: сюда снаружи приходит один бит. output logic and_out означает: отсюда наружу уходит один бит. Ширину, если она больше одного бита, пишут диапазоном, и это мы увидим ниже на 64-битных словах.

Дальше четыре строки assign. Каждая ставит на чертёж один вентиль и соединяет его выход с портом. & это and, | это or, ~ это not, ^ это xor. В этом контексте они работают побитово, то есть над каждым разрядом независимо. Для однобитных сигналов разница между побитовым & и логическим && незаметна, но привыкать надо к побитовым: со словами шириной 64 бита работают только они.

И endmodule, без точки с запятой. Это не блок в фигурных скобках, это отдельное ключевое слово.

Обрати внимание на то, чего здесь нет. Нет return. Нет порядка. Нет состояния. Есть четыре независимых куска схемы, которые существуют одновременно.

Тестбенч: сценарий вокруг схемы

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

// Тестбенч это не схема, а сценарий: подать входы, подождать, напечатать.
module tb_gates;

  logic a, b;
  logic and_out, or_out, not_a, xor_out;

  gates dut (
      .a      (a),
      .b      (b),
      .and_out(and_out),
      .or_out (or_out),
      .not_a  (not_a),
      .xor_out(xor_out)
  );

  initial begin
    $display("a b | and or not_a xor");
    for (int i = 0; i < 4; i++) begin
      {a, b} = i[1:0];
      #1 $display("%b %b |  %b   %b    %b    %b", a, b, and_out, or_out, not_a, xor_out);
    end
    $finish(0);
  end

endmodule

Что здесь нового.

У модуля tb_gates нет портов вообще: он верхний, снаружи к нему никто не подключается.

gates dut ( ... ); это инстанцирование: на чертёж ставится копия модуля gates под именем dut (от device under test, проверяемое устройство). Порты соединяются по именам: .a(a) читается как “порт a инстанса соединён с проводом a тестбенча”. Соединять можно и по позиции, но по именам надёжнее: перестановка портов в модуле не сломает молча всех, кто его использует.

initial begin ... end это тот самый сценарий. В отличие от схемы, содержимое initial исполняется по шагам, сверху вниз, один раз, с начала симуляции. Это единственное место в файле, где порядок строк имеет значение.

{a, b} = i[1:0]; это конкатенация слева от знака равенства: два однобитных провода склеиваются в двухбитный и получают два младших разряда счётчика. Приём экономный: одна строка вместо двух и перебор всех комбинаций через обычный счётчик.

#1 это задержка на одну единицу времени. Она нужна вот зачем. Присваивание входу и вычисление выхода происходят в один и тот же момент модельного времени, и если напечатать выход сразу, можно увидеть старое значение. #1 даёт схеме “устояться”. Для комбинационной логики это чистая формальность модели, а не физика.

$display печатает строку с форматом. %b это двоичное значение сигнала, %h будет шестнадцатеричным, %d десятичным. Обрати внимание, что $display печатает не число, а значение провода, и если провод не определён, ты увидишь x или z, а не ноль.

$finish(0) останавливает симуляцию. Аргумент это уровень диагностики, и ноль означает “не печатать ничего лишнего”. Без аргумента Icarus допишет в вывод строку про то, где и когда вызван $finish, вместе с именем файла и версией. Для проверки код-задачи, которая сравнивает вывод посимвольно, это была бы катастрофа, поэтому во всех тестбенчах курса стоит именно $finish(0).

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

iverilog -g2012 -s tb_gates -o sim gates.sv tb_gates.sv
vvp -n sim

Первая команда компилирует, вторая исполняет. Ключ -s tb_gates говорит, какой модуль верхний: без него Icarus сам решит, и при нескольких кандидатах решит не так, как ты ожидал. Ключ -n у vvp отключает интерактивный режим, чтобы симулятор не ждал команд с клавиатуры.

Вывод:

a b | and or not_a xor
0 0 |  0   0    1    0
0 1 |  0   1    1    1
1 0 |  0   1    0    1
1 1 |  1   1    0    0

Это таблица истинности четырёх вентилей, снятая с настоящей модели. Проверь глазами каждую строку: and единица только в последней, or ноль только в первой, not_a зависит от a и не зависит от b, xor единица там, где входы разные.

Порядок строк действительно не важен

Обещанный эксперимент. Возьмём тот же модуль и перевернём в нём порядок четырёх assign:

// gates_shuffled.sv: тот же модуль, четыре assign в обратном порядке.
module gates (
    input  logic a,
    input  logic b,
    output logic and_out,
    output logic or_out,
    output logic not_a,
    output logic xor_out
);

  assign xor_out = a ^ b;
  assign not_a   = ~a;
  assign or_out  = a | b;
  assign and_out = a & b;

endmodule

Собираем с тем же самым тестбенчем и сравниваем вывод:

iverilog -g2012 -s tb_gates -o sim2 gates_shuffled.sv tb_gates.sv
vvp -n sim  > out1.txt
vvp -n sim2 > out2.txt
diff out1.txt out2.txt && echo "ВЫВОД СОВПАЛ"
ВЫВОД СОВПАЛ

Ни одного отличия. В программе на Zig такая перестановка сломала бы всё, если строки зависят друг от друга. Здесь она не значит ничего, потому что четыре вентиля висят на плате одновременно, а текст лишь перечисляет их в произвольном порядке. Это стоит сделать руками один раз: пока эксперимент не поставлен, “одновременно” остаётся словом.

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

Поиграй с вентилями

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

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

Что тут полезно попробовать. Собери xor из and, or и not сам, с пустого места, и только потом нажми готовый пресет: таблицы обязаны совпасть до последней строки, а формулы у тебя и у пресета, скорее всего, выйдут разные. Так же поступи с мажоритарным элементом на три входа, который выдаёт единицу, когда единиц на входах больше, чем нулей: сначала своя формула, потом сверка с (a & b) | (a & c) | (b & c) из пресета. Напиши выражение, которое зависит только от одного входа, и посмотри, как в таблице появляются одинаковые пары строк: это и есть машинное определение “сигнал не влияет”.

И загляни в пресет word_mux: он на четыре бита, а не на 64. Причина в самой таблице. У 64-битного мультиплексора входов сто двадцать девять, и строк в переборе было бы два в степени сто двадцать девять. Поэтому виджет держит потолок в восемь бит и сам предлагает выбрать, какие входы перебирать, а какие оставить постоянными. Это не ограничение симулятора, а честное свойство таблицы истинности: она перечисляет всё, и с ростом ширины перечислить всё уже не получится.

Виджет считает те же выражения, что и Icarus, но не заменяет его: настоящую сборку и настоящую симуляцию мы гоняем в терминале и в песочнице.

Почему logic, а не wire и reg

Если открыть старый код на Verilog, там вместо logic будут два разных слова, и это первая вещь, которая сбивает с толку новичка.

В Verilog-2005 было два семейства типов. wire это провод: у него нет своего значения, он лишь передаёт то, что на него подаёт драйвер, и присваивать ему можно только через assign. reg это переменная модели: ей можно присваивать внутри процедурных блоков вроде always и initial. Название reg предельно неудачное, потому что оно ничего не говорит о регистре в железе: reg, которому присваивают внутри комбинационного always, превращается в обычную логику без всякой памяти, а настоящий регистр получается из другой конструкции.

Из-за этого деления автор постоянно решал вопрос, не имеющий отношения к железу: каким словом объявить сигнал, чтобы компилятор разрешил то, что он собирается написать. Ошибся, и получаешь ошибку не про схему, а про правила языка.

SystemVerilog это чинит одним типом. logic можно и подключать через assign, и присваивать внутри always. Единственное ограничение осталось разумным: у сигнала не может быть двух драйверов сразу, то есть нельзя одновременно вести его и через assign, и из always. Поэтому правило простое.

Объявляй всё как logic. Все примеры этого блока, весь код Y86-64 на Verilog, все порты и все промежуточные сигналы. wire и reg тебе понадобятся, только когда будешь читать чужой старый код, и тогда переводи их в голове: wire это то, что ведут через assign, reg это то, чему присваивают в always, и ни то, ни другое не говорит, будет ли в железе память.

Ещё одна вещь, которая удивляет программиста. Бит в Verilog принимает не два значения, а четыре, и это четырёхзначная логика: 0, 1, x и z. x значит “неизвестно”, z значит “никто не ведёт”. Это не украшение, а рабочий инструмент отладки: x в выводе тестбенча почти всегда означает, что ты забыл что-то подключить или проинициализировать, а z означает висящий в воздухе провод. В конце урока мы увидим и то, и другое на реальном примере.

assign и always_comb: две записи одной схемы

assign удобен для одного выражения, но у него есть предел. Как только логика ветвится, выражение превращается в нечитаемую цепочку тернарных операторов. Для таких случаев есть второй способ описать комбинационную схему: блок always_comb.

always_comb eq = (a & b) | (~a & ~b);

Это ровно то же самое, что assign eq = (a & b) | (~a & ~b);, и синтезируется в те же вентили. Разница в том, что внутри always_comb можно писать if, case и несколько присваиваний подряд, а внутри assign нельзя.

Слово comb в названии это обещание компилятору: “здесь комбинационная логика, памяти нет”. Компилятор это обещание проверяет. Если внутри блока найдётся путь, на котором сигнал не получает значения ни в одной ветке, значит, схема должна где-то запомнить прошлое, а память в комбинационном блоке взяться не может. Verilator сообщит об этом сразу. Старая форма always @(*) в такой ситуации молча создавала защёлку, и это классический источник ошибок, которые проявляются только на плате.

В коде Y86-64 мы используем обе формы: assign для одной строки, always_comb там, где есть ветвление. В следующем уроке always_comb с case станет основным инструментом, потому что почти вся управляющая логика процессора это выбор по коду инструкции.

HCL из книги на Verilog

Глава 4 книги описывает логику процессора на учебном языке HCL. Там всего несколько конструкций: логические выражения, сравнение, мультиплексор в виде списка “условие, значение” и проверка принадлежности множеству. HCL никуда не компилируется, авторы отдают симулятор готовым. У нас другой договор: всё, что в книге написано на HCL, мы пишем на настоящем Verilog и запускаем.

Перевод почти дословный. HCL-выражение a && b это a & b. HCL-сравнение A == B для слов это модуль word_eq, который мы сейчас напишем. HCL-мультиплексор это word_mux или, если ветвей много, case внутри always_comb. Разница только в том, что Verilog заставляет объявить ширину каждого провода и явно собрать иерархию, а HCL это прятал.

Начнём с самого мелкого кирпича. Сравнение двух битов.

// Сравнение двух битов: единица, когда биты равны.
// Это функция XNOR, собранная из И, ИЛИ и НЕ, чтобы было видно вентили.
module bit_eq (
  input  logic a,
  input  logic b,
  output logic eq
);

  always_comb eq = (a & b) | (~a & ~b);

endmodule

Формула читается прямо: биты равны, если оба единицы, или оба нули. Это функция xnor, и в SystemVerilog её можно было бы записать одним оператором ~^, но здесь она нарочно собрана из трёх базовых вентилей. Смысл в том, чтобы видеть схему: два and, один or, два not, пять вентилей и два уровня глубины. Дальше из этого кирпича вырастет всё остальное.

Второй кирпич, и он важнее первого. Мультиплексор.

// Мультиплексор на один бит: при 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

Мультиплексор это выбор одного из двух входов сигналом управления, и это единственный способ, которым железо умеет ветвиться. У процессора нет if. Когда книга пишет, что при icode равном IRRMOVQ в порт dstE идёт rB, а иначе RNONE, на схеме это мультиплексор: оба значения физически приходят на его входы всегда, а сигнал выбора решает, какое пройдёт дальше. Ни одна ветка не пропускается: обе вычисляются одновременно, просто одна отбрасывается.

Формулу тоже стоит прочитать вслух. При s равном нулю член s & b гасится нулём, остаётся ~s & a, то есть a. При s равном единице наоборот. Два and, один or, один not.

Из этих двух кирпичей собирается вся комбинационная часть Y86-64. Дальше только ширина и количество.

Слово это шина из 64 проводов

До сих пор каждый сигнал был одним битом. Процессору нужны слова. Слово в Verilog это шина, группа проводов под одним именем, и объявляется она диапазоном разрядов.

logic [63:0] a;   // 64 провода, старший разряд a[63], младший a[0]
logic [3:0]  icode;  // 4 провода: код инструкции Y86-64
logic        eq;  // один провод, диапазон не нужен

Порядок [63:0], а не [0:63], это соглашение: слева старший разряд. Так пишут почти везде, и наш код Y86-64 тоже. Смешивать оба порядка в одном проекте это верный способ потерять день на отладке.

Теперь 64-битный компаратор. Задача из упражнения 4.10 книги: собрать сравнение слов из однобитных сравнений.

// Сравнение двух слов: единица, когда равны все биты.
// Собрано из W однобитных сравнений, результаты сводятся операцией И.
module word_eq #(
  parameter int W = 64
) (
  input  logic [W-1:0] a,
  input  logic [W-1:0] b,
  output logic         eq
);

  logic [W-1:0] bit_equal;

  genvar i;
  generate
    for (i = 0; i < W; i++) begin : g_bits
      bit_eq u_bit (.a(a[i]), .b(b[i]), .eq(bit_equal[i]));
    end
  endgenerate

  always_comb eq = &bit_equal;

endmodule

Здесь три новые вещи, и все три пригодятся до конца блока.

Параметр. Запись #(parameter int W = 64) объявляет параметр модуля со значением по умолчанию. Это не переменная времени исполнения, а число, известное на этапе элаборации, до того как схема начнёт работать. Тот же модуль можно поставить и на 64 бита, и на 4, и на 3, если понадобится сравнить коды состояния. Ширины портов записаны через W, поэтому они меняются вместе с параметром.

Generate. Блок generate с genvar это не цикл во времени, а инструкция “поставь на чертёж 64 копии”. После элаборации в схеме будет ровно 64 экземпляра bit_eq, каждый со своим номером разряда: g_bits[0].u_bit, g_bits[1].u_bit и так далее. Метка : g_bits обязательна и попадает в имена инстансов, поэтому её видно в сообщениях об ошибках и на временных диаграммах. Именно так в железе делают всё широкое: 64-битный сумматор это 64 одинаковых узла, соединённых переносом, а не “цикл по разрядам”.

Редукция. Запись &bit_equal это редукция: один оператор & перед шиной, без второго аргумента. Он означает “И всех разрядов сразу”, то есть единица, только если подняты все 64 бита. Есть ещё |x (“хотя бы один поднят”) и ^x (чётность числа единиц). Превращается это не в цикл, а в сбалансированное дерево вентилей глубиной шесть уровней для 64 входов. Задержка растёт логарифмом от ширины, и это одна из причин, почему 64-битный компаратор в процессоре стоит дёшево.

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

// Мультиплексор на слово: 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

Тот же приём: 64 копии однобитного мультиплексора, у всех общий сигнал выбора s. Заметь, что s разветвляется на 64 входа: в реальном железе такое разветвление нагружает провод, и сигнал приходится усиливать буферами. На схемах тракта данных в книге такой блок рисуют одним прямоугольником с толстой стрелкой, и теперь ты знаешь, что внутри прямоугольника.

Все четыре модуля лежат в эталоне рядом, в каталоге hdl/lib. Дальше в блоке к ним добавятся alu, cond, regfile и memory, и из этой библиотеки соберётся сначала SEQ, а потом PIPE.

Тестбенч на четыре блока сразу

Проверим всё вместе одним тестбенчем. Он ставит по одному экземпляру каждого модуля и прогоняет их по своим векторам.

// Тестбенч базовых комбинационных блоков: bit_eq, bit_mux, word_eq, word_mux.
// Ничего не тактируется, только подаём входы и печатаем выходы.
//   iverilog -g2012 -s tb_lib -o /tmp/tb_lib \
//     hdl/lib/bit_eq.sv hdl/lib/bit_mux.sv hdl/lib/word_eq.sv \
//     hdl/lib/word_mux.sv hdl/tb/tb_lib.sv
//   vvp -n /tmp/tb_lib
module tb_lib;

  // Однобитные входы, общие для bit_eq и bit_mux.
  logic a, b, s;
  logic eq_bit, mux_bit;

  bit_eq  u_bit_eq  (.a(a), .b(b), .eq(eq_bit));
  bit_mux u_bit_mux (.s(s), .a(a), .b(b), .out(mux_bit));

  // Слова шириной 64 бита.
  logic [63:0] wa, wb, wout;
  logic        eq_word;

  word_eq  #(.W(64)) u_word_eq  (.a(wa), .b(wb), .eq(eq_word));
  word_mux #(.W(64)) u_word_mux (.s(s), .a(wa), .b(wb), .out(wout));

  initial begin
    $display("bit_eq: a b -> eq");
    for (int i = 0; i < 4; i++) begin
      {a, b} = i[1:0];
      #1 $display("        %b %b -> %b", a, b, eq_bit);
    end

    $display("bit_mux: s a b -> out");
    for (int i = 0; i < 8; i++) begin
      {s, a, b} = i[2:0];
      #1 $display("         %b %b %b -> %b", s, a, b, mux_bit);
    end

    $display("word_eq: a b -> eq");
    wa = 64'h0123_4567_89ab_cdef;
    wb = 64'h0123_4567_89ab_cdef;
    #1 $display("         %h %h -> %b", wa, wb, eq_word);
    wb = 64'h0123_4567_89ab_cdee;
    #1 $display("         %h %h -> %b", wa, wb, eq_word);
    wb = 64'h8123_4567_89ab_cdef;
    #1 $display("         %h %h -> %b", wa, wb, eq_word);

    $display("word_mux: s -> out");
    wa = 64'h0000_0000_dead_beef;
    wb = 64'h0000_0000_c0ff_ee00;
    s  = 1'b0;
    #1 $display("          %b -> %h", s, wout);
    s = 1'b1;
    #1 $display("          %b -> %h", s, wout);

    $finish(0);
  end

endmodule

Пара деталей. Запись 64'h0123_4567_89ab_cdef это литерал: ширина в битах, апостроф, основание (h шестнадцатеричное, b двоичное, d десятичное), само число. Подчёркивания внутри числа игнорируются и служат только глазу, как в Zig. Ширину надо писать всегда: без неё константа получит ширину по умолчанию, и на 64-битной шине это приведёт к молчаливому обрезанию.

Запись #(.W(64)) при инстанцировании передаёт параметр. Здесь она избыточна, потому что 64 и так значение по умолчанию, но написано явно, чтобы было видно, что ширина это решение, а не константа языка.

Запускаем из корня эталона:

iverilog -g2012 -s tb_lib -o /tmp/tb_lib \
  hdl/lib/bit_eq.sv hdl/lib/bit_mux.sv hdl/lib/word_eq.sv \
  hdl/lib/word_mux.sv hdl/tb/tb_lib.sv
vvp -n /tmp/tb_lib
bit_eq: a b -> eq
        0 0 -> 1
        0 1 -> 0
        1 0 -> 0
        1 1 -> 1
bit_mux: s a b -> out
         0 0 0 -> 0
         0 0 1 -> 0
         0 1 0 -> 1
         0 1 1 -> 1
         1 0 0 -> 0
         1 0 1 -> 1
         1 1 0 -> 0
         1 1 1 -> 1
word_eq: a b -> eq
         0123456789abcdef 0123456789abcdef -> 1
         0123456789abcdef 0123456789abcdee -> 0
         0123456789abcdef 8123456789abcdef -> 0
word_mux: s -> out
          0 -> 00000000deadbeef
          1 -> 00000000c0ffee00

Читаем внимательно, потому что в этих строках спрятаны две вещи, которые легко проглядеть.

Таблица bit_mux показывает, что при s равном нулю выход повторяет a (третий столбец не влияет), а при s равном единице повторяет b. Проверь по строкам: 0 1 0 -> 1 и 1 1 0 -> 0. Одни и те же данные, разный выбор, разный результат.

В word_eq две последние пары отличаются ровно одним битом: сначала младшим (f против e), потом старшим (0 против 8). Оба раза eq падает в ноль. Это и есть настоящая проверка компаратора: схема, которая смотрит не на все разряды, споткнётся именно здесь, а на случайных числах будет казаться рабочей.

Практика

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

Второй это 64-битный компаратор. В уроке он собран из 64 инстансов bit_eq, а здесь ты пойдёшь вторым путём, короче и ближе к тому, как это пишут в реальном коде: побитовый xor и редукция. Оператор == использовать нельзя, иначе упражнение теряет смысл.

Линтер ловит то, на что симулятор молчит

Теперь про инструмент, без которого писать Verilog больно. Возьмём модуль с двумя типичными ошибками начинающего.

// bad_mux.sv: промежуточный сигнал уже, чем данные, которые в него ведут.
module bad_mux (
    input  logic        s,
    input  logic [63:0] a,
    input  logic [63:0] b,
    output logic [63:0] out
);

  logic [31:0] narrow;
  assign narrow = a;
  assign out    = s ? {32'b0, narrow} : b;

endmodule
// bad_top.sv: при инстанцировании забыт порт b.
module bad_top (
    input  logic        s,
    input  logic [63:0] a,
    output logic [63:0] out
);

  bad_mux u_mux (.s(s), .a(a), .out(out));

endmodule

Ошибок две, и обе смертельные. Промежуточный сигнал narrow шириной 32 бита, а в него ведут 64: старшая половина слова молча теряется. И при инстанцировании забыт порт b, то есть один из двух входов мультиплексора вообще ни к чему не подключён.

Что скажет компилятор:

iverilog -g2012 -s bad_top -o bad bad_mux.sv bad_top.sv
echo "rc=$?"
rc=0

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

Что скажет линтер:

verilator --lint-only -Wall --top-module bad_top bad_mux.sv bad_top.sv
%Warning-PINMISSING: bad_top.sv:7:11: Instance has missing pin: 'b'
    7 |   bad_mux u_mux (.s(s), .a(a), .out(out));
      |           ^~~~~
                     bad_mux.sv:4:25: ... Location of port declaration
    4 |     input  logic [63:0] b,
      |                         ^
%Warning-WIDTHTRUNC: bad_mux.sv:9:17: Operator ASSIGNW expects 32 bits on the Assign RHS, but Assign RHS's VARREF 'a' generates 64 bits.
                                    : ... note: In instance 'bad_top.u_mux'
    9 |   assign narrow = a;
      |                 ^
%Warning-UNUSEDSIGNAL: bad_mux.sv:3:25: Bits of signal are not used: 'a'[63:32]
                                      : ... note: In instance 'bad_top.u_mux'
    3 |     input  logic [63:0] a,
      |                         ^
%Error: Exiting due to 3 warning(s)

Три находки, и каждая по делу. PINMISSING показывает забытый порт и заодно печатает, где он объявлен. WIDTHTRUNC называет точную строку, где 64 бита втискиваются в 32. UNUSEDSIGNAL добавляет следствие: старшие 32 разряда входа a никуда не идут, то есть половина данных потеряна. С ключом -Wall предупреждения считаются ошибкой, и код возврата ненулевой, поэтому линтер можно ставить в CI.

Симуляция всё-таки покажет вторую ошибку, если знать, куда смотреть. Прогоним bad_top с крошечным тестбенчем, который подаёт s равным единице, а потом нулю:

s=1 -> out=00000000deadbeef
s=0 -> out=zzzzzzzzzzzzzzzz

Вот они, шестнадцать z подряд. При s равном нулю мультиплексор выбирает вход b, а его никто не ведёт, и на выходе честно появляется “высокое сопротивление”. Это и есть польза четырёхзначной логики: неподключённый провод не притворяется нулём, он виден невооружённым глазом. Первая же ошибка, с шириной, в этом выводе никак не проявилась: deadbeef целиком помещается в 32 бита, и обрезание не задело ни одного значащего разряда. На случайных числах она проявилась бы, а на удобных прячется.

Отсюда рабочая привычка на весь блок: сначала линтер, потом симулятор. Порядок такой.

verilator --lint-only -Wall --top-module <модуль> <файлы>.sv
iverilog -g2012 -s <тестбенч> -o sim <файлы>.sv tb.sv
vvp -n sim

Verilator по умолчанию строже, чем нужно учебному коду: он ругается на неиспользованный порт в заготовке и на несовпадение имени файла с именем модуля. Это нормально. Читай список находок глазами и чини те, что про схему, а не про оформление.

Упражнения

Итоги

  • Verilog описывает схему, а не программу: все assign и все always_comb существуют одновременно, порядок строк в файле не значит ничего, и это проверяется перестановкой строк с побайтово совпадающим выводом.
  • Модуль это блок логики с портами. Инстанцирование это не вызов, а физическая копия схемы, поставленная рядом. Соединять порты надо по именам.
  • Объявляй всё как logic. Деление на wire и reg из старого Verilog решало проблему языка, а не железа, и SystemVerilog его убрал.
  • Бит принимает четыре значения. x в выводе почти всегда означает забытую инициализацию или два драйвера на одном проводе, z означает неподключённый вход.
  • Мультиплексор это единственный способ ветвиться в железе: обе ветки вычисляются всегда, сигнал выбора решает, какая пройдёт дальше.
  • Широкий блок собирается из узких через generate с genvar, а редукция (&x, |x, ^x) сводит шину в один бит деревом вентилей глубиной логарифм от ширины.
  • Тестбенч это единственное место, где есть последовательность: initial, задержки #1, $display, и обязательно $finish(0), чтобы вывод не зависел от версии симулятора.
  • Рабочая последовательность на весь блок: verilator --lint-only -Wall, потом iverilog -g2012, потом vvp -n. Линтер ловит забытый порт и несовпадение ширин, на которые компилятор молчит.

Дальше

Четыре кирпича готовы, и на них уже можно строить. В следующем уроке про слова, мультиплексоры и ALU мы перестанем ставить блоки по одному и займёмся широкими: case внутри always_comb как мультиплексор на много входов, проверка принадлежности множеству (та самая, которой книга описывает need_regids), и главный комбинационный блок процессора, ALU Y86-64 на четыре операции с флагами ZF, SF и OF (с теми самыми, которые ты читал в ассемблере x86-64, только теперь ты увидишь, откуда они берутся). Там же будут минимум и медиана трёх слов, собранные из компараторов и мультиплексоров этого урока.

Потом появится такт, и вместе с ним регистры и память (урок 25), а после него мы начнём складывать SEQ из этапов, которые ты уже написал на Zig. К концу блока трасса, которую печатает твой Verilog, совпадёт с трассой Zig-симулятора на всех двенадцати программах, и это будет лучшим доказательством, что процессор ты понял.

домашка

Домашка