Embedded HandbookПриложения
Все разделы › XV. Язык C глубже
STM32F103, STM32F407 · уровень средний · 13 мин чтения
Открыть в интерактивном справочнике

129. MISRA C и защитное программирование

Разделы 122–128 показали, где Си подводит: продвижение типов, неопределённое поведение, макросы, раскладка в памяти. MISRA C — свод правил, который запрещает самые опасные конструкции языка целиком, чтобы о них не приходилось помнить. Защитное программирование — привычка писать так, чтобы неверные данные и собственные ошибки не превращались в сбой прибора. Раздел проверяет на опыте, что находит каждый инструмент: компилятор, cppcheck с правилами MISRA, анализ путей выполнения gcc и защита стека armclang.

Как это называется в документации

Оригинал Перевод
MISRA C свод правил языка C для надёжных систем
mandatory / required / advisory обязательное / требуемое / рекомендательное правило
deviation отступление: оформленное исключение из правила
static analysis статический анализ: проверка без запуска
essential type существенный тип в правилах MISRA
defensive programming защитное программирование
fail-safe state безопасное состояние при ошибке
stack protector, canary защита стека, контрольное слово
false positive ложное срабатывание
coding standard правила оформления и написания кода

129.1 Зачем правила

MISRA C появился в автомобильной промышленности, а теперь его требуют в медицине, на железной дороге, в промышленной автоматике. Правила делятся на три вида: обязательные — без исключений; требуемые — отступить можно, но письменно, с обоснованием; рекомендательные — по усмотрению команды. Отступление не стыдно: стыдно нарушить правило и не знать об этом.

Тексты правил продаются отдельно, поэтому анализаторы печатают только номера, например misra-c2012-15.6. Смысл правил дальше пересказан своими словами — этого хватает, чтобы исправить код.

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


129.2 Опыт: разбор команды «по-быстрому»

Функция разбирает пакет: сумма байтов, команда в первом байте, копия для журнала. Обычный код, каких много:

uint8_t parse(const uint8_t *p, int n)
{
    int i, sum = 0;
    int level = 0;
    for (i = 0; i < n; i++)
        sum += p[i];
    if (sum > 1000)
        goto fail;
    switch (p[0]) {
    case 1:
        level++;
    case 2:
        level--;
        break;
    }
    uint8_t *copy = malloc(n);
    memcpy(copy, p, n);
    free(copy);
    return sum + level;
fail:
    return 0xFF;
}

Кроме parse, в файле есть глобальная int level и функция count_below (обе в листинг не вошли). cppcheck с дополнением MISRA нашёл в этом файле 15 нарушений по 10 правилам:

Правило Что не так Где
15.6 тело for и if без фигурных скобок 3 места
15.1 goto переход на fail
16.3 метка case без break case 1 проваливается в case 2
16.4 switch без default неизвестная команда молча пропущена
21.3 malloc и free динамическая память
11.5 void * превращён в указатель на данные результат malloc
17.7 результат функции не использован memcpy
10.4 смешаны разные типы в одном выражении int с uint8_t, знаковое с беззнаковым
12.3 несколько переменных в одном объявлении: оператора «запятая» здесь нет, но cppcheck относит такую строку к правилу о нём int i, sum = 0;
8.4 внешнее имя без объявления в заголовке level, parse, count_below

Часть этих мест — уже ошибки, и прогон на компьютере их показал. При n == 0 функция читает p[0] за концом пакета, а при нехватке памяти memcpy пишет по нулевому адресу — на компьютере оба раза отказ. return sum + level обрезает сумму до 8 бит: пакет с суммой 1000 даёт 232. Команда 1 проваливается в case 2 и не меняет level вовсе — это и есть забытый break. Остальные места сами по себе не ошибки, но каждое уже где-то к ним приводило: тело без скобок — строка, добавленная «внутрь» if, которая выполняется всегда.


129.3 Та же функция по правилам

status_t parse(const uint8_t *p, uint32_t n, int32_t *level)
{
    status_t st = ST_OK;

    if ((p == NULL) || (level == NULL) || (n == 0u) || (n > FRAME_MAX)) {
        st = ST_BAD_ARG;
    } else {
        uint32_t sum = 0u;
        for (uint32_t i = 0u; i < n; i++) {
            sum += (uint32_t)p[i];
        }
        if (sum > SUM_LIMIT) {
            st = ST_BAD_SUM;
        } else {
            switch ((uint32_t)p[0]) {
            case CMD_UP:
                *level += 1;
                break;
            case CMD_DOWN:
                *level -= 1;
                break;
            default:
                st = ST_BAD_CMD;
                break;
            }
        }
    }
    return st;
}

Что изменилось:

cppcheck с правилами MISRA — ни одного замечания; gcc с -Wconversion -Wshadow -pedantic — ни одного предупреждения. Код стал длиннее, но каждая строка понятна без догадок.


129.4 Компилятор — первый анализатор

Бесплатный и уже установленный анализатор — предупреждения компилятора. На небрежном файле gcc показал:

Ключи Предупреждений
-Wall -Wextra 2
+ -Wconversion -Wsign-conversion -Wshadow 6

-Wall -Wextra заметили только проваливание case и сравнение знакового с беззнаковым. Дополнительные ключи нашли ещё локальную level, которая закрывает глобальную, и три неявных преобразования с потерей знака или значения. Набор для прошивки: -Wall -Wextra -Wconversion -Wsign-conversion -Wshadow, а в сборке на сервере — ещё -Werror, чтобы предупреждение нельзя было пропустить (раздел 110). armclang понимает те же ключи и на небрежном файле выдал 5 предупреждений, но проваливание case он отмечает только с отдельным ключом -Wimplicit-fallthrough — его стоит добавить.

Сколько предупреждений дал небрежный файл: gcc с двумя наборами ключей и armclang


129.5 Анализ путей выполнения

Некоторые ошибки видны только тому, кто прошёл по всем веткам программы. Файл с четырьмя ошибками памяти, например чтение после освобождения:

int last_byte(uint8_t *f)
{
    free(f);
    return f[0];
}

Что нашёл каждый инструмент:

Ошибка -Wall -Wextra cppcheck -fanalyzer
NULL после malloc — — да
утечка на раннем выходе — да да
чтение после free да — да
двойной free да да да

gcc -fanalyzer нашёл все четыре и для каждой напечатал путь, по которому до неё дошёл. Компилятор и cppcheck нашли по две — и разные. Вывод практический: инструменты дополняют друг друга, запускать их нужно вместе. -fanalyzer медленный — его место в ночной сборке, а не в каждой.

Замечания к небрежному файлу у каждого инструмента, таблица находок ошибок памяти и четыре слоя защиты


129.6 Не доверять входным данным

Всё, что пришло снаружи — по UART, радио, от датчика, из флеш-памяти, — может быть неверным. Защищённый разбор испытан на испорченных пакетах, на трёх машинах одинаково:

Пакет Результат Уровень
команда 1 ST_OK +1
команда 2 ST_OK −1
неизвестная команда 7 ST_BAD_CMD (3) не тронут
сумма больше предела ST_BAD_SUM (2) не тронут
пустой, NULL, длиннее FRAME_MAX ST_BAD_ARG (1) не тронут

Правила, которые за этим стоят:


129.7 Безопасное состояние

default в switch — место, где программа признаёт, что случилось невозможное. В автомате состояний (раздел 125) переменная состояния может испортиться от записи за границу массива или сбоя питания; если default отсутствует, автомат молча застынет. Правильно — перейти в безопасное состояние: выключить силовые выходы, записать причину, дождаться сброса сторожевым таймером (раздел 12.1).

Так же поступают с перечислениями из внешних данных: значение, не совпавшее ни с одним известным, — ошибка, а не «ничего не делать».


129.8 Защита стека на плате

Функция, которая доверяет длине из пакета:

uint8_t buf[32];
memcpy(buf, src, n);

При n больше 32 копия затирает кадр стека, в том числе адрес возврата. armclang с ключом -fstack-protector-strong кладёт в кадр контрольное слово и сверяет его перед возвратом. В объектном файле F103 функция copy_packet выросла с 28 байт до 60, кадр стека — с 0x20 до 0x28: слово __stack_chk_guard читается при входе, сравнивается при выходе, а при расхождении вызывается __stack_chk_fail.

Обе функции пишет прошивка: __stack_chk_guard — переменная, которую при старте заполняют случайным числом (на F407 — от аппаратного генератора, раздел 43), __stack_chk_fail — обработчик, который переводит прибор в безопасное состояние. Слово записывают до вызова защищённых функций — в стартовом коде или первой строкой main, а функцию, которая его меняет, собирают с __attribute__((no_stack_protector)): иначе при выходе из неё сверка не сойдётся и сработает __stack_chk_fail. Защита стека — страховка, а не лечение: лечение — проверка длины до копирования:

if ((src != NULL) && (n <= sizeof buf)) {

129.9 Проверки во время работы


129.10 Грабли

Анализатор выдал сотни замечаний, их отключили все. Начинать с нового кода и с обязательных правил; старые файлы — постепенно, отступления — письменно.

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

-Wconversion на старом коде — стена предупреждений. Включать по модулям и исправлять явными приведениями там, где потеря значения действительно невозможна.

__stack_chk_fail не найден при компоновке. Включили -fstack-protector-strong, но не написали обработчик и контрольное слово.

Защитный код «никогда не выполняется» и выброшен. Проверки неверных данных — не лишний код; их проверяют испытаниями на испорченных пакетах.

default есть, но пустой. Это то же, что его отсутствие. Невозможное значение — ошибка, её надо обработать.


129.11 Итоги

Часть XV «Язык C глубже» продолжают два практических раздела: 129а — модульные тесты прошивки на ПК, 129б — статический анализ.


Полные исходники

bad129.c — разбор команды «по-быстрому» — нарочно с нарушениями.

/* Раздел 129: разбор команды так, как его пишут «по-быстрому».
 * Файл нарочно нарушает правила: cppcheck с дополнением MISRA и
 * предупреждения gcc обязаны это найти. Логика та же, что в good129.c. */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

int level;

uint8_t parse(const uint8_t *p, int n)
{
    int i, sum = 0;
    int level = 0;
    for (i = 0; i < n; i++)
        sum += p[i];
    if (sum > 1000)
        goto fail;
    switch (p[0]) {
    case 1:
        level++;
    case 2:
        level--;
        break;
    }
    uint8_t *copy = malloc(n);
    memcpy(copy, p, n);
    free(copy);
    return sum + level;
fail:
    return 0xFF;
}

unsigned count_below(const int *v, unsigned n, int limit)
{
    unsigned count = 0;
    for (int i = 0; i < n; i++)
        if (v[i] < limit)
            count++;
    return count;
}

good129.c — та же логика по правилам защитного программирования.

/* Раздел 129: та же логика, что в bad129.c, по правилам защитного
 * программирования: проверка аргументов, фигурные скобки, default в switch,
 * одна точка выхода, без динамической памяти и без смешения знаковых типов. */
#include <stddef.h>
#include "good129.h"

#define SUM_LIMIT 1000u
#define CMD_UP 1u
#define CMD_DOWN 2u

status_t parse(const uint8_t *p, uint32_t n, int32_t *level)
{
    status_t st = ST_OK;

    if ((p == NULL) || (level == NULL) || (n == 0u) || (n > FRAME_MAX)) {
        st = ST_BAD_ARG;
    } else {
        uint32_t sum = 0u;
        for (uint32_t i = 0u; i < n; i++) {
            sum += (uint32_t)p[i];
        }
        if (sum > SUM_LIMIT) {
            st = ST_BAD_SUM;
        } else {
            switch ((uint32_t)p[0]) {
            case CMD_UP:
                *level += 1;
                break;
            case CMD_DOWN:
                *level -= 1;
                break;
            default:
                st = ST_BAD_CMD;
                break;
            }
        }
    }
    return st;
}

uint32_t count_below(const int32_t *v, uint32_t n, int32_t limit)
{
    uint32_t count = 0u;

    if (v != NULL) {
        for (uint32_t i = 0u; i < n; i++) {
            if (v[i] < limit) {
                count++;
            }
        }
    }
    return count;
}

guard129_arm.c — защита стека на F103: контрольное слово компилятора и проверка длины.

/* Раздел 129: защита стека на плате — armclang для F103.
 * copy_packet доверяет длине из пакета; с -fstack-protector-strong
 * компилятор ставит в кадр контрольное слово и проверяет его перед
 * возвратом. copy_packet_checked защищается проверкой длины. */
#include <stdbool.h>
#include <stdint.h>
#include <string.h>

void process(uint8_t *buf, uint32_t n);

void copy_packet(const uint8_t *src, uint32_t n)
{
    uint8_t buf[32];
    memcpy(buf, src, n);
    process(buf, sizeof buf);
}

bool copy_packet_checked(const uint8_t *src, uint32_t n)
{
    bool ok = false;
    uint8_t buf[32];

    if ((src != NULL) && (n <= sizeof buf)) {
        memcpy(buf, src, n);
        process(buf, n);
        ok = true;
    }
    return ok;
}

Дальше: раздел 129а «Модульные тесты прошивки на ПК: подмена железа, свой маленький раннер и покрытие» — как гонять на ПК драйвер, который пишет в регистры и зовёт HAL.

Открыть в интерактивном справочнике