View Categories

Разделение на «модули»

Ардуино - платформа для обучения и быстрого создания макетов и прототипов, поэтому программа в этой среде называется "скетч" (sketch, набросок). Так как пользователями Arduino обычно являются новички - они учатся по примерам из библиотек и проектам друг друга. Большинство примеров и проектов из Интернета имеют следующую структуру:

  • Один большой файл или в лучшем случае Ардуино-вкладки
  • Куча глобальных переменных
  • Огромный loop, содержащий почти весь код программы

Данный подход отлично работает для маленького проекта, но когда программа растёт и становится сложнее - начинаются проблемы:

  • Становится сложнее добавлять новый функционал - кода становится всё больше, в нём сложнее ориентироваться
  • Нужно придумывать более уникальные имена глобальных переменных и констант
  • Приходится переписывать часть старого кода, чтобы добавить новый
  • Приходится дублировать некоторый код, если он понадобился в другом месте
  • Такой код сложно поддерживать и дорабатывать, как автору, так и сообществу
  • Отдельное удовольствие - возвращаться к такому проекту спустя месяц/полгода/год - его захочется переписать заново

Чтобы превратить скетч в читаемый и хорошо структурированный код, нужно научиться разделять программу на независимые "модули", которые взаимодействуют между собой в основной программе. Если в программе есть набор переменных, которые работают со своим набором функций - их можно и нужно вынести в отдельный модуль. Если внутри модуля наблюдается такая же ситуация - то его тоже можно разделить на подмодули и разложить по файлам. Когда модуль независимый - его удобно дорабатывать и тестировать отдельно от основной программы. По сути, любая библиотека как раз и является таким модулем.

Организация функций #

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

const uint8_t btn1 = 2;

bool readButton() {
    return !digitalRead(btn1);
}

Здесь функция зависит от внешней константы. Если нам понадобится ещё одна кнопка - придётся написать ещё одну функцию, появится дублирование кода. Гораздо правильнее будет сделать универсальную функцию и передавать в неё номер пина как параметр. В этом случае функция будет одна, её можно будет дальше дорабатывать без дублирования кода:

const uint8_t btn1 = 2;
const uint8_t btn2 = 3;
const uint8_t btn3 = 4;

bool readButton(uint8_t buttonPin) {
    return !digitalRead(buttonPin);
}

// readButton(btn1);
// readButton(btn2);
// readButton(btn3);

Противоположный пример - функция должна при каждом вызове увеличивать свой счётчик и возвращать его значение, например:

int counter = 0;

int getCounter() {
    return counter++;
}

Тут есть смысл сделать переменную статической внутри функции, чтобы функция стала самостоятельной единицей программы, а также избавиться от глобальной переменной:

int getNewCounter() {
    static int counter = 0;
    return counter++;
}

Модуль #

Рассмотрим обработку нажатия кнопки (с флагом) как пример выделения в модуль. Обработка одной кнопки в формате "скетча новичка" может выглядеть так:

// пин, кнопка на GND
const uint8_t btn = 2;

// состояние
bool state;

// инициализация
void buttonInit() {
    pinMode(btn, INPUT_PULLUP);
    state = digitalRead(btn);
}

// вернёт true однократно при нажатии
bool buttonClick() {
    if (state != digitalRead(btn)) {
        state = !state;
        if (!state) return true; // LOW - кнопка нажата
    }
    return false;
}

void setup() {
    Serial.begin(115200);
    buttonInit();
}

void loop() {
    if (buttonClick()) Serial.println("btn");
}

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

Переменные и функции #

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

// === ФУНКЦИИ КНОПКИ
void buttonInit(bool* state, uint8_t pin) {
    pinMode(pin, INPUT_PULLUP);
    *state = digitalRead(pin);
}

bool buttonClick(bool* state, uint8_t pin) {
    if (*state != digitalRead(pin)) {
        *state = !*state;
        if (!*state) return true;
    }
    return false;
}
// === СКЕТЧ
// пины
const uint8_t btn1 = 2;
const uint8_t btn2 = 3;

// состояния
bool state1;
bool state2;

void setup() {
    Serial.begin(115200);
    buttonInit(&state1, btn1);
    buttonInit(&state2, btn2);
}

void loop() {
    if (buttonClick(&state1, btn1)) Serial.println("btn 1");
    if (buttonClick(&state2, btn2)) Serial.println("btn 2");
}

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

Структура и функции #

Это основной "сишный" подход к организации модулей: создаётся структура и набор функций, которые принимают эту структуру по указателю:

// === ФУНКЦИИ КНОПКИ
struct Button {
    uint8_t pin;
    bool state;
};

void buttonInit(Button* btn) {
    pinMode(btn->pin, INPUT_PULLUP);
    btn->state = digitalRead(btn->pin);
}

bool buttonClick(Button* btn) {
    if (btn->state != digitalRead(btn->pin)) {
        btn->state = !btn->state;
        if (!btn->state) return true;
    }
    return false;
}
// === СКЕТЧ
Button btn1{2};
Button btn2{3};

void setup() {
    Serial.begin(115200);
    buttonInit(&btn1);
    buttonInit(&btn2);
}

void loop() {
    if (buttonClick(&btn1)) Serial.println("btn 1");
    if (buttonClick(&btn2)) Serial.println("btn 2");
}

Теперь модуль кнопки может дорабатываться независимо от основной программы, так как содержит в себе всё необходимое.

Класс #

В C++ подход "структура и функции" выглядит архаично и многословно, ведь есть классы, которые позволяют сделать всё то же самое, но гораздо более удобно - скрыть все функции внутри объекта:

// === КЛАСС КНОПКИ
class Button {
   public:
    Button(uint8_t pin) : pin(pin) {
        pinMode(pin, INPUT_PULLUP);
        state = digitalRead(pin);
    }

    bool click() {
        if (state != digitalRead(pin)) {
            state = !state;
            if (!state) return true;
        }
        return false;
    }

   private:
    const uint8_t pin;
    bool state;
};
// === СКЕТЧ
Button btn1(2);
Button btn2(3);

void setup() {
    Serial.begin(115200);
}

void loop() {
    if (btn1.click()) Serial.println("btn 1");
    if (btn2.click()) Serial.println("btn 2");
}

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

class Button {
   public:
    Button(uint8_t pin) : pin(pin) {
        pinMode(pin, INPUT_PULLUP);
        state = digitalRead(pin);
    }

    bool click() {
        if (uint8_t(uint8_t(millis()) - tmr) < 50) return false;

        tmr = millis();
        if (state != digitalRead(pin)) {
            state = !state;
            if (!state) return true;
        }
        return false;
    }

   private:
    const uint8_t pin;
    uint8_t tmr;
    bool state;
};

Разделение loop #

Рекомендуется изучить следующие уроки:

Разделение "полотна" на модули скорее всего увеличит вес программы в памяти и слегка замедлит её выполнение - появятся новые переменные и вызовы функций. Здесь нужно соблюдать баланс между абстрактностью кода и его размером - мы всё таки пишем программу под микроконтроллер, а не под ПК. В то же время, современные МК имеют всё больше и больше памяти и скорости работы при меньшей стоимости, чем старые модели, поэтому можно смещать подход к разработке в сторону удобства программиста, а не процессора.

Стадии разделения полотна на модули:

  • Разделить содержимое loop на функции с читаемыми названиями, которые будут вызываться в loop. По сути это - тикеры, они не должны блокировать код
  • Заменить глобальные переменные статическими там, где это возможно (переменные, которые используются только внутри одной функции)
  • Перенести эти функции в файлы вместе с нужными для них переменными
  • Выделить повторяющиеся конструкции (таймеры, кнопки, фильтры..) в независимые модули, например обернуть в классы
  • Даже если некая "сущность" (набор переменных и функций) используется в программе только один раз - есть смысл обернуть её в класс и создать объект. Просто для того, чтобы уменьшить количество глобальных имён и получить подсказки IDE по методам. Так например сделаны системные объекты Serial, EEPROM, SPI, Wire
  • Хороший модуль может стать библиотекой и использоваться в других проектах

Разберём на примере примера из урока про таймер - есть три задачи, которые выполняются по таймерам:

uint32_t myTimer1, myTimer2, myTimer3;

void setup() {
    Serial.begin(115200);
}

void loop() {
    // таймер на 500 мс (2 раза в сек)
    if (millis() - myTimer1 >= 500) {
        myTimer1 = millis();
        Serial.println("action 1");
    }

    // таймер на 333 мс (3 раза в сек)
    if (millis() - myTimer2 >= 333) {
        myTimer2 = millis();
        Serial.println("action 2");
    }

    // таймер на 100 мс (10 раз в сек)
    if (millis() - myTimer3 >= 100) {
        myTimer3 = millis();
        Serial.println("action 3");
    }
}

Для начала просто разделим их на функции:

uint32_t myTimer1, myTimer2, myTimer3;

void task1() {
    if (millis() - myTimer1 >= 500) {
        myTimer1 = millis();
        Serial.println("action 1");
    }
}
void task2() {
    if (millis() - myTimer2 >= 333) {
        myTimer2 = millis();
        Serial.println("action 2");
    }
}
void task3() {
    if (millis() - myTimer3 >= 100) {
        myTimer3 = millis();
        Serial.println("action 3");
    }
}

void setup() {
    Serial.begin(115200);
}

void loop() {
    task1();
    task2();
    task3();
}

loop уже стал более читаемым. Сейчас видно, что переменные таймеров используются только в рамках своих функций. Их можно сделать статическими и внести в функции - теперь им не нужны уникальные имена:

void task1() {
    static uint32_t tmr;
    if (millis() - tmr >= 500) {
        tmr = millis();
        Serial.println("action 1");
    }
}
void task2() {
    static uint32_t tmr;
    if (millis() - tmr >= 333) {
        tmr = millis();
        Serial.println("action 2");
    }
}
void task3() {
    static uint32_t tmr;
    if (millis() - tmr >= 100) {
        tmr = millis();
        Serial.println("action 3");
    }
}

void setup() {
    Serial.begin(115200);
}

void loop() {
    task1();
    task2();
    task3();
}

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

class Timer {
  public:
    Timer(uint32_t prd) : _prd(prd) {}

    bool ready() {
        if (millis() - _tmr >= _prd) {
            _tmr = millis();
            return true;
        }
        return false;
    }

  private:
    uint32_t _tmr, _prd;
};

Теперь основная программа примет вид:

void task1() {
    static Timer tmr(500);
    if (tmr.ready()) {
        Serial.println("action 1");
    }
}

void task2() {
    static Timer tmr(333);
    if (tmr.ready()) {
        Serial.println("action 2");
    }
}

void task3() {
    static Timer tmr(100);
    if (tmr.ready()) {
        Serial.println("action 3");
    }
}

void setup() {
    Serial.begin(115200);
}

void loop() {
    task1();
    task2();
    task3();
}

Программа станет работать чуть медленнее (незначительно относительного того, что она делает) и занимать больше места в оперативной памяти, но код стал более читаемым и легко масштабируемым. Давайте разделим его на файлы (проект для VS Code, main.cpp соответствует главному скетчу .ino). Переменные таймеров я вынес за функции и оставил static - они будут видны только в своих исходных файлах и не будут пересекаться:

#include <Arduino.h>

void task1();
void task2();
void task3();

void setup() {
    Serial.begin(115200);
}

void loop() {
    task1();
    task2();
    task3();
}
#include <Arduino.h>

#include "Timer.h"

static Timer tmr(500);

void task1() {
    if (tmr.ready()) {
        Serial.println("action 1");
    }
}
#include <Arduino.h>

#include "Timer.h"

static Timer tmr(333);

void task2() {
    if (tmr.ready()) {
        Serial.println("action 2");
    }
}
#include <Arduino.h>

#include "Timer.h"

static Timer tmr(100);

void task3() {
    if (tmr.ready()) {
        Serial.println("action 3");
    }
}
#pragma once
#include <Arduino.h>

class Timer {
   public:
    Timer(uint32_t prd) : _prd(prd) {}

    bool ready() {
        if (millis() - _tmr >= _prd) {
            _tmr = millis();
            return true;
        }
        return false;
    }

   private:
    uint32_t _tmr, _prd;
};

По сравнению с изначальным вариантом программа стала занимать на 2 байта Flash и 12 байт RAM памяти больше - незначительно по сравнению с тем, насколько удобнее с ней стало работать.

Свой "setup" #

Если модули полностью независимые и нуждаются только в вызове "тикеров" в лупе, то можно организовать им и свой блок setup - код, который выполнится однократно при запуске программы: либо прокинуть вызов в основной setup, либо по флагу:

Проброс вызова #

#include <Arduino.h>

void task1_setup();
void task2_setup();
void task3_setup();

void task1_loop();
void task2_loop();
void task3_loop();

void setup() {
    task1_setup();
    task2_setup();
    task3_setup();
}

void loop() {
    task1_loop();
    task2_loop();
    task3_loop();
}
#include <Arduino.h>

void task1_setup() {
    // setup
}
void task1_loop() {
    // loop
}
#include <Arduino.h>

void task2_setup() {
    // setup
}
void task2_loop() {
    // loop
}
#include <Arduino.h>

void task3_setup() {
    // setup
}
void task3_loop() {
    // loop
}

По флагу #

#include <Arduino.h>

void task1();
void task2();
void task3();

void setup() {
}

void loop() {
    task1();
    task2();
    task3();
}
#include <Arduino.h>

void task1() {
    static bool s;
    if (!s) {
        s = 1;
        // setup
    }
    // loop
}
#include <Arduino.h>

void task2() {
    static bool s;
    if (!s) {
        s = 1;
        // setup
    }
    // loop
}
#include <Arduino.h>

void task3() {
    static bool s;
    if (!s) {
        s = 1;
        // setup
    }
    // loop
}

Arduino-вкладки #

В Arduino IDE есть свой способ разделения крупного проекта на файлы - вкладки. Этот механизм позволяет разделить текст программы на несколько вкладок - при компиляции они просто соединяются в одно общее полотно слева направо:

Вкладка не является самостоятельным файлом, как .cpp или .h

  • Каждая вкладка хранится в отдельном файле с расширением .ino
  • При открытии любой вкладки откроется весь проект
  • Главная вкладка - та, имя которой совпадает с именем папки проекта
  • Вкладки упорядочиваются по алфавиту и в этом же порядке будут собираться в общий файл при компиляции
  • Все вкладки имеют общую область видимости, по сути это один файл - в них нельзя создавать переменные с одинаковыми именами и функции с одинаковыми сигнатурами

Не рекомендуется использовать вкладки - лучше сразу учиться писать нормально

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

Подписаться
Уведомить о
guest

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