
Содержание
Как написать скетч на Arduino?
Послесловие к базовым урокам
Вот и закончился базовый курс уроков программирования Arduino. Мы с вами изучили самые базовые понятия, вспомнили (или изучили) часть школьной программы по информатике, изучили большую часть синтаксиса и инструментов языка C++, и вроде бы весь набор Ардуино-функций, который предлагает нам платформа. Подчеркну – мы изучили C++ и функции Ардуино, потому что никакого “языка Arduino” нет, это ложное понятие. Arduino программируется на C или ассемблере, а платформа предоставляет нам всего лишь несколько десятков удобных функций для работы с микроконтроллером, именно функций, а не язык. Теперь перед нами чистый лист блокнота Arduino IDE и желание творить и программировать, давайте попробуем!
Структура программы
Прежде, чем переходить к реальным задачам, нужно поговорить о некоторых фундаментальных вещах. Микроконтроллер, как мы обсуждали в самом начале пути, это комплексное устройство, состоящее из вычислительного ядра, постоянной и оперативной памяти и различных периферийных устройств (таймеры/счётчики, АЦП и проч.). Обработкой нашего с вами кода занимается именно ядро микроконтроллера, оно раздаёт команды остальным “железкам”, которые в дальнейшем могут работать самостоятельно. Ядро выполняет различные команды, подгоняемое тактовым генератором: на большинстве плат Arduino стоит генератор с частотой 16 МГц. Каждый толчок тактового генератора заставляет вычислительное ядро выполнить следующую команду, таким образом Ардуино выполняет 16 миллионов операций в секунду. Много ли это? Для большинства задач более чем достаточно, главное использовать эту скорость с умом.
Зачем я об этом рассказываю: микроконтроллер может выполнить только одну задачу в один момент времени, так как у него только одно вычислительное ядро, поэтому реальной “многозадачности” нет и быть не может, но за счёт большой скорости выполнения ядро может выполнять задачи по очереди, и для человека это будет казаться многозадачностью, ведь что для нас “раз Миссисипи“, для микроконтроллера – 16 миллионов действий! Есть всего два варианта организации кода:
- Основной парадигмой работы с микроконтроллером является так называемый суперцикл, то есть главный цикл программы, который выполняется сверху вниз (если смотреть на код) и начинает с самого начала, когда доходит до конца, и так далее. В Arduino IDE нашим суперциклом является
loop()
. В главном цикле мы можем опрашивать датчики, управлять внешними устройствами, выводить данные на дисплеи, производить вычисления и всё такое, но в любом случае эти действия будут происходить друг за другом, последовательно. В этом и заключается основной механизм параллельности задач: на самом деле все они выполняются последовательно друг за другом, но в то же время достаточно быстро для того, чтобы казаться “параллельными”. - Помимо основного цикла у нас есть прерывания, которые позволяют реализовать некую “поточность” выполнения задач, особенно в тех ситуациях, когда важна скорость работы. Прерывание позволяет остановить выполнение основного цикла в любом его месте, отвлечься на выполнение некоторого блока кода, и после успешного его завершения вернуться к основному циклу и продолжить работу. Некоторые задачи можно решить только на прерываниях, не написав ни одной строчки в цикл
loop()
! Мы с вами изучали аппаратные прерывания, позволяющие прерваться при замыкании контактов. Такие прерывания являются внешними, то есть их провоцируют внешние факторы (человек нажал на кнопку, сработал датчик, и т.д.). Также у микроконтроллера есть внутренние прерывания, которые вызываются периферией микроконтроллера, и этих прерываний может быть не один десяток! Одним из таких прерываний является прерывание таймера: по настроенному периоду программа будет прерываться и выполнять указанный код. Об этом мы поговорим ниже, а также на сайте есть отдельный урок по работе с прерываниями таймера. Такой подход хорош для задач, которые должны выполняться часто и с большой частотой, для всего остального можно настроить один таймер на счёт времени и работать с этим временем. - По умолчанию Arduino IDE настраивает один из таймеров (нулевой) на счёт реального времени, благодаря этому у нас работают такие функции как
millis()
иmicros()
. Именно эти функции являются готовым инструментом для тайм-менеджмента нашего кода и позволяют создавать работу по расписанию. Самый важный и критический момент: задачи не должны тормозить выполнение программы на бОльший период, чем период самой короткой задачи, иначе все задачи будут выполняться с периодом самой долгой! Именно поэтому нужно отказываться от задержек и ожиданий: задержку всегда можно заменить на проверку таймера при следующих итерациях цикла, и то же самое с ожиданием чего-то, например ответа датчика. Задачи должны быть максимально асинхронными и не блокировать код, к сожалению не во всех библиотеках есть неблокирующие аналоги функций. Даже родной блокирующийanalogRead()
можно сделать неблокирующим, но Arduino решили не усложнять жизнь новичкам.
“Многозадачность” с 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 timer 1 timer 1 timer 3 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; ......... }
Таким образом организуется выбор и выполнение выбранных участков кода. Переключение переменной 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
Начинаем гуглить информацию по подключению и примеру для каждой железки:
- Дисплей (arduino lcd 1602 i2c) – урок по первой ссылке из Гугла
- Часы RTC (arduino ds3231) – урок по первой ссылке из Гугла
- Термометр (arduino ds18b20) – урок по первой ссылке из Гугла
Из уроков из Гугла мы узнаём такую важную информацию, как схемы подключения: дисплей и часы подключаются к шине i2c, а датчик ds18b20 можно подключить в любой другой пин. Схема нашего проекта:
Качаем библиотеки для наших модулей и устанавливаем. Библиотеку дисплея нам дают прямо в статье: 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()
:
void setup() { // дисплей lcd.init(); lcd.backlight(); // Включаем подсветку дисплея // термометр sensors.begin(); // часы rtc.begin(); // установка времени равному времени компиляции if (rtc.lostPower()) { rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); } }
Отлично! Теперь самое сложное: нужно продумать логику работы программы. Разобьём на простые действия:
- 1 раз в секунду – вывод часов (ЧЧ:ММ:СС) и актуального значения с датчика
- 2 раза в секунду – мигание светодиодом на плате
- 5 раз в секунду – измерение температуры и усреднение
Вот так будет выглядеть наш 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(); } }
Выполняемые по таймерам функции мы создадим и заполним
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()); }
Для функционирования таймеров и счёта температуры нам также понадобились глобальные переменные, запишем их до setup()
:
uint32_t myTimer1, myTimer2, myTimer3; boolean LEDflag = false; float tempSum = 0, temp; byte tempCounter;
И в целом наш проект завершён! По мере разборок с термометром выяснилась интересная особенность: чтение сильно тормозит код, команда requestTemperatures()
ждёт ответа датчика и блокирует выполнение кода, из-за чего часы не успевают идти 1 раз в секунду. Покопавшись в примерах, я нашёл асинхронный опрос датчика: в setup добавилась строчка sensors.setWaitForConversion(false);
. Соответственно вот весь код проекта:
// НАСТРОЙКИ #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()); }
И вот так это выглядит в жизни
Как видите, ничего особо сложного здесь нет: взяли три примера “из гугла”, проверили их, сократили, и объединили в проект. Да, своего кода тоже пришлось написать прилично, но иначе и не бывает! В программировании главное – практика и наработка собственных “ходов” и алгоритмов, которые потом очень быстро пишутся: я написал код к этому уроку со скоростью печати, особо не задумываясь и не отлаживая, это на самом деле всё очень просто!
В конце следующего блока “Продвинутого программирования”, в уроке о создании крупных проектов мы вернёмся к этому примеру и сделаем его более взрослым: обернём всё в классы и разобьём на файлы, чтобы дорабатывать проект было проще.
Важные страницы
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту (alex@alexgyver.ru)
- Articles coming soon