Ардуино - платформа для обучения и быстрого создания макетов и прототипов, поэтому программа в этой среде называется "скетч" (sketch, набросок). Так как пользователями Arduino обычно являются новички - они учатся по примерам из библиотек и проектам друг друга. Большинство примеров и проектов из Интернета имеют следующую структуру:
- Один большой файл или в лучшем случае Ардуино-вкладки
- Куча глобальных переменных
- Огромный
loop
, содержащий почти весь код программы
Данный подход отлично работает для маленького проекта, но когда программа растёт и становится сложнее - начинаются проблемы:
- Становится сложнее добавлять новый функционал - кода становится всё больше, в нём сложнее ориентироваться
- Нужно придумывать более уникальные имена глобальных переменных и констант
- Приходится переписывать часть старого кода, чтобы добавить новый
- Приходится дублировать некоторый код, если он понадобился в другом месте
- Такой код сложно поддерживать и дорабатывать, как автору, так и сообществу
- Отдельное удовольствие - возвращаться к такому проекту спустя месяц/полгода/год - его захочется переписать заново
Чтобы превратить скетч в читаемый и хорошо структурированный код, нужно научиться разделять программу на независимые "модули", которые взаимодействуют между собой в основной программе. Если в программе есть набор переменных, которые работают со своим набором функций - их можно и нужно вынести в отдельный модуль. Если внутри модуля наблюдается такая же ситуация - то его тоже можно разделить на подмодули и разложить по файлам. Когда модуль независимый - его удобно дорабатывать и тестировать отдельно от основной программы. По сути, любая библиотека как раз и является таким модулем.
Разделение 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 памяти больше - незначительно по сравнению с тем, насколько удобнее с ней стало работать.
Arduino-вкладки #
В Arduino IDE есть свой способ разделения крупного проекта на файлы - вкладки. Этот механизм позволяет разделить текст программы на несколько вкладок - при компиляции они просто соединяются в одно общее полотно слева направо:
Вкладка не является самостоятельным файлом, как .cpp
или .h
- Каждая вкладка хранится в отдельном файле с расширением
.ino
- При открытии любой вкладки откроется весь проект
- Главная вкладка - та, имя которой совпадает с именем папки проекта
- Вкладки упорядочиваются по алфавиту и в этом же порядке будут собираться в общий файл при компиляции
- Все вкладки имеют общую область видимости, по сути это один файл - в них нельзя создавать переменные с одинаковыми именами и функции с одинаковыми сигнатурами
Не рекомендуется использовать вкладки - лучше сразу учиться писать нормально