Как стать автором
Обновить

МКА (машина конечных автоматов) для чайников на примере класса «кнопка» в arduino

Время на прочтение 9 мин
Количество просмотров 47K

Зачем всё это нужно?


Когда чайник, уперевшись в необходимость отойти от простой последовательности действий, задаёт на хабре вопрос типа "как сделать вот это?", ему с вероятностью 70% отвечают "погугли конечные автоматы" и 30% "используй finite state machine" в зависимости от страны работодателя профессионала. На следующий вопрос "а как?" отправляют в гугл. Идёт такой чайник, что только закончил мигать светодиодом и вытер пот со лба, что учил в школе немецкий и всю жизнь работал бульдозеристом в этот гугл и видит там статьи типа Википедия про конечные автоматы с формулами и в которых понятны только предлоги.


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


Итак, задача стоит, например, научить ардуину понимать нажатие кнопки типа клик, нажатие и удержание, то есть, короткий тык в кнопку, длинный и очень длинный. Любой начинающий может сделать это в функции loop() и гордиться этим, но как быть, если кнопок несколько? А если при этом надо не мешать выполнению другого кода? Я написал библиотеку SmartButton для Arduino, чтобы потом не возвращаться к теме кнопок. Здесь же напишу, как она работает.


Что нам мешает жить и как с этим бороться?


Нам мешает жить функция delay(), я её поборол при помощи МКА и написал свой класс SmartDelay. Я уже сделал новый класс (SmartDelayMs) порождённый от SmartDelay, там всё то же самое, но в миллисекундах. В данной статье я не использую эту библиотеку, просто хвастаюсь.


Чтобы не утомлять читателя повторно рассказом о том, как плохо пользоваться функцией delay() и как жить без неё, я рекомендую сначала прочитать мою старую статейку Замена delay() для неблокирующих задержек в Arduino IDE. Я немножко повторю основные тезисы здесь ещё раз ниже по мере написания кода.


Немного теории


Так или иначе, но у вас есть объекты. Вы можете называть их кнопками, дисплеями, светодиодными лентами, роботами и измерителем уровня воды в бачке. Если ваш код выполняется не в одну ниточку последовательно, а по каким-то событиям, вы храните состояние объектов. Вы можете называть это как угодно, но это факт. Для кнопки есть, например, состояния "нажата" и "отпущена". Для робота, например: стоит, едет прямо, поворачивает. Количество этих состояний конечно. Ну, в нашем случае, да. Добавим состояния "клик", "нажатие" и "удержание". Уже пять состояний, которые нам надо различать. Причём, интересны нам лишь последние три. Этими пятью состояниями живёт кнопка. В страшном внешнем мире происходят события, которые от кнопки в общем-то никак не зависят: тыкания в неё пальцем, отпускания, удержания на разное время итп. Назовём их событие.


Итак, мы имеем объект "кнопка" у которого конечное количество состояний и на него действуют события. Это и есть конечный автомат (КА). В теории КА список возможных событий называют словарём, а события словами. Я дико извиняюсь, я это проходил 30 лет назад и плохо помню терминологию. Вам же не терминология нужна, правда?


В данной статье мы напишем замечательный код, который будет переводить наш КА из состояния в состояние в зависимости от событий. Он и является собственно машиной конечных автоматов (МКА).


Не обязательно всё ваше устройство запихивать в один огромный КА, можно разбить на отдельные объекты, каждый из которых обслуживается своей МКА и даже находится в отдельном файле кода. Многие свои объекты вы сможете поместить на GitHub в виде готовых библиотек ардуины и использовать потом не вникая в их реализацию.


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


Практика


Основой проектирования МКА является таблица.


  • По горизонтали пишем все-все возможные события.
  • По вертикали все состояния.
  • В клетках действия. Переход в новое состояние — это тоже действие.

Для кнопки это будет выглядеть вот так:


Состояние \ Событие Нажата Отпущена Нажата 20мс Нажата 250мс Нажата 1с Нажата 3с
Не Нажата
Кликнута
Нажата
Удержана
Слишком долго удержана

Зачем так сложно? Надо же описать все возможные состояния и учесть все возможные события.


События:


  • Нажата — это когда мы обнаружили, что контакт замкнут.
  • Отпущена — это когда выяснилось, что контакт разомкнут.
  • Нажата 20мс — надо учесть дребезг контактов и игнорировать размыкание какое-то время, дальше уже считатется, что клик.
  • Нажата 250мс — для клика 250мс достаточно, всё что дольше — это уже нажатие.
  • Нажата 1с — больше 1с это уже удержание.
  • нажата 3с — удержание больше 3с посчитаем ошибкой.

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


#define SmartButton_debounce 20
#define SmartButton_hold 250
#define SmartButton_long 1000
#define SmartButton_idle 3000

Состояния:


  • Не нажата — рапортуем, что никто кнопочку не трогал.
  • Дребезг — Ждём конец дребезга контактов.
  • Кликнута — был клик.
  • Нажата — кнопку нажали.
  • Удержана — кнопку нажали и подержали.
  • Слишком долго удержана — что-то пошло не так, предлагаю отказаться от нажатия в этом случае.

Итак, переводим русский язык на язык C++


// Нога контроллера для кнопки
byte btPin;
// События
enum event {Press, Release, WaitDebounce, WaitHold, WaitLongHold, WaitIdle};
// Состояния
enum state {Idle, PreClick, Click, Hold, LongHold, ForcedIdle};
// Текущее состояния
enum state btState = Idle;
// Время от нажатия кнопки
unsigned long pressTimeStamp;

Обращу внимание, что и события и состояния указаны не как целая переменная типа byte или int, а как enum. Такая запись не столь любима профессионалами так как не даёт экономить биты столь дорогой в микроконтроллерах памяти, но, с другой стороны очень наглядна. При использовании enum нас не заботит каким числом закодирована каждая константа, мы пользуемся словами, а не числами.


Как расшифровать enum?

enum Имя {Константа, Константа, Константа};
Так мы создаём тип данных enum Имя, у которого есть набор фиксированных значений из тех, что в фигурных скобках. На самом деле, конечно же, это числовой целый тип, но значение констант нумеруется автоматически.
Можно указать значения и вручную:
enum Имя {КонстантаА=0, КонстантаБ=25};


Для объявления переменной такого нового типа можно написать так:
enum Имя Переменная;


В примере с кнопкой:


  • enum event myEvent;
  • enum state currentState;

Давайте уже заполним таблицу. Значком -> я обозначу переход в новое состояние.


Состояние \ Событие Нажата Отпущена Нажата 20мс Нажата 250мс Нажата 1с Нажата 3с
Не Нажата ->Дребезг ->Не Нажата
Дребезг ->Не Нажата ->Кликнута
Кликнута ->Не Нажата ->Нажата
Нажата ->Не Нажата ->Удержана
Удержана ->Не Нажата ->Слишком долго удержана
Слишком долго удержана ->Не Нажата

Для реализации таблицы мы сделаем функцию void doEvent(enum event e). Эта функция получает событие и выполняет действие как описано в таблице.


Откуда берутся события?


Настало время ненадолго покинуть нашу уютную кнопку и окунуться в ужасный внешний мир функции loop().


void loop() {
  // запоминаем текущий счётчик времени
  unsigned long mls = millis();
  // генерим события
  // нажатие кнопки
  if (digitalRead(btPin)) doEvent(Press)
  else doEvent(Release)
  // ожидание дребезга прошло
  if (mls - pressTimeStamp > SmartButton_debounce) doEvent(WaitDebounce);
  // ожидание нажатия прошло
  if (mls - pressTimeStamp > SmartButton_hold) doEvent(WaitHold);
  // ожидание удержания прошло
  if (mls - pressTimeStamp > SmartButton_long) doEvent(WaitLongHold);
  // совсем перебор по времени
  if (mls - pressTimeStamp > SmartButton_idle) doEvent(WaitIdle);
}

Итак, имея под рукой генератор событий, можно приступить к реализации МКА.


На самом деле, события могут генерироваться не только по подъёму уровня сигнала на ноге контроллера и по времени. События могут передаваться одним объектом другому. Например, вы хотите, чтобы нажатие одной кнопки автоматически "отпускало" другую. Для этого достаточно вызвать её doEvent() с параметром Release. МКА — это же не только кнопки. Превышение порога температуры является событием для МКА и вызывает включение вентилятора в теплице, к примеру. Датчик неисправности вентилятора меняет состояние теплицы на "аварийное" и так далее.


Три подхода к реализации таблицы в код


План-А: if {} elsif {} else {}


Это первое, что приходит в голову начинающему. На самом деле, это неплохой вариант, если в таблице совсем мало заполненных клеток и действия сводятся только к смене состояний.


void doAction(enum event e) {
  if (e == Press && btState == Idle) { // нажата кнопка в состоянии кнопка отпущена
    btState=PreClick; // переходим в состояние ожидания дребезга
    pressTimeStamp=millis(); // запоминаем время нажатия кнопки
  }
  if (e == Release) { // отпущена кнопка
    btState=Idle; // Переходим в состояние кнопка отпущена
  }
  if (e == WaitDebounce && btState == PreClick) { // Прошло время дребезга
    btState=Click; // считаем, что это клик
  }
  // и так далее для всех сочетаний событий и состояний
}

Плюсы:


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

Минусы:


  • Сложно читать код.
  • Сложно модифицировать код, если что-то поменялось в таблице.
  • Ад, если большая часть клеток таблицы заполнена.

План-Б: Таблица указателей на функции


Другая крайность — это таблица переходов или функций. Я про такой подход писал в статье Обработка нажатий кнопок для Arduino. Скрестить ООП и МКА.. В двух словах, вы создаёте для каждой разной клетки таблицы отдельную функцию и делаете таблицу из указателей на них.


// определяем тип, так проще
typedef void (*MKA)(enum event e);
// делаем таблицу 
MKA action[6][6]={
{&toDebounce,$toIdle,NULL,NULL,NULL,NULL},
{NULL,&toIdle,&toClick,NULL,NULL,NULL},
{NULL,&toIdle,NULL,&toHold,NULL,NULL},
{NULL,&toIdle,NULL,NULL,&toLongHold,NULL},
{NULL,&toIdle,NULL,NULL,NULL,&toVeryLongHold},
{NULL,&toIdle,NULL,NULL,NULL,NULL}
};

// функция doEvent получается совсем простой
void doEvent(enum event e) {
  if (action[btState][e] == NULL) return;
  (*(action[btState][e]))(e);
}

// Примеры функций из таблицы

// В состояние "не нажата"
void toIdle(enum event e) {
  btState=Idle;
}

// В состояние ожидания дребезга
void toDebounce(enum event e) {
  btState=PreClick;
  pressTimeStamp=millis();
}

// и так далее

Что за странные слова и значки здесь использованы?

typedef позволяет определить свой тип данных и частенько этим удобно пользоваться. Например, enum event можно определить так:


typedef enum event BottonEvent;

В программе после этого можно писать:


BottonEvent myEvent;

Я определил указатель на функцию, которая принимает один аргумент типа enum event и ничего не возвращает:


typedef void (*MKA)(enum event e);

Так я сделал новый тип данных MKA.


Значок & переводится на русский как "адрес" или "указатель". Таким образом, MKA это не значение функции, а указатель на неё.


MKA action[6][6]; // Двумерный массив action переменных типа МКА

Я его сразу же заполнил указателями на функции, что выполняют действия. Если действия нет, я ставлю "указатель в никуда" NULL.


Функция doEvent проверяет указатель из таблицы в координатах "текущее состояние" x "случившееся событие" и если для этого есть указатель на функцию — вызывает её с параметром "событие".


  if (action[btState][e] == NULL) return; // если функции нет - выходим
  (*(action[btState][e]))(e); // вызываем функцию по указателю из таблицы

Более подробно про указатели и адресную арифметику в языке C можно нагуглить в книжках типа "Язык Си для чайников".


Плюсы:


  • Очень читаемый код. Не смейтесь. Достаточно один раз вкурить про массивы с указателями и вы получите простой красивый код. У вас табличка как табличка, ячейки отдельно расписаны — красота.
  • Легко писать.
  • Очень легко модифицировать логику.

Минусы:


  • Жрёт память. Каждый указатель — это от двух до четырёх байтов. 6х6=36 клеток занимают от 72 до 144 байтов памяти и это на каждую кнопку, а если их, скажем, четыре? Напомню, у вас всего 2048 байтов памяти для популярных чипов ардуины.

Минус оказался таким жирным, что я после написания Обработка нажатий кнопок для Arduino. Скрестить ООП и МКА. и первого же применения кода в деле выпилил это всё нафиг. В итоге я пришёл к золотой середине "switch { switch {}}".


План-В: Золотая середина или switch { switch {}}


Самый распространённый, средний вариант — это использование вложенных операторов switch. На каждое событие как case оператора switch надо писать switch с текущим состоянием. Получается длинно, но более-менее понятно.


void doEvent(enum event e) {
  switch (e) {
    case Press:
      switch (btState) {
        case Idle:
          btState=PreClick;
          pressTimeStamp=millis();
          break; 
      }
      break;
    case Release:
      btState=Idle;
      break;
    // ... так далее ...
  }
}

Умный компилятор всё-равно построит таблицу переходов, как описано в плане Б выше, но эта таблица будет в флеше, в коде и не будет занимать столь дорогую нам память переменных.


Плюсы:


  • Быстрый код.
  • Экономится память.
  • Логика понятна. Можно читать.
  • Легко менять логику.

Минусы:


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

На самом деле, можно использовать план-В и план-А, то есть, switch по событиям, но if внутри по состояниям. На мой вкус, switch и на то и на то понятнее и удобнее, если потребуется что-то потом поменять.


Куда вставлять мой код, который зависит от нажатий кнопки?


В табличке мы не учитывали наличие других МКА сосредоточившись на нашем обработчике событий кнопки. На практике же требуется не только радоваться тому, что кнопка работает, но и выполнять другие действия.


У нас есть два важных места для вставки хорошего кода:


case Release:
  switch(btState) {
    case Click:
      // Вот здесь был клик и отпустили кнопку. это точно был клик.
      break;

    case WaitDebounce:
      switch (st) {
        case PreClick:
          btState=Click;
          // Вот здесь случился клик. Кнопка может быть потом ещё нажата и будет ещё и "нажатие" итп.
          break;

Лучше всего, чтобы не портить красоту, написать свои функции типа offClick() и onClick(), в которых будет обрабатываться уже эти события, возможно, что они передадут его другому МКА :D


Я обернул всю логику работы МКА кнопки в класс C++. Это удобно, так как не отвлекает от написания основного кода, все кнопки работают самостоятельно, мне не надо изобретать имена кучи переменных. Только это совсем другая история и я могу написать, как оформлять ваши МКА в классы и делать из них библиотеки для Arduino. Пишите в комментариях пожелания.


Что в итоге?


В итоге, в теории, после прочтения этой статьи должно появиться желание заменить в любимом ардуиновом скетче свой нечитаемый глючный говнокод на красивый структурированный и с использованием МКА.


Обратите внимание, что в функции loop(), которая в нашем случае лишь генерит события, нет ни одной задержки. Таким образом, после приведённого кода можно писать что-то ещё: генерить события для других МКА, выполнять ещё какой-то свой код, не содержащий delay();


Я очень надеюсь, что эта статья помогла вам понять, что такое МКА и как их реализовать в вашем скетче.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Полезна ли статья для освоения ардуины?
72.82% Да, однозначно. 75
3.88% Понял то, что не долго смог осилить сам. 4
0.97% Подписался на гитхабе заодно. 1
7.77% Фигня, таких статей полно. 8
6.8% Мне не интересно. 7
1.94% Что такое «микроконтроллер»? 2
5.83% Что такое «программирование»? 6
Проголосовали 103 пользователя. Воздержались 40 пользователей.
Теги:
Хабы:
+12
Комментарии 28
Комментарии Комментарии 28

Публикации

Истории

Работа

Программист C++
128 вакансий
QT разработчик
15 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн