Директивы препроцессора
Препроцессор
Процесс компиляции прошивки очень непростой и проходит в несколько этапов, один из первых – работа препроцессора. Препроцессору можно давать команды, которые он выполнит перед компиляцией кода прошивки: это может быть подключение файлов, замена текста, условные конструкции и некоторые другие вещи. Также у препроцессора есть макросы, которые позволяют добавлять в код некоторые интересные вещи.
#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
просто убираются из кода и экономят память!
Также есть директива #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
.
При помощи условной компиляции можно буквально включать и выключать целые части кода из компиляции, то есть из финальной версии программы, которая будет загружена в микроконтроллер. Рассмотрим несколько конструкций для примера:
Сообщения от компилятора
Для вывода сообщения можно использовать директиву #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__
можно использовать для генерации уникальных имён переменных, но об этом мы поговорим когда нибудь в другой раз.
Полезные страницы
- Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])