Как написать скетч?

Послесловие к базовым урокам


Вот и закончился базовый курс уроков программирования Arduino. Мы с вами изучили самые базовые понятия, вспомнили (или изучили) часть школьной программы по информатике, изучили большую часть синтаксиса и инструментов языка C++, и вроде бы весь набор Ардуино-функций, который предлагает нам платформа. Подчеркну – мы изучили C++ и функции Ардуино, потому что никакого “языка Arduino” нет, это ложное понятие. Arduino программируется на C или ассемблере, а платформа предоставляет нам всего лишь несколько десятков удобных функций для работы с микроконтроллером, именно функций, а не язык. Теперь перед нами чистый лист блокнота Arduino IDE и желание творить и программировать, давайте попробуем!

Структура программы


Прежде, чем переходить к реальным задачам, нужно поговорить о некоторых фундаментальных вещах. Микроконтроллер,  как мы обсуждали в самом начале пути, это комплексное устройство, состоящее из вычислительного ядра, постоянной и оперативной памяти и различных периферийных устройств (таймеры/счётчики, АЦП и проч.). Обработкой нашего с вами кода занимается именно ядро микроконтроллера, оно раздаёт команды остальным “железкам”, которые в дальнейшем могут работать самостоятельно. Ядро выполняет различные команды, подгоняемое тактовым генератором: на большинстве плат Arduino стоит генератор с частотой 16 МГц. Каждый толчок тактового генератора заставляет вычислительное ядро выполнить следующую команду, таким образом Ардуино выполняет 16 миллионов операций в секунду. Много ли это? Для большинства задач более чем достаточно, главное использовать эту скорость с умом. Зачем я об этом рассказываю: микроконтроллер может выполнить только одну задачу в один момент времени, так как у него только одно вычислительное ядро, поэтому реальной “многозадачности” нет и быть не может, но за счёт большой скорости выполнения ядро может выполнять задачи по очереди, и для человека это будет казаться многозадачностью, ведь что для нас “раз Миссисипи“, для микроконтроллера – 16 миллионов действий! Есть всего два варианта организации кода:

  • Основной парадигмой работы с микроконтроллером является так называемый суперцикл, то есть главный цикл программы, который выполняется сверху вниз (если смотреть на код) и начинает с самого начала, когда доходит до конца, и так далее. В Arduino IDE нашим суперциклом является loop(). В главном цикле мы можем опрашивать датчики, управлять внешними устройствами, выводить данные на дисплеи, производить вычисления и всё такое, но в любом случае эти действия будут происходить друг за другом, последовательно. В этом и заключается основной механизм параллельности задач: на самом деле все они выполняются последовательно друг за другом, но в то же время достаточно быстро для того, чтобы казаться “параллельными”.
  • Помимо основного цикла у нас есть прерывания, которые позволяют реализовать некую “поточность” выполнения задач, особенно в тех ситуациях, когда важна скорость работы. Прерывание позволяет остановить выполнение основного цикла в любом его месте, отвлечься на выполнение некоторого блока кода, и после успешного его завершения вернуться к основному циклу и продолжить работу. Некоторые задачи можно решить только на прерываниях, не написав ни одной строчки в цикл loop()! Мы с вами изучали аппаратные прерывания, позволяющие прерваться при замыкании контактов. Такие прерывания являются внешними, то есть их провоцируют внешние факторы (человек нажал на кнопку, сработал датчик, и т.д.). Также у микроконтроллера есть внутренние прерывания, которые вызываются периферией микроконтроллера, и этих прерываний может быть не один десяток! Одним из таких прерываний является прерывание таймера: по настроенному периоду программа будет прерываться и выполнять указанный код. Об этом мы поговорим ниже, а также на сайте есть отдельный урок по работе с прерываниями таймера. Такой подход хорош для задач, которые должны выполняться часто и с большой частотой, для всего остального можно настроить один таймер на счёт времени и работать с этим временем.
  • По умолчанию Arduino IDE настраивает один из таймеров (нулевой) на счёт реального времени, благодаря этому у нас работают такие функции как millis() и micros(). Именно эти функции являются готовым инструментом для тайм-менеджмента нашего кода и позволяют создавать работу по расписанию. Самый важный и критический момент: задачи не должны тормозить выполнение программы на бОльший период, чем период самой короткой задачи, иначе все задачи будут выполняться с периодом самой долгой! Именно поэтому нужно отказываться от задержек и ожиданий: задержку всегда можно заменить на проверку таймера при следующих итерациях цикла, и то же самое с ожиданием чего-то, например ответа датчика. Задачи должны быть максимально асинхронными и не блокировать код, к сожалению не во всех библиотеках есть неблокирующие аналоги функций. Даже родной блокирующий analogRead() можно сделать неблокирующим, но Arduino решили не усложнять жизнь новичкам.

“Многозадачность” с yield()


В уроке про функции времени мы коснулись функции yield(), которая позволяет выполнять свой код внутри задержек delay(). Данный костыль позволяет очень быстро реализовать “параллельное” выполнение двух задач: одной по таймеру, а второй – постоянно. В том уроке мы рассмотрели пример, в котором мигает светодиод и опрашивается кнопка:

void setup() {
  pinMode(13, OUTPUT);
}

void loop() {
  digitalWrite(13, 1);
  delay(1000);
  digitalWrite(13, 0);
  delay(1000);
}

void yield() {
  // а тут можно опрашивать кнопку
  // и не пропустить нажатия из за delay!
}

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

void setup() {  
}

void loop() {
  // задать целевой угол №1
  delay(1000);
  // задать целевой угол №2
  delay(120);
  // задать целевой угол №3
  delay(2000);
  // задать целевой угол №4
  delay(250);
  // задать целевой угол №5
  delay(600);
}

void yield() {
  // вращать мотор
}

Таким образом мы быстро и просто расписали “траекторию” движения для шагового мотора по времени, не используя какие-то таймеры или библиотеки таймеров. Для более сложных программ, например с движением двух моторов, такой фокус уже может не пройти и проще работать с таймером.

“Многозадачность” с millis()


Большинство примеров к различным модулям/датчикам используют задержку delay() в качестве “торможения” программы, например для вывода данных с датчика в последовательный порт. Именно такие примеры портят восприятие новичка, и он тоже начинает использовать задержки. А на задержках далеко не уедешь! Давайте вспомним конструкцию таймера на millis() из урока о функциях времени: у нас есть переменная, которая хранит время прошлого “срабатывания” таймера. Мы вычитаем это время из текущего времени, эта разница постоянно увеличивается, и по условию мы можем поймать тот момент, когда пройдёт нужное нам время. Будем учиться избавляться от delay()! Начнём с простого: классический blink:

void setup() {
  pinMode(13, OUTPUT);  // пин 13 как выход
}

void loop() {
  digitalWrite(13, HIGH); // включить
  delay(1000);            // ждать
  digitalWrite(13, LOW);  // выключить
  delay(1000);            // ждать
}

Программа полностью останавливается на команде delay(), ждёт указанное время, а затем продолжает выполнение. Чем это плохо? (А вы ещё спрашиваете?) Во время этой остановки мы ничего не можем делать в цикле loop(), например не сможем опрашивать датчик 10 раз в секунду: задержка не позволит коду пойти дальше. Можно использовать прерывания (например – таймера), но о них мы поговорим в продвинутых уроках. Сейчас давайте избавимся от задержки в самом простом скетче.

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

boolean LEDflag = false;

void setup() {
  pinMode(13, OUTPUT);
}

void loop() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
  delay(1000);        // ждать
}

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

boolean LEDflag = false;
uint32_t myTimer; // переменная времени

void setup() {
  pinMode(13, OUTPUT);
}

void loop() {
  if (millis() - myTimer >= 1000) {
    myTimer = millis(); // сбросить таймер
    digitalWrite(13, LEDflag); // вкл/выкл
    LEDflag = !LEDflag; // инвертировать флаг
  }
}

Что здесь происходит: цикл loop() выполняется несколько сотен тысяч раз в секунду, как ему и положено, потому что мы убрали задержку. Каждую свою итерацию мы проверяем, не настало ли время переключить светодиод, не прошла ли секунда? При помощи этой конструкции и создаётся нужная многозадачность, которой хватит для 99% всех мыслимых проектов, ведь таких “таймеров” можно создать очень много!

boolean LEDflag = false;
// переменные времени
uint32_t myTimer, myTimer1, myTimer2;
uint32_t myTimer3;

void setup() {
  pinMode(13, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  // каждую секунду
  if (millis() - myTimer >= 1000) {
    myTimer = millis(); // сбросить таймер
    digitalWrite(13, LEDflag); // вкл/выкл
    LEDflag = !LEDflag; // инвертировать флаг
  }

  // 3 раза в секунду
  if (millis() - myTimer1 >= 333) {
    myTimer1 = millis(); // сбросить таймер
    Serial.println("timer 1");
  }

  // каждые 2 секунды
  if (millis() - myTimer2 >= 2000) {
    myTimer2 = millis(); // сбросить таймер
    Serial.println("timer 2");
  }

  // каждые 5 секунд
  if (millis() - myTimer3 >= 5000) {
    myTimer3 = millis(); // сбросить таймер
    Serial.println("timer 3");
  }
}

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

timer 1
timer 1
timer 1
timer 1
timer 1
timer 1
timer 2
timer 1
timer 1
timer 1
timer 1
timer 1
timer 1
timer 2
timer 1

Это означает, что у нас спокойно работают 4 таймера с разным периодом срабатывания, работают “параллельно”, обеспечивая нам многозадачность: мы можем выводить данные на дисплей раз в секунду, и заодно опрашивать датчик 10 раз в секунду и усреднять его показания. Хороший пример для первого проекта! Обязательно вернитесь к уроку о функциях времени, там мы разобрали несколько конструкций на таймере аптайма!

“Многозадачность” с прерываниями таймера


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

  • Динамическая индикация;
  • Генерация определённого сигнала/протокола связи;
  • Программный ШИМ;
  • “Тактирование” шаговых моторов;
  • Любой другой пример выполнения через строго указанное время или просто периодическое выполнение по строгому периоду (несколько микросекунд). Так как это прерывание – задача будет обрабатываться в приоритете к остальному коду в суперцикле.

Настройка таймера на нужную частоту и режим работы – непосильная для новичка задача, хоть и решается в 2-3 строчки кода, поэтому предлагаю использовать библиотеки. Для настройки прерываний по таймеру 1 и 2 есть библиотеки TimerOne и TimerTwo. Мы сделали свою библиотеку, GyverTimers, в которой есть также таймер 0 (для программирования без использования Arduino.h), а также все таймеры на Arduino MEGA, а их там целых 6 штук. Ознакомиться с документацией и примерами можно на странице библиотеки. Сейчас рассмотрим простой пример, в котором “параллельно” выполняющемуся Blink будут отправляться данные в порт. Пример оторван от реальности, так делать нельзя, но он важен для понимания самой сути: код в прерывании выполнится в любом случае, ему безразличны задержки и мёртвые циклы в основном коде.

#include "GyverTimers.h"

void setup() {
  Serial.begin(9600);

  // Устанавливаем период таймера 333000 мкс -> 0.333 c (3 раза в секунду)
  Timer2.setPeriod(300000);
  Timer2.enableISR();   // запускаем прерывание на канале А таймера 2

  pinMode(13, OUTPUT);  // будем мигать
}

void loop() {
  // "блинк"
  digitalWrite(13, 1);
  delay(1000);
  digitalWrite(13, 0);
  delay(1000);
}

// Прерывание А таймера 2
ISR(TIMER2_A) {
  Serial.println("isr!");
}

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

Переключение задач


Важнейшим инструментом по организации логики работы программы является так называемый конечный автомат (англ. State Machine) – значение, которое имеет заранее известный набор состояний. Звучит сложно, но на самом деле речь идёт об операторе swith и переменной, которая переключается кнопкой или по таймеру. Например:

if (клик по кнопке 1) mode++;
if (клик по кнопке 2) mode--;

switch (mode) {
  case 0:
    // задача 0
    break;
  case 1:
    // задача 1
    break;
  case 2:
    // задача 2
    break;
  .........
}
Переменная режима должна быть знаковой (int или int8_t) чтобы избежать переполнения в обратную сторону при получении отрицательного значения!!!

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

  • Ограничить диапазон переменной mode по минимальному коду задачи (обычно 0) и максимальному (количество задач минус 1).
  • Сделать переключение с последней задачи на первую и наоборот, т.е. “закольцевать” изменение.

Ограничить диапазон при увеличении можно несколькими способами. Способы абсолютно одинаковые по своей сути, но записать можно по разному:

// ограничиваем mode числом 10

// способ 1 
mode++; 
if (mode > 10) mode = 10; 

// способ 2 
mode = min(mode++, 10); 

// способ 3 
if (++mode > 10) mode = 10;

Аналогично при уменьшении:

// способ 1 
mode--; 
if (mode < 0) mode = 0; 

// способ 2 
mode = max(mode--, 0);

// способ 3 
if (--mode < 0) mode = 0;

Переключение с первой на последнюю и обратно делается точно так же:

// переключаем mode в диапазоне 0-10 (11 режимов)
// с переходом через крайние значения

// СПОСОБ 1
// на увеличение
mode++;
if (mode > 10) mode = 0;

// на уменьшение
mode--;
if (mode < 0) mode = 10;


// СПОСОБ 2
// на увеличение
if (++mode > 10) mode = 0;

// на уменьшение
if (--mode < 0) mode = 10;

Рассмотрим несколько готовых примеров на базе библиотеки GyverButton:

Переключение режимов работы кнопкой. Постоянное выполнение
/*
   Данный код демонстрирует переключение режимов работы при помощи кнопки
   Для удобства используется библиотека отработки нажатий кнопки
*/

#define PIN 3        // кнопка подключена сюда (PIN --- КНОПКА --- GND)
#define MODE_AM 5    // количество режимов (от 0 до указанного)

#include "GyverButton.h"
// моя библиотека для более удобной работы с кнопкой
// скачать мождно здесь https://github.com/AlexGyver/GyverLibs

GButton butt1(PIN);  // создаём нашу "кнопку"

byte mode = 0;       // переменная режима

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

void loop() {
  butt1.tick();             // обязательная функция отработки. Должна постоянно опрашиваться
  if (butt1.isPress()) {    // правильная отработка нажатия с защитой от дребезга

    // увеличиваем переменную номера режима. Если вышла за количество режимов - обнуляем
    if (++mode >= MODE_AM) mode = 0;
  }

  // свитч крутится в цикле loop и задачи постоянно вызываются
  switch (mode) {
    case 0: task_0();
      break;
    case 1: task_1();
      break;
    case 2: task_2();
      break;
    case 3: task_3();
      break;
    case 4: task_4();
      break;
  }
}

// наши задачи, внутри функций понятное дело может быть всё что угодно
void task_0() {
  Serial.println("Task 0");
}
void task_1() {
  Serial.println("Task 1");
}
void task_2() {
  Serial.println("Task 2");
}
void task_3() {
  Serial.println("Task 3");
}
void task_4() {
  Serial.println("Task 4");
}
Переключение режимов работы кнопкой. Однократное выполнение
/*
   Данный код демонстрирует переключение режимов работы при помощи кнопки
   Для удобства используется библиотека отработки нажатий кнопки
   В этом варианте примера функции "режимов" вызываются только один раз
*/

#define PIN 3        // кнопка подключена сюда (PIN --- КНОПКА --- GND)
#define MODE_AM 5    // количество режимов (от 0 до указанного)

#include "GyverButton.h"
// моя библиотека для более удобной работы с кнопкой
// скачать мождно здесь https://github.com/AlexGyver/GyverLibs

GButton butt1(PIN);  // создаём нашу "кнопку"

byte mode = 0;       // переменная режима

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

void loop() {
  butt1.tick();             // обязательная функция отработки. Должна постоянно опрашиваться
  if (butt1.isPress()) {    // правильная отработка нажатия с защитой от дребезга

    // увеличиваем переменную номера режма. Если вышла за количество режимов - обнуляем
    if (++mode >= MODE_AM) mode = 0;

    // всё переключение в итоге сводится к оператору switch
    // переключение и вызов происходит только при нажатии!!! Внутри isPress
    switch (mode) {
      case 0: task_0();
        break;
      case 1: task_1();
        break;
      case 2: task_2();
        break;
      case 3: task_3();
        break;
      case 4: task_4();
        break;
    }
  }
}

// наши задачи, внутри функций понятное дело может быть всё что угодно
void task_0() {
  Serial.println("Task 0");
}
void task_1() {
  Serial.println("Task 1");
}
void task_2() {
  Serial.println("Task 2");
}
void task_3() {
  Serial.println("Task 3");
}
void task_4() {
  Serial.println("Task 4");
}

Флаги


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

boolean flag = false;

void loop() {
  // если был клик по кнопке, поднять флаг
  if (buttonClick()) flag = true;

  if (flag) {
    // какой-то код
  }
}

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

boolean flag = false;

void loop() {
  // если был клик по кнопке, поднять флаг
  if (buttonClick()) flag = true;

  if (flag) {
    flag = false;
    // выполнится один раз
  }
}

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

boolean flag = false;

void loop() {
  // допустим, условие выполняется периодчисеки по таймеру
  if (timerElapsed()) {
    flag = !flag; // инвертировать флаг

    // например, нужно передавать в функцию два значения,
    // чередуя их по таймеру
    setSomeValue(flag ? 10 : 200);
  }
}

Флаги – очень мощный инструмент, не забывайте о них!

Избавляемся от циклов и задержек


Как мигать светодиодом без задержки мы обсуждали выше. А как избавиться от цикла? Очень просто – цикл заменяется на счётчик и условие. Пусть у нас есть цикл for, выводящий значение счётчика:

for (int i = 0; i < 10; i++) {
  Serial.println(i);
}

Для избавления от цикла нам нужно сделать свою переменную-счётчик, поместить всё это дело в другой цикл (например, в loop) и самостоятельно увеличивать переменную и проверять условие:

int counter = 0;
void loop() {
  Serial.println(counter);
  counter++;
  if (counter >= 10) counter = 0;
}

И всё. А как быть, если в цикле была задержка? Вот пример

for (int i = 0; i < 30; i++) {
  // например, зажигаем i-ый светодиод
  delay(100);
}

Нужно избавиться и от цикла, и от delay(). Введём таймер на millis(), и будем работать по нему:

int counter = 0;      // замена i
uint32_t timer = 0;   // переменная таймера
#define T_PERIOD 100  // период переключения

void loop() {
  
  if (millis() - timer >= T_PERIOD) { // таймер на millis()
    timer = millis(); // сброс
    // действие с counter - наш i-ый светодиод например
    counter++;  // прибавляем счётчик
    if (counter > 30) counter = 0;  // закольцовываем изменение
  }
  
}

Вот собственно и всё! Вместо переменной цикла i у нас теперь свой глобальный счётчик counter, который бегает от 0 до 30 (в этом примере) с периодом 100 мс.  

Как соединить несколько скетчей?


Чтобы соединить несколько проектов в один, нужно разобраться со всеми возможными конфликтами:

  • Проекты построены на одной и той же плате/платформе?
    • Да – отлично!
    • Нет – нужно убедиться, что “общая” плата сможет работать с железками, которые есть в объединяемых проектах, а также сама обладает нужной периферией.
  • Есть ли в проектах железки, подключенные к интерфейсам связи?
    • Нет – отлично!
    • Да, I2C – все железки подключаются на I2C общей платы. Убедитесь, что адреса устройств не совпадают (случается крайне редко)!
    • Да, SPI – у шины SPI все пины “общие”, кроме CS (Chip Select), этот пин может быть любым цифровым. Подробнее можно почитать вот тут.
    • Да, UART – беда, к UART может быть подключено только одно устройство. Можно повесить одну железку на аппаратный UART, а вторую на SoftwareSerial. Либо заморачиваться с мультиплексорами.
  • Есть ли пины, занятые в обоих проектах?
    • Нет – отлично!
    • Да – разобраться, какую функцию выполняет пин в каждом из проектов и подобрать замену, как в железе, так и в программе:
      • Если это обычный цифровой вход-выход, можно заменить на любой другой
      • Если это измерение аналогового сигнала – заменить на другой аналоговый пин
      • Если это генерация ШИМ, подключить соответственно на другой ШИМ пин и подкорректировать программу
      • Если это прерывание – быть внимательным
  • Используются ли одни и те же периферийные блоки микроконтроллера? Для этого нужно изучить железки и их библиотеки:
    • Нет – ОТЛИЧНО!
    • Да – ситуация требует хорошего опыта работы с Ардуино…
    • Используется один и тот же таймер – нельзя одновременно использовать ШИМ на ногах первого таймера и управление сервоприводами при помощи библиотеки Servo.h
    • Используется генерация звука при помощи tone() – нельзя использовать ШИМ на ногах второго таймера
    • Используются прерывания по таймеру и генерация ШИМ на соответствующем таймере – сложная ситуация
    • И т.д., ситуаций может быть бесконечно много…

Можно внести все правки в схемы и программы объединяемых проектов, чтобы они не конфликтовали. Далее приступаем к сборке общей программы:

  • Подключаем все библиотеки. Некоторые библиотеки могут конфликтовать, например Servo и Timer1, как обсуждалось выше.
  • Сравниваем имена глобальных переменных и дефайны в объединяемых программах: они не должны совпадать. Совпадающие меняем при помощи замены по коду (Правка/Найти) на другие. Далее копипастим все глобальные переменные и дефайны в общую программу
  • Объединяем содержимое блока setup()
  • Копипастим в общую программу все “пользовательские” функции
  • Остаётся у нас только loop(), и это самая сложная задача

Раньше у нас было два (или больше) отдельно работающих проекта. Теперь наша задача как программиста – продумать и запрограммировать работу этих нескольких проектов в одном, и тут ситуаций уже бесконечное множество:

  • Основной код (который в loop()) из разных проектов должен выполняться по очереди по таймеру
  • Набор действий из разных проектов должен переключаться кнопкой или ещё как-то
  • К одному проекту добавляется датчик  из другого проекта – данные нужно обработать и запрограммировать их дальнейшее движение (вывод на дисплей, отправку и т.д.)
  • Все “проекты” должны работать одновременно на одной Ардуине
  • И так далее

В большинстве случаев нельзя просто так взять и объединить содержимое loop() из разных программ, я надеюсь все это понимают. Даже мигалку и пищалку таким образом объединить не получится, если изначально код был написан с задержками или замкнутыми циклами.

Пример “Метеостанция”


Сила Arduino как конструктора заключается в том, что абсолютно по любой железке вы сможете найти в Гугле подробное описание, библиотеку, схему подключения и пример работы: полностью готовый набор для интеграции в свой проект! Вернёмся к нашим метео-часам и попробуем “собрать” такой проект из скетчей-примеров, ведь именно для этого примеры и нужны! Нам понадобится:

  • Arduino NANO
  • Дисплей. Пусть будет LCD1602 с переходником на i2c
  • Модуль реального времени, возьмём DS3231
  • Термометр ds18b20

Начинаем гуглить информацию по подключению и примеру для каждой железки:

Из уроков из Гугла мы узнаём такую важную информацию, как схемы подключения: дисплей и часы подключаются к шине i2c, а датчик ds18b20 можно подключить в любой другой пин. Схема нашего проекта: blank   Качаем библиотеки для наших модулей и устанавливаем. Библиотеку дисплея нам дают прямо в статье: https://iarduino.ru/file/134.html, библиотеку для часов по своему опыту советую RTClib (та, что в статье – не очень удобная). В статье про датчик температуры нам рассказали про библиотеку DallasTemperature.h, ссылку – не дали. Ну чтож, поищем сами “DallasTemperature.h”, найдём по первой ссылке. Для неё нужна ещё библиотека OneWire, ссылку на неё дали в статье про термометр. Итого у нас должны быть установлены 4 библиотеки. Сейчас наша цель – найти рабочие примеры для каждой железки, убедиться в их работоспособности и выделить для себя минимальный набор кода для управления модулем, это бывает непросто – в статьях бывают ошибки и просто нерабочий код: эти статьи чаще всего являются копипастой от людей, далёких от темы. Я взял пример работы с дисплеем из статьи, а вот часы и термометр пришлось смотреть в примерах библиотеки. Немного причешем примеры, оставим только нужные нам функции получения значений или вывода, я оставил всё что мне нужно в setup():

Дисплей
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // Устанавливаем дисплей
// адрес может быть 0x27 или 0x3f
void setup() {
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея

  // Устанавливаем курсор на вторую строку и нулевой символ.
  lcd.setCursor(0, 1);
  lcd.print("Hello!");  // пишем
}
void loop() {
}
Часы
#include "RTClib.h"
RTC_DS3231 rtc;

void setup () {
  Serial.begin(9600);
  // проверка, подключен ли модуль
  if (! rtc.begin()) {
    Serial.println("Couldn't find RTC");
    while (1);
  }

  // установка времени равному времени компиляции
  // если у модуля был сброс питания!
  if (rtc.lostPower()) {
    Serial.println("RTC lost power, lets set the time!");
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }

  // вывод значений времени
  DateTime now = rtc.now();

  Serial.print(now.year(), DEC);
  Serial.print('/');
  Serial.print(now.month(), DEC);
  Serial.print('/');
  Serial.print(now.day(), DEC);
  Serial.print(" ");
  Serial.print(now.hour(), DEC);
  Serial.print(':');
  Serial.print(now.minute(), DEC);
  Serial.print(':');
  Serial.print(now.second(), DEC);
  Serial.println();
}

void loop () {
}
Термометр
#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS 2
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

void setup() {
  Serial.begin(9600);
  sensors.begin();
  sensors.requestTemperatures(); // запрос температуры
  float tempC = sensors.getTempCByIndex(0);  // получаем
  Serial.println(tempC);  // выводим
}

void loop() {
}
Итак, примеры проверены, все модули работают корректно. Начнём соединять всё в один проект! Этот блок уроков является базовым, поэтому данный проект мы напишем в стиле “скетч” – закинем весь код в один файл и будем молиться, чтобы всё работало и не было конфликтов имён. В конце следующего блока уроков, в уроке о создании крупных проектов, мы вернёмся к этому примеру и сделаем его с более серьёзным подходом, без глобальных переменных и с разбивкой на самостоятельные файлы подпрограмм. Первым делом соединяем в начале скетча все библиотеки, объявленные объекты, типы данных и переменные. Для красоты и понятности – сортируем: сначала настройки (define), затем подключенные библиотеки, и в конце уже данные:
Начало скетча
// НАСТРОЙКИ
#define ONE_WIRE_BUS 2  // пин ds18b20

// БИБЛИОТЕКИ
#include "RTClib.h"
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <OneWire.h>
#include <DallasTemperature.h>

// ОБЪЕКТЫ И ПЕРЕМЕННЫЕ
// адрес может быть 0x27 или 0x3f
LiquidCrystal_I2C lcd(0x3f, 16, 2); // Устанавливаем дисплей
RTC_DS3231 rtc;
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

void setup() {
}

void loop() {
}
Далее переносим инициализацию в setup():
setup()
void setup() {
  // дисплей
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея

  // термометр
  sensors.begin();

  // часы
  rtc.begin();
  // установка времени равному времени компиляции
  if (rtc.lostPower()) {
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
}
Отлично! Теперь самое сложное: нужно продумать логику работы программы. Разобьём на простые действия:

  • 1 раз в секунду – вывод часов (ЧЧ:ММ:СС) и актуального значения с датчика
  • 2 раза в секунду – мигание светодиодом на плате
  • 5 раз в секунду – измерение температуры и усреднение

Вот так будет выглядеть наш loop():

loop()
void loop() {
  // 2 раза в секунду
  if (millis() - myTimer1 >= 500) {
    myTimer1 = millis(); // сбросить таймер
    toggleLED();
  }

  // 5 раз в секунду
  if (millis() - myTimer2 >= 200) {
    myTimer2 = millis(); // сбросить таймер
    getTemp();
  }

  // каждую секунду
  if (millis() - myTimer3 >= 1000) {
    myTimer3 = millis(); // сбросить таймер
    redrawDisplay();
  }
}
Выполняемые по таймерам функции мы создадим и заполним
toggleLED()
void toggleLED() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
}
getTemp()
void getTemp() {
  // суммируем температуру в общую переменную
  tempSum += sensors.getTempCByIndex(0);
  sensors.requestTemperatures();

  // счётчик измерений
  tempCounter++;
  if (tempCounter >= 5) { // если больше 5
    tempCounter = 0;  // обнулить
    temp = tempSum / 5; // среднее арифметическое
    tempSum = 0;  // обнулить
  }
}
redrawDisplay()
void redrawDisplay() {
  // ВРЕМЯ
  DateTime now = rtc.now(); // получить время
  lcd.setCursor(0, 0);      // курсор в 0,0
  lcd.print(now.hour());    // часы
  lcd.print(':');
  // первый ноль для красоты
  if (now.minute() < 10) lcd.print(0);
  lcd.print(now.minute());
  lcd.print(':');
  // первый ноль для красоты
  if (now.second() < 10) lcd.print(0);
  lcd.print(now.second());

  // TEMP
  lcd.setCursor(11, 0);    // курсор в 11,0
  lcd.print("Temp:");

  lcd.setCursor(11, 1);    // курсор в 11,1
  lcd.print(temp);

  // ДАТА
  lcd.setCursor(0, 1);      // курсор в 0,1
  // первый ноль для красоты
  if (now.day() < 10) lcd.print(0);
  lcd.print(now.day());
  lcd.print('.');
  // первый ноль для красоты
  if (now.month() < 10) lcd.print(0);
  lcd.print(now.month());
  lcd.print('.');
  lcd.print(now.year());
}
Для функционирования таймеров и счёта температуры нам также понадобились глобальные переменные, запишем их до setup():

uint32_t myTimer1, myTimer2, myTimer3;
boolean LEDflag = false;
float tempSum = 0, temp;
byte tempCounter;

И в целом наш проект завершён! По мере разборок с термометром выяснилась интересная особенность: чтение сильно тормозит код, команда requestTemperatures() ждёт ответа датчика и блокирует выполнение кода, из-за чего часы не успевают идти 1 раз в секунду. Покопавшись в примерах, я нашёл асинхронный опрос датчика: в setup добавилась строчка sensors.setWaitForConversion(false); . Соответственно вот весь код проекта:

meteoClock.ino
// НАСТРОЙКИ
#define ONE_WIRE_BUS 2  // пин ds18b20
// БИБЛИОТЕКИ
#include "RTClib.h"
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <OneWire.h>
#include <DallasTemperature.h>
// ОБЪЕКТЫ И ПЕРЕМЕННЫЕ
// адрес может быть 0x27 или 0x3f
LiquidCrystal_I2C lcd(0x3f, 16, 2); // Устанавливаем дисплей
RTC_DS3231 rtc;
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
uint32_t myTimer1, myTimer2, myTimer3;
boolean LEDflag = false;
float tempSum = 0, temp;
byte tempCounter;
void setup() {
  Serial.begin(9600); // для отладки
  pinMode(13, 1);
  // дисплей
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея
  // термометр
  sensors.begin();
  sensors.setWaitForConversion(false);  // асинхронное получение данных
  // часы
  rtc.begin();
  // установка времени равному времени компиляции
  if (rtc.lostPower()) {
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
}
void loop() {
  // 2 раза в секунду
  if (millis() - myTimer1 >= 500) {
    myTimer1 = millis(); // сбросить таймер
    toggleLED();
  }
  // 5 раз в секунду
  if (millis() - myTimer2 >= 200) {
    myTimer2 = millis(); // сбросить таймер
    getTemp();
  }
  // каждую секунду
  if (millis() - myTimer3 >= 1000) {
    myTimer3 = millis(); // сбросить таймер
    redrawDisplay();
  }
}
void toggleLED() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
}
void getTemp() {
  // суммируем температуру в общую переменную
  tempSum += sensors.getTempCByIndex(0);
  sensors.requestTemperatures();
  // счётчик измерений
  tempCounter++;
  if (tempCounter >= 5) { // если больше 5
    tempCounter = 0;  // обнулить
    temp = tempSum / 5; // среднее арифметическое
    tempSum = 0;  // обнулить
  }
}
void redrawDisplay() {
  // ВРЕМЯ
  DateTime now = rtc.now(); // получить время
  lcd.setCursor(0, 0);      // курсор в 0,0
  lcd.print(now.hour());    // часы
  lcd.print(':');
  // первый ноль для красоты
  if (now.minute() < 10) lcd.print(0);
  lcd.print(now.minute());
  lcd.print(':');
  // первый ноль для красоты
  if (now.second() < 10) lcd.print(0);
  lcd.print(now.second());
  // TEMP
  lcd.setCursor(11, 0);    // курсор в 11,0
  lcd.print("Temp:");
  lcd.setCursor(11, 1);    // курсор в 11,1
  lcd.print(temp);
  // ДАТА
  lcd.setCursor(0, 1);      // курсор в 0,1
  // первый ноль для красоты
  if (now.day() < 10) lcd.print(0);
  lcd.print(now.day());
  lcd.print('.');
  // первый ноль для красоты
  if (now.month() < 10) lcd.print(0);
  lcd.print(now.month());
  lcd.print('.');
  lcd.print(now.year());
}
И вот так это выглядит в жизни blank   Как видите, ничего особо сложного здесь нет: взяли три примера “из гугла”, проверили их, сократили, и объединили в проект. Да, своего кода тоже пришлось написать прилично, но иначе и не бывает! В программировании главное – практика и наработка собственных “ходов” и алгоритмов, которые потом очень быстро пишутся: я написал код к этому уроку со скоростью печати, особо не задумываясь и не отлаживая, это на самом деле всё очень просто! В конце следующего блока “Продвинутого программирования”, в уроке о создании крупных проектов мы вернёмся к этому примеру и сделаем его более взрослым: обернём всё в классы и разобьём на файлы, чтобы дорабатывать проект было проще.

Видео


Важные страницы


  • Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
  • Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
  • Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
  • Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
  • Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
  • Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
  • Поддержать автора за работу над уроками
  • Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту (alex@alexgyver.ru)
Назад Используем библиотеки
guest
0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии