Константы, переменные и арифметика

Рассмотрим пример кода «железнодорожного светофора», то есть программу, которая заставляет поочерёдно мигать два светодиода:

void setup()
{
    pinMode(5, OUTPUT);
    pinMode(6, OUTPUT);
}
 
void loop()
{
    digitalWrite(5, HIGH);
    digitalWrite(6, LOW);
    delay(1000);
 
    digitalWrite(5, LOW);
    digitalWrite(6, HIGH);
    delay(1000);
}

Как видно, предполагается, что светодиоды подключаются к пинам 5 и 6 на Arduino, а переключение происходит раз в 1000 миллисекунд, т.е. ежесекундно.

Представьте теперь, что в силу обстоятельств, стало необходимо перенести светодиоды с этих пинов на 12-й и 13-й пины. К тому же стало понятно, что устройство смотрится лучше при переключении не раз в секунду, а раз в 2 секунды. Что делать?

Можно модифицировать программу и всюду, где речь шла о 5-м пине, использовать 12-й; 6-й пин заменить на 13-й, а в функциях delay изменить задержку с 1000 на 2000.

void setup()
{
    pinMode(12, OUTPUT);
    pinMode(13, OUTPUT);
}
 
void loop()
{
    digitalWrite(12, HIGH);
    digitalWrite(13, LOW);
    delay(2000);
 
    digitalWrite(12, LOW);
    digitalWrite(13, HIGH);
    delay(2000);
}

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

В данном случае мы имеем дело с довольно простой программой и проделать изменения не так сложно. Но что если устройство довольно сложное и скетч расписан на сотни строк?! В этом случае сделать все изменения правильно, не ошибиться и не пропустить ни одного изменения становится крайне сложно: мы люди и нам свойственно ошибаться из-за невнимательности.

Кроме того, если устройство управляет кучей индикаторов, моторов, считывает данные с нескольких сенсоров, запоминать что к какому пину подключено становится сложно. При чтении сложной программы, когда видим вызов digitalWrite(4, HIGH), понять имели ли мы в виду включение светодиода или остановку двигателя, или что-то ещё уже не так просто. Что можно сделать, чтобы решить проблему?

Макроопределения

Мы можем единожды указать, что левый светодиод — это пин 13, правый — пин 12, а переключать состояния нужно каждые X миллисекунд. Для этого каждому значению назначается понятное имя, которое затем и используется для обращения:

#define LEFT_LED      13
#define RIGHT_LED     12
#define SWITCH_TIME   2000
 
void setup()
{
    pinMode(LEFT_LED, OUTPUT);
    pinMode(RIGHT_LED, OUTPUT);
}
 
void loop()
{
    digitalWrite(LEFT_LED, HIGH);
    digitalWrite(RIGHT_LED, LOW);
    delay(SWITCH_TIME);
 
    digitalWrite(LEFT_LED, LOW);
    digitalWrite(RIGHT_LED, HIGH);
    delay(SWITCH_TIME);
}

Всё, теперь для изменения параметров устройства достаточно изменить нужные значения в начале программы и не думать об изменениях в самой логике. Кроме того выражение вроде digitalWrite(RIGHT_LED, LOW) гораздо более информативно нежели digitalWrite(12, LOW) и даёт чёткое понимание того, что имел в виду автор.

Конструкция #define называется макроопределением. Она говорит компилятору о том, что всякий раз, когда он видит указанное имя, стоит использовать на этом месте указанное значение.

Обратите внимание: макроопределения #define не завершаются точкой с запятой в конце строки. Дело в том, что #define — это не обычное выражение, а так называемая препроцессорная директива. Подстановка конкретных значений вместо имён происходит ещё до компиляции, на стадии предварительной обработки исходного файла. На этой стадии, по сути, компилятор проделывает операцию как в текстовом редакторе: «Найти все и заменить». Просто результат этой операции не сохраняется в файл, а тут же им используется для непосредственной компиляции.

Если бы вы поставили ; после #define, в результате обработки компилятор увидел бы такой код:

// Если завершать директивы точками с запятой…
#define LEFT_LED      13;
#define RIGHT_LED     12;
#define SWITCH_TIME   2000;
 
// …они появятся в самом неподходящем месте
void setup()
{
    pinMode(12;, OUTPUT);
    pinMode(13;, OUTPUT);
}
 
void loop()
{
    digitalWrite(12;, HIGH);
    digitalWrite(13;, LOW);
    delay(2000;);
 
    digitalWrite(12;, LOW);
    digitalWrite(13;, HIGH);
    delay(2000;);
}

Попытка скомпилировать такой скетч приведёт к ошибке.

Встроенные макроопределения

Вы могли догадаться, что уже знакомые нам значения HIGH, LOW, OUTPUT — это также не что иное как макроопределения. Просто #define для них написан в некотором другом месте и мы можем просто сразу ими пользоваться.

На самом деле код мигания светодиодом:

void setup()
{
    pinMode(13, OUTPUT);
}
 
void loop()
{
    digitalWrite(13, HIGH);
    delay(100);
    digitalWrite(13, LOW);
    delay(900);
}

с точки зрения компилятора есть ни что иное как:

void setup()
{
    pinMode(13, 1);
}
 
void loop()
{
    digitalWrite(13, 1);
    delay(100);
    digitalWrite(13, 0);
    delay(900);
}

Если вы откроете файл hardware/arduino/cores/arduino/Arduino.h в вашем дистрибутиве Arduino IDE, вы сможете увидеть, что HIGH — это макроопределение 1, LOW0, OUTPUT — 1 и т.д. Эти значения используются настолько часто, что они встроены таким образом.

Использование понятных имён вместо магических чисел — это один из признаков профессионализма. Это делает код более понятным и простым для изменений, что всегда уважается.

Об именах макроопределений

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

// плохо
#define ledpin  13
#define led_pin 13
#define LedPin  13
 
// хорошо
#define LED_PIN 13

Это настолько привычное правило, что вы можете ввести в заблуждение других, если не будете его придерживаться. Опять же, следование общепринятым канонам — признак профи.

Стоит отметить, что язык C++, как и многие другие считает строчные и заглавные буквы различными, поэтому если к макроопределению LED_PIN вы затем попытаетесь обратиться, написав led_pin будет выдана ошибка компилятора о том, что он не понимает что такое led_pin. То же самое касается имён функций и всего остального.

Переменные

Макроопределения хороши для именования значений, которые не могут измениться по ходу выполнения программы. Мы вряд ли захотим на лету, без изменения кода и перезагрузки Arduino, перенести светодиод маячка с одного пина Arduino на другой. Но что делать, если какие-то параметры программы всё же должны изменяться с течением времени?

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

Например, давайте рассмотрим программу «помирающего маячка». Первый раз он мигает через 1000 мс, затем через 1100 мс, затем через 1200 мс и так далее до бесконечности:

#define LED_PIN 13
int blinkDelay = 900;
 
void setup()
{
    pinMode(LED_PIN, OUTPUT);
}
 
void loop()
{
    digitalWrite(LED_PIN, HIGH);
    delay(100);
 
    digitalWrite(LED_PIN, LOW);
    delay(blinkDelay);
 
    blinkDelay += 100;
}

Вместо того, чтобы обозначить время до следующего подмигивания через макроопределение #define, мы использовали конструкцию:

int blinkDelay = 900;

Это называется определением переменной. Мы таким образом заявили, что хотим иметь ячейку памяти, к которой будем обращаться по имени blinkDelay и изначально, при старте Arduino, в ней должно лежать значение 900.

Перед именем переменной в определении указывается тип данных для этой переменной. В нашем случае — это int, что означает «целое число» (int — сокращение от английского «integer»: целочисленный).

Обратите внимание: объявление переменной, в отличие от макроопределения — это обычное выражение, поэтому оно должно завершаться точкой с запятой.

Итак, мы сообщили компилятору, что у нас есть переменная целочисленного типа, с именем blinkDelay и начальным значением 900. Теперь мы можем ей пользоваться.

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

delay(blinkDelay);

В нашей программе это выражение означает «уснуть на столько миллисекунд, сколько сейчас записано в переменной с именем blinkDelay». При первом исполнении этого выражения значение будет изначальным, таким образом мы уснём на 900 мс.

Далее в нашем скетче мы можем видеть:

blinkDelay += 100;

Это так называемое арифметическое выражение (англ. expression). Символ += называется оператором и означает в C++ «увеличить на». Таким образом после исполнения этого выражения, значение переменной blinkDelay станет на сотню больше и сохранится в ней до следующего изменения.

Как результат:

  • При первом исполнении функции loop мы выжидаем с выключенным светодиодом 900 мс
  • К началу второго исполнения blinkDelay уже равна 1000, поэтому мы будем выжидать 1000 мс
  • При третьем исполнении мы будем выжидать 1100 мс
  • …и так далее до бесконечности

В итоге мы получаем что и хотели: «помирающий» маячок.

Об именах переменных

Как и в случае с макроопределениями существует общепринятая конвенция о том как нужно называть переменные. Их принято именовать в так называемом «верблюжьем стиле» (camelCase). То есть, начинать строчными буквами, а каждое новое слово писать слитно, с заглавной буквы.

// плохо
int BLINK_DELAY;
int BlinkDelay;
int blink_delay;
 
// хорошо
int blinkDelay;

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

Ничто так не выносит мозг, как использование одно- или двухбуквенных имён без смысла или применение транслита.

// плохо
int d;
int asd;
int zaderzhkaMigalki;
 
// хорошо
int blinkDelay;
int offDelay;
int blinkInterval;
int blinkTimeout;

Составление арифметических выражений

В нашем примере мы использовали оператор += для увеличения целочисленного значения нашей переменной.

Конечно же, это не единственный оператор в C++. Кроме того += — это так называемый синтаксический сахар (syntax sugar) — удобная и короткая запись полного выражения. На самом деле выражение:

blinkDelay += 100;

эквивалентно такой, полной записи:

blinkDelay = blinkDelay + 100;

Не стоит воспринимать символ = дословно, как «равно». В C++ этот символ называется оператором присваивания или просто присваиванием. Нужно читать это выражение так: присвоить переменной blinkDelay (то, что слева от =) вычисленное значение blinkDelay + 100 (то, что справа от =).

Вас не должно смущать, что blinkDelay упоминается и в левой и в правой части выражения. Опять же, это не означает «900 = 1000», это означает «записать в переменную blinkDelay новое значение: результат сложения текущего значения blinkDelay и числа 100.

Зацикленная змейка

В одном арифметическом выражении может быть сколько угодно операторов и участвовать сколько угодно переменных. Для демонстрации давайте напишем программу для устройства-гирлянды. Допустим, вы подключили к Arduino 10 светодиодов, к пинам с 4-го по 13-й и хотите, чтобы они включались поочерёдно, как бегущая змейка. Тогда скетч может выглядеть так:

#define FIRST_LED   4
#define TOTAL_LEDS  10
 
int ledPin = FIRST_LED;
 
void setup()
{
    pinMode(4,  OUTPUT);
    pinMode(5,  OUTPUT);
    pinMode(6,  OUTPUT);
    pinMode(7,  OUTPUT);
    pinMode(8,  OUTPUT);
    pinMode(9,  OUTPUT);
    pinMode(10, OUTPUT);
    pinMode(11, OUTPUT);
    pinMode(12, OUTPUT);
    pinMode(13, OUTPUT);
}
 
void loop()
{
    digitalWrite(ledPin, HIGH);
    delay(100);
 
    digitalWrite(ledPin, LOW);
    ledPin = (ledPin - FIRST_LED + 1) % TOTAL_LEDS + FIRST_LED;
}

Да, код в setup выглядит несколько громоздко, но не будем пока заострять на нём внимание. Как сделать «правильно» будет показано при изучении понятия циклов. Сосредоточимся на функции loop.

Первым делом мы поджигаем светодиод на пине номер которого записан в переменной ledPin. При старте программы, как видно из определения, ей присваивается значение FIRST_LED, которое в свою очередь мы определили как 4. То есть, при первом проходе loop мы зажигаем светодиод на 4-м пине.

Далее мы выжидаем 100 мс и выключаем его.

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

ledPin = (ledPin - FIRST_LED + 1) % TOTAL_LEDS + FIRST_LED;

Давайте поймём что здесь происходит. Выражение даёт предписание процессору присвоить переменной ledPin вычисленное значение выражения, стоящего справа от =. В этом выражении используются операторы -, +, %, скобки, текущее значение переменной ledPin, макроопределения FIRST_LED и TOTAL_LEDS.

Итоговое значение вычисляется по тем же правилам, что и в обычной математике: сначала то, что в скобках; затем умножение и деление; и наконец, сложение и вычитание. В языке C++ символ % — это оператор «модуло» или оператор остатка от деления:

  • 4 % 10 = 4
  • 9 % 10 = 9
  • 10 % 10 = 0
  • 11 % 10 = 1
  • 19 % 10 = 9
  • 1259 % 10 = 9
  • …и т.д.

Вернёмся к нашему выражению. Если развернуть его для первого прохода loop, получится:

  • (4 - 4 + 1) % 10 + 4 =
  • (1) % 10 + 4 =
  • 1 + 4 =
  • 5

То есть, после четвёртого пина переменная ledPin примет значение 5-го.

Аналогично, после 5-го последует 6-й, после 6-го — 7-й и так далее до 13-го. Теперь давайте посмотрим что присвоится переменной ledPin, если на текущем проходе она равна 13, то есть мы только что мигнули последним светодиодом нашей змейки:

  • (13 - 4 + 1) % 10 + 4 =
  • (10) % 10 + 4 =
  • 0 + 4 =
  • 4

Таким образом после 13-го пина снова последует 4-й, а это то, что нам нужно! Оператор остатка от деления часто используют как раз для зацикливания чего-либо.

Пульсирующий маячок

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

#define LED_PIN  5
 
int step = 0;
int brightness = 0;
int sign;
 
void setup()
{
    pinMode(LED_PIN, OUTPUT);
}
 
void loop()
{
    analogWrite(LED_PIN, brightness);
    step++;
 
    sign = (step / 256) % 2;
    brightness = sign ? brightness + 1 : brightness - 1;
 
    delay(5);
}

Давайте разберёмся что здесь происходит. Мы определили 3 переменные: step, sign, brightness. При этом начальное значение присвоили только переменным step и brightness.

В C++ можно не указывать начальное значение. В этом случае переменная при появлении примет значение абстрактного мусора: случайного значения, оставшегося в памяти от прошлой программы. Если по нашему замыслу перед чтением переменной мы точно сначала установим её значение — это не проблема.

В первой строке loop мы использовали функцию analogWrite, а не digitalWrite, как делали раньше. analogWrite не просто выдаёт логический ноль или единицу на заданный пин, а выдаёт на него ШИМ-сигнал (PWM signal). То есть, пин начинает очень быстро (32 000 раз в секунду) переключаться между 0 и 5 вольтами и если подключить к такому пину светодиод, создастся иллюзия, что он горит в пол силы.

Функция analogWrite принимает 2 аргумента: пин, о котором идёт речь и значение скважности. Значение скважности — это целое число от 0 до 255, которое определяет отношение длительности ступеньки в 0 В к длительности ступеньки в 5 В. Например:

  • 0 — это всегда 0 В (светодиод не горит)
  • 64 — это четверть времени 5 В и три четверти — 0 В (свечение в четверть силы)
  • 128 — это половина времени 5 В и половина 0 В (свечение в пол силы)
  • 255 — это всегда 5 В (свечение в полную силу)

Как известно, не все пины Arduino поддерживают ШИМ. Нужно выбрать тот, который отмечен символом ~ на плате. Мы выбрали 5-й, что и определили именем LED_PIN.

Ещё раз взглянем на наш loop:

void loop()
{
    analogWrite(LED_PIN, brightness);
    step++;
 
    sign = (step / 256) % 2;
    brightness = sign ? brightness + 1 : brightness - 1;
 
    delay(5);
}

В переменной step мы хотим хранить порядковый номер прохождения функции loop: 1, 2, 3, …, 2345, 2346 и т.д. При её определении мы присвоили начальное значение 0 и хотим, чтобы оно увеличивалось каждый вызов loop. Это мы и делаем в первом выражении. Символ ++ означает в C++ оператор инкремента, оператор, который увеличивает значение на единицу. Теперь вы догадываетесь откуда взялось название языка «C++», надстроенного над «C». Оператор инкремента можно ставить как перед переменной, так и после. Следующие строки эквивалентны:

step++;
++step;
step += 1;
step = step + 1;

В третьей строке основного цикла мы вычисляем и присваиваем новое значение переменной sign. Как видите, оно рассчитывается на основе значения другой переменной step.

В C++ символ / означает оператор деления. Поскольку мы оперируем целыми числами оператор деления всегда отсекает дробную часть:

  • 40 / 10 = 4
  • 41 / 10 = 4
  • 49 / 10 = 4
  • 0 / 10 = 0
  • 1 / 10 = 0
  • 9 / 10 = 0

Таким образом значение step / 256 будет становиться больше на единицу всякий раз, когда происходит 256 вызовов loop. Из этого значения мы дополнительно берём остаток от деления на 2. В итоге, значение sign будет становится поочерёдно то единицей, то нулём, переключаясь каждые 256 вызовов loop.

Далее идёт, вероятно, немного пугающая по началу конструкция:

brightness = sign ? brightness + 1 : brightness - 1;

Это на самом деле обычное арифметическое выражение, в котором мы вычисляем и присваиваем новое значение переменной brightness. Символы ? и : в C++ называются тернарным условным оператором. Его суть такова:

  • Выражение, стоящее перед символом ? называется условием. В нашем случае всё условное выражение — это просто переменная sign
  • Если значение условия не равно нулю, результатом оператора является выражение, стоящее между символами ? и :
  • Если значение условия равно нулю, результатом оператора является выражение, стоящее после символа :

То есть тернарный оператор образует выражение, в котором участвуют 3 подвыражения: условие, «что если не ноль» и «что если ноль». Если рассматривать наш пример, то:

  • Если только что посчитанный sign — не ноль, brightness примет значение выражения brightness + 1
  • А если sign — ноль, brightness примет значение выражения brightness - 1

Таким образом, если вспомнить о том, как ведёт себя sign в нашем алгоритме, brightness будет рассчитываться то по одной формуле, то по другой. Сама формула переключается как и sign, каждые 256 циклов.

  • Первая формула — увеличение brightness на единицу
  • Вторая формула — уменьшение brightness на единицу

В итоге, brightness с начала программы, с каждым новым loop будет то расти от 0 до 255, то уменьшаться обратно от 255 до 0. А это то, что нам нужно!

Нам остаётся лишь чуть задержаться, чтобы дать светодиоду посветиться на текущем уровне яркости. В примере сделана задержка на 5 мс. Вы можете легко посчитать: при таком значении полное нарастание или затухание занимает 5 мс × 256 = 1280 мс.

О компактной записи и области видимости

В примере с маячком мы объявляли переменную sign рядом с остальными. Однако на самом деле мы использовали её исключительно как временную, вспомогательную переменную для вычисления brightness. Мы рассчитывали её каждый раз непосредственно перед вычислением brightness и она не зависела от собственного значения на предыдущем проходе loop.

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

Разобраться в том, какая переменная для чего предназначена, будет крайне сложно. Для решения этой и других проблем в C++ переменные могут объявляться непосредственно в том месте, где используются.

Если применить это к нашему примеру, получится такой код:

#define LED_PIN  5
 
int step = 0;
int brightness = 0;
 
void setup()
{
    pinMode(LED_PIN, OUTPUT);
}
 
void loop()
{
    analogWrite(LED_PIN, brightness);
    delay(5);
 
    step++;
 
    int sign = (step / 256) % 2;
    brightness = sign ? brightness + 1 : brightness - 1;
}

Как видите, переменная sign теперь определяется непосредственно в том месте, где начинает использоваться.

Мы не можем сделать то же самое с step и brightness, т.к. в этом случае, при каждом новом вызове loop они будут создаваться заново, а потому не сохранят своё значение с предыдущего прохода loop. Что касается sign — это не важно: мы всё равно каждый loop рассчитываем её заново.

Любая объявленная переменная «живёт» только в рамках того блока кода {…}, где она определена. Это называется областью видимости (scope). Попытка использовать переменную sign из другой функции, например из setup приведёт к ошибке компиляции: мы объявили её в рамках loop, поэтому использовать её можем только там.

Переменные объявленные вне функций, такие как step и brightness называются глобальными переменными. Они инициализируются в самом начале исполнения программы и доступны в скетче отовсюду.

Встраивание выражений

Если снова посмотреть на наш пример можно увидеть, что мы рассчитываем sign с помощью довольно простого выражения, а затем используем всего в одном месте, при расчёте brightness на следующей строке.

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

void loop()
{
    analogWrite(LED_PIN, brightness);
    delay(5);
 
    step++;
 
    brightness = (step / 256 % 2) ? brihtness + 1 : brightness - 1;
}

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

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

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

void loop()
{
    analogWrite(LED_PIN, (step++ / 256 % 2) ? brightness++ : brightness--);
    delay(5);
}

Да, это весь наш loop и он делает всё то же самое, что и раньше. Конструкция с analogWrite находится уже на грани читаемости, но для демонстрации вполне подходит. Разберёмся что же здесь написано.

Компилятор видит, что мы вызываем функцию analogWrite. Он знает, что для неё нужно 2 аргумента и ищет их в круглых скобках. С первым аргументом, LED_PIN всё понятно. Это просто 5. Но на месте второго аргумента компилятор видит выражение. Поэтому перед непосредственным вызовом, значение выражения вычисляется и уже результат используется в качестве аргумента.

В нашем случае на месте второго аргумента стоит тернарное выражение, поэтому первым делом вычисляется условие, то что стоит перед символом ?. Там мы видим: step++ / 256 % 2. Всё так же, как и раньше, но к step добавлен оператор инкремента ++. Всё вместе это означает:

  1. Взять значение step и запомнить его
  2. Увеличить значение step на единицу
  3. Целочисленно разделить запомненное в пункте (1) значение на 256
  4. Взять остаток от деления результата на 2

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

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

int x = 5;
int y = x++;
 
// x = 6
// y = 5

Если ++ стоял бы перед переменной, то она сначала увеличилась бы на единицу и только затем участвовала в вычислениях:

int x = 5;
int y = ++x;
 
// x = 6
// y = 6

Вернёмся к нашему вызову:

analogWrite(LED_PIN, (step++ / 256 % 2) ? brightness++ : brightness--);

С расчётом условия теперь всё понятно. Далее, как уже говорилось об операторе ? :, в случае если условие не оказалось равным нулю, берётся значение выражения между символами ? и :. В нашем случае — это brightness++. То есть, если условие с step оказалось не равным нулю, функция analogWrite в качестве аргумента получит значение выражения brightness++.

Как уже говорилось, выражение brightness++ означает: «использовать текущее значение, но сразу после использования увеличить его на 1».

Аналогичная ситуация со сценарием, когда условие оказывается равным нулю. Просто вместо оператора инкремента ++, мы используем оператор декремента для уменьшения значения на единицу, а не увеличения.

Функции с возвращаемыми значениями

До сих пор мы использовали только переменные, которые так или иначе зависели только от номера вызова функции loop, т.е. так или иначе зависящие от времени, прошедшего с момента старта Arduino.

Это интересно, но не даёт таких возможностей к написанию программ, как получение значений из-вне. Допустим, к Arduino подключён какой-то сенсор: датчик освещённости, датчик газа, простой потенциометр или что-то ещё. Как получить его показания и использовать их в программе для чего-то полезного?

Если говорить о сенсорах с аналоговым сигналом, для получения показаний с них существует встроенная функция analogRead. Давайте воспользуемся ей, чтобы сделать программу для устройства, которое изменяет яркость свечения светодиода, подключённого к 5-му пину в зависимости от поворота ручки потенциометра, подключённого к пину A0.

#define LED_PIN  5
#define POT_PIN  A0
 
void setup()
{
    pinMode(LED_PIN, OUTPUT);
}
 
void loop()
{
    int value = analogRead(POT_PIN);
    analogWrite(LED_PIN, value / 4);
}

Первое, что мы видим — это макроопределение пина с потенциометром:

#define POT_PIN  A0

В качестве значения используется не просто число, а нечто с именем A0. Оно написано большими буквами, поэтому можно предположить, что это встроенное макроопределение. И это действительно так! A0 для Arduino Uno, например, определено как 14. Для других плат значение может быть другим. Мы не хотим помнить об этих тонкостях, поэтому просто использовали A0. Это всегда означает «нулевой аналоговый вход».

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

Теперь рассмотрим loop. В первой строке определяется переменная value, а в качестве значения ей присваивается значения выражения analogRead(POT_PIN). Но ведь это вызов функции, а не арифметическое выражение!

Совершенно верно. Некоторые функции помимо того, что делают что-то полезное умеют так же возвращать значение обратно, в вызывающий код. Функции вроде pinMode или analogWrite не возвращают ничего, по задумке их автора, а вот analogRead возвращает некоторое целочисленное значение.

Чтобы понять какие аргументы функция принимает, возвращает ли она что-нибудь и если возвращает, то что, следует обращаться к документации на эту функцию.

Что касается analogRead, она принимает один аргумент: номер пина, с которого необходимо считать значение аналогового сигнала. А возвращает эта функция значение от 0 до 1023, где:

  • Входное напряжение в 0 В возвращается как 0
  • 2 В возвращается как 409
  • 2,5 В возвращается как 512
  • И так далее, до напряжения в 5 В, которому ставится в соответствие 1023

Таким образом в первой строке loop мы просто считываем сигнал с потенциометра, получая угол поворота его ручки в виде целого числа в пределах от 0 до 1023.

Как мы помним, функция analogWrite, которой мы пользуемся для управления яркостью светодиода ожидает целое число от 0 до 255 в качестве второго аргумента. Но у нас оказалась переменная с другим диапазоном. Что делать? Просто поделить значение на 4. Так наш диапазон будет смасштабирован до того, который подходит для analogWrite. Максимуму из одного диапазона равному 1023 станет соответствовать максимум из другого диапазона: 1023 / 4 = 255.

Вспоминая о компактной записи, мы можем сделать наш loop чуть лаконичнее:

void loop()
{
    analogWrite(LED_PIN, analogRead(POT_PIN) / 4);
}

Итак, вы научились работать со сложными выражениями, макроопределениями и переменными. Использовать функции с возвращаемыми значениями и встраивать вычисления. Этих знаний уже достаточно для создания нехитрых устройств. Пробуйте, экспериментируйте, учитесь!