Директивы препроцессора

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


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

#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, которая отменяет #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, чтобы никто другой случайно не пролез со своими дефайнами. В то же время, своя библиотека может начать конфликтовать с другими библиотеками.

Какой тут выход? Очень простой! Делать имена дефайнов максимально уникальными: если это библиотека - оставлять префикс библиотеки (например библиотека FastBot, префиксы дефайнов FB_MY_CONST), а если это скетч - делать префикс с именем скетча. Также можно отказаться от define в пользу констант или enum, enum кстати удобнее define в плане создания набора констант, а места занимает совсем немного!

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


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

  • #if - аналог if в логической конструкции
  • #elif - аналог else if в логической конструкции
  • #else - аналог else в логической конструкции
  • #endif - директива, завершающая условную конструкцию
  • #ifdef - если "определено"
  • #ifndef - если "не определено"
  • defined - данный оператор возвращает true если указанное слово "определено" через #define, и false - если нет. Используется для конструкций условной компиляции.

Пример:

#define TEST 1    // определяем TEST как 1
#if (TEST == 1)   // если TEST 1
#define VALUE 10  // определить VALUE как 10
#elif (TEST == 0) // TEST 0
#define VALUE 20  // определить VALUE как 20
#else             // если нет
#define VALUE 30  // определить VALUE как 30
#endif            // конец условия

Таким образом мы получили задефайненную константу VALUE, которая зависит от "настройки" TEST.

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

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

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

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

}
void loop() {
}
Пример 2
#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
Пример 3
#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 позволяет более рационально распределять структуры в памяти. Тема сложная, читайте на Хабре.

Операторы

#


Оператор # превращает следующее за ним слово в строку, т.е. оборачивает в дойные кавычки. Например:

#define MAKE_STR(x) #x

MAKE_STR(text);   // равносильно записи "text"

##


Оператор ## "склеивает" переданные названия в одно:

#define CONCAT(x,y) x##y

int CONCAT(my, val);  // равносильно записи int myval;

Макросы


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

Константы


У препроцессора есть несколько интересных макросов, которыми можно пользоваться в своём коде. Рассмотрим некоторые полезные из них, которые работают на 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__ можно использовать для генерации уникальных имён переменных, например:

#define _CONCAT(a, b) a##b
#define CONCAT_INNER(a, b) _CONCAT(a, b)
#define MAKE_VAR(name) CONCAT_INNER(name, __COUNTER__)

int MAKE_VAR(var) = 123;   // создана переменная var20
int MAKE_VAR(var) = 456;   // создана переменная var21
 

Полезные страницы


5 1 голос
Рейтинг статьи
Подписаться
Уведомить о
guest

7 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх