Главная chevron_right Уроки chevron_right Программирование chevron_right Основы HDL для FPGA

Основы HDL для FPGA

Введение в программируемые логические матрицы (FPGA) и языки описания аппаратуры HDL: соглашения, интерфейсы, SystemVerilog и реальные примеры.

Необходимое оборудование

Что такое FPGA

Field Programmable Gate Arrays (сокращённо FPGAs) — это давно существующий способ создавать собственное аппаратное обеспечение без обращения к кремниевым фабрикам. Звучит заманчиво, но большинство разработчиков всё равно предпочитает готовые микросхемы: проще, дешевле, предсказуемее. Сложность проектирования никуда не исчезает — она просто перекладывается на плечи разработчика.

Как в мире программного обеспечения есть библиотеки, так и для FPGAs существуют готовые «библиотеки» — IP-блоки. Но они, как правило, дорого стоят и не имеют единого стандарта подключения, что превращает интеграцию в головную боль.

Вводя FPGAs в свою линейку продуктов, Arduino стремится использовать гибкость программируемого железа для создания расширяемого набора периферийных устройств для микроконтроллеров — и при этом убрать большую часть сложности. Чтобы это работало, нужно принять некоторые ограничения и договориться о стандартных способах соединения блоков, чтобы это можно было делать автоматически.

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

Поскольку мы работаем с микроконтроллером, первым делом нужна шина для связи процессора с периферией. Такая шина должна существовать как минимум в двух вариантах: контроллер и периферия — сигналы одинаковые, но направления инвертированы. Подробнее о шинах и архитектуре контроллер/периферия можно почитать здесь: document.

Второй тип интерфейса — входные и выходные сигналы, связывающие блок с внешним миром. Здесь стандарт ввести не получится: каждый блок предоставляет свой набор сигналов. Мы просто объединяем их в группу — назовём её conduit.

Третий класс интерфейсов — потоковая передача данных. Нужно передавать непрерывный поток, но с возможностью приостановить его, если принимающий блок не успевает обрабатывать. Для этого, помимо данных, нужны сигналы управления потоком — примерно как в UART.

Чтобы код был читаемым и понятным, мы также договоримся о соглашениях по его оформлению.

И раз уж зашла речь о соглашениях — поговорим о языках. Мы предпочитаем (System)Verilog, а не VHDL: Verilog ближе к C по синтаксису и позволяет создавать элегантные параметрические блоки.

Соглашения по написанию кода

  • Перед каждым объявленным элементом ставим префикс, определяющий его тип. Имя переменной пишется полностью заглавными буквами, слова разделяются подчёркиванием. В частности:
PrefixDescription
wWire, for all combinatorial signals, for example wDATA. Typically defined with wire directive
rReg, for all sequential signals, for example rSHIFTER. Typically defined with reg directive
iInput, for all input signals in module declaration, for example iCLK. Typically defined with input directive
oOutput, for all output signals in module declaration, for example oREAD. Typically defined with output directive
bBidirectional, for all inout signals in module declaration, for example bSDA. Typically defined with inout directive
pParameter, for all parameters that can be used to parametrize block, for example pCHANNELS. Typically defined with param directive
cConstant, for all definitions which are constant or are derived values and can't be directly used to parametrize the block. For example cCHANNEL_BITS. Typically defined with localparam directive
eEnumerated, for all the possible constant values used by one or more signals or registers. For example a state machine state can be defined as eSTATE. Typically defined with enum directive
  • Пробелы предпочтительнее табуляций: код выглядит одинаково хорошо при любом размере таба.
  • Отступ — два пробела.
  • Блоки условных операторов всегда оборачиваются в begin/end, даже если внутри одна строка. begin/end пишется на той же строке, что if/else.
  • Сигналы одной группы должны иметь общий префикс.

Прототипы интерфейсов

Lightweight Bus (облегчённая шина)

Шина для подключения периферии. По соглашению шина данных — 32 бита, шина адреса — переменной ширины, зависит от количества регистров. Шина требует следующий набор сигналов:

SignalDirectionDirectionWidthDescription
ControllerPeripheral
ADDRESSOIvar.Register address, width determines
READOI1Read strobe
READ_DATAIO32Data being read
WRITEOI1Write strobe
WRITE_DATAOI32Data to write at a given address
BYTE_ENABLEOI4Optional signal to flag which bytes of the 32 bit word are actually going to be written
WAIT_REQUESTIO1Optional signal to flag the peripheral is busy. Read and write strobes will be considered valid only if this signal is not asserted.

По соглашению при операции записи ADDRESS и WRITE_DATA защёлкиваются в том же такте, что и строб WRITE. При операции чтения READ_DATA выставляется периферией в такте, сразу следующем за стробом READ, который также указывает читаемый ADDRESS.

Pipelined Bus (конвейерная шина)

Шина для сложных блоков, способных обрабатывать несколько команд одновременно и отвечать за переменное время. Расширяет Lightweight Bus следующими сигналами:

Такое поведение называется «задержкой чтения в 1 такт». Периферия может использовать опциональный сигнал WAIT_REQUEST для задержки ответа на READ или WRITE, но это блокирует контроллер и не позволяет ему выполнять другие операции — примерно как busy loop в программировании вместо yield к ОС.

SignalDirectionDirectionWidthDescription
ControllerPeripheral
BURST_COUNTOIvar.Number of sequential operations to perform
READ_DATAVALIDIO1Peripheral uses this signal to flag when data is being provided to controller. Can be asserted with any delay and there is no guarantee on contiunity. A read operation with a burst size of 4 will assert 4 times READ_DATAVALID per each READ strobe

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

Контроллер удерживает WAIT_REQUEST до готовности принять операцию. При записи BURST_COUNT и ADDRESS сэмплируются только на первом стробе, после чего периферия ожидает, что строб WRITE будет удерживаться нужное количество тактов, автоматически инкрементируя адрес. Для чтения одиночный строб READ при деассертированном WAIT_REQUEST сообщает периферии прочитать BURST_COUNT слов, которые возвращаются путём удержания READ_DATAVALID в течение запрошенного числа тактов. После начала операции чтения периферия сама решает, принимать ли новые операции, но в общем случае должна поддерживать хотя бы две параллельные операции.

Потоковый интерфейс (Streaming)

Скоро...

Структура модуля (System)Verilog

Объявление модуля SystemVerilog можно записать по-разному, но мы предпочитаем форму с параметрами — она позволяет настраивать входы блока на этапе компиляции:

module COUNTER #(
pWIDTH=8
) (
input                   iCLK,
input                   iRESET,
output reg [pWIDTH-1:0] oCOUNTER
);
endmodule

Здесь мы определили прототип модуля и его порты. Теперь добавим полезную логику между заголовком модуля и оператором endmodule.

Продолжим пример со счётчиком и напишем код, который его реализует:

module COUNTER #(
pWIDTH=8
) (
input               iCLK,
input               iRESET,
output [pWIDTH-1:0] oCOUNTER
);
always @(posedge iCLK)
begin
if (iRESET) begin
oCOUNTER<=0;
end else begin
oCOUNTER<= oCOUNTER+1;
end
end
endmodule

Код говорит сам за себя: на каждом положительном фронте тактового сигнала, если вход iRESET высокий — сбрасываем счётчик, иначе — инкрементируем на единицу. Сигнал сброса, возвращающий блок в известное состояние, часто полезен, но не всегда обязателен.

Есть один нюанс: мы объявили oCOUNTER как output reg, то есть это не просто набор проводов — у него есть память. Поэтому мы используем присваивание <=, которое «регистровое»: значение сохраняется до следующего тактового цикла.

Альтернативный вариант — убрать reg из объявления модуля и определить счётчик так:

module COUNTER #(
pWIDTH=8
) (
input               iCLK,
input               iRESET,
output [pWIDTH-1:0] oCOUNTER
);
reg [pWIDTH-1:0] rCOUNTER;
always @(posedge iCLK)
begin
if (iRESET) begin
rCOUNTER<=0;
end else begin
rCOUNTER<= rCOUNTER+1;
end
end
assign oCOUNTER=rCOUNTER;
endmodule

Результат тот же, но теперь мы явно объявляем регистр, работаем с ним и через «непрерывное» присваивание = транслируем его в выходной сигнал. Разница: <= означает, что сигнал меняется только на фронтах тактового сигнала, а = присваивает значение непрерывно. Но если присваивать регистр, который меняется только на фронтах, итоговый сигнал — просто псевдоним.

Важная особенность HDL: присваивания, как и любые другие операторы, выполняются параллельно. Порядок записи в коде не имеет принципиального значения — всё исполняется одновременно. Поэтому мы могли бы написать присваивание oCOUNTER к rCOUNTER и до блока always. Но это не совсем так — к этому мы ещё вернёмся.

Непрерывные присваивания также позволяют создавать логические уравнения. Например, счётчик можно переписать так:

module COUNTER #(
pWIDTH=8
) (
input               iCLK,
input               iRESET,
output [pWIDTH-1:0] oCOUNTER
);
reg [pWIDTH-1:0] rCOUNTER;
wire [pWIDTH-1:0] wNEXT_COUNTER;
assign wNEXT_COUNTER = rCOUNTER+1;
assign oCOUNTER = rCOUNTER;
always @(posedge iCLK)
begin
if (iRESET) begin
rCOUNTER<=0;
end else begin
rCOUNTER<= wNEXT_COUNTER;
end
end
endmodule

Логика та же, но более явная: мы непрерывно присваиваем сигналу wNEXT_COUNTER значение rCOUNTER плюс один. Это значит, что wNEXT_COUNTER изменится почти мгновенно, как только изменится rCOUNTER, однако rCOUNTER обновится только на следующем положительном фронте (из-за <= присваивания). Итог: rCOUNTER меняется строго по фронту тактового сигнала.

Параллелизм и приоритет

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

Например, следующий блок приведёт к тому, что все регистры изменятся одновременно на одном фронте тактового сигнала:

reg [pWIDTH-1:0] rCOUNT_UP, rCOUNT_DOWN;
always @(posedge iCLK)
begin
if (iRESET) begin
rCOUNT_UP<=0;
rCOUNT_DOWN<=0;
end else begin
rCOUNT_UP<= rCOUNT_UP+1;
rCOUNT_DOWN<= rCOUNT_DOWN-1;
end
end

Чтобы «упорядочить» выполнение, используют конечный автомат (state machine) — систему, которая генерирует выходы на основе входов и своего внутреннего состояния.

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

Автомат будет иметь три состояния: eST_IDLE, eST_PULSE_HIGH и eST_PULSE_LOW.

  • В eST_IDLE ждём команды на вход. Получив её, переходим в eST_PULSE_HIGH.
  • В eST_PULSE_HIGH остаёмся заданное число тактов (параметр pHIGH_COUNT), затем переходим в eST_PULSE_LOW.
  • В eST_PULSE_LOW остаёмся pLOW_COUNT тактов, после чего возвращаемся в eST_IDLE.

Вот как это выглядит в коде:

module PULSE_GEN #(
pWIDTH=8,
pHIGH_COUNT=240,
pLOW_COUNT=40
) (
input       iCLK,
input       iRESET,
input       iPULSE_REQ,
output reg  oPULSE
);
reg [pWIDTH-1:0] rCOUNTER;
enum reg [1:0] {
eST_IDLE,
eST_PULSE_HIGH,
eST_PULSE_LOW
} rSTATE;
always @(posedge iCLK)
begin
if (iRESET) begin
rSTATE<=eST_IDLE;
end else begin
case (rSTATE)
eST_IDLE: begin
if (iPULSE_REQ) begin
rSTATE<= eST_PULSE_HIGH;
oPULSE<= 1;
rCOUNTER <= pHIGH_COUNT-1;
end
end
eST_PULSE_HIGH: begin
rCOUNTER<= rCOUNTER-1;
if (rCOUNTER==0) begin
rSTATE<= eST_PULSE_LOW;
oPULSE<= 0;
rCOUNTER<= pLOW_COUNT-1;
end
end
eST_PULSE_LOW: begin
rCOUNTER<= rCOUNTER-1;
if (rCOUNTER==0) begin
rSTATE<= eST_IDLE;
end
end
endcase
end
end
endmodule

Несколько новых вещей, на которые стоит обратить внимание.

Во-первых, переменная rSTATE объявлена через enum. Это позволяет давать состояниям понятные имена вместо жёстко закодированных чисел и упрощает добавление новых состояний.

Во-вторых, используется блок case/endcase — синтаксис очень похож на C. Операторы внутри разных веток case по-прежнему выполняются параллельно, но поскольку они обусловлены разными значениями переменной, активна только одна ветка.

В состоянии eST_IDLE мы ждём, пока iPULSE_REQ не станет высоким — тогда меняем состояние, сбрасываем счётчик на период высокого уровня и начинаем выдавать импульс.

Поскольку oPULSE — регистровый сигнал, он сохраняет своё значение до следующего присваивания.

В следующем состоянии чуть сложнее: на каждом такте декрементируем счётчик, а когда он достигает нуля — меняем состояние, устанавливаем oPULSE в 0 и снова присваиваем rCOUNTER.

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

Состояние eST_PULSE_LOW работает аналогично: декрементируем счётчик и возвращаемся в eST_IDLE при достижении нуля.

Когда мы возвращаемся в eST_IDLE, rCOUNTER декрементируется ещё раз, так что в eST_IDLE он будет равен 0xFF (или -1). Но нас это не беспокоит: при следующем получении iPULSE_REQ он будет переинициализирован правильным значением.

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

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

Реальный пример

Теперь рассмотрим реальный пример простой периферии, используемой в Vidor, — PWM.

Цель — создать небольшой блок с несколькими выходами PWM и возможностью задавать относительную фазу каждого канала PWM.

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

Вот код:

module PWM #(
parameter pCHANNELS=16,
parameter pPRESCALER_BITS=32,
parameter pMATCH_BITS=32
)
(
input                              iCLK,
input                              iRESET,
input [$clog2(2*pCHANNELS+2)-1:0]  iADDRESS,
input [31:0]                       iWRITE_DATA,
input                              iWRITE,
output reg [pCHANNELS-1:0]         oPWM
);
// объявление регистров
reg [pPRESCALER_BITS-1:0] rPRESCALER_CNT;
reg [pPRESCALER_BITS-1:0] rPRESCALER_MAX;
reg [pMATCH_BITS-1:0] rPERIOD_CNT;
reg [pMATCH_BITS-1:0] rPERIOD_MAX;
reg [pMATCH_BITS-1:0] rMATCH_H [pCHANNELS-1:0];
reg [pMATCH_BITS-1:0] rMATCH_L [pCHANNELS-1:0];
reg rTICK;
integer i;
always @(posedge iCLK)
begin
// логика взаимодействия с шиной.
// карта регистров:
// 0: значение предделителя
// 1: период PWM
// чётные регистры >=2: значение, при котором выход PWM устанавливается в HIGH
// нечётные регистры >=2: значение, при котором выход PWM устанавливается в LOW
if (iWRITE) begin
// следующий оператор выполняется только если адрес >=2. case по iADDRESS[0]
// определяет, является ли адрес нечётным (iADDRESS[0]=1) или чётным (iADDRESS[0]=0)
if (iADDRESS>=2) case (iADDRESS[0])
0: rMATCH_H[iADDRESS[CLogB2(pCHANNELS):1]-1]<= iWRITE_DATA;
1: rMATCH_L[iADDRESS[CLogB2(pCHANNELS):1]-1]<= iWRITE_DATA;
endcase
else begin
// сюда попадаем, если iADDRESS<2
case (iADDRESS[0])
0: rPRESCALER_MAX<=iWRITE_DATA;
1: rPERIOD_MAX<=iWRITE_DATA;
endcase
end
end
// предделитель постоянно инкрементируется
rPRESCALER_CNT<=rPRESCALER_CNT+1;
rTICK<=0;
if (rPRESCALER_CNT>= rPRESCALER_MAX) begin
// если предделитель достиг или превысил максимальное значение
// сбрасываем его и устанавливаем флаг tick, который запускает остальную логику
// tick длится только один такт, так как сбрасывается строкой rTICK<= 0 выше
rPRESCALER_CNT<=0;
rTICK <=1;
end
if (rTICK) begin
// сюда попадаем каждый раз при сбросе rPRESCALER_CNT. отсюда инкрементируем счётчик PWM,
// который тактируется на более низкой частоте.
rPERIOD_CNT<=rPERIOD_CNT+1;
if (rPERIOD_CNT>=rPERIOD_MAX) begin
// и, разумеется, сбрасываем счётчик при достижении максимального периода.
rPERIOD_CNT<=0;
end
end
// этот блок реализует параллельные компараторы, непосредственно формирующие выходы PWM
// цикл for фактически создаёт массив логики, сравнивающей счётчик
// со значениями совпадения HIGH и LOW для каждого канала и устанавливающей выход соответственно.
for (i=0;i<pCHANNELS;i=i+1) begin
if (rMATCH_H[i]==rPERIOD_CNT)
oPWM[i] <=1;
if (rMATCH_L[i]==rPERIOD_CNT)
oPWM[i] <=0;
end
end
endmodule

Несколько новых вещей для изучения.

В объявлении модуля используется встроенная функция для вычисления необходимой ширины адресной шины. Цель — ограничить адресное пространство минимально необходимым. Например, для 10 каналов нужно 22 адреса. Поскольку каждый бит адреса удваивает количество адресов, достаточно 5 бит (32 адреса). Чтобы сделать это параметрическим, ширина iADDRESS определяется как $clog2(2*pCHANNELS+2), а регистры объявляются как двумерный массив.

Двумерный массив можно объявить двумя способами. Здесь используется «unpacked»-вариант: регистры объявляются как отдельные сущности с индексами слева от имени. Второй способ — «packed», когда индексы стоят слева от объявления и весь 2D-массив можно рассматривать как один большой регистр, содержащий конкатенацию всех элементов.

Интересный приём — логика обработки регистров. Здесь реализованы только регистры записи, поэтому сигналов iREAD и iREAD_DATA нет.

Параметрический набор регистров устроен так: первые два регистра присутствуют всегда, остальные создаются динамически в зависимости от числа каналов. Для различения используется младший бит адреса: если адрес меньше 2 — это общие регистры (предделитель и период счётчика), если больше — LSB определяет, записываем ли значение для компаратора высокого или низкого уровня.

Ещё один простой пример

Другой поучительный пример — квадратурный энкодер. На первый взгляд он проще PWM, но решает нетривиальные задачи.

Первая проблема при работе с сигналами из внешнего мира: нет гарантии, что они синхронны с внутренним тактовым сигналом. Это может привести к метастабильности — состоянию, когда данные в регистре не определены и могут произвольно измениться в течение тактового цикла. Причина: если данные меняются на входе регистра в момент защёлкивания, регистр может перейти в нестабильное состояние и «свалиться» в 0 или 1 в произвольный момент.

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

Ещё один интересный приём: непрерывные присваивания для вычисления строба и направления из квадратурных сигналов энкодера. В коде есть простые диаграммы сигналов. Чтобы понять, как получаются сигналы, нужно учитывать, что уравнения используют сигналы в разные моменты времени. Это реализуется через сдвиговый регистр синхронизации: обращаясь к разным позициям регистра, мы получаем сигнал с разной задержкой. Позиции ближе к входу — «свежее» данные, ближе к выходу — «старее».

В уравнениях используется оператор ^ (XOR): возвращает 1, если операнды различаются, и 0 в противном случае.

Строб генерирует импульс всякий раз, когда A или B имеют фронт — путём XOR каждого сигнала с его задержанной версией. Сигнал направления сложнее, но он устойчиво равен 0 или 1 в моменты активности строба в зависимости от направления вращения. Импульсы на сигнале направления, не совпадающие со стробом, игнорируются.

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

Побитовая логическая операция над таким массивом мгновенно создаёт множество параллельных логических уравнений. Это работает только для «packed»-массивов: элементы «unpacked»-массивов считаются отдельными и не могут участвовать в уравнениях таким образом — к ним нужно обращаться по одному.

Как и в предыдущих примерах, блок обрабатывает несколько входов через цикл for, проверяя сигнал разрешения (массив шириной, равной числу каналов). Когда он высокий — проверяется направление и в зависимости от него счётчик канала инкрементируется или декрементируется с помощью оператора ? : — точно как в C.

Шинный интерфейс прост: единственные регистры — счётчики только для чтения. Реализуется проверкой сигнала read и присваиванием выходных данных из массива счётчиков по индексу адреса — почти как обращение к RAM.

module QUAD_ENCODER #(
pENCODERS=2,
pENCODER_PRECISION=32
)(
input                             iCLK,
input                             iRESET,
// ИНТЕРФЕЙС ПЕРИФЕРИИ AVALON
input  [$clog2(pENCODERS)-1:0]	  iAVL_ADDRESS,
input        	                    iAVL_READ,
output reg [31:0]                 oAVL_READ_DATA,
// ВХОДЫ ЭНКОДЕРА
input [pENCODERS-1:0]             iENCODER_A,
input [pENCODERS-1:0]             iENCODER_B
);
// двумерные массивы, содержащие состояния входов энкодера в 4 разных момента времени
// первые два отвода задержки используются для синхронизации входов с внутренними тактами,
// а два других — для сравнения двух моментов времени этих сигналов.
reg [3:0][pENCODERS-1:0] rRESYNC_ENCODER_A,rRESYNC_ENCODER_B;
// двумерные массивы, содержащие счётчики для каждого канала
reg [pENCODERS-1:0][pENCODER_PRECISION-1:0] rSTEPS;
// декрементирование энкодера
// A       __----____----__
// B       ____----____----
// ENABLE  __-_-_-_-_-_-_-_
// DIR     __---_---_---_--
//
// инкрементирование энкодера
// A       ____----____----
// B       __----____----__
// ENABLE  __-_-_-_-_-_-_-_
// DIR     ___-___-___-___-
wire [pENCODERS-1:0] wENABLE =  rRESYNC_ENCODER_A[2]^rRESYNC_ENCODER_A[3]^rRESYNC_ENCODER_B[2]^rRESYNC_ENCODER_B[3];
wire [pENCODERS-1:0] wDIRECTION = rRESYNC_ENCODER_A[2]^rRESYNC_ENCODER_B[3];
integer i;
initial rSTEPS <=0;
always @(posedge iCLK)
begin
if (iRESET) begin
rSTEPS<=0;
rRESYNC_ENCODER_A<=0;
rRESYNC_ENCODER_B<=0;
end
else begin
// реализуем сдвиговые регистры для каждого канала. так как массивы упакованы, их можно рассматривать как одномерный массив
// и, добавляя входы снизу, мы фактически сдвигаем данные на один бит
rRESYNC_ENCODER_A<={rRESYNC_ENCODER_A,iENCODER_A};
rRESYNC_ENCODER_B<={rRESYNC_ENCODER_B,iENCODER_B};
for (i=0;i<pENCODERS;i=i+1)
begin
// если strobe в HIGH..
if (wENABLE[i])
// инкремент или декремент в зависимости от направления
rSTEPS[i] <= rSTEPS[i]+ ((wDIRECTION[i]) ? 1 : -1);
end
// если интерфейс PERIPHERAL читается...
if (iAVL_READ)
begin
// возвращаем значение счётчика, индексированного адресом
oAVL_READ_DATA<= rSTEPS[iAVL_ADDRESS];
end
end
end
endmodule

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

Конечно, одну и ту же задачу можно решить по-разному. Этот вариант — один из наиболее компактных, но требует хорошего понимания возможностей (System)Verilog.

Последнее обновление: 2018/07/17, DP & SM