View Categories

Машина состояний и флаги

Для урока понадобится
В наборе 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;
    }
}

Полезные страницы #

5 1 голос
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх