В наборе GyverKIT | START | IOT | EXTRA | |
---|---|---|---|---|
![]() |
Arduino NANO | ✔ | ||
![]() |
Макетная плата | ✔ | ✔ | |
![]() |
Кнопка | ✔ | ✔ |
Машина состояний (Finite State Machine, FSM) или конечный автомат - математическая абстракция, состоящая из набора состояний и правил перехода между ними при входящих данных или событиях, в каждый момент времени система может находиться только в одном состоянии. По конечным автоматам есть огромное количество философской теории, в которую мы не будем углубляться и сосредоточимся на практике.
Данный инструмент позволяет сделать например навигацию по меню, переключать режимы работы прибора, описать логику игры или автоматического устройства (кофемашина, робот-пылесос), написать режимы для гирлянды или просто выделить из текста фрагмент нужного формата...
Практически любую логику или алгоритм можно представить в виде конечного автомата - в таком виде его можно описать схемой и проще реализовать в программе. По сути, почти любой робототехнический проект - это набор конечных автоматов
Конечный автомат замечательно работает в суперцикле - система всегда находится в известном состоянии и ждёт события. Задачу с асинхронным автоматом можно вызывать в цикле совместно с другими задачами - они не будут тормозить друг друга.
Типы автоматов #
Конечные автоматы можно разделить на два типа - Мили и Мура - по именам учёных, которые их придумали. В автоматах Мура выходное значение зависит от состояния автомата, условно такой автомат будет выполнять код, пока длится соответствующее состояние. В автомате Мили выход меняется при смене состояния - то есть условный код будет выполняться при переходе между состояниями. Запоминать это необязательно, нужно просто иметь в виду, что есть два типа автоматов:
- Автомат Мура - переключатель эффектов для гирлянды. В зависимости от выбранного режима постоянно выполняется функция, внутри которой воспроизводится нужный эффект
- Автомат Мили - кнопочное меню: при нажатии на кнопку однократно происходит действие, например переключение пункта меню или смена экрана
Примеры конечных автоматов в уроках на сайте:
Флаги #
Простейшим конечным автоматом из двух состояний является флаг - логическая переменная, которая имеет всего два состояния - true
и false
и может переходить между ними вручную или при помощи инверсии:
bool flag;
flag = false; // опускание флага
flag = true; // поднятие флага
flag = !flag; // инверсия флага
flag ^= 1; // тоже инверсия флага
"Поднятием" флага считается присваивание значения true
, а опусканием
- false
Флаг обычно имеет область существования за суперциклом - глобальную или статическую. При помощи флага можно помнить предыдущее состояние, чтобы сравнивать его с текущим или для других действий.
Количество нулей #
Сделаем функцию, которая анализирует строку и выдаёт результат - чётное ли в ней количество символов "ноль" '0'
:
bool evenZeroes(const char* str) {
bool even = true;
while (*str) {
if (*str == '0') even ^= 1;
++str;
}
return even;
}
Система имеет два состояния, когда встречает в строке символ "ноль" - переходит в противоположное состояние. Начальное состояние - true
, потому что ноль - это чётное число. Тест:
Serial.println(evenZeroes("1111")); // 1
Serial.println(evenZeroes("1011")); // 0
Serial.println(evenZeroes("1001")); // 1
Пример со светодиодом #
Самый простой пример - с мигающим светодиодом. Модифицируем пример из предыдущего урока так, чтобы подавать сигнал один раз за вызов задачи, а не два, как в оригинале:
Статическая переменная имеет область видимости внутри функции, в которой объявлена, но сохраняет своё значение на всём протяжении работы программы
void blinkLED() {
static bool ledState; // флаг запоминает состояние светодиода
ledState = !ledState; // инвертировать
digitalWrite(LED_BUILTIN, ledState); // переключить в состояние
delay(500);
}
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
blinkLED();
}
У нас получилась простейшая машина из двух состояний - светодиод включен и светодиод выключен. Каждый вызов функции blinkLED
переключает состояние светодиода, как программное (флаг), так и физическое - через digitalWrite
. Вход автомата - просто вызов функции, выход - состояние, в которое нужно переключить светодиод. Мы избавились от половины цикла мигания: вместо того, чтобы включить-ждать-выключить-ждать
мы делаем просто переключить-ждать
. Но от задержки мы пока не избавились - это тема следующего урока о программном таймере.
Кстати, код переключения можно чуть "сократить":
void blinkLED() {
static bool ledState;
digitalWrite(LED_BUILTIN, ledState ^= 1);
delay(500);
}
Пример с кнопкой #
Второй пример - с кнопкой на пине 3:
Вместо ожидания нового состояния кнопки, как в предыдущем уроке, мы просто запомним старое:
#define BTN_PIN 3
void checkButton() {
static bool pState; // флаг запоминает прошлое состояние кнопки
bool state = !digitalRead(BTN_PIN); // state == 1 - кнопка нажата
if (pState) { // кнопка была нажата
if (!state) { // и не нажата сейчас
Serial.println("Кнопка отпущена");
pState = false; // переход в состояние "отпущена"
delay(20); // гашение дребезга
}
} else { // кнопка не была нажата
if (state) { // и нажата сейчас
Serial.println("Кнопка нажата");
pState = true; // переход в состояние "нажата"
delay(20); // гашение дребезга
}
}
}
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
}
void loop() {
checkButton();
}
Здесь флаг помог нам полностью избавиться от блокирующего цикла ожидания из прошлого урока - задача стала асинхронной и уже не будет мешать остальным задачам.
В данном примере используется "гашение дребезга" при помощи задержки delay
. На практике нужно делать по другому, как и зачем - читайте в уроке про обработку кнопки. Здесь я пишу delay
для простоты, т.к. этот урок - про другое
Кстати, эту конструкцию можно "свернуть", исходя из того что у нас есть всего два состояния:
void checkButton() {
static bool pState; // флаг запоминает прошлое состояние кнопки
bool state = !digitalRead(BTN_PIN); // state == 1 - кнопка нажата
if (pState != state) { // состояние изменилось
pState = state; // запомнить новое состояние
state ? Serial.println("Кнопка нажата") : Serial.println("Кнопка отпущена");
delay(20);
}
}
Можно пойти дальше и сделать независимую функцию, которая будет обрабатывать кнопку. Действия по нажатию кнопки будем делать в другом месте программы, чтобы эту функцию как готовый инструмент можно было использовать и в других примерах. Например так:
#define BTN_PIN 3
// функция возвращает true при нажатии кнопки
bool checkButton() {
static bool pState;
bool state = !digitalRead(BTN_PIN);
if (pState != state) {
pState = state;
delay(20);
return state;
}
return false;
}
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
}
void loop() {
if (checkButton()) Serial.println("Кнопка нажата");
}
Кнопка и светодиод #
Можно объединить два предыдущих примера - пусть кнопка переключает состояние светодиода. Задачу можно решить по-другому, цель примера - показать, как две машины состояний взаимодействуют между собой:
#define BTN_PIN 3
// функция возвращает true при нажатии кнопки
bool checkButton() {
static bool pState;
bool state = !digitalRead(BTN_PIN);
if (pState != state) {
pState = state;
delay(20);
return state;
}
return false;
}
// переключить состояние светодиода
void toggleLED() {
static bool ledState;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState);
}
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
// если кнопка нажата - переключить светодиод
if (checkButton()) toggleLED();
}
Передача событий #
Также флаги часто используются для передачи событий из одного конца программы в другой или между функциями, например:
bool flag;
void task1() {
if (...) flag = true; // поднять флаг
}
void task2() {
if (flag) {
flag = false; // сбросить флаг
// ...
}
}
Более сложные автоматы #
Максимум нулей подряд #
Давайте усложним прошлый пример со строкой - сделаем функцию, которая посчитает максимальное количество нулей, которые встречаются в строке подряд (друг за другом):
int maxZeroes(const char* str) {
int res = 0, count = 0;
while (*str) {
if (*str == '0') {
++count;
if (res < count) res = count;
} else {
count = 0;
}
++str;
}
return res;
}
Для этого нужен автомат, состояние которого будет увеличиваться на 1 каждый раз, когда встречается символ "ноль". Но если это не "ноль" - сбрасываться в состояние 0. Промежуточный результат будем запоминать в отдельную переменную, которую и вернём из функции. Тест:
Serial.println(maxZeroes("11110111001101")); // 2
Serial.println(maxZeroes("00000101")); // 5
Serial.println(maxZeroes("1111")); // 0
Количество последовательностей #
В прошлом примере у нас получился автомат - счётчик. Давайте усложним его: пусть автомат ищет в строке последовательности букв, например abc
, а дальше функция вернёт их количество. Автомат имеет 3 состояния: ожидание a
, ожидание b
, ожидание c
:
int countAbc(const char* str) {
enum State {
WaitA,
WaitB,
WaitC,
};
State state = State::WaitA;
int res = 0;
while (*str) {
switch (*str) {
case 'a':
state = State::WaitB;
break;
case 'b':
if (state == State::WaitB) state = State::WaitC;
else state = State::WaitA;
break;
case 'c':
if (state == State::WaitC) ++res;
state = State::WaitA;
break;
default:
state = State::WaitA;
break;
}
++str;
}
return res;
}
Конкретно эту задачу можно решить гораздо более компактно и эффективно, не используя конечный автомат или громоздкий switch
- здесь я его применяю в качестве очень наглядного примера. Читаем строку посимвольно и проверяем, соответствует ли следующий символ нашим ожиданиям. Тест:
Serial.println(countAbc("aaabbbccc")); // 0
Serial.println(countAbc("abcabcabc")); // 3
Serial.println(countAbc("bccbabcaacbcbabcabcbabcbbabcaabcb")); // 6
Автомат кнопки #
Хороший пример с опросом кнопки и получения из неё нескольких состояний. Здесь кнопка опрашивается по таймеру - самый простой "антидребезг". Конечный автомат имеет следующие состояния кнопки:
enum class State : uint8_t {
Idle, // холостой (простаивает)
Press, // событие - нажатие
Click, // событие - клик (отпущено до удержания)
WaitHold, // ожидание удержания
Hold, // событие - удержание
ReleaseHold, // событие - отпущено до импульсов
WaitPulse, // ожидание импульсов
Pulse, // событие - импульс
WaitNextPulse, // ожидание следующего импульса
ReleasePulse, // событие - отпущено после импульсов
};
Позволяет обрабатывать следующие сценарии:
- Нажали и отпустили до таймаута удержания - клик
- Удерживаем дольше таймаута удержания - удержание
- Удерживаем дольше таймаута импульсов - режим импульсов (кнопка "сигналит" с заданным периодом)
- Отдельное отпускание во всех режимах
// === Button.h
#pragma once
#define BTN_DEB_TIME 50 // период опроса кнопки (дебаунс)
#define BTN_HOLD_TIME 500 // время до перехода в состояние "удержание"
#define BTN_PULSE_TIME 400 // время до перехода в состояние "импульс"
#define BTN_PULSE_PRD 150 // период импульсов
class Button {
public:
enum class State : uint8_t {
Idle, // холостой (простаивает)
Press, // событие - нажатие
Click, // событие - клик (отпущено до удержания)
WaitHold, // ожидание удержания
Hold, // событие - удержание
ReleaseHold, // событие - отпущено до импульсов
WaitPulse, // ожидание импульсов
Pulse, // событие - импульс
WaitNextPulse, // ожидание следующего импульса
ReleasePulse, // событие - отпущено после импульсов
};
Button(uint8_t pin) : _pin(pin) {
pinMode(pin, INPUT_PULLUP);
}
State tick() {
// простой дебаунс по таймеру
if (millis() - _deb >= BTN_DEB_TIME) {
_deb = millis();
bool press = !digitalRead(_pin); // статус кнопки
uint32_t time = millis() - _tmr; // время с прошлого действия
switch (_state) {
case State::Idle:
if (press) {
_state = State::Press;
}
break;
case State::Press:
_state = State::WaitHold;
_tmr = millis();
break;
case State::WaitHold:
if (!press) {
_state = State::Click;
} else if (time > BTN_HOLD_TIME) {
_state = State::Hold;
}
break;
case State::Hold:
_state = State::WaitPulse;
_tmr = millis();
break;
case State::WaitPulse:
if (!press) {
_state = State::ReleaseHold;
} else if (time > BTN_PULSE_TIME) {
_state = State::Pulse;
}
break;
case State::Pulse:
_state = State::WaitNextPulse;
_tmr = millis();
break;
case State::WaitNextPulse:
if (!press) {
_state = State::ReleasePulse;
} else if (time > BTN_PULSE_PRD) {
_state = State::Pulse;
}
break;
case State::Click:
case State::ReleaseHold:
case State::ReleasePulse:
_state = State::Idle;
break;
}
return _state;
}
return State::Idle;
}
State getState() {
return _state;
}
private:
uint8_t _pin;
uint32_t _deb = 0;
uint32_t _tmr = 0;
State _state = State::Idle;
};
Можно подключить кнопку на пин 3 и пощупать обработку:
void loop() {
switch (b.tick()) {
case Button::State::Press: Serial.println("Press"); break;
case Button::State::Click: Serial.println("Click"); break;
case Button::State::Hold: Serial.println("Hold"); break;
case Button::State::ReleaseHold: Serial.println("ReleaseHold"); break;
case Button::State::Pulse: Serial.println("Pulse"); break;
case Button::State::ReleasePulse: Serial.println("ReleasePulse"); break;
default: break;
}
}
Пример использования - изменение значения переменной. По удержанию изменяется в выбранном направлении (с шагом 1
или -1
), а само направление меняется по клику:
int var = 0;
int dir = 1;
void loop() {
switch (b.tick()) {
case Button::State::Click:
dir = -dir;
Serial.println("Reverse");
break;
case Button::State::Hold:
case Button::State::Pulse:
var += dir;
Serial.println(var);
break;
default: break;
}
}
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками
