В предыдущей статье мы поморгали диодом. Большое дело, вообще‑то. После привычных сред разработки, вроде VSCode, CubeIDE, или продуктов JetBrains (поклонники Vim вышли из чата), Квартус не кажется очень уж дружелюбным. Плюс смена подхода к разработке: от программы к схеме. Но ничего, вроде, справились. Получается, мы погрузились в тему, наверное, на уровне «намочить ноги». Теперь неспеша зайдём по щиколотку. Разрабатывать под ПЛИС, используя редактор схем, конечно можно. Можно разбивать большую схему на блоки, потом объединять блоки в блоки более высокого уровня и т. д.
Но, всё же, не зря придумали всякие HDL. С их помощью получается сделать всё то же самое, но намного более лаконично и более переносимо. И ещё много всяких «более». Есть и «менее»: Менее наглядно, наверное, и менее похоже на схему электрическую принципиальную, каковой по сути и является проект для ПЛИС.
Да, небольшое отвлечение на терминологию. Есть ПЛИС, FPGA, CPLD, ПВМ, а ещё, в комментариях дополнят, надеюсь. Я попытался найти чёткое разделение что из этого списка есть что. Кто‑то пытается делить эти штуки по сложности, кто‑то по внутреннему устройству, но при этом единодушия нет. Что значит каждая аббревиатура можно прочитать на википедии, а здесь я буду писать по‑русски ПЛИС, по‑нерусски FPGA, имея в виду совокупность логических элементов, триггеров, регистров и добавленных спец модулей (навроде памяти и умножителей), которые можно коммутировать без паяльника и бредборда, используя специальную среду разработки.
HDL. Hardware Description Language. Язык описания железа. Я выбрал для себя Verilog. По увещеваниям бездушной (LLM) железяки, он самый простой. Такой себе питон для ПЛИСоводов.
Давайте создадим новый проект и назовём его SevenSegs. Только имя top‑level entity сделаем «main» (не знаю, такова ли общая практика, опять надеюсь на комменты). На этапе, когда мы добавляли в пустой проект файл, добавим не схему, а file → new → Verilog HDL File.
Отступление на тему удобства редактирования текста
Не знаю как вам, а мне ну очень не нравится редактор текста квартуса. Как будто пытаешься делать HTML в блокноте. Спасибо, хоть скобочки подсвечивает. Вкусовщина, конечно, но я предпочитаю в таких случаях (когда не нравится редактор) использовать VSCode. У него приятная интеграция с git, умеет подсвечивать синтаксис (с доп. расширением, не очень хорошо, но лучше, чем квартус), ещё есть копилот (настоятельно не рекомендую vibe‑coding во избежание атрофии мозга, но отрицать пользу такого помощника — глупо. У себя я отключаю автокомплит и советуюсь с ним что да как. По верилогу в обучающих материалах у него, видимо, было не очень много данных, так что тупость некоторых его советов видна даже новичку). Расширение для подсветки синтаксиса верилог я выбрал с помощью поиска по ключевому слову «verilog» и установил то, которое устанавливали чаще всего. Чтобы можно было редактировать файл, созданный в квартусе, сначала его надо сохранить в квартусе же.
Точно так же, как и на схеме, в текстовом файле можно описывать модули (они похожи на функции в Си). Только если у функций есть аргументы и возвращаемые значения, то у модуля это входы и выходы. То, что на схеме обозначается чёрточками с подписью, торчащими из прямоугольника (oh, come on!) – выводы, в модуле пишется как input и output.
Хорош воды, давайте кодить! Пишем текст:
module main(input wire clk, output wire led);
reg[25:0] cntr;
assign led = cntr[25];
always @(posedge clk) cntr <= cntr + 1;
endmodule
Да, 5 строчек. Лучше, чем 25 d-триггеров в прошлой статье. Довольно-таки лаконично. Top-level entity при создании проекта мы назначили "main", также, модуль мы в коде назвали main, так что после "analysis and elaboration", выводы модуля clk и led появятся в пин-планнере. Назначаем на них входы и выходы платы разработки (в моём случае PIN_g1, PIN_y17), компилируем проект, заливаем на плату, и опять мигает светодиод точно также, как и в предыдущей статье.
Давайте к нюансам. После описания модуля со входами и выходами, после закрывающей скобочки ставится точка с запятой. Сколько раз я допустил ошибку, её не поставив – не сосчитать. Ещё. Записи "reg[25:0] cntr;" и "reg cntr[25:0];" похожи, но значат совсем разное.
Чтобы понять этот пример, нужно рассказать пару базовых правил языка: wire и reg. Тут буквальнее не придумаешь. Провод и регистр. Регистр - это несколько бит, объединённых таким образом, что с ними можно выполнять привычные операции как с переменными в Си. 25-битный регистр объявлен в строке 2. Провод (wire) это провод. С его помощью можно соединять другие провода, регистры и модули. Полезно представлять их в голове как буквально проводники, соединяющие что-то между собой. Можно объявить кучу нумерованных проводов wire[25:0] some_wire и обращаться к ним через скобочки:
assign some_wire[8] = some_other_wire[3].
У нас не функция, а модуль, и нет цикла, в котором выполняется код. Для операций с регистрами нужно найти момент времени, когда они будут выполняться. Просто так регистру нельзя присвоить значение. Ему можно присвоить значение только внутри блока "always" (строка 4). Тут тоже очень буквально:
"Всегда по положительному фронту сигнала clk присваивать регистру cntr значение, равное сумме текущего значения регистра cntr и единицы".
В строке 3 мы просто подсоединяем провод от 25-го бита регистра cntr к выходу led модуля. Сохраняем файл как main.v, назначаем пины в пин-планнере так же, как и в проекте из прошлой статьи и вуаля!
И так мы опять поморгали диодом, да сколько можно уже?!
Ещё немного нюансов, которые, как бы сами собой разумеющиеся, но у меня покушали времени. В проекте может быть сколько угодно файлов ".v". В каждом файле может быть сколько угодно модулей. Файлы не надо включать друг в друга наподобие
#include "MyLib.h"
И при этом, модули из одного файла можно вкладывать в модули в другом файле – квартус это всё соображает и делает сам.
И вот с этими знаниями мы готовы чуть-чуть усложнить проект, и сделать не мигалку диодом, а считалку на семисегментниках. На моей плате (QMTECH Cyclone 10 Starter Kit) разведен трёхразрядный семисегментный индикатор:

Программист embedded, как правило, знает, как работает семисегментный индикатор.
Для тех, кто не знает, кратко
Если разряд 1 (одна цифра), то у нас просто 8 лампочек (светодиодов, конечно же), 7 сегментов и одна точка. Для экономии выводов, ног у индикатора не 16 (8×2), а 9. То есть просто скрутили все «минусы» (есть индикаторы, где соединены «плюсы»), скрутку — наружу, и ещё 8 выводов наружу.
Если разрядов больше одного, то всё усложняется. Опять выводы экономят. Поставили бы 3 односегментных, то получилось бы 9×3=27 выводов. но тут они скрутили между собой выводы каждого сегмента (те, которых по 8). Так получилось 8 + 3 = 11 выводов на 24 лампочки (ну светодиода же!). Теперь можно подать на 8 выводов сигналы так, чтобы получилась, например, цифра 8, и подключить один из трёх общих выводов, чтобы цифра 8 зажглась в первом, втором, или третьем разряде.
Чтобы показывать разные цифры одновременно в трёх разрядах используется динамическая индикация — 3 цифры не горят одновременно. По очереди включается каждый разряд с нужной комбинацией сегментов и переключается очень быстро, чтобы на глаз казалось что горят все 3 цифры.
Воспользуемся модульностью и разобьём задачу на маленькие подзадачки. Крупная задача: сделать отсчёт от 999 до 0 и так по кругу с частотой, скажем 10 герц. Так он за 100 секунд будет пробегать целый круг.
Первая маленькая подзадача: сделать модуль, который на входе принимает 3 8-битных значения и тактовый сигнал, а на выходе имеет 8+3=11 выходов на семисегментники. Ожидаемое поведение такое: при подаче тактового сигнала и трёх 8-битных значений модуль по каждому фронту тактового сигнала подключает один из трёх разрядов и зажигает на нём сегменты, соответствующие одной из трёх входных последовательностей.
Сегменты мы сопоставим входным битам так: pGFEDCBA (p — точка). Сегмент A — самый младший бит, точка — самый старший. Не обязательно так, но важно запомнить порядок, чтобы потом цифры отображать. И так наш модуль должен при подаче на него сигналов, к примеру: 0xFF, 0xAA, 0×01 (да, кстати, в verilog пишут так: 8'hFF, 8'hAA, 8'h01) выдавать сигналы на семисегментник так:

В применённом на Cyclone 10 Starter Kit семисегментнике лог. ноль на сегменте соответствует его включенному состоянию, при этом лог. единица на общем аноде разряда зажигает этот разряд. Снова немного кода. Давайте создадим ещё один файл: file->new->Verilog HDL File. Добавим в него такой текст и будем с ним разбираться:
module LowLevel3_7Seg(
input wire seg_sw_clk,
input wire [7:0] segs1,
input wire [7:0] segs2,
input wire [7:0] segs3,
output wire[7:0] seg,
output wire[2:0] dig
);
reg [1:0] curr_dig;
assign seg = (curr_dig == 0) ? ~segs1 : (curr_dig == 1) ? ~segs2 : (curr_dig == 2) ? ~segs3 : 8'b00000000;
assign dig = (curr_dig == 0) ? 3'b001 : (curr_dig == 1) ? 3'b010 : (curr_dig == 2) ? 3'b100 : 3'b000;
always @(posedge seg_sw_clk) curr_dig <= (curr_dig == 2) ? 0 : curr_dig + 1;
endmodule
Тут всё тоже буквально. Входы и выходы, как и собирались. Всё провода. (Пишем "input wire" и "output wire").
Три разряда, чтобы их перечислить, достаточно двух бит (даже логарифм можно не брать, и так понятно). Делаем двухбитный регистр curr_dig, а дальше просим (строка 16), чтобы:
"Всегда по положительному фронту seg_sw_clk присваивать регистру curr_dig значение, равное сумме текущего значения регистра curr_dig и единицы, кроме случаев, если curr_dig равен 2 (в этом случае присвоить регистру curr_dig значение 0)"
Тренарный оператор по синтаксису один в один как Си, либо в JS. Но ещё и ещё раз напоминаем себе это не ЯП. Это HDL. Тренарный оператор на выходе генерирует комбинаторику (И / ИЛИ / НЕ во всевозможных комбинациях) Потому его можно использовать не только в блоке always.
Так что на 3 проводяшки, которые мы обозвали dig, навешиваем значение 001 или 010 или 100
Как в верилоге записать двоичное число
3'b010 – так записывается двоичное трёхбитное число (тройка значит 3 бита, амперсэнд и b означает двоичную систему, остальное собственно число).
Не знаю как вам, а мне эта запись царапает мозг (скорее всего выпирающим амперсэндом) и я стараюсь её избегать. Но почти везде советуют применять именно такую нотацию даже для десятичных чисел.
В случае, когда у нашего счётчика curr_dig значение 0, появляется единица на первом проводе. Если не 0, то ещё один тренарный оператор, где мы спрашиваем: "А не единице ли он равен?", тогда единичка на втором. Для значения 2 то же самое. И хоть счётчик у нас только до 2, и он не будет равен 3 никогда, но мы всё равно пишем значение 000 для всех остальных случаев (не 0 не 1 и не 2). Иначе синтаксис не позволит.
Для семи проводяшек seg логика та же самая, только нюанс - мы инвертируем биты из-за особенности работы семисегментника (сегмент зажигается нулём а не единицей).
Есть всякое ПО для симуляций, но о нём в следующий раз, а в этот раз проверим полученный модуль на железке. Сохраним этот файл как Indicator.v и чуть-чуть подправим наш модуль main с его мигающим диодом.

Чтобы переключиться с отображения иерархии так, чтобы были видны файлы проекта, нажимаем сюда:
Во-первых, добавим в модуль main (он же, по совместительству, top-level entity) выходы семисегментника, чтобы их назначить в пин-планнере на правильные для нашей платы выходы чипа. Также, во-вторых, добавим инстанс (тоже аналогия с ЯП класс один, а его объектов (инстансов) может быть много, так же и с модулями. А схемотехники могут представлять инстанс как отдельную микросхему среди множества одинаковых, и так даже правильнее). Слишком длинная скобочка, ещё раз. Во-вторых, добавим инстанс нашего тестируемого подмодуля, который мы назвали LowLevel3_7Seg. Получается такое:
module main(
input wire clk,
output wire led,
output wire[7:0] seg,
output wire[2:0] dig
);
reg[25:0] cntr;
assign led = cntr[25];
always @(posedge clk) cntr <= cntr + 1;
LowLevel3_7Seg LowLevel3_7Seg_inst(
.seg(seg),
.dig(dig),
.seg_sw_clk(cntr[25]),
.segs1(8'hFF),
.segs2(8'hAA),
.segs3(8'h01)
);
endmodule
Вот так вставляется подмодуль в модуль. с точкой в списке входов и выходов - название входа/выхода в нашем суб-модуле (то что писали во входах/выходах LowLevel3_7Seg), а в скобочках - то, что мы подключаем к нему. В этом случае мы просто пробрасываем провода наружу для выходов семисегментника (строка 12 и 13), для входов пишем значения, которые хотим проверить. А для клока воспользуемся нашей моргалкой диодом, её код ведь мы никуда не убрали. При этом никакого ухудшения быстродействия не будет! Строчек больше, а быстродействие то же самое! ПЛИС. В качестве сигнала переключения разрядов возьмём самый медленный бит счётчика, он же по совместительству выход на светодиод. Так как они соединены, можно написать либо cntr[25], либо led – результат один.
Теперь в пин-планнере, не ошибаясь, внимательно сверяясь с документацией, назначаем выводы на пины:

Для ленивых (а значит продвинутых)
Можно не тыркать мышкой, а текстом это сделать. Пины для главного модуля хранятся в файле, одноимённом с модулем top-level, с расширением qsf в папке проекта. В данном случае main.qsf
там же, где описываются другие пины (внизу файла) просто вставляем нужные нам. Это очень удобно, пинов на плате много и натыркивать их мышкой каждый раз для нового проекта очень нудно. А так:
set_location_assignment PIN_G1 -to clk
set_location_assignment PIN_Y17 -to led
set_location_assignment PIN_AB18 -to dig[0]
set_location_assignment PIN_U19 -to dig[1]
set_location_assignment PIN_R18 -to dig[2]
set_location_assignment PIN_AA19 -to seg[0]
set_location_assignment PIN_R19 -to seg[1]
set_location_assignment PIN_U20 -to seg[2]
set_location_assignment PIN_AB19 -to seg[3]
set_location_assignment PIN_AA18 -to seg[4]
set_location_assignment PIN_W20 -to seg[5]
set_location_assignment PIN_R20 -to seg[6]
set_location_assignment PIN_W19 -to seg[7]
Компилируем, заливаем в чип и вот вам, что и заказывали:

Мы писали @(posedge seg_sw_clk), но картинка меняется, когда диод гаснет. Это потому, что диод здесь тоже зажигается нулём, а не единицей.
Ещё можно немного поиграться, подбирая частоту переключения. Например, подключить сигнал seg_sw_clk к другому разряду счётчика cntr (написав .seg_sw_clk(cntr[10]) в строке 14). Если частота клока clk 50 МГц, то младший разряд счётчика переключается с частотой 25 МГц. Следующий (первый) – 12.5 МГц и так далее. На очень высоких частотах заметно, что сегменты не успевают погаснуть и зажечься вовремя, и картинка меняется.
Следующий подмодуль это, как сказали бы олды, делавшие всё на рассыпухе, знакогенератор. На входе 4 бита – число. На выходе 8 бит – сегменты. Можно было бы вспомнить про карты таро Карно, и сделать компактный модуль на 8 строк, но тут, мне кажется, это преждевременная оптимизация. Воспользуемся тренарным оператором (есть ещё вариант с case, но он требует использовать фиктивный always, наверное, пока не надо).
module dig_gen(input wire[3:0] val, output wire [7:0] seg);
assign seg =
(val == 0) ? 8'b00111111 :
(val == 1) ? 8'b00000110 :
(val == 2) ? 8'b01011011 :
(val == 3) ? 8'b01001111 :
(val == 4) ? 8'b01100110 :
(val == 5) ? 8'b01101101 :
(val == 6) ? 8'b01111101 :
(val == 7) ? 8'b00000111 :
(val == 8) ? 8'b01111111 :
(val == 9) ? 8'b01101111 :
(val == 10) ? 8'b01110111 :
(val == 11) ? 8'b01111100 :
(val == 12) ? 8'b00111001 :
(val == 13) ? 8'b01011110 :
(val == 14) ? 8'b01111001 :
(val == 15) ? 8'b01110001 : 0;
endmodule
Модуль даже пояснять не надо. Пояснять надо как его применять. В моём первом подходе к теме FPGA я чуть свою программистскую голову не сломал, как подключить модуль вроде dig_gen к модулю вроде LowLevel3_7Seg. "Гроком" оказалось забыть про переменные и воспринимать wire и reg как провода и регистры.
Опять проверяем в железе. В модуле main создадим 3 инстанса знакогенератора, как входы зададим им какие-нибудь рандомные цифры (чтобы в 4 бита поместились), а к выходам подсоединим пучки по 8 проводов, которые предварительно объявим. И эти же пучки подсоединим ко входам LowLevel3_7Seg_inst вместо тестовых последовательностей. Получится такой модуль main (частоту я изменил в строке 22):
module main(
input wire clk,
output wire led,
output wire[7:0] seg,
output wire[2:0] dig
);
reg[25:0] cntr;
assign led = cntr[25];
always @(posedge clk) cntr <= cntr + 1;
wire[7:0] dig1;
wire[7:0] dig2;
wire[7:0] dig3;
dig_gen dig_gen_inst1(.val(0), .seg(dig1));
dig_gen dig_gen_inst2(.val(4), .seg(dig2));
dig_gen dig_gen_inst3(.val(2), .seg(dig3));
LowLevel3_7Seg LowLevel3_7Seg_inst(
.seg(seg),
.dig(dig),
.seg_sw_clk(cntr[8]),
.segs1(dig1),
.segs2(dig2),
.segs3(dig3)
);
endmodule
После компиляции и заливки правильный результат должен появиться на семисегментнике.
Следующая подзадача: отображение положительного десятибитного числа в десятичной форме. То есть, на входе 10 бит числа и клок, на выходе 11 проводов на семисегментники (8 + 3). Пишем в файле indicator.v ещё модуль:
module number_on_3_7Seg(
input wire seg_sw_clk,
input wire[9:0] num,
output wire[7:0] seg,
output wire[2:0] dig
);
wire[3:0] dig1_val = num / 100;
wire[3:0] dig2_val = (num / 10) % 10;
wire[3:0] dig3_val = num % 10;
wire[7:0] dig1;
wire[7:0] dig2;
wire[7:0] dig3;
dig_gen dig_gen_inst1(.val(dig1_val), .seg(dig1));
dig_gen dig_gen_inst2(.val(dig2_val), .seg(dig2));
dig_gen dig_gen_inst3(.val(dig3_val), .seg(dig3));
LowLevel3_7Seg LowLevel3_7Seg_inst(
.seg(seg),
.dig(dig),
.seg_sw_clk(seg_sw_clk),
.segs1(dig1),
.segs2(dig2),
.segs3(dig3)
);
endmodule
По сути, мы скопировали то, что было в модуле main, и добавили вычисление значений разрядов для знаков и подключили эти провода вместо 042. Обратите внимание, что провода можно назначать при их объявлении (строки 8, 9, 10. Опять эти опасные аналогии с языками программирования). Деление на 100 и на 10 это также комбинаторика. Кстати, для любителей схем есть такая кнопочка:

Если очень интересно, то можно проверить и этот модуль, инстанциировав его в main. Забавно, что для чисел от 999 до 1023 он выведет значение, начинающееся с A. Но тут мы уже научились инстанциировать модули и подключать к ним провода. Дорешаем крупную задачу. Добавим модуль отсчёта от 999 до 0. На входе сигнал клок, на выходе – 10 бит числа. В этот раз модуль не относится к семисегментнику, так что поместим его в файл main.v выше модуля main:
module cntr999(input wire count_clk, output reg[9:0] value);
always @(posedge count_clk) begin
value <= (value == 0) ? 999 : value - 1;
end
endmodule
Из того, чего раньше не было: reg можно писать прямо в списке выходов. Просто сокращение. Регистр это часть модуля, при этом его выходы являются также и выходами модуля. При этом нельзя писать reg как вход. Входом может быть только wire. Ещё блок always можно сделать многострочным. Вместо фигурных скобочек привет Паскаль. Где-то здесь ещё начинают закрадываться сомнения по поводу начальных значений регистров. Вообще-то ко всем сигналам надо добавлять ещё и линию сброса (reset). Но мы пока не заметим этого маленького слоника, и будем полагаться на чип, который при включении обнуляет все регистры, которые мы используем.
Соединяем модуль cntr999 с модулем number_on_3_7Seg. Для входа count_clk немного сжульничаем и сделаем не ровно 10 Гц, а 11.9, чтобы не делать ещё один счётчик. Воспользуемся битом 21 нашего счётчика для мигания диодом. main теперь выглядит так:
module main(
input wire clk,
output wire led,
output wire[7:0] seg,
output wire[2:0] dig
);
reg[25:0] cntr;
assign led = cntr[25];
always @(posedge clk) cntr <= cntr + 1;
wire [9:0] num;
cntr999 cntr999_inst(.count_clk(cntr[21]), .value(num));
number_on_3_7Seg number_on_3_7Seg_inst(
.seg_sw_clk(cntr[8]),
.num(num),
.seg(seg),
.dig(dig)
);
endmodule
Компилируем, заливаем, и убеждаемся, что идёт отсчёт. И лампочка по-прежнему моргает.
Всё, вроде. С семисегментником разобрались. Попутно разобрались с некоторыми нюансами. И да. Это была передышка – всего лишь некоторые базовые конструкции.
В следующей статье попробуем сделать что-нибудь такое, чего не сделаешь на ардуинке, и для чего действительно без ПЛИС (в любительском смысле) не обойтись.