Конечный автомат. Verilog

fsm-dude_400px.png

Шауэрман Александр А. 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 как:

wire button = ~sw_i;

Никто не мешает в конечном автомате использовать 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 нам нужно запустить таймер. А таймера пока нет. Давайте соберем вместе все сведения о таймере и подумаем, какой он должен быть. Нас интересует только функционал без содержания. О будущем таймере мы знаем:

  1. На вход его должны поступать синхроимпульсы, по которым счетчик будет менять свое значение.
  2. У таймера должен быть вход глобального сброса, для того, чтобы при подаче питания, он начал считать с нуля.
  3. У таймера должен быть выход timer_overflow, который мы использовали в переходах автомата.
  4. У таймера должен быть вход, разрешающий работу.

Опираясь только на требования создадим, пустой модуль таймера:

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):

reg [WIDTH-1:0] count;

где 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" в переводе с английского – "ширина". Мы же любим говорящие названия?). Для каждого экземпляра таймера это значение может быть своим. В результате конечный вариант модуля таймера примет вид:

Компилируем проект. Загружаем бинарный файл *.rbf в учебный стенд. Проверяем работоспособность: после нажатия кнопки светодиод должен загореться и потухнуть. При длительном удержании кнопки светодиод должен мигать. Можно попробовать изменить время свечения с помощью параметра WIDTH.

Что дальше?

Для самопроверки и тренировки, рекомендую усложнить задачу. Сделайте так, чтобы время свечения отличалось от времени гарантированного "не свечения", или при повторном нажатии, загорался второй светодиод. Задайте различное время свечения у разных светодиодов. Или, чтобы после второго нажатия, светодиод светился в два раза дольше. Создайте систему с большим числом состояний.

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

27 января 2016
Орфографическая ошибка в тексте:
Чтобы сообщить об ошибке автору, нажмите кнопку "Отправить сообщение об ошибке". Вы также можете отправить свой комментарий.