Основы FPGA и HDL
Изучите основы программируемых логических интегральных схем (ПЛИС/FPGA) и языков описания аппаратуры (HDL).
Последнее обновление: 16.01.2024
Необходимое оборудование
Программируемые логические интегральные схемы
Программируемые логические интегральные схемы, сокращённо ПЛИС (FPGA) — относительно старый способ создания специализированного аппаратного обеспечения, позволяющий обойтись без затрат, связанных с производством кремниевых чипов. К сожалению, большая часть сложности проектирования чипов здесь всё равно присутствует, и именно по этой причине большинство людей предпочитает использовать готовые микросхемы, нередко принимая их ограничения, вместо того чтобы браться за разработку оптимизированного и эффективного устройства с точно необходимой аппаратной начинкой.
Как это происходит в мире программного обеспечения — где существует множество библиотек, от которых можно отталкиваться, — так и для ПЛИС существуют своеобразные «библиотеки», называемые IP-блоками. Однако они, как правило, весьма дороги и лишены стандартизированного интерфейса «подключи и работай», что вызывает головную боль при интеграции всего в единую систему. То, что Arduino пытается сделать, вводя ПЛИС в свою линейку продуктов, — это воспользоваться гибкостью программируемого аппаратного обеспечения специально для предоставления расширяемого набора периферийных устройств для микроконтроллеров, избавив при этом от большей части сложности. Разумеется, для достижения этой цели необходимо наложить определённые ограничения и определить стандартный способ соединения блоков, чтобы это можно было делать автоматически.
Первый шаг — определить набор стандартных интерфейсов, которые должны строго соответствовать заданному набору правил. Но прежде чем углубляться в это, важно понять, какие именно виды интерфейсов могут нам понадобиться. Поскольку мы взаимодействуем с микроконтроллером, первый интерфейс, который нам нужно определить, — это шина для соединения процессора с периферийными устройствами. Такая шина должна существовать как минимум в двух вариантах: контроллера и периферийного устройства, где сигналы одинаковы, но с инвертированными направлениями. За дополнительными подробностями о шинах и архитектуре контроллер/периферия обратитесь к этому документу.
Второй интерфейс, который важен, но не может быть стандартизирован, — это входные/выходные сигналы, соединяющие устройство с внешним миром. Здесь нельзя определить стандарт, поскольку каждый блок будет иметь собственный набор сигналов; однако мы можем просто сгруппировать набор сигналов вместе, назвав эту группу «conduit» (трубопровод).
Наконец, есть третий класс интерфейсов, который может оказаться полезным, — он предназначен для передачи потоковых данных. В данном случае нам нужно передавать непрерывный поток данных, но при этом иметь возможность приостановить поток, если принимающий блок не успевает его обрабатывать. Поэтому, помимо данных, нам нужны некоторые сигналы управления потоком — примерно так же, как это происходит в UART.
Поскольку мы хотим также немного стандартизировать читабельность, мы хотим установить некоторые соглашения по кодированию. Здесь, конечно, существует множество «религий», определяющих использование пробелов/табуляций, нотацию и так далее — мы выбираем те, которые нам нравятся…
Говоря о «религиях», мы неизбежно переходим к разговору о языках… Мы предпочитаем (System)Verilog вместо VHDL, и большинство наших IP-блоков написаны именно на нём. Причина нашего выбора в том, что Verilog в целом более похож на C, а также позволяет использовать очень удобные конструкции, упрощающие создание параметрических блоков.
Соглашения по кодированию
Мы используем префикс перед каждой объявляемой сущностью, чтобы идентифицировать её тип; имя переменной пишется полностью заглавными буквами, а несколько слов разделяются подчёркиванием. В частности:
Префикс |
Описание |
|---|---|
w |
Wire (провод) — для всех комбинационных сигналов, например wDATA. Обычно объявляется директивой wire |
r |
Reg (регистр) — для всех последовательных сигналов, например rSHIFTER. Обычно объявляется директивой reg |
i |
Input (вход) — для всех входных сигналов в объявлении модуля, например iCLK. Обычно объявляется директивой input |
o |
Output (выход) — для всех выходных сигналов в объявлении модуля, например oREAD. Обычно объявляется директивой output |
b |
Bidirectional (двунаправленный) — для всех двунаправленных сигналов в объявлении модуля, например bSDA. Обычно объявляется директивой inout |
p |
Parameter (параметр) — для всех параметров, используемых для параметризации блока, например pCHANNELS. Обычно объявляется директивой param |
c |
Constant (константа) — для всех определений, являющихся константами или производными значениями, которые нельзя напрямую использовать для параметризации блока. Например cCHANNEL_BITS. Обычно объявляется директивой localparam |
e |
Enumerated (перечислимый) — для всех возможных константных значений, используемых одним или несколькими сигналами или регистрами. Например, состояние конечного автомата может быть объявлено как eSTATE. Обычно объявляется директивой enum |
Мы предпочитаем пробелы вместо табуляций! Причина в том, что независимо от размера табуляции код всегда выглядит хорошо.
Отступ устанавливается в два пробела.
Блоки условных операторов всегда должны иметь конструкцию begin/end, даже если в них содержится только один оператор; begin/end должны располагаться на той же строке, что и if/else.
Сигналы, принадлежащие одной группе, должны иметь общий префикс.
Прототипы интерфейсов
Облегчённая шина (Lightweight Bus)
Шина для соединения периферийных устройств. По соглашению шина данных имеет ширину 32 бита, а шина адреса — переменную ширину, определяемую количеством экспонируемых регистров. Шина требует следующего набора сигналов:
Сигнал |
Направление (Контроллер) |
Направление (Периферия) |
Ширина |
Описание |
|---|---|---|---|---|
ADDRESS |
O |
I |
var. |
Адрес регистра; ширина определяет диапазон адресов |
READ |
O |
I |
1 |
Строб чтения |
READ_DATA |
I |
O |
32 |
Данные, считываемые с шины |
WRITE |
O |
I |
1 |
Строб записи |
WRITE_DATA |
O |
I |
32 |
Данные для записи по заданному адресу |
BYTE_ENABLE |
O |
I |
4 |
Опциональный сигнал, указывающий, какие байты 32-битного слова будут фактически записаны |
WAIT_REQUEST |
I |
O |
1 |
Опциональный сигнал, сигнализирующий о занятости периферии. Стробы чтения и записи считаются действительными только если этот сигнал не активен. |
По соглашению при цикле записи ADDRESS и WRITE_DATA защёлкиваются в том же такте, что и строб WRITE. В отличие от этого, при цикле чтения READ_DATA представляется периферией в такте, непосредственно следующем за стробом READ, который также указывает считываемый ADDRESS.
Конвейерная шина (Pipelined Bus)
Шина для соединения сложных блоков, способных обрабатывать более одной команды одновременно и отвечающих на запросы за переменное время. Эта шина расширяет облегчённую шину следующими сигналами:
Такое поведение также называется задержкой чтения в 1 такт и означает, что хотя периферия всё ещё может иметь переменное число тактов для ответа на операцию READ или WRITE с помощью опционального сигнала WAIT_REQUEST, это блокирует контроллер, не давая ему выполнять другие операции. В некотором роде это можно сравнить с использованием в программировании циклов ожидания вместо задержек с передачей управления ОС для многозадачности.
Сигнал |
Направление (Контроллер) |
Направление (Периферия) |
Ширина |
Описание |
|---|---|---|---|---|
BURST_COUNT |
O |
I |
var. |
Количество последовательных операций для выполнения |
READ_DATAVALID |
I |
O |
1 |
Периферия использует этот сигнал для оповещения, когда данные предоставляются контроллеру. Может быть активирован с любой задержкой, непрерывность не гарантируется. Операция чтения с размером пакета 4 будет 4 раза активировать READ_DATAVALID на каждый строб READ |
Основное преимущество этого подхода в том, что контроллер может сообщить периферии о намерении прочитать или записать несколько данных за одну транзакцию. Как для стробов чтения, так и для стробов записи сигнал BURST_COUNT сообщает периферии длительность транзакции.
Контроллер будет удерживать WAIT_REQUEST до тех пор, пока не будет готов принять операцию. В случае записи BURST_COUNT и ADDRESS сэмплируются только по первому стробу, после чего периферия будет ожидать, что строб WRITE будет активирован столько раз, сколько было запрошено слов, и будет автоматически инкрементировать адрес. Для операций чтения единственный строб READ, поданный при неактивном WAIT_REQUEST, сообщает периферии о необходимости прочитать BURST_COUNT слов, которые будут возвращены путём активации READ_DATAVALID нужное количество раз. После инициации операции чтения периферия сама решает, принимать ли дополнительные операции, но в целом должна быть возможность иметь как минимум две одновременные операции, чтобы воспользоваться преимуществами конвейерной шины.
Потоковый интерфейс (Streaming Interface)
Скоро будет…
Структура модуля (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
По сути это то же самое, но мы определили регистр, работали с ним, а затем «непрерывным» присваиванием = назначили его выходному сигналу. Разница в том, что <= означает изменение сигнала только по фронту тактового сигнала, тогда как = назначает значение непрерывно, так что сигнал может измениться в любой момент времени. Однако если мы назначаем его, как в примере, регистру, который меняется только по фронту такта, результирующий сигнал фактически является просто псевдонимом.
Интересно, что присваивания, как и любые другие операторы в языках описания аппаратуры, выполняются параллельно, то есть их порядок в коде не столь важен — все они выполняются одновременно. Поэтому мы могли бы присвоить 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, где будем оставаться заданное число тактов (параметризованное через pHIGH_COUNT), затем перейдём в 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 является зарегистрированным, он будет удерживать своё состояние до следующего присваивания. В следующем состоянии всё немного сложнее… в каждом такте мы декрементируем счётчик, и если счётчик достигает 0, мы также меняем состояние, устанавливаем oPULSE в 0 и снова присваиваем rCOUNTER. Поскольку два присваивания выполняются параллельно, нам нужно понять, что это означает. К счастью, все HDL предписывают: если два параллельных оператора выполняются над одним регистром, только последний действительно будет применён. Таким образом, смысл написанного таков: обычно мы декрементируем счётчик, но когда счётчик достигает 0, мы меняем состояние и реинициализируем его значением pLOW_COUNT.
В этот момент то, что происходит в eST_PULSE_LOW, становится совершенно очевидным: мы просто декрементируем счётчик и возвращаемся в eST_IDLE, как только он достигает 0. Заметим, что когда мы возвращаемся в eST_IDLE, rCOUNTER вновь декрементируется, и в итоге rCOUNTER будет равен 0xFF (или -1) в состоянии eST_IDLE, но нас это не волнует, поскольку мы сбросим его до нужного значения при получении iPULSE_REQ.
Хотя мы могли бы сбросить rCOUNTER и при выходе из eST_PULSE_LOW, в HDL всегда лучше делать только действительно необходимое: всё лишнее потребляет ресурсы и замедляет аппаратуру. Поначалу это может казаться рискованным, но с опытом станет ясно, как это помогает. Тот же принцип применяется к логике сброса. Если это не является действительно необходимым, сброс в зависимости от реализации может потреблять ресурсы и ухудшать быстродействие системы, поэтому его следует применять осторожно.
Реальный пример: ШИМ-контроллер
Теперь давайте погрузимся в реальный пример простой периферии, которую мы используем в Vidor, — модуль ШИМ (PWM). Нашей целью было создание небольшого блока с несколькими ШИМ-выходами с возможностью определять относительную фазу каждого канала ШИМ.
Для этого нам нужен счётчик и несколько компараторов, которые сообщают нам, когда счётчик превышает заданные значения, чтобы мы могли переключать выходы. Поскольку мы также хотим, чтобы частота ШИМ была программируемой, нам нужен счётчик, работающий на частоте, отличной от базовой, используемой в системе, чтобы его период был именно таким, каким нам нужно. Для этого мы используем предделитель (prescaler) — по сути ещё один счётчик, который делит базовую частоту до более низкого значения способом, аналогичным генераторам скорости передачи данных в UART.
Посмотрим на код:
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: период ШИМ
// чётные регистры >= 2: значение, при котором выход ШИМ устанавливается в 1
// нечётные регистры >= 2: значение, при котором выход ШИМ устанавливается в 0
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. отсюда мы инкрементируем счётчик ШИМ,
// который тактируется на более низкой частоте.
rPERIOD_CNT<=rPERIOD_CNT+1;
if (rPERIOD_CNT>=rPERIOD_MAX) begin
// и разумеется, сбрасываем счётчик при достижении максимального периода.
rPERIOD_CNT<=0;
end
end
// этот блок реализует параллельные компараторы, которые фактически генерируют ШИМ-выходы.
// цикл for генерирует массив логики, сравнивающей счётчик со значениями компараторов
// высокого и низкого уровня для каждого канала и устанавливающей выход соответственно.
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), при котором индексы находятся только слева от имени, и в результате двумерный массив можно также рассматривать как один большой регистр, содержащий конкатенацию всех регистров.
Ещё один интересный приём — то, как мы определяем логику, обрабатывающую регистры. Прежде всего, мы реализуем только регистры с возможностью записи, поэтому вы не найдёте сигналов iREAD и iREAD_DATA. Во-вторых, мы хотели иметь параметрический набор регистров, где только первые два регистра всегда присутствуют, а остальные динамически определяются и обрабатываются в зависимости от количества каналов, которое мы хотим реализовать. Для этого мы замечаем, что в двоичном числе наименее значимый бит определяет, нечётное оно или чётное. Поскольку у нас два регистра на канал, это удобно: мы можем дифференцировать поведение в зависимости от того, находимся ли мы ниже адреса 2 или нет.
Если мы ниже адреса 2, мы реализуем общие регистры — предделитель и период счётчика. Если мы выше 2, мы используем LSB, чтобы определить, записываем ли мы значение для компаратора высокого или низкого уровня.
Ещё один простой пример: квадратурный энкодер
Ещё один простой пример, из которого можно почерпнуть полезное, — квадратурный энкодер. Хотя он может показаться проще ШИМ-модуля, он также затрагивает некоторые нетривиальные задачи. Первая проблема, с которой мы сталкиваемся при работе с сигналами из внешнего мира, — отсутствие гарантии их синхронности с нашим внутренним тактовым сигналом. Из-за этого мы можем столкнуться с явлением, называемым метастабильностью, которое приводит к неопределённому состоянию данных в регистрах и потенциальному их изменению в течение тактового цикла. Причина в том, что если данные меняются на входе регистра в момент их защёлкивания, регистр может перейти в неустойчивое состояние, которое может «распасться» до 0 или 1 в любое время. По этой причине нам необходимо ресинхронизировать входной сигнал, добавив цепочку регистров: даже если первый регистр станет метастабильным, последующие будут иметь стабильное состояние, способное питать последующую логику без риска «заражения» нестабильным состоянием.
Ещё одна интересная вещь, которую мы делаем здесь, — использование непрерывного присваивания для определения строба и направления из квадратурных сигналов энкодера. В коде есть простые графики, показывающие, как выглядят формы сигналов, хотя для полного понимания того, как получаются формы, нужно учесть, что уравнения используют сигналы в разные моменты времени. Это делается просто путём использования сдвигового регистра, применяемого для синхронизации асинхронных входов, также и для их задержки: подключаясь к разным точкам сдвигового регистра, мы видим состояние сигнала на один такт назад. В частности, если мы движемся к входу сдвигового регистра, мы получаем «более новые» данные, а если к концу — «более старые».
Если мы смотрим на уравнения, мы видим оператор ^, который является логическим исключающим ИЛИ (XOR): возвращает 1, если два операнда различны, и 0 в противном случае.
Глядя на формы сигналов, мы видим, что строб генерирует импульс при каждом фронте A или B — это делается простым XOR каждого сигнала с его задержанной версией. Сигнал направления немного сложнее, но мы замечаем, что он постоянно равен либо 0, либо 1, когда строб высокий, в зависимости от направления вращения энкодера. На самом деле мы видим импульсы на сигнале направления, но они не совпадают со стробами, поэтому они будут проигнорированы.
Одна вещь, которая может не показаться очевидной с первого взгляда: уравнения параллельно вычисляют одну и ту же логику для всех входов. Регистры rRESYNC_ENCODER представляют собой упакованные двумерные массивы, организованные так, что первый индекс идентифицирует отвод сдвигового регистра, а второй — канал энкодера. Это означает, что при обращении к rRESYNC_ENCODER с конкретным индексом мы выбираем одномерный массив, содержащий все входы энкодера одновременно с задержкой, определяемой индексом. Это также означает, что при побитовой логической операции над массивом мы фактически инстанцируем несколько параллельных логических уравнений одновременно. Заметим, что это возможно только потому, что массив «упакован»: с «нераспакованными» массивами элементы считаются отдельными сущностями, не могут участвовать в уравнениях таким образом и должны адресоваться по отдельности.
Как и в других примерах, блок реализует несколько входов с помощью цикла for, который проверяет сигнал разрешения (который также является массивом шириной, равной количеству каналов), и когда тот высокий, проверяет направление и в зависимости от него либо инкрементирует, либо декрементирует счётчик соответствующего канала. Это легко делается с помощью оператора ? : (условное выражение), который работает точно так же, как в C.
Наконец, интерфейс шины довольно прост, поскольку единственными регистрами у нас являются счётчики только для чтения, что мы реализуем простой проверкой сигнала чтения и присваиванием выходных данных из массива счётчиков, индексированных по адресу, — примерно как если бы это была оперативная память.
module QUAD_ENCODER #(
pENCODERS=2,
pENCODER_PRECISION=32
)(
input iCLK,
input iRESET,
// ИНТЕРФЕЙС AVALON PERIPHERAL
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
// если строб высокий..
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.
Последнее изменение: 17.07.2018, DP & SM