Конечные автоматы, перечисления, выражения switch

Часто бывает так, что программа должна вести себя по-разному в зависимости от наступления каких-то событий, от времени прошедшего с начала работы и т.д.

Например, давайте рассмотрим устройство, подобие гирлянды. Допустим мы хотим, чтобы к Arduino были подключены светодиоды, режим свечения которых можно было бы менять нажатием кнопки. Режима может быть три:

  1. Светодиоды горят постоянно
  2. Светодиоды то выключаются, то включаются каждые 2 секунды
  3. Светодиоды плавно набирают яркость от нуля до максимума за 10 секунд, гаснут и начинают набирать яркость заново

Нажимая кнопку, мы хотим переходить к очередному режиму: 1 → 2, 2 → 3, 3 → 1. Собственно точно так же, как это происходит во многих ёлочных гирляндах.

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

При таком раскладе, скетч, делающий всю работу, может выглядеть так:

#define LED_PIN     5
#define BUTTON_PIN  6
 
#define STATE_SHINE   0
#define STATE_BLINK   1
#define STATE_FADE    2
 
#define BLINK_DELAY   2000
 
#define FADE_TIME         10000
#define FADE_STEP_DELAY   (FADE_TIME / 256)
 
int ledState = STATE_SHINE;
bool wasButtonDown = false;
 
void setup()
{
    pinMode(LED_PIN, OUTPUT);
    pinMode(BUTTON_PIN, INPUT);
}
 
void loop()
{
    bool isButtonDown = digitalRead(BUTTON_PIN);
    if (isButtonDown && !wasButtonDown) {
        if (ledState == STATE_SHINE)
            ledState = STATE_BLINK;
        else if (ledState == STATE_BLINK)
            ledState = STATE_FADE;
        else
            ledState = STATE_SHINE;
 
        delay(10);
    }
 
    wasButtonDown = isButtonDown;
 
    if (ledState == STATE_SHINE)
        digitalWrite(LED_PIN, HIGH);
    else if (ledState == STATE_BLINK)
        digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);
    else if (ledState == STATE_FADE)
        analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);
}

Получилась не самая простая программа. Но и логики у нас предостаточно. Шаг за шагом разберёмся что здесь написано. Начнём с некоторых макроопределений.

Состояния

Как уже говорилось, у нас есть 3 режима: постоянное свечение, мигание, нарастание яркости. Но как их обозначать, чтобы сказать процессору какой из них текущий, какой следующий и т.п.? Как мы уже знаем, всё, с чем работает процессор — целые числа. Зная это, мы можем все возможные режимы просто пронумеровать:

  • 0 — режим постоянного свечение, мы назвали его именем STATE_SHINE
  • 1 — режим мигания, мы назвали его STATE_BLINK
  • 2 — режим нарастания яркости, мы назвали его STATE_FADE

Использование общего префикса в названиях, конечно, не обязательно — это всего-навсего макроопределения — но для группировки связанных по смыслу значений префиксы довольно удобны и встретить их можно довольно часто.

В каждом режиме наше устройство делает что-то уникальное, отличное от того, что происходит в других режимах. Текущим может быть лишь один режим. А возможные переходы чётко определены: каждый следующий включается при нажатии кнопки. Такие системы называются конечными автоматами, а их режимы называются состояниями (англ. state).

Если говорить формально, конечный автомат — это система с конечным, известным количеством состояний, условия переходов между которыми фиксированы и известны, а текущим всегда является ровно одно состояние.

Что ж, мы делаем конечный автомат — отлично. Текущее состояние, т.е. режим свечения будем хранить в переменной с именем ledState, которую изначально установим в значение STATE_SHINE. Таким образом, при включении Arduino система будет находиться в режиме постоянного свечения.

О предназначении других макроопределений и переменных поговорим по ходу дела.

Цепочка переходов между состояниями

Разберём функцию loop. А точнее, её первую часть и те определения, которые её касаются:

// ...
 
#define STATE_SHINE   0
#define STATE_BLINK   1
#define STATE_FADE    2
 
// ...
 
int ledState = STATE_SHINE;
bool wasButtonDown = false;
 
// ...
 
void loop()
{
    bool isButtonDown = digitalRead(BUTTON_PIN);
    if (isButtonDown && !wasButtonDown) {
        if (ledState == STATE_SHINE)
            ledState = STATE_BLINK;
        else if (ledState == STATE_BLINK)
            ledState = STATE_FADE;
        else
            ledState = STATE_SHINE;
 
        delay(10);
    }
 
    wasButtonDown = isButtonDown;
 
    // ...
}

Первым делом мы считываем состояние кнопки в логическую переменную isButtonDown. А сразу после этого проверяем условие того, что она была нажата только что, а не находится в этом состоянии с предыдущего вызова loop. Вы могли узнать типичный приём для определения клика кнопки. Это он и есть, поэтому вызов delay и назначение wasButtonDown в конце должны быть вам понятны. Сосредоточимся на происходящем внутри условного выражения if.

Оператор равенства

В блоке кода if мы видим ещё одно, вложенное условное выражение оформленное цепочкой.

if (ledState == STATE_SHINE)
    ledState = STATE_BLINK;
else if (ledState == STATE_BLINK)
    ledState = STATE_FADE;
else
    ledState = STATE_SHINE;

Его суть в том, чтобы установить вместо текущего состояния следующее. С помощью условий определяется текущее состояние, а в коде веткок устанавливается соответствующее следующее состояние.

Обратите внимание, что в C++ проверка на равенство осуществляется символом ==, т.е. двойным знаком равенства. Так записывается оператор «равно». Значение логического выражения будет истинным, если значения слева и справа от == равны.

Типичная ошибка при программировании — перепутать оператор равенства == с оператором присваивания =. Если написать:

if (ledState = STATE_SHINE) {
    // ...
}

программа будет абсолютно корректна с точки зрения компилятора C++, но происходить будет совсем не то, что предполагается: сначала переменной ledState будет присвоено значение STATE_SHINE, а только затем будет проверенно истинно ли её значение.

Поэтому, например, в нашем случае, если бы мы допустили такую ошибку, при прохождении этого кода ledState всегда бы перезаписывалась значением STATE_SHINE, которое в свою очередь объявлено как 0, 0 в условии эквивалентен false, а следовательно внутрь блока кода мы бы никогда не попали.

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

Своя логика в каждом состоянии

Мы сделали всё, что необходимо для того, чтобы по переменной ledState можно было понять, что именно нужно делать со светодиодами прямо сейчас. Осталось лишь реализовать эту логику на практике.

Рассмотрим вторую половину кода функции loop и определения, которые в ней используются.

// ...
 
#define BLINK_DELAY   2000
 
#define FADE_TIME         10000
#define FADE_STEP_DELAY   (FADE_TIME / 256)
 
// ...
 
void loop()
{
    // ...
 
    if (ledState == STATE_SHINE)
        digitalWrite(LED_PIN, HIGH);
    else if (ledState == STATE_BLINK)
        digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);
    else if (ledState == STATE_FADE)
        analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);
}

В теле loop мы видим цепочку условных выражений, где в каждом из условий мы сравниваем текущее состояние ledState поочерёдно со всеми возможными. Суть точно та же, что и была ранее, при переключении состояний по нажатию кнопки: в зависимости от текущего значения ledState исполнить тот или иной блок кода.

В приведённом коде можно отчётливо увидеть 3 блока кода: по одному на каждый режим свечения. Первый из них исполняется в случае, если ledState равно STATE_SHINE, т.е. если текущий режим — непрерывное свечение. Блок кода в этом случае примитивен, нам просто нужно убедиться, что светодиоды включены:

digitalWrite(LED_PIN, HIGH);

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

Режим мигания

Дальше интереснее. Если текущее состояние было установлена в STATE_BLINK, т.е. режим мигания каждые 2 секунды, выполняется ветка с кодом:

digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);

Вы знаете, что для того, чтобы просто помигать светодиодом на Arduino достаточно последовательно вызывать digitalWrite и delay, то включая, то выключая пин:

digitalWrite(LED_PIN, HIGH);
delay(BLINK_DELAY);
digitalWrite(LED_PIN, LOW);
delay(BLINK_DELAY);

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

Дело в том, что вызов delay приводит к усыплению процессора, и он просто «застывает» на вызове на указанный промежуток времени. Он не сможет заниматься ничем другим, кроме как спать. А теперь вспомним, что наша гирлянда всегда «одновременно» делает 2 вещи:

  1. Управляет свечением светодиодов
  2. Ловит момент нажатия кнопки, чтобы переключать режимы

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

Как решить проблему? Не использовать delay вовсе! Вместо этого, мы каждый раз попадая в нашу ветку можем заново рассчитывать: должна ли гирлянда быть включена или выключена именно в текущий момент времени.

Для этого мы используем небольшое арифметическое выражение: (millis() / BLINK_DELAY) % 2.

Встроенная функция millis просто возвращает значение равное количеству милисекунд, прошедших с момента включения или перезагрузки Arduino. Мы используем целочисленное деление её результата на BLINK_DELAY, которое мы в свою очередь определили как 2000, т.е. 2000 миллисекунд.

Таким образом значение millis() / BLINK_DELAY будет увеличиваться на единицу всякий раз, когда с момента старта Arduino пройдут очередные 2000 мс, т.е. 2 секунды.

Далее мы просто берём остаток от деления на 2 этого промежуточного результата на. Таким образом, итоговое значение будет то 0, то 1, переключаясь каждые 2 секунды. То, что нам нужно! И мы просто передаём вычисленное значение в качестве аргумента при вызове digitalWrite:

digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);

Режим нарастания яркости

Если текущее состояние нашей гирлянды — STATE_FADE, будет исполняться третий блок, который заставит светодиоды плавно набирать свою яркость в течение 10 секунд, гаснуть и набирать яркость снова:

analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);

Суть примерно та же, что и для мигания. Просто мы используем немного другое выражение для расчётов и вызываем analogWrite вместо digitalWrite.

Наша задача: заставить светодиоды набирать яркость от нуля до максимума ровно за 10 секунд или, что то же самое, 10000 миллисекунд. Функция analogWrite в качестве параметра яркости принимает значения от 0 до 255, т.е. всего 256 градаций. Поэтому, для увеличения яркости на одну градацию должно пройти 10000 мс ÷ 256 ≈ 39 мс. Именно эти значения мы определили в начале программы:

#define FADE_TIME         10000
#define FADE_STEP_DELAY   (FADE_TIME / 256)

Так значение выражения millis() / FADE_STEP_DELAY будет становиться на единицу больше каждый раз, когда проходит 39 мс.

Обратите внимание на скобки в определении FADE_STEP_DELAY. Поскольку значения макроопределений подставляются в программу как есть, мы получаем millis() / (10000 / 256); а если скобок бы не было, получилось бы millis() / 10000 / 256, что совершенно не одно и то же с точки зрения математики. Поэтому добавляйте круглые скобки вокруг любых арифметических выражений, когда используете их в качестве значений макроопределений.

Наконец, от промежуточного значения millis() / FADE_STEP_DELAY мы получаем остаток деления на 256. Таким образом, всё будет начинаться сначала всякий раз, когда промежуточное значение будет становиться кратным 256. То, что нужно!

Арифметика состояний

Ещё раз взглянем на код, который обеспечивал нам включение очередного состояния при клике кнопки:

if (ledState == STATE_SHINE)
    ledState = STATE_BLINK;
else if (ledState == STATE_BLINK)
    ledState = STATE_FADE;
else
    ledState = STATE_SHINE;

Если бы состояний у нас было не 3, а 33, код бы растянулся на много строк, но при этом ничего нового и уникального в программу бы не добавлял: он бы оставался однотипным. Если вам при написании скетча кажется, что какие-то его места сводятся к монотонному набору инструкций, слабо отличающихся друг от друга, почти наверняка существует способ упростить этот код, сделать его проще, понятнее, компактнее. Стоит лишь подумать.

Что можно сделать с нашей цепочкой? Вспомним, что состояния для процессора — это всего лишь целые числа. Мы определили их с помощью макроопределений.

#define STATE_SHINE   0
#define STATE_BLINK   1
#define STATE_FADE    2

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

ledState = ++ledState % TOTAL_STATES;

Где TOTAL_STATES — общее количество состояний, которое мы можем определить как:

#define TOTAL_STATES  3

Перечисления enum

Задача перечисления состояний и присвоение им последовательных целых значений довольно распространённая. Для подобных случаев, чтобы чуть упростить работу и повысить читаемость программы, в C++ существуют так называемые перечисления (англ. enumeration). Для объявления нового перечисления используется конструкция:

enum State
{
    STATE_SHINE,
    STATE_BLINK,
    STATE_FADE,
 
    TOTAL_STATES
}

С точки зрения компиляции это ничем не отличается от:

#define STATE_SHINE   0
#define STATE_BLINK   1
#define STATE_FADE    2
 
#define TOTAL_STATES  3

Но в случае с enum нам не пришлось явно прописывать значения 0, 1, 2, 3 и т.д. В перечислениях, первая константа автоматически получает значение 0, а каждая следующая на единицу больше.

И чем ещё хорошо перечисление: мы можем использовать его имя (State в нашем случае) в качестве типа данных, так же как int, bool и т.п. То есть мы можем определить переменную текущего состояния так:

State ledState = STATE_SHINE;

За кадром ledState осталась всё тем же целым числом, что и раньше, но сама программа теперь стала чуть понятнее и нагляднее: мы чётко обозначили, что собираемся хранить в нашей переменной.

Собрав всё вместе, получим обновлённый вариант нашей программы:

#define LED_PIN     5
#define BUTTON_PIN  6
 
#define BLINK_DELAY   2000
 
#define FADE_TIME         10000
#define FADE_STEP_DELAY   (FADE_TIME / 256)
 
enum State
{
    STATE_SHINE,
    STATE_BLINK,
    STATE_FADE,
 
    TOTAL_STATES
}
 
State ledState = STATE_SHINE;
bool wasButtonDown = false;
 
void setup()
{
    pinMode(LED_PIN, OUTPUT);
    pinMode(BUTTON_PIN, INPUT);
}
 
void loop()
{
    bool isButtonDown = digitalRead(BUTTON_PIN);
    if (isButtonDown && !wasButtonDown) {
        ledState = ++ledState % TOTAL_STATES;
        delay(10);
    }
 
    wasButtonDown = isButtonDown;
 
    if (ledState == STATE_SHINE)
        digitalWrite(LED_PIN, HIGH);
    else if (ledState == STATE_BLINK)
        digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);
    else if (ledState == STATE_FADE)
        analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);
}

Выражение выбора switch

Для того, чтобы понять что делать со светодиодами гирлянды в данный конкретный момент мы использовали цепочку из выражений if, где поочерёдно сравнивали ledState со всеми возможными состояниями. Это довольно распространённый сценарий и для него в C++ существует выражение выбора switch. Мы можем использовать его для нашей цели:

switch (ledState) {
    case STATE_SHINE:
        digitalWrite(LED_PIN, HIGH);
        break;
 
    case STATE_BLINK:
        digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);
        break;
 
    case STATE_FADE:
        analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);
        break;
}

Не сказать, что код стал компактнее, но он стал чуть нагляднее.

Суть выражения switch такова. Сначала вычисляется значение арифметического выражения, записанного в круглых скобках. В нашем случае — это просто получение значения ledState. Затем, в блоке кода между фигурными скобками ищется метка case, чьё значение равно вычисленному. Исполнение кода начинается с неё и идёт последовательно до конца блока (не до следующего case). Исполнение блока можно завершить досрочно выражением break.

Частая ошибка от невнимательности — забыть поставить break. В этом случае процессор выполнит код, принадлежащий другим меткам, а это чаще всего — не то, что нужно. Да, это неприятная особенность C++, но так уж сложилась история. Изначально это было сделано для того, чтобы можно было перечислить сразу несколько значений на одну ветку. Например, если бы для состояний STATE_FADE и STATE_BLINK мы, по задумке, должны бы были делать одно и то же, мы могли бы написать:

switch (ledState) {
    case STATE_SHINE:
        digitalWrite(LED_PIN, HIGH);
        break;
 
    case STATE_BLINK:
    case STATE_FADE:
        analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);
        break;
}

Также в switch, на последнем месте можно указать метку со специальным именем default. Она будет выполнена, если ни одна другая метка case не подошла по значению.

switch (ledState) {
    case STATE_SHINE:
        digitalWrite(LED_PIN, HIGH);
        break;
 
    case STATE_BLINK:
        digitalWrite(LED_PIN, (millis() / BLINK_DELAY) % 2);
        break;
 
    case STATE_FADE:
        analogWrite(LED_PIN, (millis() / FADE_STEP_DELAY) % 256);
        break;
 
    default:
        // очевидно мы что-то понапутали с алгоритмом
        // и ledState приняла непредвиденное значение,
        // дадим об этом знать:
        digitalWrite(ALARM_LED_PIN, HIGH);
}

Итак, вы познакомились с конечными автоматами, состояниями, переходами, принципами одновременного выполнения нескольких задач. В нашем примере, для разных состояний мы использовали довольно простую логику: один вызов digitalWrite с нехитрым арифметическим выражением; а для переключения состояний и вовсе использовали последовательные переходы по клику кнопки.

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

Так же, как и со всем остальным, ничто не мешает делать вложенные конечные автоматы: то есть состояния, которые сами по себе являются конечными автоматами. Главное поддерживать код стройным и читаемым, тогда у вас всё получится!