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

Препроцессор — это программа, которая подготавливает исходный код программы к компиляции, совершая подстановки и включения файлов, а также вводит макроопределения. Рассмотрим основные директивы препроцессора (они начинаются со знака решетки, #).

Директива #include

Директива include позволяет включать содержимое файлов на место строчки, где она написана. Если этот файл располагается в стандартной директории (имеется в системных путях операционной системы), то его имя, как правило, заключается в угловые скобки <>, если же файл находится в текущем каталоге, то используются кавычки "".

#include <stdio.h>          // system path directory
#include "ds18b20.h"        // current directory
#include "config/device.h"  // nested folder

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

Директива #define

Хороший код от плохого отличается в том числе тем, что в нем отсутствуют непонятные константы в середине исходного файла, которые еще называют хардкодом (англ. hardcode). Для создания констант часто применяется директива #define. Общая форма записи при этом выглядит следующем образом:

#define [ИДЕНТИФИКАТОР][ОТСТУП][ЗАМЕНА] 

Это очень удобно, когда одно и то же значение используется множество раз в одном и том же файле и участвует, скажем, в настройке периферии. Например,

#define REG_DIGIT_0 (1 << 8)
// ...
void max7219_clean() {
    send_data(REG_DIGIT_0 | 0x00);
    // ...
}

Достаточно один раз определить значение REG_DIGIT_0, и (1 << 8) будет подставлено во все места, где указывается макрос.

С помощью суффиксов можно определять тип данных:

#define A             1200U    // unsigned int
#define B             1200L    // long int
#define C             1200UL   // unsigned long int
#define D             1200LL   // long long int
#define E             1200ULL  // unsigned long long int
#define F             0.0      // double
#define G             0.0F     // float

Для того что бы гарантировать тип данных на этапе компиляции, можно использовать макросы вида UINT32_C. Их часто используют при определении констант.

#define UINT32_C(value) \
    __CONCAT(value, UL)

Используя ту же директиву, можно создавать макросы для каких-нибудь формул или, наоборот, вставлять туда участки кода.

// circle area
#define S(x)            (3.1415926f * x * x)
// enable port A clocking
#define RCC_PORT_A_ON()   (RCC->APB2ENR |= RCC_APB2ENR_IOPBEN)
// ...
int main(void) {
    RCC_PORT_A_ON();
    int a = S(10);
    return 0;
}

Данную директиву можно использовать еще одним способом, в частности, создавая «метку» для препроцессора.

//#define PCB_REV_A
#define PCB_REV_B

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

Чтобы убрать метку, можно воспользоваться директивой #undef.

Условные директивы

Описанный выше сценарий с включением нужного кода в сборку можно реализовать при помощи директив #ifdef / #ifndef. Например так:

#ifdef PCB_REV_A
#define BUTTON_PORT GPIOA
#else
#define BUTTON_PORT GPIOB
#endif

Если PCB_REV_A была где-то определена ранее, то создается макрос BUTTON_PORT со значением GPIOA, в противном случае — с GPIOB. По такому же принципу построена «защита» от двойного включения файлов при использовании директивы #include. Описать словами это можно следующим образом: если не определена метка, определим ее, далее делаем что-то полезное. В противном случае ничего не делаем либо переходим к секции #else.

Помимо защитных скобок #ifndef / #define / #endif допустимо использовать директиву #pragma once. Она не входит в стандарт языка, но поддерживается большинством компиляторов. При ее использовании предотвращает появление коллизий имён (включение одного и того же файла несколько раз) компилятор, без участия препроцессора. В общем случае время компиляции уменьшается.

Можно поступить по-другому: определить некоторую константу и в зависимости от ее содержимого принимать решение.

#define PCB_VERSION     2
#if PCB_VERSION == 1
#define BUTTON_PORT     GPIOA
#elif PCB_VERSION == 2
#define BUTTON_PORT     GPIOB
#else
#define BUTTON_PORT     GPIOC
#endif

Другие директивы и макросы

В работе могут пригодится и другие директивы. Допустим, для предотвращения зависаний при работе устройства используется сторожевой таймер (англ. watchdog timer). Суть его работы заключается в том, что если некоторый регистр не обнулить до того, как таймер дойдет до определенного значения, то микроконтроллер будет принудительно перезагружен. При отладке эта функция скорее вредит, чем помогает: микроконтроллер просто перезагрузится, пока вы обдумываете значение какой-нибудь переменной. В таком случае таймер лучше отключить.

Космический аппарат Clementine занимался картографированием поверхности Луны. Маневровыми двигателями управляли с земной станции, но в случае аварийной ситуации бортовой компьютер мог принять решение самостоятельно. 7 мая 1994 года, когда аппарат уже сошёл с орбиты Луны, произошла исключительная ситуация при работе с плавающей точкой. Данное событие не было чем-то необычным, предыдущие ~3000 были обработаны корректно. Однако после этого события аппарат стал отправлять случайные данные, а затем и вовсе одно и тоже. Операторы потратили 20 минут в попытках перезапустить систему, отправляя команды, но все они были проигнорированы. Через какое-то время произошёл аппаратный сброс (по всей видимости, когда закончилось топливо), и аппарат вернулся к жизни. Как оказалось, бортовой компьютер завис, и включив маневровый двигатель, израсходовал всё топливо и раскрутил аппарат до 80 оборотов в минуту. Встроенный в процессор Honeywell 1750 сторожевой таймер не был использован, несмотря на возражения главного инженера. Пара строк кода могла спасти миссию. // http://www.ganssle.com/item/great-watchdog-timers.htm

При некоторых условиях сторожевой таймер может зависнуть, как это происходит в контроллерах AVR в поле сильного ЭМИ.

Никто не застрахован от человеческого фактора: в финальной сборке можно просто забыть включить его обратно. На помощь приходит директива #warning.

int main(void) {
#ifdef RELEASE
    watchdog_init();
#else
#warning "ATTENTION! Debug mode is on. WatchDog is off."
#endif
    //...
    return 0;
}

При компиляции сторожевой таймер будет включен, если определена метка RELEASE. В противном случае компилятор выдаст предупреждение.

Есть и другие директивы. #error выведет сообщение и остановит работу компилятора. Директива #line укажет имя файла и номера текущей строки для компилятора.

Кроме директив, есть предопределенные константы. Например:

  • __LINE__ заменяется на номер текущей строки;
  • __FILE__ на имя файла;
  • __FUNCTION__ на имя текущей функции;
  • __DATE__ на текущую дату;
  • __TIME__ на текущее время (на момент обработки кода препроцессором);
  • и другие.

Изменено: