12 листопада 2016 р.

8. Програмна архітектура і багатозадачність

Думальний процес робота включає багато елементів. Це і логіка низького рівня (ввімкнути двигуни, блимнути світлодіодом, пропищати щось в динамік), і логіка середнього рівня (доїхати до перешкоди, зупинитися, помітивши край прірви - зупинитися, розвернутися, втекти), і логіка високого рівня (вибрати стратегію поведінки, дослідити і запам'ятати схему приміщень). Всі ці "думки" у вигляді програмного коду крутяться в "голові" робота постійно і одночасно. І буде дуже недобре, якщо, робот захопиться розрахунком маршруту, і гепнеться зі сходів не помітивши прірву перед собою. Можливість думати про багато речей одночасно називається багатозадачністю.

Якщо ви добре розбираєтеся в програмуванні і не відчуваєте потреби заглиблюватися у роздуми про особливості реалізації багатозадачності - просто скачайте архів з кодом і правте його на свій розсуд. Весь наступний текст просто пояснює звідки цей код взявся і чому він є саме таким.

Насправді Arduino використовує одноядерний процесор, тому багатозадачність є ілюзією. В будь-який момент часу Arduino може опрацьовувати лише одну "думку" (задачу). Ефекту багатозадачності вдається досягти, якщо опрацьовувати кожну задачу короткими порціями, швидко перемикаючись між ними по черзі.

https://www.hackster.io/feilipu/using-freertos-multi-tasking-in-arduino-ebc3cc
Є бібліотеки, які дозволяють реалізувати досить потужну систему багатозадачності, співмірну з такими, що використовуються на великих комп'ютерах. Наприклад, у системі FreeRTOS працює повноцінна багатозадачність із системою пріорітизації і витіснення (peemptive multitasking).

Чудовий опис основних ідей побудови багатозадачності можна знайти в статті Multi-tasking the Arduino (англ, частина 1, частина 2) з сайту Adafruit. Інші статті, які нам попадалися на очі - це зазвичай переклади або перекази саме Адафрутівської (напр. Багатозадачність на Arduino (рос.)).

Цікаві унікальні ідеї хіба є ще тут: Витісняюча багатозадачність для Arduino (рос, частина 1, частина 2).

На організацію "правильної" багатозадачності витрачається пам'ять і процесорний час. Та й не для кожного випадку потрібні такі складнощі.

Ми вирішили створити спрощену систему багатозадачності, з мінімальною надлишковістю. В основу поклали чотири основні принципи:
  1. Кооперативність. Всі задачі повинні писатися з розрахунку спільної роботи і не займати процесор надовго. Також задачі повинні добровільно і без затримок відпускати процесор іншим задачам.
  2. Не використовувати delay() в коді, бо на час вичікування робот повністю втрачає розум. І в гіршому випадку може навіть влетіти в стіну, чи звалитися зі сходів. Тому - забудьте про delay і не використовуйте його ніколи. Ну хіба на дуже короткі проміжки часу (10-20 мілісекунд).
  3. Початок або завершення дії визначається постійним перевірянням чи наступив відповідний момент за допомогою функції millis().
  4. Виняткові чи катастрофічні ситуації треба ловити за допомогою переривань (interrupts). Наприклад, якщо робот помічає прірву в себе під гусеницями.

Простий багатозадачний код, побудований на таких принципах може виглядати так:
loop() {
    <якщо пора ввімкнути двигун> {
        <ввімкнути двигун>
        <запам'ятати, коли двигун треба вимкнути>
    }
    <якщо пора перевірити відстань до перешкоди> {
        <перевірити відстань до перешкоди>
        <подумати що робити з тою відстанню> {
            <може поставити в чергу команду зупинки двигунів?>
            <може поставити в чергу команду виконати поворот?>
        }
    }
    <якщо пора блимнути діодом> {
        <блимнути>
    }
    …
}

Зрозуміло, що для процесора такий текст програми буде дуже зручним. Але дуже швидко він перестане поміщатися у пам'ять програміста. Розбиратися в блоці коду, довшому ніж два екрани - річ дуже непевна. Уникайте цього будь-якими способами. Як мінімум можна виділити кожен блок в окрему функцію:
loop() {
    опрацюватиЗадачіДвигунів();
    опрацюватиЗадачіСенсораВідстані();
    опрацюватиЗадачіГенератораЗвуків():
    опрацюватиЗадачіШтучногоІнтелекту():
}
В кожній окремій функції вже можна додати логіку, яка буде займатися одною конкретною задачею. Мозку програміста стане працювати легше - код стане якіснішим, мети ви досягнете швидше. Але і це не є межею досконалості. Набагато легше працювати з кодом програми, який погрупований в автономні блоки, які повністю незалежні один від одного і взаємодіють тільки через наперед задані і обмежені канали зв'язку (інтерфейси). Такі автономні блоки можна побудувати за допомогою Об'єктно-Орієнтованого Підходу (ООП).

Так наприклад можна описати всі наші задачі як нащадки класу такого вигляду:
class TaskInterface {
public:
    virtual void processTask() = 0;
};
Такий запис означає, що до об'єкту такого класу можна звернутися лише через виклик метода processTask(). Присвоєння нуля цьому методу змушує всі класи-нащадки описувати цей метод по своєму.

Насправді - це все, що треба знати головній програмі, щоб по черзі викликати код кожної задачі. Головна програма може виглядати зовсім просто:

#include "Arduino.h"// створюємо об'єкти відповідних задач
// класи цих задач описуються в окремих файлах
// всі вони повинні наслідуватися від класу TaskInterface
static RobotMotors motors(); // двигуни
static RobotVoice robotVoice(); // звукові ефекти
static RobotAI robotAI(); // штучний інтелект

// створюємо список задач, які будемо опрацьовувати у вічному циклі по черзі
// таких задач у прикладі є 3 штуки
static TaskInterface* (robotTasks[3]);

void setup() {
    // зберігаємо посилання у список задач
    robotTasks[0] = &motors;
    robotTasks[1] = &robotVoice;
    robotTasks[2] = &robotAI;
}

void loop() {
    // викликаємо кожну задачу в циклі через метод processTask()
    for(uint8_t i=0; i<3; i++) {
        robotTasks[i]->processTask();
    }
}

Залишається дрібниця - для кожної задачі описати її поведінку в окремому класі, який буде зберігатися в окремих файлах.

Для прикладу розглянемо як міг би виглядати опис класу, відповідального за виведення звуку на динамік робота

/*
 * Файл RobotVoice.h
 */

#ifndef ROBOTVOICE_H_
#define ROBOTVOICE_H_

#include "TaskInterface.h"

// це просто собі зручний спосіб кодувати тип звуку,
// який ми збираємося відтворювати
enum VoiceSounds {sndNoSound, sndHello, sndOK, sndQuestion, sndScared};

// опис самого класу відтворювача звуку
// він наслідується від TaskInterface,
// тому може бути доданий у список задач в головній програмі
class RobotVoice: public TaskInterface {

private:
    // зовнішні програми не мають доступу до цих змінних і методів
  
    // тут буде запам'ятовуватися код звуку,
    // який ми хочемо відтворити наступним
    VoiceSounds nextSound;
  
    // дві функції з бібліотеки, яку ми розглядали минулого разу - пам'ятаєте?
    void playTone(unsigned int toneFrequency, byte beats);
    void playFreqGlissando( float freqFrom, float freqTo, float duty, float duration);

public:
    // конструктор, який ініціалізує всі поля об'єкта
    RobotVoice();
  
    // деструктор - мав би як мінімум гарантовано вимикати всі звуки
    ~RobotVoice();
  
    // метод, який викликається в циклі і перевіряє чи не пора зіграти якийсь звук
    // якщо пора - грає
    virtual void processTask();
  
    // метод, який дозволяє ставити в чергу якийсь звук
    // саме цей метод буде викликатися з модуля штучного інтелекту,
    // коли йому буде потрібно пропищати страх або питання
    // тут головне якомога швидше записати звук у чергу
    // і відпустити процесор
    // сам звук буде відтворено під час чергового виклику метода processTask()
    void queueSound(VoiceSounds soundCode);
};

#endif /* ROBOTVOICE_H_ */

Повний текст програми зі всіма модулями можна побачити на GitHub.

В порівнянні із прикладами наведеними тут - у фінальному коді є такі ускладнення:
  1. Створення об'єктів супроводжується передаванням їм номерів виходів Arduino, якими вони повинні користуватися для керування своїми модулями. Таким чином всі номери виходів задаються в одному місці (robot.ino) і їх не треба виловлювати по всій програмі.
  2. Посилання на об'єкти передаються один одному, якщо один об'єкт повинен використовувати іншого. Наприклад об'єкт штучного інтелекту подає команди об'єктам двигунів, звуку і т.д.
  3. В опис класу TaskInterface включено два типові методи, які дозволяють додавати події в чергу і легко перевіряти чи не наступив вже час діяти. Завдяки використанню ООП нема потреби копіювати цей код у всі описи класів.
Програмка писалася в середовищі Sloeber, але без проблем може компілюватися і в стандартному Arduino IDE. Просто розпакуйте архів в будь-яке місце і відкрийте файл robot.ino.

Немає коментарів:

Дописати коментар