Проверка кода утверждениями

В стандартной библиотеке периферии от ST есть специальный макрос,

#define assert_param(expr) ((expr) ? (void)0 : assert_failed((uint8_t *)__FILE__, __LINE__)),

задача которого — проверять переданное в него выражение (expr). Если оно истинно, то макрос ничего не делает ((void)0), а если ложно, то вызывается функция assert_failed().

Для активации нужно определить макрос-метку USE_FULL_ASSERT, в противном случае проверка не выполнится.

void assert_failed(uint8_t* file, uint32_t line) {
    /* User can add his own implementation to report the file name and line number,
    ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
​
    /* Infinite loop */
    while (1) {
    }
}

Такие штуки называют «утверждением» (англ. assert). Но зачем всё это нужно? Вам никто не мешает передать в качестве аргумента какую-нибудь ерунду или указатель на NULL. Данный макрос позволяет исключить такую ситуацию (при условии, что код вы всё же отлаживаете). Посмотрите на реализацию функции инициализации модуля GPIO из стандартной библиотеки:

Может показаться, что использование утверждений не нужно и вы полностью понимаете поведение кода. Вот вам пример от Mozilla: добавление одного утверждения выявило 66 багов — FrameArena::~FrameArena should assert that it’s empty.

// stm32f10x_gpio.c
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct) {
    // ...
    assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
    assert_param(IS_GPIO_MODE(GPIO_InitStruct->GPIO_Mode));
    assert_param(IS_GPIO_PIN(GPIO_InitStruct->GPIO_Pin));
    // ...

Вместо указателя на GPIO_InitDef запросто можно передать указатель на GPIO_InitTypeDef. С включенной проверкой такие фокусы не пройдут.

#define IS_GPIO_ALL_PERIPH(PERIPH) (((PERIPH) == GPIOA) || \
                                    ((PERIPH) == GPIOB) || \
                                    ((PERIPH) == GPIOC) || \
                                    ((PERIPH) == GPIOD) || \
                                    ((PERIPH) == GPIOE) || \
                                    ((PERIPH) == GPIOF) || \
                                    ((PERIPH) == GPIOG))

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

void do_something(uint32_t index) {
    assert_param(index < BUFFER_SIZE);
    // ...
}

assert_param() — это не выдумка создателей библиотеки, такая функциональность уже есть в стандартной библиотеке языка Си и подключается через заголовочный файл <assert.h>.

Реализация стандартной библиотеки для микроконтроллера обычно отличается от реализации для универсального компьютера. В частности, assert() из стандартной библиотеки Си ведёт к вызову функции abort() и завершению программы. В любом случае лучше определить собственный макрос и пользоваться им.

Обратите внимание! Смысл макроса assert*() в поиске багов, а не в обработке ошибок. Например, если вы используете динамическую память, то проверка работы malloc() через макрос assert() — неправильное его использование. Программа вылетит в обработчик вместо того, чтобы продолжить работу и пытаться как-то решить возникшую проблему.

uint8_t arr = malloc(/* ... */);
assert(arr != NULL); // missusing

Такие проверки эффективны на этапе разработки, но они не нужны в финальной версии прошивки. Макрос assert_param() отключается удалением определения USE_FULL_ASSERT, а assert() из стандартной библиотеки Си — определением макроса NDEBUG или добавлением к компилятору флага -DNDEBUG.

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

// assert.h
#ifdef NDEBUG           /* required by ANSI standard */
# define assert(__e) ((void)0)
#else
# define assert(__e) ((__e) ? (void)0 : __assert_func (__FILE__, __LINE__, \
                               __ASSERT_FUNC, #__e))
// ...

Использование assert(), как ни странно, может навредить. В качестве параметра expr можно передавать что-то, что изменяет состояние системы, например функцию или инкремент переменной.

assert(f() < MAX_VALUE); // bad
assert(++i < MAX_VALUE); // bad ether

При отключении утверждений поведение программы изменится, так как нет необходимости действительно проверять передаваемое значение, а значит, его и не нужно вычислять. Посмотрите внимательно на то, как выглядит макрос assert() при определении NDEBUG. Вы сами должны следить за тем, что не передаёте в качестве аргумента что-либо изменяющее состояние системы.

В стандарте c11 появилось ещё одно ключевое слово, _Static_assert. Оно, в отличие от библиотечного макроса assert(), работает на этапе компиляции.

_Static_assert(expr, "Debug Message");

Если результат expr равен нулю, то программа не скомпилируется.

static assertion failed: "Debug Message" main.c

Статическое утверждение в случае проверки аргумента функции (известного на этапе компиляции, конечно) подойдёт лучше, так как просто не позволит скомпилировать код.


Изменено: