Препроцессор


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

#include – подключить файл


С подключением файлов мы уже знакомы: директива #include подключает новый документ в текущий, например библиотеку. После #include нужно указать имя файла, который подключается. Указать можно в "двойных кавычках", а можно в <угловых скобках>. В чём разница? Файл, имя которого указано в двойных кавычках, компилятор будет искать в папке с основным документом, если не найдёт – будет искать в папке с библиотеками. Если указать в скобках – будет сразу искать в папке с библиотеками, путь к которой обычно можно настроить.

#include "mylib.h"  // подключить mylib.h, сначала поискать в папке со скетчем
#include <mylib.h>  // подключить mylib.h из папки с библиотеками

Также можно указать путь к файлу, который нужно подключить. Например у нас в папке со скетчем есть папка libs, а в ней – файл mylib.h. Чтобы подключить такой файл, пишем:

#include "libs/mylib.h"

Компилятор будет искать его в папке со скетчем, в подпапке libs.

#define / undef


Мы с вами уже сталкивались с #define в предыдущих уроках, сейчас хочу рассказать о некоторых частных случаях. Напомню, #define – это команда препроцессору заменить один порядок символов на другой, например #define MOTOR_SPEED 50 заменит все встречающиеся в коде MOTOR_SPEED цифрой 50 при компиляции. Если не писать ничего после указания первого набора символов, препроцессор заменит их на “ничего”. То есть #define MOTOR_SPEED просто удалит из кода все сочетания MOTOR_SPEED.

Также #define позволяет создавать макро-функции, об этом мы говорили в уроке про функции. Например при помощи дефайна можно создавать удобные конструкции в стиле вечного цикла

#define FOREVER for(;;)

......

FOREVER {
  // код крутится, байты мутятся
}

Или быстрого и удобного отключения отладки в коде:

#ifdef DEBUG
#define DEBUG_PRINT(x) Serial.println(x)
#else
#define DEBUG_PRINT(x)
#endif

Если DEBUG задефайнен, то DEBUG_PRINT – это макро-функция, которая выводит значение в порт. А если не задефайнен – все вызовы DEBUG_PRINT просто убираются из кода и экономят память!

При разработке проекта важна отладка, мы делаем её средствами Serial.println(). Чтобы после окончания разработки не убирать из кода все вызовы Serial и не нагружать код условными конструкциями #ifdef DEBUG…. #endif, можно сделать так:

#ifdef DEBUG_ENABLE
#define DEBUG(x) Serial.println(x)
#else
#define DEBUG(x)
#endif

Если DEBUG_ENABLE задефайнен – все вызовы DEBUG() в коде будут заменены на вывод в порт. Если не задефайнен – они будут заменены НИЧЕМ, то есть просто “вырежутся” из кода! Также по DEBUG_ENABLE можно запустить сериал и получить полный контроль над отладкой: если она не нужна – убрали DEBUG_ENABLE и из кода убрался запуск порта и все выводы, что резко сокращает объём занимаемой памяти:

// раздефайнить или задефайнить для использования
//#define DEBUG_ENABLE

#ifdef DEBUG_ENABLE
#define DEBUG(x) Serial.println(x)
#else
#define DEBUG(x)
#endif

void setup() {
#ifdef DEBUG_ENABLE
  Serial.begin(9600);
#endif
}

void loop() {
  DEBUG("kek");
  delay(100);
}

#undef

Также есть директива #undef, которая отменяет #define, в некоторых случаях может оказаться полезным.

Проблемы


В чём же состоит опасность #define? Он распространяется на все документы, которые подключаются в код после него, а также работают в коде, будучи описанными в другом документе. Рассмотрим подробнее:

Если ПЕРЕД подключением файла вы объявите #define, то данный define будет распространяться на этот файл и заменит указанный текст.

Если что-то в подключаемом файле (имена функций и переменных) совпадёт в вашим дефайном – будет ошибка компиляции. Например, в библиотеке FastLED есть цвет DarkMagenta, внутри библиотеки цвета объявлены как enum. Если я сделаю дефайн на такое имя – получу ошибку:

Но, если в подключаемом файле есть свой #define с таким же именем, то работать будет #define файла!

Важный момент: наш скетч в Arduino IDE по сути является .cpp файлом, и #define из него могут распространяться только на заголовочные файлы .h! То есть в файле .h подключаемой библиотеки дефайн будет “видно”, а вот в .cpp – уже нет!

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

  • Поместить исполнительный код библиотеки в заголовочном .h файле (.cpp не создавать вообще), тогда дефайном из скетча можно будет влиять на компиляцию исполнительного кода. Этот пример мы рассматривали в самом первом скриншоте.
  • Создать в  папке с библиотекой отдельный заголовочный файл, например config.h, в нём собрать необходимые дефайны “настроек”, и этот файл подключать во все файлы библиотеки, в этом случае .cpp файл библиотеки сможет подхватить нужный define. Так сделано, например, в библиотеке FastLED.

На этом сложности не заканчиваются: #define из одной библиотеки может пролезть в другую библиотеку, которая подключена после первой! Вернёмся к тому же примеру с DarkMagenta – если в моей библиотеке я задефайню это слово и подключу библиотеку до подключения FastLED – я получу ошибку компиляции! Если поменять подключение местами – ошибки не будет. Но, если я захочу использовать DarkMagenta в своём скетче, я буду неприятно удивлён =)

Что я хочу сказать в итоге: #define – гораздо более мощный инструмент, чем может показаться на первый взгляд. Использование define с невнимательным отношением к именам может привести к ошибке, которую будет непросто отловить. Это палка о двух концах: с одной стороны хочется использовать в своей библиотеке define, чтобы никто другой случайно не пролез со своими дефайнами. В то же время, своя библиотека может начать конфликтовать с другими библиотеками. Какой тут выход? Очень простой! Делать имена дефайнов максимально уникальными: если это библиотека – оставлять префикс библиотеки, если это скетч – делать префикс с именем скетча. Также можно отказаться от define в пользу констант или enum, enum кстати удобнее define в плане создания набора констант, а места занимает совсем немного!

#if – условная компиляция


Условная компиляция является весьма мощным инструментом, при помощи которого можно вмешиваться в компиляцию кода и делать его очень универсальным как для пользователя, так и для железа. Рассмотрим директивы условной компиляции:

  • #if – если
  • #ifdef – если определено
  • #ifndef – если не определено
  • #else – иначе
  • #elif – иначе если
  • #endif – конец условия
  • defined – проверка, определён ли

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

#define USE_DISPLAY 1 // настройка для пользователя

#if (USE_DISPLAY == 1)
#include <библиотека дисплея.h>
#endif

void setup() {
#if (USE_DISPLAY == 1)
  // дисплей.инициализация
#endif

}
void loop() {
}

#define SENSOR_TYPE 3   // настройка для пользователя

// подключение выбранной библиотеки
#if (SENSOR_TYPE == 1 || SENSOR_TYPE == 2)
#include <библиотека сенсора 1 и 2.h>
#elif (SENSOR_TYPE == 3)
#include <библиотека сенсора 3.h>
#else <библиотека сенсора 4.h>
#endif

#if defined(__AVR_ATmega1280__) || defined(__AVR_ATmega2560__)
// код для ATmega1280 и ATmega2560
#elif defined(__AVR_ATmega32U4__)
// код для ATmega32U4
#elif defined(__AVR_ATmega1284__)
// код для ATmega1284
#else
// код для остальных МК
#endif

Сообщения от компилятора


Для вывода сообщения можно использовать директиву #pragma message , выглядит вот так:

Также есть директива #error, она тоже выводит текст, но вызывает ошибку компиляции:

И pragma message и error можно вызывать при помощи условной компиляции, рассмотренной в предыдущей главе.

#pragma


#pragma это целый класс директив с разными возможностями. Выше мы уже рассмотрели #pragma message, здесь рассмотрим ещё некоторые.

#pragma once

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

#ifndef _MY_LIB
#define _MY_LIB
// код
#endif

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

#pragma pack/pop

Конструкция с #pragma pack и #pragma pop позволяет более рационально распределять структуры в памяти. Тема сложная, читайте на Хабре.

Макросы


У препроцессора есть несколько интересных макросов, которыми можно пользоваться в своём коде. Рассмотрим некоторые полезные из них, которые работают на Arduino (точнее, на компиляторе avr-gcc).

__func__ и __FUNCTION__


Макросы __func__ и __FUNCTION__ “возвращают” в виде символьного массива (строки) название функции, внутри которой они вызваны. Являются аналогом друг друга. Например:

void myFunc() {
  Serial.println(__func__); // выведет myFunc
}

__DATE__ и __TIME__


__DATE__ возвращает дату компиляции по системному времени в виде символьного массива (строки) в формате <первые три буквы месяца> <число> <год>

__TIME__ возвращает время компиляции по системному времени в виде символьного массива (строки) в формате ЧЧ:ММ:СС

Serial.println(__DATE__); // Feb 27 2020
Serial.println(__TIME__); // 14:32:18

Работать напрямую с этим макросом очень неудобно, это ведь просто набор символов. У меня есть библиотека buildTime, которая позволяет получать отдельно каждый параметр (день, месяц, год, часы, минуты, секунды). Скачать/почитать можно здесь.

__FILE__ и __BASE_FILE__


__FILE__ и __BASE_FILE__ возвращают полный путь к текущему файлу, опять же как строку. Являются аналогами друг друга.

Serial.println(__FILE__);
// вывод C:\Users\Alex\Desktop\sketch_feb27a\sketch_feb27a.ino

__LINE__


__LINE__ возвращает номер строки в документе, в которой вызван этот макрос

__COUNTER__


__COUNTER__ возвращает значение, начиная с 0. Значение __COUNTER__ увеличивается на единицу с каждым вызовом макроса в коде.

int val = __COUNTER__;

void setup() {
  Serial.begin(9600);  
  Serial.println(__COUNTER__);  // 1
  Serial.println(val);          // 0
  Serial.println(__COUNTER__);  // 2
}
void loop() {}

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

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