Иерархия: как не утонуть в проводах

В прошлой статье ты собрал свой первый настоящий вычислительный блок — Полусумматор (Half Adder). Два входа, два выхода, пара вентилей — XOR для суммы и AND для переноса. Просто, элегантно, работает.

Но давай посмотрим правде в глаза: Полусумматор умеет складывать только один бит. А что, если нам нужно сложить 113 и 29? 113 в двоичной системе — это 01110001. Девять бит. Нам потребуется складывать их поразрядно, и каждый разряд зависит от переноса предыдущего.

И тут мы упираемся в проблему: у Полусумматора всего два входа. У него нет входа для переноса. А реальное сложение в столбик (вспомни школу!) выглядит так: ты складываешь цифры разряда, прибавляешь к ним то, что «в уме» от предыдущего разряда, записываешь результат и передаёшь новый перенос дальше. Три входных сигнала, не два.

Нам нужен Полный сумматор (Full Adder).

Третий вход: carry in

Полный сумматор — это эволюция полусумматора. Его главное супер-свойство: у него есть третий вход cin (carry in — «перенос на входе»).

Сигнатура модуля:

module FullAdder(
    input  a,
    input  b,
    input  cin,
    output sum,
    output cout
);

Три входа, два выхода. На выходе sum — результат сложения a + b + cin. На выходе cout — новый перенос в следующий разряд (то самое «единица в уме»).

Давай вспомним двоичную арифметику из первой статьи. Сложение в двоичной системе — это просто: 0+0=0, 0+1=1, 1+1=0 с переносом 1. А теперь у нас три слагаемых. Сколько всего комбинаций? 2³ = 8. И вот как выглядит полная таблица истинности:

a  b  cin │ sum  cout
──────────┼─────────
0  0  0   │  0    0
0  0  1   │  1    0
0  1  0   │  1    0
0  1  1   │  0    1
1  0  0   │  1    0
1  0  1   │  0    1
1  1  0   │  0    1
1  1  1   │  1    1

Обрати внимание на закономерность: sum — это 1, когда количество единиц среди входов нечётное (1 или 3). А cout — это 1, когда количество единиц 2 или больше. Просто? Просто. Но как это реализовать?

Два полусумматора + один OR

А вот и главный трюк всей статьи. Полный сумматор можно собрать из двух экземпляров Полусумматора и одного вентиля OR. Давай разберём эту сборку шаг за шагом.

Шаг 1. Подаём a и b на первый Полусумматор. На его выходе s1 — это a XOR b, а c1 — это a AND b.

Шаг 2. Подаём s1 (результат первого полусумматора) вместе с cin на второй Полусумматор. На его выходе sum — это финальная сумма s1 XOR cin, а c2 — это s1 AND cin.

Шаг 3. Финальный перенос cout — это c1 OR c2. Почему OR, а не XOR? Потому что перенос мог возникнуть и на первом этапе (если a и b оба равны 1), и на втором (если s1 и cin оба равны 1). Поскольку эти ситуации не могут произойти одновременно (в чём легко убедиться по таблице истинности), OR покрывает оба случая.

Получается лаконичная, иерархическая структура: сложное строится из простого.

Вернёмся в 1970-й: почему иерархия — это важно

Представь, что ты инженер в начале 1970-х и проектируешь процессор Intel 4004 — первый в мире коммерческий микропроцессор. В нём около 2300 транзисторов. Если бы каждый транзистор проектировали с нуля, чертежи заняли бы целую стену. Никто бы не смог уследить за таким количеством соединений.

Но инженеры не рисуют каждый транзистор по отдельности. Они собирают их в логические вентили (AND, OR, NOT), из вентилей собирают функциональные блоки (сумматоры, мультиплексоры, регистры), а из этих блоков — процессор целиком. Каждый уровень абстракции прячет внутреннюю сложность и открывает только интерфейс (входы и выходы).

В программировании ты называешь это функцией или классом. Ты написал однажды функцию calculateTotal(items) — и теперь вызываешь её из ста разных мест, не думая о том, какие циклы и условия внутри.

В Verilog это называется модулем и его инстанцированием.

Инстанцирование модулей: как «вызвать» железо

Когда ты написал module HalfAdder(...), ты описал, как устроен полусумматор. Но сам по себе этот код не создаёт никаких вентилей в схеме. Чтобы реально разместить полусумматор внутри другого модуля, нужно его инстанцировать — то есть создать конкретный экземпляр.

Синтаксис очень похож на вызов функции, но не путай: инстанцирование происходит на этапе «компиляции» схемы (синтеза), а не во время работы.

Способ 1: именованное подключение (рекомендуемый)

HalfAdder ha1 (
    .a(a),
    .b(b),
    .sum(s1),
    .cout(c1)
);

HalfAdder — это имя модуля (тип). ha1 — это имя конкретного экземпляра. В скобках мы явно пишем: «к порту .a модуля HalfAdder подключи сигнал a из текущего модуля».

Способ 2: позиционное подключение

HalfAdder ha2 (s1, cin, sum, c2);

Здесь порядок сигналов должен строго совпадать с порядком портов в объявлении модуля.

Собираем FullAdder из двух HalfAdder

module FullAdder(
    input  a,
    input  b,
    input  cin,
    output sum,
    output cout
);
    wire s1;
    wire c1;
    wire c2;

    HalfAdder ha1 (
        .a(a), .b(b),
        .sum(s1), .cout(c1)
    );

    HalfAdder ha2 (
        .a(s1), .b(cin),
        .sum(sum), .cout(c2)
    );

    or (cout, c1, c2);
endmodule

Видишь, что получилось? Внутри модуля FullAdder нет ни одного логического вентиля в явном виде. Вместо этого мы сказали: «поставь сюда ha1, поставь сюда ha2, добавь один OR». Verilog-синтезатор развернёт ha1 и ha2 в их внутреннюю структуру (XOR + AND), и на физическом уровне получится схема из трёх вентилей.

Аналогия с программным кодом

В софте каждый вызов функции выполняется в своё время, на одном и том же CPU. В железе каждый экземпляр модуля — это отдельный кусок кремния. ha1 и ha2 существуют одновременно и работают параллельно.

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

Совет: давай портам осмысленные имена

Хорошие имена портов и сигналов:

Плохие имена:

Резюме

1. Полный сумматор нужен, чтобы складывать биты с учётом переноса от предыдущего разряда. У него три входа (a, b, cin) и два выхода (sum, cout).

2. FullAdder собирается из двух HalfAdder и одного OR — это первый пример иерархического проектирования.

3. Verilog позволяет инстанцировать модули внутри других модулей, подключая порты по именам (.port(signal)) или по порядку.

Иерархия — это не просто удобство. Это единственный способ справиться со сложностью цифровых схем. Процессор на миллиарде транзисторов спроектирован как многоуровневая иерархия: транзисторы → вентили → функциональные блоки → модули → ядра → чип.

На уровне 3.3 тебе предстоит собрать FullAdder из двух HalfAdder (которые ты уже собрал на уровне 3.2) и одного вентиля OR. Не забудь использовать именованное подключение портов — привыкай к хорошему стилю с самого начала.