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;
}
Что изменилось:
- аргументы проверены до первого обращения, длина ограничена
FRAME_MAX; - функция возвращает код результата, а новое значение — через указатель,
и перечисление
status_tназывает каждую ошибку; - все тела в скобках, у
switchестьdefault, у каждогоcase—break; - одна точка выхода, без
goto; - без динамической памяти;
- одно объявление — одна переменная;
- все переменные беззнаковые там, где отрицательных значений не бывает.
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 — его стоит
добавить.
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) |
не тронут |
Правила, которые за этим стоят:
- проверять длину до копирования, индекс — до обращения к массиву;
- возвращать код ошибки и проверять его у вызывающего;
- на ошибке не менять состояние: частично применённая команда хуже отвергнутой;
- считать в беззнаковых типах подходящей ширины: сумма 16 байтов по 255
не переполнит
uint32_t.
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 Проверки во время работы
assertв отладочной сборке останавливает программу там, где нарушено условие; в рабочей его выключаетNDEBUG. На платеassertнаправляют в свой обработчик: записать файл и строку (раздел 127), перейти в безопасное состояние.- Сторожевой таймер сбрасывает зависшую программу (раздел 12.1); сбрасывать его — только из главного цикла, а не из прерывания.
- Контрольная сумма прошивки при старте — CRC всей флеш-памяти (разделы 12.5 и 43.1): испорченная прошивка не должна запускаться.
- Контрольные слова в начале и конце важных структур в ОЗУ ловят запись за границу соседнего массива.
- Разный уровень защиты — по цене ошибки: лампочку в коридоре так не защищают, привод двери — обязательно.
129.10 Грабли
Анализатор выдал сотни замечаний, их отключили все. Начинать с нового кода и с обязательных правил; старые файлы — постепенно, отступления — письменно.
Ложное срабатывание. Анализатор не знает, что указатель уже проверен в другом файле. Переписать понятнее или оформить отступление, а не выключать проверку целиком.
-Wconversion на старом коде — стена предупреждений. Включать
по модулям и исправлять явными приведениями там, где потеря значения
действительно невозможна.
__stack_chk_fail не найден при компоновке. Включили
-fstack-protector-strong, но не написали обработчик и контрольное слово.
Защитный код «никогда не выполняется» и выброшен. Проверки неверных данных — не лишний код; их проверяют испытаниями на испорченных пакетах.
default есть, но пустой. Это то же, что его отсутствие.
Невозможное значение — ошибка, её надо обработать.
129.11 Итоги
- MISRA C — список опасных конструкций; правила обязательные, требуемые и рекомендательные, отступления — письменно.
- cppcheck с дополнением MISRA нашёл в небрежном разборе 15 нарушений
по 10 правилам: скобки (15.6),
goto(15.1),break(16.3),default(16.4),malloc(21.3) и другие; исправленный код — ни одного. - Предупреждения gcc:
-Wall -Wextra— 2, с-Wconversion -Wsign-conversion -Wshadow— 6. - Ошибки памяти:
-fanalyzerнашёл 4 из 4, компилятор и cppcheck — по 2, и разные; инструменты запускают вместе. - Входные данные — проверять; на ошибке — код результата и нетронутое
состояние;
default— безопасное состояние. -fstack-protector-strong: 28 → 60 байт, кадр 0x20 → 0x28, при порче —__stack_chk_fail; главное лечение — проверка длины.
Часть 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.