Генерация Verilog-кода

Эта статья посвящена разработке под ПЛИС, она является пошаговой инструкцией под разработку Verilog. Предположим, мы построили в Engee модель регулятора или фильтра, подали тестовые сигналы и убедились, что алгоритм работает. Теперь нужно перенести эти вычисления в программируемую логическую интегральную схему - ПЛИС (FPGA). Это необходимо, когда требуется параллельно обрабатывать поток данных и выдавать результаты в заданном темпе. Однако работающая модель еще не определяет всех деталей будущей аппаратной реализации. Нужно выбрать, как представлять числа и где хранить результаты предыдущих шагов. В статье описывается процесс генерации Verilog-кода из модели, выбор форматов с фиксированной точкой, анализ полученного RTL и способы его верификации. Мы сосредоточимся на пути от графической модели до симуляции Verilog перед синтезом.

Сначала разберемся, что именно мы переносим в ПЛИС. В отличие от программы для процессора, RTL-описание задает структуру цифровой схемы: вычислительные узлы, связи и регистры. Независимые узлы могут работать параллельно. Для описания схем используют языки Verilog и VHDL; полученный RTL сначала проверяют в симуляции, затем синтезируют. В статье будем использовать Verilog и разбирать его конструкции по мере появления в примерах.
Чтобы перейти от вычислений к схеме, нужно явно определить, где хранятся данные и когда они изменяются. Такое описание называют уровнем регистровых передач (Register Transfer Level, RTL). Например, сумму входных сигналов может вычислять комбинационная логика: ее результат зависит от текущих входов. Накопленную ошибку регулятора, напротив, нужно сохранять в регистре и обновлять по фронту тактового сигнала. Инструмент синтеза преобразует RTL в нетлист - список логических элементов и связей между ними.
После синтеза средства проектирования размещают элементы на кристалле, трассируют соединения и формируют битстрим для загрузки в ПЛИС.
Графическую модель можно преобразовать в RTL-описание на Verilog, но автоматическая генерация не отменяет необходимости настройки типов данных, разрядностей и параметров хранения состояния. Эти параметры влияют на структуру полученного кода и его поведение. Первое из них - представление чисел. От него зависят диапазон значений, точность вычислений и затраты аппаратных ресурсов.
Типы данных с фиксированной точкой
При моделировании удобно использовать плавающую точку: она позволяет работать с числами разного масштаба без заранее заданного положения двоичной точки. В ПЛИС такую арифметику тоже можно реализовать - с помощью IP-блоков, а в некоторых семействах есть специализированная поддержка. Однако обработка мантиссы, порядка и особых значений требует дополнительной логики. Если диапазон сигнала известен заранее, часто можно выбрать более простое представление: отвести постоянное число битов под целую и дробную части. Это формат с фиксированной точкой. Выбирая разрядность и положение точки, мы согласуем нужные точность и диапазон с затратами ресурсов. Формат задается функцией fi(Value, Sign, Total_bits, Fractional_bits), где:
● Value - значение;
● Sign - знак;
● Total_bits - суммарное кол-во бит;
● Fractional_bits - кол-во бит на дробную часть.
Процесс перевода в фиксированную точку включает три этапа: вычисление диапазона представимых значений, квантование исходного числа с учетом шага и формирование итогового битового представления. Выбрать форматы входов недостаточно: нужно проследить, что происходит с числами внутри модели. Полная сумма двух знаковых W-битных чисел с одинаковым масштабом требует W+1 бит; при разных форматах сначала согласуют дробные части. Для полного произведения складывают разрядности операндов, а число дробных битов результата равно сумме дробных разрядностей операндов. При приведении результата к более узкому формату можно потерять точность или получить переполнение. Поэтому настройки типов данных и преобразований в блоках должны учитывать промежуточные значения.

Округление и переполнение
При преобразовании числа к более узкому формату важно заранее определить два правила: как округлять результат и что делать при выходе за допустимый диапазон.
Округление. Усечение отбрасывает младшие биты - как в примере с трапецией ниже, где выбираются биты Productnew_T_1[23:8]. Такой способ прост в аппаратной реализации, но создает систематическую погрешность: в дополнительном коде отбрасывание младших битов равносильно округлению к минус бесконечности, ошибка лежит в пределах от −1 LSB до 0, а в среднем составляет около −½ LSB выходного формата. Альтернативы - округление к ближайшему значению или к ближайшему четному.
Переполнение. При переносе (wrap-around) старшие биты отбрасываются, поэтому значение может резко изменить знак. При насыщении (saturation) результат ограничивается максимальным или минимальным представимым значением. Насыщение требует дополнительной логики, но обычно обеспечивает более предсказуемое поведение. После настройки генератора выбирают подсистему и запускают преобразование. Входы и выходы выбранной подсистемы формируют интерфейс Verilog-модуля.
Архитектура генератора Verilog-кода
Выбранные форматы задают параметры, которые учитываются при построении RTL. Генератор анализирует блоки модели, их параметры и соединения, а затем строит аппаратное описание соответствующих вычислений. Например, блок задержки задает хранение состояния, а арифметический блок - операцию над сигналами. Для каждого блока должен быть определен способ преобразования в RTL.
Процесс проходит в несколько этапов:
● Анализ графической модели: определение модульной структуры, выделение виртуальных и атомарных подсистем.
● Трансляция вычислительных блоков в аппаратные примитивы на языке Chisel.
● Преобразование Chisel в Verilog с учетом типов данных, разрядностей и тактирования.
Chisel служит промежуточным языком внутри генератора. Для обычной генерации пользователю не нужно писать на нем код: достаточно выбрать подсистему, которую требуется реализовать в ПЛИС, и указать каталог для результата. Например, генерацию можно запустить из скрипта следующим образом:
engee.generate_code("path/to/model.engee","path/to/output_dir",subsystem_name="SubSystem",target="verilog")
Параметры:
1. Путь к файлу модели;
2. Выходная директория;
3. Subsystem_name - имя подсистемы для генерации;
4. Target - целевой язык.
Тот же запуск доступен из графической модели: сначала настраиваем генератор кода, как показано ниже.

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

Примеры сгенерированного кода
ПИ-регулятор

На входы setpoint и feedback поступают требуемое и измеренное значения, а выход command задает управляющее воздействие. Модуль называется pi_fixed_SubSystem, но в приведенном фрагменте реализованы только пропорциональная ветвь и накопление ошибки (это ПИ-регулятор).
Основные конструкции RTL в этом фрагменте:
● reg: описывает регистры состояния (обновляются по фронту clock);
● wire: описывает комбинационную логику;
● Явное знаковое расширение: старший бит повторяется (например, {13{_AddAccum_T[15]}}), чтобы при умножении отрицательных чисел результат был корректным.
module pi_fixed_SubSystem(
input clock, reset,
input [15:0] io_setpoint, io_feedback,
output [15:0] io_command
);
reg [15:0] UnitDelay_state;
wire [15:0] AddAccumT = io_setpoint - io_feedback;
wire [28:0] Gain2_new_T_1 = {{13{_AddAccum_T[15]}}, AddAccumT} * 29'hA4;
wire [29:0] Gainnew_T_1 = {{14{_AddAccum_T[15]}}, AddAccumT} * 30'h6000;
Сначала из заданного значения вычитается измеренное - так вычисляется ошибка регулирования. Разность считается в 16 битах без расширения, поэтому при больших значениях противоположных знаков она переполнится: для точной разности нужно W+1 бит. Затем ошибка умножается на коэффициенты двух ветвей.
Константы 0xA4 и 0x6000 - это целочисленные коды коэффициентов. Положение двоичной точки задается форматами сигналов. Если у коэффициентов 14 дробных битов, то срез [28:14] возвращает произведению ту же дробность, что и у входа. Коды соответствуют примерно 0,01 (0xA4 / 2¹⁴) и 1,5 (0x6000 / 2¹⁴). Отбрасывание 14 младших битов реализует усечение. В интегральной ветви добавленный младший ноль (1'h0) эквивалентен сдвигу влево на один разряд; этот сдвиг компенсируется отбрасыванием младшего бита на выходе, сохраняя баланс дробной части.
Для пропорциональной ветви достаточно текущей ошибки. Для интегральной ветви нужно учитывать и предыдущие значения: к сохраненному состоянию добавляется очередной вклад ошибки. В модели эту память задает блок Unit Delay, а в RTL - регистр UnitDelay_state. Процесс always @(posedge clock) обновляет регистр по положительному фронту clock. Если в этот момент активен reset, состояние обнуляется.
Здесь важно соотнести шаг модели с тактом ПЛИС. В данном фрагменте UnitDelay_state обновляется на каждом положительном фронте clock. Однако шаг дискретизации модели может составлять, например, 1 мс, тогда как ПЛИС работает на частоте 100 МГц. Если регистр обновлять на каждом фронте такого clock, накопитель будет изменяться каждые 10 нс, а не каждую миллисекунду: регулятор будет интегрировать примерно в 100 000 раз быстрее, что нарушит устойчивость системы.
Пример реализации делителя для clock enable:
reg [16:0] clk_divider = 0;
wire ce_1ms = (clk_divider == 17'd99999);
always @(posedge clock) begin
if (reset) clk_divider <= 0;
else if (clk_divider == 17'd99999) clk_divider <= 0;
else clk_divider <= clk_divider + 1;
end
always @(posedge clock) begin
if (reset) UnitDelay_state <= 16'h0;
else if (ce_1ms) UnitDelay_state <= Add1Accum_T; // Обновление только по CE
end
Когда обе составляющие вычислены, их нужно сложить и получить управляющее воздействие. В коде перед сложением выбираются нужные биты промежуточных результатов:
assign io_command = Gainnew_T_1[29:14] + {_Add_1Accum_T[15], Add1Accum_T[15:1]};
Во втором слагаемом знак сохраняется, а младший разряд отбрасывается: это эквивалентно арифметическому сдвигу вправо на один бит. В следующем примере памяти нет: арифметические формулы сразу преобразуются в комбинационную логику Verilog.
Калькулятор параметров трапеции
Возьмем три знакомые формулы: периметр P, среднюю линию m и площадь S трапеции, их можно записать напрямую, а для двух значений высоты сразу вычислить вектор площадей. Эти числа служат исключительно тестовыми данными для проверки формул; геометрически они не описывают реальную трапецию (высота не может превышать боковую сторону c = 3, а здесь взяты 4 и 5).
a, b, c, d = 5, 7, 3, 4
h = [4, 5]
P = a + b + c + d
m = (a + b) / 2
S = m .* h

Те же формулы соберем из блоков и зададим сигналам формат fixdt(1, 16, 8). В тестовом окружении блок Signal From Workspace последовательно подает значения высоты. Для каждого набора входных данных модель вычисляет m, P и S. Таким образом, вместо обработки целого вектора выполняется серия расчетов для отдельных значений. Это еще не аппаратный конвейер: между операциями нет регистров, а выходы зависят от текущих входов. Такая схема называется комбинационной. Порты clock и reset присутствуют в интерфейсе, но в данном фрагменте кода не используются. Для создания конвейера потребовалось бы добавить регистры между вычислительными ступенями.
module model_trapeze_calculator(
input clock, reset,
input [15:0] io_a, io_b, io_h, io_c, io_d,
output [15:0] io_m, io_P, io_S
);
wire [15:0] __1Accum_T = io_a + io_b;
wire [23:0] Productnew_T_1 =
{{9{__1Accum_T[15]}}, __1Accum_T[15:1]} *
{{8{io_h[15]}}, io_h};
assign io_m = {__1Accum_T[15], __1Accum_T[15:1]};
assign io_P = __1Accum_T + io_c + io_d;
assign io_S = Productnew_T_1[23:8];
endmodule
Этот модуль является чисто комбинационным: выходные сигналы зависят только от текущих входных значений, регистры состояния и интегральные составляющие в нем отсутствуют.
КИХ-фильтр

Калькулятор обрабатывает текущие входы и не хранит историю, в свою очередь КИХ-фильтру нужны текущий отсчет и несколько предыдущих, в модели QPSK-модулятор соединен с КИХ-фильтром на 11 коэффициентов. Каждый выход фильтра представляет собой сумму отсчетов, умноженных на соответствующие коэффициенты. Для прямой формы нужны текущий отсчет и еще 10 задержанных: блоки задержки хранят историю, умножители задают веса, а сумматоры объединяют результаты. Генератор переносит эту структуру в RTL. Количество физических умножителей(DSP-блоков) зависит от архитектуры и оптимизаций синтезатора, помимо этого если мы используем умножение или деление на степени двойки, то вместо умножения будет использоваться битовое смещение.
Пользовательские шаблоны генерации
Если генератор не умеет преобразовывать нужный блок в аппаратное описание, можно задать способ преобразования самостоятельно с помощью пользовательского шаблона - файла с расширением .cgt. В шаблоне указывают, как использовать входы, выходы и типы блока в конструкциях промежуточного языка Chisel. Так можно расширить набор поддерживаемых операций. Ниже приведен шаблон .cgt, задающий преобразование блока умножения в Chisel.
Пример шаблона для блока умножения (промежуточный язык - Chisel):
//! BlockType = :Product
//! TargetLang = :Chisel
//! @Definitions
val $(output(1)) = Wire($(show_chisel_type(output(1))))
/*! @Step
function cast(sig)
".asTypeOf(FixedPoint($(sig.ty.bits).W,$(sig.ty.fractional_bits).BP))"
end
function maybe_cast_res_begin()
output(1).ty.fractional_bits == 0 ? "(" : ""
end
function maybe_cast_res_end()
output(1).ty.fractional_bits == 0 ? ").asSInt" : ""
end
*/
$(output(1)) := \
$(maybe_cast_res_begin())\
$(input(1))$(cast(input(1)))*$(input(2))$(cast(input(2)))\
$(maybe_cast_res_end())
Графическую модель также можно собрать из скрипта, используя средства управления Engee и поддерживаемые блоки. Создание модели и генерация RTL - два отдельных шага: сначала формируют модель, затем запускают генератор. Такой подход показан в примерах в собществе:
1. RRC-filter
2. CIC-filter
Атомарные подсистемы
Шаблоны определяют, как преобразовывать отдельные блоки. По мере роста модели возникает другой вопрос: как организовать сгенерированный код? В крупной модели регулятор, модулятор и фильтр удобно выделять в отдельные модули, для этого используют атомарные подсистемы, которые при генерации RTL сохраняют собственные входы и выходы. Обычная, виртуальная подсистема помогает организовать графическую модель, но ее содержимое включается в родительский модуль. Атомарная подсистема сохраняет это разделение и в сгенерированном RTL.

В модели данные последовательно проходят через генератор, QPSK-модулятор и фильтр. В монолитном варианте регистры и вычислительная логика всех этих частей находятся в одном модуле. Если выделить атомарные подсистемы, Gen_data, QPSK_modulator и fir станут отдельными модулями с собственными интерфейсами. Так границы основных частей модели сохраняются и в коде.

Выбор структуры иерархии
• Монолитный вариант удобен для небольшого примера и проверки функции целиком.
• Атомарные подсистемы удобны, когда нужны отдельные интерфейсы, независимые тесты и повторное использование частей проекта.
Верификация сгенерированного кода
После генерации RTL нужно убедиться, что при преобразовании сохранилось поведение исходной модели. Для этого подают одинаковые входные данные на модель и на сгенерированное описание, а затем сравнивают выходы. Если схема содержит регистры, сравнение должно учитывать начальное состояние и число тактов до появления результата, для такой проверки используется Verilator: он преобразует Verilog в C++-модель, на основе которой собирается библиотека симуляции. Если включить создание верификационного скрипта, формируется проверочная модель с блоком C Function. Этот блок использует библиотеку и позволяет сравнить поведение RTL с исходной моделью.

RTL можно проверить и независимо от Engee моделей - например, с помощью Icarus Verilog. Для этого нужен тестбенч: небольшое окружение, которое подает входные данные и наблюдает выходы схемы. Icarus компилирует RTL вместе с тестбенчем, а программа vvp выполняет событийную симуляцию. Так удобно проверять сброс, последовательности отсчетов и граничные значения. В приведенном тестбенче калькулятора сначала задается высота 4, затем она меняется на 5, после чего выводятся результаты.
module tb_trapeze;
reg clk = 0, rst = 1;
reg [15:0] a = 5 << 8, b = 7 << 8, h = 4 << 8,
c = 3 << 8, d = 4 << 8;
wire [15:0] m, P, S;
model_trapeze_calculator dut(
clk, rst, a, b, h, c, d, m, P, S
);
always #5 clk = ~clk;
initial begin
#15 rst = 0;
#10;
$display("h=4: m=%0.2f P=%0.2f S=%0.2f",
$itor($signed(m)) / 256.0,
$itor($signed(P)) / 256.0,
$itor($signed(S)) / 256.0);
h = 5 << 8;
#10;
$display("h=5: m=%0.2f P=%0.2f S=%0.2f",
$itor($signed(m)) / 256.0,
$itor($signed(P)) / 256.0,
$itor($signed(S)) / 256.0);
#10 $finish;
end
endmodule
На основе этих входных данных мы убедились, что данные совпали как в Julia-скрипте, так в модели Engee и в блоке C-функции с нашим тестбенчем, генерация прошла без ошибок.

Интеграция с инструментами синтеза
Симуляция позволяет проверить, правильно ли схема вычисляет результат на выбранных тестах. Но для реализации в ПЛИС нужно ответить еще на два вопроса: достаточно ли ресурсов выбранного устройства и сможет ли схема работать на требуемой частоте. Для этого RTL включают в проект САПР целевой ПЛИС, задают ограничения для тактового сигнала и интерфейсов, выполняют синтез, размещение и трассировку. В отчетах указываются затраты логики, регистров и DSP-блоков. Временной анализ показывает, выполняются ли заданные ограничения. Ниже представлена таблица основных поставщиков САПР.
Производитель | Среда разработки | Серии ПЛИС |
AMD/Xilinx | Vivado Design Suite | Spartan, Artix, Kintex, Virtex, Zynq, Versal |
Intel | Quartus Prime | MAX, Cyclone, Arria, Stratix, Agilex |
Lattice | Diamond, Radiant | iCE40, MachXO, ECP5, CrossLink |
Open Source | Yosys + nextpnr | iCE40, ECP5, Xilinx 7-series |
Ограничения генератора
Ниже перечислены основные особенности генератора кода верилог в Engee, о которых стоит помнить.
В сгенерированном коде часть блоков модели может отсутствовать: если выражение зависит только от констант, генератор или синтезатор вычисляет его заранее и заменяет готовым значением. Ненужная логика при этом удаляется. Если значение должно меняться во время работы устройства, его нужно передавать через входной порт подсистемы.
Для схемы с памятью важно определить, когда обновляются данные. В рассмотренных примерах используется единый clock и синхронный сброс. Сгенерированный RTL не содержит сведений о шаге дискретизации модели: предполагается, что частота clock уже соответствует темпу обновления данных либо между тактовым сигналом и регистрами предусмотрен сигнал разрешения clock enable. Если в модели разные блоки имеют разные шаги дискретизации, это само по себе не создает разные тактовые домены в RTL. При необходимости такие домены проектируют и проверяют отдельно, включая передачу данных между ними. Ручные изменения RTL необходимо согласовывать с моделью и учитывать при повторной генерации.
Разрядности промежуточных сигналов, режимы округления и переполнения задаются в модели и учитываются при генерации RTL.
Заключение
В итоге генератор Engee преобразует выбранную подсистему модели в RTL-описание на Verilog. Перед синтезом необходимо проверить соответствие RTL исходной модели, включая разрядности сигналов, режимы округления, переполнение и тактирование. После этого синтез и временной анализ позволяют оценить затраты ресурсов и возможность работы схемы на требуемой частоте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.