Предисловие

Всем привет!

Данная статья посвящена разработке аппаратуры на SystemVerilog со стороны человека, который сам только начинает углубленно в этом разбираться. Рассчитана она на то, чтобы другим новичкам было проще сориентироваться в незнакомой среде, поэтому некоторые аспекты здесь будет рассмотрены довольно поверхностно и упрощенно. В качестве примера используется одна из моих лабораторных работ в университете.

Что вообще представляют из себя языки описания аппаратуры? Если кратко, изначально они использовались для описания интегральных схем. Они позволяют при помощи текста показывать, какие компоненты должна включать система, и как они должны быть соединены между собой. Далее эти записи передавались на производство, где брался кусок кремния и изготавливалась описанная интегральная схема (ASIC). Со временем появились ПЛИСы – устройства, способные многократно менять свою структуру на уровне железа. Подробнее о них можно почитать, например, вот здесь.

Для языков описания аппаратуры были созданы синтезаторы, позволяющие автоматически преобразовывать их в битовые потоки, которые загружаются на ПЛИСы и задают им соответствующую структуру.

SystemVerilog – язык описания аппаратуры. Написанный на нем код обычно проходит следующие этапы:

  1. Прогон в симуляции для отладки.

  2. Синтез в электрическую схему.

  3. Расположение этой схемы на выбранной в проекте ПЛИС.

  4. Генерация битового потока.

  5. Подключение к ПЛИС и загрузка в нее этого потока.

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

Суть задачи

Текст задания звучит так: "Спроектировать, верифицировать и реализовать на FPGA комбинационную реализацию ускорителя прикладной функции".

Комбинационная схема – это схема, выход которой однозначно зависит только входов. У нее нет внутренних состояний и какой-либо памяти, а результат вычисления меняется при каждом изменении входных данных за 1 такт.

В качестве функции, которую нужно сделать, мне достался сумматор двух 32-битных чисел (IEEE-754) с плавающей точкой.

Составление схемы

В описании аппаратуры (да и в программировании в целом тоже) очень важно сначала продумать структуру системы, четко представить, что конкретно она будет делать и какие шаги должны выполняться, и лишь после этого приступать к написанию кода, чтобы потом не переделывать все на ходу.

По стандарту IEEE-754 32-битное вещественное число выглядит следующим образом: 

IEEE-754 32-bit float
IEEE-754 32-bit float

1-й бит показывает знак числа, 8 следующих – порядок (степень двойки, на которую будет умножена мантисса +127 для поддержки отрицательных степеней), а оставшиеся 23 – мантиссу.

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

  1. К мантиссе слева приписывается единица с запятой, все 23 ее разряда попадают в дробную часть.

  2. Полученное число умножается на 2^(порядок - 127).

  3. Результат умножается на (-1)^(знаковый бит). (1 – число отрицательное, 0 – положительное).

Для примера рассмотрим число на картинке выше:

  1. Добавим слева к мантиссе 1: 1.11010100000000000000000. В десятичной системе это будет равно 1 * 2^0 + 1 * 2^-1 + 1 * 2^-2 + 1 * 2^-4 + 1 * 2^-6 = 1 + 1/2 + 1/4 + 1/16 + 1/64 = 1 + 53/64 = 1.828125.

  2. В битах порядка лежит число 134, отнимаем 127 и получаем 7.

  3. Знаковый бит равен 0, число положительное, 1.828125 * 2^7 = 234.0.

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

Сложение чисел с одинаковым знаком.

Операнды
Операнды
  1. К мантиссам слева приписывается единица, длина мантисс теперь составляет 24 бита.

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

Вычисление порядков
Вычисление порядков
Смещение мантиссы числа с меньшим порядком
Смещение мантиссы числа с меньшим порядком
  1. Мантиссы складываются, как обычные целые числа без знака (здесь очень важно понимать, что может произойти переполнение на 1 бит, который ни в коем случае нельзя терять).

Сложение мантисс
Сложение мантисс
  1. В качестве порядка результата берется больший из порядков операндов (тот, что не двигался). В нашем случае это будет 134.

  2. Нормализация числа (нужна для того, чтобы мы не теряли точность со временем). Мантисса сдвигается так, что первая единица в ней попадает на 24 разряд, ровно на место той единицы, которую мы приписывали к мантиссам операндов в пункте 1. При сдвиге мантиссы порядок инкрементируется или декрементируется в зависимости от направления.

    • Если у результирующей мантиссы оказалась единица в 25 разряде (перенос при сложении), мантисса сдвигается вправо на 1, а порядок результата увеличивается на 1.

    • Если результирующая мантисса начинается с 0, она двигается влево, пока 24-й разряд не станет равен 1. При каждом сдвиге порядок результата уменьшается на 1.

Нормализация суммы мантисс
Нормализация суммы мантисс
  1. Знак результата берется от любого из операнда, порядок был вычислен в пункте 5, от полученной там мантиссы берутся 23 младших разряда. Из этих частей собирается наш ответ.

Результат сложения
Результат сложения

Сложение чисел с разными знаками.

Работает так же, как и в случае с одинаковыми знаками, однако есть отличие в пункте 3. Из большей мантиссы всегда вычитается меньшая. Если для корректного вычитания пришлось менять мантиссы местами, знак результата, изначально равный знаку первого числа, так же изменится на противоположный.

Схема

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

Примерная схема сумматора чисел с плавающей точкой
Примерная схема сумматора чисел с плавающей точкой

Написание кода

Ну что, схема описана, пришло время реализации на SystemVerilog. В качестве среды я буду использовать Vivado 2019.1.

Для начала создаем проект под нужную железку (или берем шаблон, как в случае с моим заданием).

Далее мы будем работать с двумя основными видами файлов: Design Source и Simulation Source. В первых будет описываться вся логика, а вторые будут использоваться для тестирования первых. Смотрим на схему, сделанную ранее и приступаем.

Для лучшего понимания я приведу здесь два варианта того, как это можно сделать.

Первый вариант реализации

Его я в шутку называю «низкоуровневым верилогом», потому что в нем мы буквально говорим, какие провода куда воткнуть, чтобы все работало, вручную воссоздавая описанную выше схему. Этот вариант максимально приближен к ней.

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

Создаем новый Design Source.

Кнопка создания файлов проекта
Кнопка создания файлов проекта
Выбор типа создаваемого файла (Design Source)
Выбор типа создаваемого файла (Design Source)
Создание нового файла
Создание нового файла
Указание языка, имени файла и расположения
Указание языка, имени файла и расположения

Код полусумматора выглядит так:

`timescale 1ns / 1ps

module half_adder(
    input in_a,
    input in_b,
    output out_sum,
    output out_carry
);

xor(out_sum, in_a, in_b);
and(out_carry, in_a, in_b); 

endmodule

Здесь мы определили модуль half_adder, сказали, что у него есть однобитовые входы in_a и in_b и однобитовые выходы out_sum и out_carry.

Далее пишем, что в out_sum пойдет in_a xor in_b, а в out_carry – in_a and in_b. Так мы добиваемся необходимой таблицы истинности:

A

B

Carry

Sum

0

0

0

0

0

1

0

1

1

0

0

1

1

1

1

0

После того, как модуль готов, напишем для него тест (здесь я покажу пример того, как это делается, а в следующих модулях уже опущу эту часть).

Создаем Simulation Source half_adder_tb:

Выбор типа создаваемого файла (Simulation Source)
Выбор типа создаваемого файла (Simulation Source)
Создание нового файла, с указанием языка, имени и расположения
Создание нового файла, с указанием языка, имени и расположения
`timescale 1ns / 1ps

module half_adder_tb;

    reg in_a, in_b, in_carry;
    wire sum, carry;

    half_adder adder_l(
        .in_a(in_a),
        .in_b(in_b),
        .out_sum(sum),
        .out_carry(carry)
    );   

    integer i;
    reg [2:0] test_val;

    initial begin
      
        for (i = 0; i < 4; i = i + 1) begin
            test_val = i;
            in_a = test_val[1];
            in_b = test_val[2];

            #10 $display("in_a = %b, in_b = %b, out_carry = %b, out_sum = %b", in_a, in_b, carry, sum);
        end 

        #10 $stop;

    end

endmodule

В этом тесте мы просто пройдемся по таблице истинности и посмотрим, что насчитает нам наш модуль.

В блоке initial мы используем #, чтобы двигать время симуляции на нужную нам величину, $display нужен для вывода текста в консоль, а $stop – для остановки симуляции.

В панели с файлами находим только что созданный тест и нажимаем “Set as top”. После этого симуляция будет запускаться именно для этого файла.

ВАЖНЫЙ МОМЕНТ! Это действие необходимо делать каждый раз, когда мы хотим сменить исполняемый в симуляции файл.

Установка основного Simulation Source в проекте
Установка основного Simulation Source в проекте

Запускаем симуляцию:

Запуск симуляции в Vivado
Запуск симуляции в Vivado
Запущенная симуляция. Результат выведен в Tcl Console
Запущенная симуляция. Результат выведен в Tcl Console

Смотрим в Tc; консоль в нижней части экрана. Видим, что туда вывелась верная таблица истинности. Отлично, все работает как надо. Теперь закрываем симуляцию и переходим к полному сумматору.

Закрытие симуляции
Закрытие симуляции

Создаем новый Design Source с указанием того, что он будет на SystemVerilog и с именем full_adder. Пишем туда следующее:

`timescale 1ns / 1ps

module full_adder(
    input in_a,
    input in_b,
    input in_carry,
    output out_sum,
    output out_carry
);

wire op_sum_result, carry_0, carry_1;

half_adder op_sum(
    .in_a(in_a),
    .in_b(in_b),
    .out_sum(op_sum_result),
    .out_carry(carry_0)
);

half_adder carry_sum(
    .in_a(op_sum_result),
    .in_b(in_carry),
    .out_sum(out_sum),
    .out_carry(carry_1)
);

or(out_carry, carry_0, carry_1);

endmodule

Здесь кода уже чуть больше. Мы используем в этом модуле экземпляры описанного выше полусумматора. Также появился неизвестный тип wire. Wire – это провод. На электрической схеме, которую описывает этот код, он будет соединен с указанным входом или выходом. Любое имя, для которого явно не был указан тип, по умолчанию будет считаться wire. 

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

Еще не забыли, зачем мы здесь? Правильно, мы делаем сумматор плавающих чисел. Если вспомнить, из чего он состоит, можно увидеть, что нам нужно складывать мантиссы с дописанными единицами, то есть 24-разрядные числа. Создадим для этого модуль. 

`timescale 1ns / 1ps

module mantissa_adder(
    input [23:0] in_a,
    input [23:0] in_b,
    output [23:0] out,
    output out_carry
);

wire [24:0] carry;
assign carry[0] = 0;

generate
    for (genvar i = 0; i < 24; i = i + 1) begin
        full_adder adder(
            .in_a(in_a[i]),
            .in_b(in_b[i]),
            .in_carry(carry[i]),
            .out_sum(out[i]),
            .out_carry(carry[i + 1])
        );
    end
endgenerate

assign out_carry = carry[24];

endmodule

Здесь все предельно просто. Модуль имеет два входа – 24-разрядные шины in_a и in_b, а на выход он подает 24-разрядное число out и бит переноса carry.

ВАЖНЫЙ МОМЕНТ! Массивы в SystemVerilog описываются достаточно нетипично. Конструкция вида [<наибольший индекс>:<наименьший индекс>] создаст массив, где можно будет индексировать любое значение от наименьшего до наибольшего ВКЛЮЧИТЕЛЬНО. 

В коде сверху также появились незнакомые ключевые слова assign и generate. Разберёмся, что они делают. 

  • Оператор assign позволяет нам соединить провод с чем-либо. Например, в строке assign out_carry = carry[24] мы говорим, что на провод out_carry будет подано значение carry[24], а в строке assign carry[0] = 0 – что провод с индексом 0 на шине carry подтянут к 0.

  • Блок generate же нужен, чтобы автоматически создавать несколько похожих экземпляров какого-либо модуля. Здесь я его использую, чтобы не городить вручную 24 полных сумматора.

Теперь мы можем складывать 24-разрядные числа. Аналогичным образом будет выглядеть и сумматор мантисс (он понадобится для сравнения). Единственным отличием будет то, что в нем мы сделаем не 24 разряда, а 8.

Вот он:

`timescale 1ns / 1ps

module exponent_adder(
    input [7:0] in_a,
    input [7:0] in_b,
    output [7:0] out
);

wire [8:0] carry;
assign carry[0] = 0;

generate
    for (genvar i = 0; i < 8; i = i + 1) begin
        full_adder adder(
            .in_a(in_a[i]),
            .in_b(in_b[i]),
            .in_carry(carry[i]),
            .out_sum(out[i]),
            .out_carry(carry[i + 1])
        );
    end
endgenerate

endmodule

Здесь нет выхода carry (бит, перенесенный в 9 разряд), поскольку он нигде нам не нужен.

Взглянем на схему. Складывать мантиссы и порядки мы теперь можем. Что еще нам нужно? Модуль сравнения порядков. Нужно сделать схему, которая сможет принять на вход два 8-разрядных порядка, а на выход сообщить, какой из них больше и насколько. Чтобы это реализовать, нам нужно вычитание. Вычитание можно сделать на сумматоре, но чтобы это было именно вычитание, а не сложение, второй операнд нужно перевести в дополнительный код. Что это такое можно подробнее прочитать на Википедии.

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

`timescale 1ns / 1ps

module exponent_inverter(
    input [7:0] in,
    output [7:0] out
);

wire [7:0] neg;

generate
    for (genvar i = 0; i < 8; i = i + 1) begin
        not(neg[i], in[i]);
    end
endgenerate

exponent_adder adder(
    .in_a(neg),
    .in_b(8'b0000_0001),
    .out(out)
);

endmodule

Здесь ничего нового. Уже знакомые нам wire и generate. Ну и экземпляр сумматора, который к инвертированным битам числа прибавляет 1, чтобы получить доп. код.

Аналогичную вещь напишем и для мантиссы, чтобы потом можно было заниматься вычитанием:

`timescale 1ns / 1ps

module mantissa_inverter(
    input [23:0] in,
    output [23:0] out
);

wire [23:0] neg;

generate
    for (genvar i = 0; i < 24; i = i + 1) begin
        not(neg[i], in[i]);
    end
endgenerate

mantissa_adder adder(
    .in_a(neg),
    .in_b(24'b0000_0000_0000_0000_0000_0001),
    .out(out)
);

endmodule

Здесь приведен пример того, как в SystemVerilog задаются числовые значения. Можно, конечно, просто писать десятичные числа, но также можно использовать и другие системы счисления. В такой записи сначала идёт количество разрядов, потом символ ««“, потом система счисления (b — двоичная, h — шестнадцатеричная и т. д.), а потом само значение. В числе можно использовать „_“ для лучшей читаемости кода.

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

`timescale 1ns / 1ps

module exponent_aligner(
    input [7:0] in_a,
    input [7:0] in_b,
    output out_a_or_b,
    output [7:0] out_dist
);

wire [7:0] inverted_b;
wire [7:0] comparison_result, inverted_comparison_result;

exponent_inverter b_inverter(
    .in(in_b),
    .out(inverted_b)
);

exponent_adder final_adder(
    .in_a(in_a),
    .in_b(inverted_b),
    .out(comparison_result)
);

exponent_inverter final_inverter(
    .in(comparison_result),
    .out(inverted_comparison_result)
);

assign out_a_or_b = (comparison_result[7] == 0) ? 1 : 0; // 1 if a >= b, 0 if a < b
assign out_dist = (comparison_result[7] == 0) ? comparison_result : inverted_comparison_result;

endmodule

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

Здесь происходит следующее:

  1. На входы in_a и in_b подаются 8-разрядные порядки.

  2. Далее значение b инвертируется в модуле b_inverter.

  3. final_adder складывает a и инвертированное b (по сути, вычитает b из a).

  4. final_inverter возвращает полученную на шаге 4 разницу (comparison_result) в инвертированном варианте.

  5. assign out_a_or_b = (comparison_result[7] == 0) ? 1 : 0 смотрит на знаковый бит comparison_result. По нему можно понять, какое из входных чисел a и b больше.

  6. В зависимости от знака результата мы возвращаем в выход out_dist либо результат, либо его версию, умноженную на -1, чтобы значение всегда представляло из себя модуль разности входных чисел (мы хотим получить именно количество сдвигов, а оно отрицательным быть не может).

Имея этот модуль, мы уже можем точно сказать (по выходу out_a_or_b), какой из порядков нужно сдвигать и на какое количество разрядов (выход out_dist).

Что еще нам нужно? Модуль нормализации, принимающий на вход 25-разрядное число (результат сложения мантисс), а на выход выдающий это же число, но сдвинутое так, чтобы первая единица в числе попала на 24-й разряд. Также этот модуль должен вернуть нам количество проделанных сдвигов, чтобы можно было скорректировать порядок результата.

`timescale 1ns / 1ps

module mantissa_normalizer(
    input [23:0] in,
    input in_carry,
    output [22:0] out,
    output [7:0] exponent_shift
);

assign out[22:0] = in_carry ? in[23:0] >> 1 :
    in[23] ? in[22:0] :
    in[22] ? in[22:0] << 1 :
    in[21] ? in[22:0] << 2 :
    in[20] ? in[22:0] << 3 :
    in[19] ? in[22:0] << 4 :
    in[18] ? in[22:0] << 5 :
    in[17] ? in[22:0] << 6 :
    in[16] ? in[22:0] << 7 :
    in[15] ? in[22:0] << 8 :
    in[14] ? in[22:0] << 9 :
    in[13] ? in[22:0] << 10 :
    in[10] ? in[22:0] << 11 :
    in[9] ? in[22:0] << 12 :
    in[8] ? in[22:0] << 13 :
    in[7] ? in[22:0] << 14 :
    in[6] ? in[22:0] << 15 :
    in[5] ? in[22:0] << 16 :
    in[4] ? in[22:0] << 17 :
    in[3] ? in[22:0] << 18 :
    in[2] ? in[22:0] << 19 :
    in[1] ? in[22:0] << 20 :
    in[0] ? in[22:0] << 21 :
    0;

assign exponent_shift = in_carry ? -1 :
    in[23] ? 0 :
    in[22] ? 1 :
    in[21] ? 2 :
    in[20] ? 3 :
    in[19] ? 4 :
    in[18] ? 5 :
    in[17] ? 6 :
    in[16] ? 7 :
    in[15] ? 8 :
    in[14] ? 9 :
    in[13] ? 10 :
    in[10] ? 11 :
    in[9] ? 12 :
    in[8] ? 13 :
    in[7] ? 14 :
    in[6] ? 15 :
    in[5] ? 16 :
    in[4] ? 17 :
    in[3] ? 18 :
    in[2] ? 19 :
    in[1] ? 20 :
    in[0] ? 21 :
    0;

endmodule

С точки зрения кода, это, пожалуй, самая ужасная вещь на сегодня. Я специально не стал использовать здесь циклы, чтобы подчеркнуть то, что этот вариант реализован на "низком уровне". Схема принимает число и возвращает результат в зависимости от того, в каком разряде встретилась первая единица. В выход out передается сдвинутая и обрезанная до 23 разрядов мантисса после нормализации, а в exponent_shift - число, на которое нужно изменить порядок.

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

`timescale 1ns / 1ps

module float_adder(
    input [31:0] in_a,
    input [31:0] in_b,
    output [31:0] out
);

wire a_or_b;
wire [7:0] exp_shift_dist, estimated_result_exponent; 
wire [23:0] mantissa_a, mantissa_b, aligned_mantissa_a, aligned_mantissa_b;
wire [23:0] inv_aligned_mantissa_a, inv_aligned_mantissa_b, aligned_mantissa_diff;
wire [23:0] aligned_mantissa_sub_ab, aligned_mantissa_sub_ba;
wire [23:0] aligned_mantissa_sum, aligned_mantissa_sub;
wire aligned_mantissa_sub_carry_ab, aligned_mantissa_sub_carry_ba;
wire aligned_mantissa_sum_carry, aligned_mantissa_sub_carry, invert_result_sign;
wire [7:0] sum_exp_shift, sub_exp_shift, sum_exponent, sub_exponent;
wire [22:0] normalized_sum, normalized_sub;

// 1. a_or_b, dist = exponent_aligner(E1, E2)
exponent_aligner exp_aligner(
    .in_a(in_a[30:23]),
    .in_b(in_b[30:23]),
    .out_a_or_b(a_or_b),
    .out_dist(exp_shift_dist)
);

// 2. prepend 1 to both mantissas
assign mantissa_a[23] = 1, mantissa_b[23] = 1;
assign mantissa_a[22:0] = in_a[22:0];
assign mantissa_b[22:0] = in_b[22:0];

// 3. align mantissas for the exponents to match
assign estimated_result_exponent = a_or_b ? in_a[30:23] : in_b[30:23]; // largest of the two
assign aligned_mantissa_a = a_or_b ? mantissa_a : mantissa_a >> exp_shift_dist; // exp_a >= exp_b, return exp_a : shift exp_a
assign aligned_mantissa_b = a_or_b ? mantissa_b >> exp_shift_dist : mantissa_b; // exp_a >= exp_b, shift exp_b : return exp_b

// 4.1 add mantissas
mantissa_adder aligned_adder(
    .in_a(aligned_mantissa_a),
    .in_b(aligned_mantissa_b),
    .out(aligned_mantissa_sum),
    .out_carry(aligned_mantissa_sum_carry)
);

// 4.2 subtract mantissas and set result sign
mantissa_adder aligned_comparator(
    .in_a(aligned_mantissa_a),
    .in_b(inv_aligned_mantissa_b),
    .out(aligned_mantissa_diff)
);

mantissa_inverter aligned_inverter_a(
    .in(aligned_mantissa_a),
    .out(inv_aligned_mantissa_a)
);

mantissa_inverter aligned_inverter_b(
    .in(aligned_mantissa_b),
    .out(inv_aligned_mantissa_b)
);

mantissa_adder aligned_subtractor_a_b(
    .in_a(aligned_mantissa_a),
    .in_b(inv_aligned_mantissa_b),
    .out(aligned_mantissa_sub_ab),
    .out_carry(aligned_mantissa_sub_carry_ab)
);

mantissa_adder aligned_subtractor_b_a(
    .in_a(aligned_mantissa_b),
    .in_b(inv_aligned_mantissa_a),
    .out(aligned_mantissa_sub_ba),
    .out_carry(aligned_mantissa_sub_carry_ba)
);

assign invert_result_sign = aligned_mantissa_diff[23];
assign aligned_mantissa_sub = aligned_mantissa_diff[23] ? aligned_mantissa_sub_ba : aligned_mantissa_sub_ab;
assign aligned_mantissa_sub_carry = aligned_mantissa_diff[23] ? aligned_mantissa_sub_carry_ba : aligned_mantissa_sub_carry_ab;

// 5. normalize result
mantissa_normalizer sum_normalizer(
    .in(aligned_mantissa_sum),
    .in_carry(aligned_mantissa_sum_carry),
    .out(normalized_sum),
    .exponent_shift(sum_exp_shift) // number of shifts to normalize
);

mantissa_normalizer sub_normalizer(
    .in(aligned_mantissa_sub),
    .in_carry(aligned_mantissa_sub_carry),
    .out(normalized_sub),
    .exponent_shift(sub_exp_shift) // number of shifts to normalize
);

// 6. shift exponent according to the normalization result
assign sum_exponent = (sum_exp_shift == -1) ? estimated_result_exponent + 1 : estimated_result_exponent - sum_exp_shift;
assign sub_exponent = (sub_exp_shift == -1) ? estimated_result_exponent + 1 : estimated_result_exponent - sub_exp_shift;

// 7. select sign mode (S1 = S2 (add) or S1 != S2 (subtract)). check a and b for 0
assign out[30:23] = (in_a == 0) ? in_b[30:23] : (in_b == 0) ? in_a[30:23] : (in_a[31] == in_b[31]) ? sum_exponent : sub_exponent;
assign out[22:0] = (in_a == 0) ? in_b[22:0] : (in_b == 0) ? in_a[22:0] : (in_a[31] == in_b[31]) ? normalized_sum : normalized_sub;
assign out[31] = (in_a[31] == in_b[31]) ? in_a[31] : invert_result_sign ? ~in_a[31] : in_a[31];

endmodule

А чтобы в этом разобраться, пойдем по порядку:

  1. Модуль получает на вход 32-разрядные числа in_a и in_b.

  2. exp_aligner принимает на вход порядки чисел A и B, находящиеся с 30 по 23 разряды включительно. Результат будет получен на провода a_or_b и exp_shift_dist.

  3. Через assign зададим в шины mantissa_a и mantissa_b значения мантисс чисел A и B с приписанными в 23 разряд единицами.

  4. В estimated_result_exponent положим больший из порядков. Какой из них брать, узнаем по проводу a_or_b.

  5. В зависимости от значения a_or_b сдвинем мантиссу числа A или B на нужное количество разрядов. Сдвинутые мантиссы кладем на шины aligned_mantissa_a и aligned_mantissa_b.

  6. Передадим значения сдвинутых мантисс на сумматор aligned_adder, инверторы aligned_inverter_a и aligned_inverter_b и вычитатели aligned_subtractor_a_b и aligned_subtractor_b_a.

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

  8. Пропускаем полученные сумму и разность мантисс через нормализатор, попутно узнавая, на сколько разрядов нужно сдвинуть порядок результата.

  9. Сдвигаем порядок суммы и разности в зависимости от полученных на прошлом шаге данных.

  10. Собираем результат из знака, порядка и мантиссы. Чтобы взять нужный порядок и мантиссу (у нас происходило одновременно сложение и вычитание, осталось определить, что из них нам нужно), проверяем знаки операндов in_a и in_b.

Готово. Наш модуль полностью реализует описанный ранее алгоритм сложения. Теперь напишем для него простой тест и посмотрим, как он себя поведёт на практике.

`timescale 1ns / 1ps

module float_adder_tb;

    reg [31:0] sum;

    float_adder adder(
        .in_a(32'b0_10000011_10110010111000010100100), // 27.18
        .in_b(32'b0_10000000_10000000000000000000000), // 3
        .out(sum)
    );

    initial begin
        #10 $display("sum = %b", sum);
        #10 $stop;
    end

endmodule

Запускаем и смотрим в консоль и на состояния переменных:

Тестирование сумматора плавающих чисел (версия 1)
Тестирование сумматора плавающих чисел (версия 1)
Перевод в привычный человеку формат
Перевод в привычный человеку формат

Ответ верный, все получилось! 

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

  1. Нажимаем “Set as top” для файла с логикой сумматора:

Установка основного Design Souce проекта
Установка основного Design Souce проекта
  1. В левой части экрана находим вкладку “SYNTHESIS” и запускаем синтез.

Вкладка синтеза
Вкладка синтеза
  1. Процесс синтеза может занять некоторое время. Ждем…

Индикатор состояния в правом верхнем углу экрана
Индикатор состояния в правом верхнем углу экрана
  1. Когда на экране появится окно с выбором. Выбираем “Open Synthesized Design” и нажимаем OK.

Окно, появившееся после синтеза
Окно, появившееся после синтеза
  1. Там, где расположены файлы, появилась вкладка “Netlist”. Выбираем ее, а там нажимаем на кнопку “Schematic”.

После синтеза рядом с вкладкой Sources появилась вкладка Netlist
После синтеза рядом с вкладкой Sources появилась вкладка Netlist

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

Синтезированная схема
Синтезированная схема

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

На этом "низкоуровневый верилог" закончен, можно выдохнуть и перейти ко второй реализации – гораздо менее трудоемкой.

Второй вариант реализации

Второй вариант – обычная версия на SystemVerilog, написанная так, как это предполагается, без излишних углублений в схемотехнику до такой степени, как мы это сделали в прошлом варианте. Здесь будут использованы такие фишки SystemVerilog, как блок always_comb (сигнал синтезатору о том, что все вошедшее в него должно быть синтезировано в комбинационную схему) и тип logic – переменные, которые, в отличие от проводов, могут быть использованы несколько раз (синтезатор сам разберется, где и как делать провода), а также они могут быть использованы в разных контекстах.

Определим входы и выходы нашего модуля:

module high_level_float_adder(
    input logic[31:0] in_a,
    input logic[31:0] in_b,
    output logic[31:0] out
);

Все так же, как и раньше (только теперь для удобства добавлено ключевое слово logic).

Опишем нужные по ходу переменные:

logic [24:0] mantissa_a, mantissa_b, mantissa_sum;
logic [7:0] exponent_a, exponent_b;
logic result_sign;
logic [7:0] result_exponent;
logic [22:0] result_mantissa;

Скажем, что на шину out подается комбинация из результирующих знака, порядка и мантиссы.

assign out = {result_sign, result_exponent, result_mantissa};

По сути, в коде выше мы просто соединили провода так, чтобы result_sign, result_exponent и result_mantissa оказались в нужных разрядах out.

А теперь самое интересное: блок always_comb, содержащий всю логику вещественного сумматора.

always_comb begin
    exponent_a = in_a[30:23];
    exponent_b = in_b[30:23];

    mantissa_a = {2'b01, in_a[22:0]};
    mantissa_b = {2'b01, in_b[22:0]};

    if (exponent_a >= exponent_b) begin
        mantissa_b = mantissa_b >> (exponent_a - exponent_b);
        result_exponent = exponent_a;
    end else begin
        mantissa_a = mantissa_a >> (exponent_b - exponent_a);
        result_exponent = exponent_b;
    end

    if (in_a[31] == in_b[31]) begin
        mantissa_sum = mantissa_a + mantissa_b;
        result_sign = in_a[31];  
    end else begin
        if (mantissa_a >= mantissa_b) begin
            mantissa_sum = mantissa_a - mantissa_b;
            result_sign = in_a[31];
        end else begin
            mantissa_sum = mantissa_b - mantissa_a;
            result_sign = in_b[31];
        end
    end

    if (mantissa_sum[24] == 1) begin
        mantissa_sum = mantissa_sum >> 1;
        result_exponent = result_exponent + 1;
    end else if (mantissa_sum[23] == 0) begin
        for (int i = 22; i >= 0; i = i - 1) begin
            if (mantissa_sum[i] == 1) begin
                mantissa_sum = mantissa_sum << (23 - i);
                result_exponent = result_exponent - (23 - i);
                break;
            end
        end
    end
      
    result_mantissa = mantissa_sum[22:0];
end

Вот и все. Функциональность всех тех модулей, которые в прошлой версии заняли кучу файлов, уместилась всего в 60 строчек кода. Пройдемся по тому, что здесь происходит.

exponent_a = in_a[30:23];
exponent_b = in_b[30:23];
mantissa_a = {2'b01, in_a[22:0]};
mantissa_b = {2'b01, in_b[22:0]};

Здесь мы вынимаем из чисел порядки и мантиссы и кладем их в соответствующие переменные, не забывая дописать 1 к мантиссам.

if (exponent_a >= exponent_b) begin
	mantissa_b = mantissa_b >> (exponent_a - exponent_b);
	result_exponent = exponent_a;
end else begin
	mantissa_a = mantissa_a >> (exponent_b - exponent_a);
	result_exponent = exponent_b;
end

Эта часть кода делает все то, чем у нас в прошлой версии занимался модуль exponent_aligner. Мы сравниваем порядки и сдвигаем мантиссу того числа, чей порядок меньше, на разницу. Как видно, для сравнения и вычитания достаточно просто написать “>=” и “-”, совсем не обязательно строить с нуля сумматоры и инверторы, как мы делали это ранее. Синтезатор сделает это за нас, увидев знаки сравнения и арифметических действий.

if (in_a[31] == in_b[31]) begin
	mantissa_sum = mantissa_a + mantissa_b;
	result_sign = in_a[31];  
end else begin
	if (mantissa_a >= mantissa_b) begin
		mantissa_sum = mantissa_a - mantissa_b;
		result_sign = in_a[31];
	end else begin
		mantissa_sum = mantissa_b - mantissa_a;
		result_sign = in_b[31];
	end
end

Эта часть заменяет нам сумматор, два инвертора и два вычитателя. Здесь сравниваются знаки аргументов, после чего происходит либо сложение, либо вычитание. И снова абстракция. Здесь мы уже не пишем, выход какого модуля нам подавать дальше в зависимости от знака, за нас это делает оператор “if” и переменные.

if (mantissa_sum[24] == 1) begin
	mantissa_sum = mantissa_sum >> 1;
	result_exponent = result_exponent + 1;
end else if (mantissa_sum[23] == 0) begin
	for (int i = 22; i >= 0; i = i - 1) begin
		if (mantissa_sum[i] == 1) begin
			mantissa_sum = mantissa_sum << (23 - i);
			result_exponent = result_exponent - (23 - i);
			break;
		end
	end
end

result_mantissa = mantissa_sum[22:0];

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

Написанный перед блоком always_comb оператор assign делает так, что результирующие знак, порядок и мантисса передадутся в соответствующие разряды шины out, и таким образом схема выдаст нам то, что насчитала.

Проверим ее следующим кодом:

`timescale 1ns / 1ps

module high_level_float_adder_tb;

    reg [31:0] sum;

    high_level_float_adder adder(
        .in_a(32'b1_01111101_10011001100110011001101), // -0.4
        .in_b(32'b0_01111101_00110011001100110011010), // 0.3
        .out(sum)
    );

    initial begin
        #10 $display("sum = %b", sum);
        #10 $stop;
    end

endmodule

Нажимаем для этого файла симуляции “Set as top” и запускаем.

Результат симуляции
Результат симуляции
Приведение к читаемому формату
Приведение к читаемому формату

Видим, что точность все же немного теряется, но с этим ничего не поделать, для вещественных чисел это нормально. Наш сумматор теперь можно синтезировать, заливать на ПЛИС и проверять на реальном железе. Ради интереса можно посмотреть на схему, которую сделает синтезатор для этого варианта кода. Проделаем все те же шаги, что и при синтезе первого варианта, и получим схему:

Синтезированная для варианта 2 схема
Синтезированная для варианта 2 схема

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

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

Заключение

Как оказалось, SystemVerilog не такой уж и страшный. После разбиения задачи на подзадачи и составления схемы написать код становится гораздо легче. Стоит отметить, что я искренне рекомендую писать так, как было приведено во втором варианте. Первый больше подойдет для безумцев вроде меня и энтузиастов-схемотехников, на практике так обычно никто не делает, т. к. такой код получается очень объемный и совершенно нечитаемый, а с этим повышается вероятность где-то ошибиться. На практике можно написать нормальный читаемый код, а всю грязную работу по преобразованию в схему из модулей доверить синтезатору (но понимать, как ваш код синтезируется и работает все равно нужно, без этого никак!)

На этом у меня пока что все. Весь исходный код я выложил на свой гитхаб: https://github.com/Yars2021/floating_adder/ 

Спасибо за внимание и, надеюсь, статья кому-то помогла, а SystemVerilog стал чуть более понятным и менее пугающим!

Ссылки

  1. https://numeral-systems.com/ieee-754-add/

  2. https://www.h-schmidt.net/FloatConverter/IEEE754.html

  3. https://ru.wikipedia.org/wiki/%D0%94%D0%BE%D0%BF%D0%BE%D0%BB%D0%BD%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9_%D0%BA%D0%BE%D0%B4 

  4. https://en.wikipedia.org/wiki/Programmable_logic_device 

Комментарии (12)


  1. nerudo
    04.11.2024 07:55

    Безумно хочется увидеть, на какой частоте эта конструкция сможет работать в каком-нибудь Kintex (но не настолько, чтобы проверять самому).


    1. Yars_SE Автор
      04.11.2024 07:55

      Мне ещё предстоит все это дело запускать на учебной Nexys A7. Вот там попутно и узнаем.


      1. KeisN13
        04.11.2024 07:55

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

        Ну или можно просто в тайминг репорте после имплементации найти самую длинную цепь и узнать максимальную частоту


    1. byman
      04.11.2024 07:55

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


  1. byman
    04.11.2024 07:55

    Для начала нормально. Только нужно оговаривать, что пока нет поддержки округления, денормализованных чисел, NAN и т.д..


    1. Yars_SE Автор
      04.11.2024 07:55

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


  1. checkpoint
    04.11.2024 07:55

    А почему (в первом варианте) нельзя было сразу определить схему полного сумматора и использовать его сигнал входного переноса для автоматического прибавления единицы ? Вместо этого Вы сначала определили полный сумматор из двух полусумматоров, а потом лишний раз ввели в без того длинную схему еще один сумматор для прибавления едницы при конвертировании в доп код. Схема получилась просто гигантская. Даже любопытно стало посмотреть на репорт - сколько LUTов затрачено и какая расчетная Fmax для такой схемы.

    PS: Ждем аналогичную статью про умножение. Тут Вы одной комбинационной схемой не отделаетесь. :)


    1. Yars_SE Автор
      04.11.2024 07:55

      В целом можно было, я с Вами согласен. Первый вариант, на самом деле, был написан скорее из интереса, а не ради какого-то практического применения. Понятно, что он далек от идеала в плане оптимизациии) Там у меня была цель максимально подробно разложить схему на составляющие и при этом не запутать читателя, который пока не особо погружен в тему (я, в общем-то, и сам начинающий в этой сфере).


      1. checkpoint
        04.11.2024 07:55

        Схема полного сумматора является христоматийной, зачем её изобретать ? Таким обьемом кода запутать читателя как два байта... Ну да ладо.


  1. belav
    04.11.2024 07:55

    .


  1. mozg37
    04.11.2024 07:55

    Побуду ворчливым критиком. Значит давать новичкам сумматор вещественных чисел с тоннами листинга это лишь отпугнёт новичков, которые так и останутся кодить на си и прочих жавах. Это нихрена не простой уровень. Во первых, для чего это? Fir фильтры что-ли, но поймет ли новичек сие, и главное - сможет ли отладить? На мой дилетантский взгляд новичкам следует начинать с всякого рода интерфейсных вещей, пусть это и будет изобретением велосипеда. К примеру, уарт. Приемник, передатчик. Куча всякого интересного при этом вылезет. Легко отладить, легко понять, легко написать, конечные автоматы пощупать. Или снифер spi - для интереса посмотреть общение микроконтроллера и spi памяти. А эти всякие сумматоры и умножители - этож цос, тут надо ещё придумать методы отладки, подумать как это все синхронизировать - может и конвейеры уже будут нужны.


    1. Yars_SE Автор
      04.11.2024 07:55

      А я побуду отвечающим на ворчливую критику :) Во-первых, спасибо за замечания, их я вполне могу понять. А теперь взглянем с моей стороны. Задачка про написание интерфейсных вещей и правда звучит интересно и полезно в качестве первого опыта, но она не особо подходит в моих реалиях, поскольку у меня, как и у большинства новичков, смею предположить, собственной железки под рукой нет. Приходится довольствоваться симуляцией (в которой при желании без особых проблем поднимается все что угодно, включая математические модули) и периодическими попытками залить и потыкать это все на университетских ПЛИСах. Тем более я писал в начале статьи, что тут затрону именно шаги с написанием кода, симуляцией и генерацией схемы, не доходя пока что до железа, потому что это вполне тянет на еще один материал. Вещественный сумматор и правда, возможно, сложноват в качестве примера, у меня были такие мысли. Но мне вариант такой попался, что ж поделать... И я решил, что было бы неплохо все это разобрать, как есть, пока решаю задачу. Как-то так эта статья и получилась.

      На железе, кстати, оно завелось, как раз сегодня тестил.