Шауэрман Александр А. shamrel@yandex.ru
Почему при переходе с программирования контроллеров на программирование ПЛИС многие программисты испытывают психологический дискомфорт? Так было со мной, так было с некоторыми моими коллегами. Простейший алгоритм может поставить в тупик опытного программиста. Причем встречал такие перекосы в сознании, когда человек знает как реализовать на verilog цифровой фильтр, но задача в виде нескольких последовательных действий ставит его в тупик. Как насчет того, чтобы после нажатия кнопки мигнуть светодиодом? На языке Си для контроллера делается это элементарно: организуется бесконечный цикл, в котором опрашивается состояние кнопки и, если кнопка нажата, то зажигаем светодиод, ждем, скажем, пол секунды и гасим светодиод. А на ПЛИС как? Стереотипность сознания заставляет думать, что ПЛИС – это регистры, триггеры, логические элементы, и при решения задач мыслить нужно в этих категориях. Для реализации алгоритма чуть сложнее, чем зажечь светодиод, люди уже смотрят в сторону встраиваемых процессорных ядер, например Nios II. Хотя вполне можно ограничится конечным автоматом, или, как принято называть в международной терминологии, Finite State Machine.
Все, кто занимался программированием, знает что такое "конечный автомат", и даже многие ответят на вопрос, чем отличается автомат Мили от автомата Мура. Во всех учебниках и справочниках по verilog даются примеры реализации, где какие-то мифические входные сигналы переводят абстрактную систему в какое-то фиксированное состояние. Но как и зачем это применять на практике?
Стандартные шаблоны (Template) из Quartus II несколько громоздки и, на мой взгляд, неудачно отформатированы. Названия состояний абстрактные и не дают представления о смысловом наполнении. Конечно, это же шаблон, там так и должно быть, но новичку разобраться тяжело. Если даже все понятно – это хорошо, но как ЭТО применять в своем проекте?
Попробуем для начала решить маленькую задачу: при нажатии кнопки мигнем светодиодом. На языке Си программа будет выглядеть примерно так:
while(1)
{
if(button)
{
led = 1;
delay();
led = 0;
delay();
}
}
Отлично, но как это сделать в ПЛИС? Я специально задал этот вопрос одному своему знакомому инженеру-электронщику и коллеге-программисту. Оба - профессионалы своего дела, но без существенного опыта разработки на ПЛИС. И оба мне рассказали, как кнопка будет запускать счетчик с автоблокировкой, потом сбрасывать триггер, который будет управлять еще чем-то … То есть у каждого получилось принципиально рабочее решение, но ориентированное на строго определенную поставленную задачу, а значит плохо масштабируемое. А задача имеет универсальное решение – использование автомата состояний. Попробуем показать это.
В качестве аппаратной платформы выберем плату с ПЛИС LESO2. Для того, чтобы не тратить время на назначение выводов, воспользуемся готовым демонстрационным проектом (Пишем "демку" для LESO2 на Verilog). Никто не запрещает нам удалить все лишнее. Оставляем основной модуль leso2_demo
, порт для источника таковых импульсов clk_50MHz_i
, выводы светодиодов led_o
и кнопку sw_i
.
Для удобства восприятия будем опираться на исходник программы на Си. Проанализируем в каких состояниях находится система (читай: микроконтроллер). При этом, каждому состоянию постараемся дать осмысленное название. Первое и основное состояние можно охарактеризовать так: система ничего не делает, только опрашивает кнопку. Назовем его "IDLE" – в переводе с английского "простой", в значении "простаивать". Система же ничего не делает, простаивает? На самом деле, лексема "IDLE" широко используется в программировании для обозначения режимов, состояний, в которых система пребывает в ожидании чего-либо или просто в спящем режиме, потому привычна и понятна большинству программистов. Не стоит пренебрегать общепринятыми логическими именами. Итак, после нажатия кнопки система переходит в другое состояние: светодиод горит. Назовем его "LED_ON". В этом состоянии система должна находиться некоторое время, достаточное для уверенной фиксации человеком горящего светодиода. Положим, приблизительно 1 секунду. Теперь неочевидный момент: для того, что бы избежать продолжительного горения светодиода при удержании нажатой кнопки, необходимо предусмотреть принудительное выключение светодиода, хотя бы на туже секунду. В языке Си, для этого я добавил задержку после выключения, а в ПЛИС введем состояние "LED_OFF".
Для дальнейшего рассуждения нам нужно знать, что в ПЛИС, впрочем как и микропроцессоре, все приводится в движения какими-то тактовыми импульсами. Как правило, импульсы поступают от внешнего задающего генератора. В учебном стенде LESO2 такой генератор работает на частоте 50МГц. Таким образом, каждый период генератора нам нужно определиться, в каком состоянии находится система: остаемся ли мы в настоящем, текущем состоянии или осуществляем переход в другое.
Подведем итог. Система находится в трех состояниях: IDLE
, LED_ON
и LED_OFF
. Переход из состояния IDLE
в состояние LED_ON
осуществляется внешним событием – нажатием кнопки. В состоянии LED_ON
система пребывает пока не сработает таймер (да, нам придется реализовать таймер-счетчик). Запуск таймера осуществляется переходом из состояния IDLE
в состояние LED_ON
. Аналогично для состояния LED_OFF
.
Переходы и состояния удобно изображать с помощью графов:
Эллипсы показывают состояния системы, а стрелочки – переход между ними. Над стрелочками указаны условия перехода. Если ничего не указано, то переход безусловный.
Реализация автомата на verilog
Введем две переменные (на самом деле регистры, но по назначению, вполне допустимая аналогия), хранящие состояние системы. В одной переменной, назовем ее state
, будем хранить текущее значение, в другой переменной next_state
– следующее. Разделим функционал автомата на два поведенческих блока always
. В первом блоке реализуем логику переходов между состояниями, а во втором - смену состояния. Можно в начале описать все типы сигналов, объявить состояния, но я предпочитаю начинать с сути: описываю переходы. Причем, когда ввожу код, я смело придумываю названия пока еще не существующим сигналам и состояниям (но все же рекомендую, до начала работы продумать состояния системы). Переходы оформляем через оператор case
:
always @*
case(state) // Выбор текущего состояния
IDLE:
if(button) // Если кнопка нажата,
next_state = LED_ON; // то переходим в состояние LED_ON,
else // иначе
next_state = IDLE; // остаемся в состоянии IDLE.
LED_ON:
if(timer_overflow) // Если таймер переполнен,
next_state = LED_OFF; // то переходим в состояние LED_OFF,
else // иначе
next_state = LED_ON; // остаемся в состоянии LED_ON.
LED_OFF:
if(timer_overflow)
next_state = IDLE;
else
next_state = LED_OFF;
default: // Для всех остальных, неописанных
next_state = IDLE; // состояний, переходим в IDLE
endcase
Смысл данного блока в том, чтобы для каждого состояния (state
) определить следующее состояние (next_state
). Следует отметить, что в списке чувствительности always
не указаны сигналы, а это значит, что во время синтеза будет создано комбинационное устройство. При описании переходов избегайте двусмысленности, должны быть отражены все варианты, каждому if должен соответствовать свой else. На случай непредвиденный, если в результате сбоя, переменная state примет какое-либо не описанное значение, в ветви default предусмотрим переход в начальное состояние.
Второй блок always
, предназначенный для смены состояний, должен выполняться синхронно с остальной частью схемы, в наших простых примерах, для задания главного тактового сигнала используем внешний генератор на 50МГц (соответствующий порт ПЛИС объявлен как clk_50MHz_i
). Помимо тактов в список чувствительности always
поместим сигнал reset
– глобальный сброс: мы ведь хотим, чтобы после подачи питания устройство начало свою работу в состоянии IDLE
?
always @(posedge reset or posedge clk_50MHz_i)
if(reset)
state <= IDLE;
else
state <= next_state;
Для формирования сигнала глобального сброса при включении питания воспользуемся такой простой конструкцией:
reg reset;
reg [3:0]rst_delay = 0;
always @(posedge clk_50MHz_i)
rst_delay <= { rst_delay[2:0], 1'b1 };
always @*
reset = ~rst_delay[3];
В результате на линии reset
после подачи питания устанавливает единица, а после трех периодов глобальных тактов (задержка реализована на сдвиговом регистре rst_delay
) устанавливается ноль. Получившийся сигнал можно использовать во всех модулях нашего проекта для перевода элементов с памятью (регистров, триггеров) в начальное значение.
Итак, граф описан. Займемся объявлениями. За словами "IDLE", "LED_ON", "LED_OFF" нужно закрепить определенное значение. Сделать это можно с помощью директивы `define
, либо с помощью объявления параметра (ключевое слово localparam
):
localparam IDLE = 2'd0;
localparam LED_ON = 2'd1;
localparam LED_OFF = 2'd2;
Область видимости константы, введенной через `define
распространяется на все файлы проекта. А кто знает, вдруг мы захотим где-нибудь еще использовать слово "IDLE, и переопределим значение? Область действия константы, введенной как параметр, ограничивается текущим модулем, но существует принципиальная возможность изменить это значение извне, при создании экземпляра модуля параметр можно переопределить (задать). Если мы не знаем, нужно ли это нам, значит, не нужно. Для этих целей в языке verilog и был создан localparam
, его нельзя переопределить.
По большому счету разницы нет, какие именно значения будут присвоены состояниям. Дело в том, что когда компилятор распознает этот кусок кода как конечный автомат, то перекодирует состояния по своему усмотрению, для получения наилучшего результата по быстродействию, надежности и занимаемым ресурсам. Компилятору можно запретить делать это, оставить все как есть, с помощью специальных атрибутов синтеза, но рассмотрение их отложим для следующих статей.
Ниже на диаграмме показан переход из состояния IDLE
в состояние LED_ON
при нажатии кнопки. Отметим, что значение next_state
обновляется сразу как появился внешний сигнал, а значение state
обновляется синхронно с тактами clk_50MHz_i
.
Рассмотрим сигналы переходов. Из состояния IDLE
система выходит при логической единице на линии button
. И опять, я постарался подобрать говорящее название: "button" переводится как "кнопка". Да, это сигнал с кнопки. В проекте demo порт ПЛИС, к которому подключена кнопка, объявлен как sw_i
. Кнопка работает с инверсией, потому введем button
как:
Никто не мешает в конечном автомате использовать sw_i
непосредственно, но на мой взгляд, наглядность потеряется.
Сигнал timer_overflow
, судя по названию, должен сигнализировать о том, что таймер отсчитал положенное ему время и переполнился. Логическая единица на линии timer_overflow
обеспечивает переход из LED_ON
в LED_OFF
, а из LED_OFF
в IDLE
. Линия timer_overflow
должна генерироваться таймером.
После того, как автомат состояний описан, мы можем использовать текущее состояние для управления какими-либо сигналами. То есть выходом конечного автомата можно считать текущее состояние (state
). Никто не запрещает в логике работы использовать вместо текущего состояния следующее (next_state
), а иногда и то, и другое.
Напомню, основная задача этого демонстрационного примера – управлять светодиодом. Для управления можно использовать комбинаторную схему, либо схему с триггером. Первый вариант будет выглядеть так:
assign led_o[0] = (state == LED_ON)? 1'b1 : 1'b0;
Здесь используется тернарный условный оператор выбора. Если текущее состояние соответствует LED_ON
, то на светодиод выводим единичку. Для всех других состояний будет выводиться ноль. Такая схема выглядит кратко и лаконично, при синтезе займет минимум ресурсов. Но при такой реализации компилятор оставляет за собой право, между регистром, в котором содержится значения состояния, и непосредственно приемником сигнала, поставить комбинационное устройство, что может увеличить задержку прохождения сигнала. Это становится важным в синхронных высокоскоростных схемах. В этом случае, выход можно буферизовать:
reg led;
always @(posedge clk_50MHz_i)
if(state == LED_ON)
led <= 1'b1;
else led <= 1'b0;
assign led_o[0] = led;
Как следствие, при синтезе этого кода используется больше выделенных логических регистров (Dedicated logic registers), правда только на один. Останавливаемся на варианте с назначением assign
.
Полная временная диаграмма переходов:
При переходе в состояния LED_ON и LED_OFF нам нужно запустить таймер. А таймера пока нет. Давайте соберем вместе все сведения о таймере и подумаем, какой он должен быть. Нас интересует только функционал без содержания. О будущем таймере мы знаем:
- На вход его должны поступать синхроимпульсы, по которым счетчик будет менять свое значение.
- У таймера должен быть вход глобального сброса, для того, чтобы при подаче питания, он начал считать с нуля.
- У таймера должен быть выход timer_overflow, который мы использовали в переходах автомата.
- У таймера должен быть вход, разрешающий работу.
Опираясь только на требования создадим, пустой модуль таймера:
module timer
(
input clk_i, rst_i, enable_i,
output reg overflow_o
);
// здесь будет код модуля
endmodule
И в основной модуль вставим его экземпляр:
timer timer_inst
(
.clk_i(clk_50MHz_i),
.rst_i(reset),
.enable_i(timer_enable),
.overflow_o(timer_overflow)
);
На этом этапе проект должен компилироваться, но, естественно, без реализации таймера работать не будет.
Счетчик-таймер на Verilog
Как реализовать простейший счетчик было показано в статье "Пишем "демку" для LESO2 на Verilog". В том примере сброс счетчика в нулевое значение происходил по нажатию кнопки. Теперь у нас есть для этого глобальный reset. Кроме того, нам нужно останавливать и запускать счет по сигналу разрешения:
always @ (posedge rst_i or posedge clk_i)
begin
if (rst_i) // Если получен глобальный сброс, то
count <= 'b0; // сбрасываем счетчик.
else if (enable_i) // Если счет разрешен,
count <= count + 1'b1; // то считаем,
else // иначе
count <= 'b0; // сбрасываем счетчик в ноль.
end
Формируем сигнал переполнения:
always @ (posedge rst_i or posedge clk_i)
begin
if (rst_i)
overflow_o <= 'b0;
else if (&count) // Логическое "и" всех разрядов.
overflow_o <= 1'b1;
else
overflow_o <= 1'b0;
end
Логическое "и" всех разрядов регистра счетчика становится равно единице только тогда, когда во всех разрядах установлены единицы, а это и есть признак переполнения. Если внимательно посмотреть на код реализации, возникает вопрос, почему я в некоторых случаях явно указал разрядность числовой константы, например 1'b1
и 1'b0
, а где-то оставил это на совесть компилятора? Дело в том, что нам пока еще неизвестно, какой разрядности должен быть регистр count
. Очевидно, что его разрядность определит максимальное время счета до переполнения, а мы пока не знаем, какое оно должно быть, поэтому, напишем универсальный код. Более того, я предлагаю разрядность регистра ввести в виде параметра (parametr
):
где WIDTH
– разрядность счетчика. В заголовке модуля укажем это значение по умолчанию:
module timer
#(parameter WIDTH=32)
(
input clk_i, rst_i, enable_i,
output reg overflow_o
);
Теперь, если при создании экземпляра, мы захотим изменить этот параметр, то это можно будет сделать так:
// Экземпляр таймера
timer #(.WIDTH(25)) timer_inst
(
.clk_i(clk_50MHz_i),
.rst_i(reset),
.enable_i(timer_enable),
.overflow_o(timer_overflow)
);
Значение WIDTH = 25
заменит значение WIDTH = 32
, объявленное в модуле. (Кто еще не понял, "width" в переводе с английского – "ширина". Мы же любим говорящие названия?). Для каждого экземпляра таймера это значение может быть своим. В результате конечный вариант модуля таймера примет вид: