Вебинар: Go-Go-Gadg...Error? Смотрим, как ошибаются Go разработчики! - 26.08
Никто не спорит о пользе статического или динамического анализа кода. Но некоторые разработчики воспринимают эту пользу как абстрактную и останавливаются на уровне написания юнит-тестов. Сейчас вспомнился один пример из мира C, который хорошо показывает, что юнит-тесты иногда плохо помогают находить даже самые типовые ошибки.

Баг, который я сейчас покажу, я уже рассматривал в статье "Красивая ошибка в реализации функции конкатенации строк". Быстро напомню про него, а затем рассмотрим его под углом раннего обнаружения.
В проекте LFortran была вот такая функция для конкатенации (объединения) двух строк в новом буфере:
void _lfortran_strcat(char** s1, char** s2, char** dest)
{
int cntr = 0;
char trmn = '\0';
int s1_len = strlen(*s1);
int s2_len = strlen(*s2);
int trmn_size = strlen(&trmn);
char* dest_char = (char*)malloc(s1_len+s2_len+trmn_size);
for (int i = 0; i < s1_len; i++) {
dest_char[cntr] = (*s1)[i];
cntr++;
}
for (int i = 0; i < s2_len; i++) {
dest_char[cntr] = (*s2)[i];
cntr++;
}
dest_char[cntr] = trmn;
*dest = &(dest_char[0]);
}
Здесь классическая ошибка, когда выделяемый буфер на 1 байт меньше необходимого. Не учтён терминальный ноль. Вернее, учтён, но его размер вычисляется неправильно.
char trmn = '\0';
int trmn_size = strlen(&trmn);
Здесь символ trmn интерпретируется как пустая строка. Её длина нулевая. Соответственно, переменная trmn_size, название которой хранит размер терминального нуля, всегда будет равна 0. В результате терминальный ноль будет записан уже за пределами выделенного буфера.
Исправить код можно как-то так (добавлен +1 при вычислении аргумента функции malloc):
void _lfortran_strcat(char** s1, char** s2, char** dest)
{
if (s1 == NULL || *s1 == NULL ||
s2 == NULL || *s2 == NULL || dest == NULL)
{
// Какая-то обработка ошибки, уместная в данном проекте.
....
}
int s1_len = strlen(*s1);
int s2_len = strlen(*s2);
char* dest_char = (char*)malloc(s1_len + s2_len + 1);
if (dest_char == NULL)
{
// Какая-то обработка ошибки, уместная в данном проекте.
....
}
memcpy(dest_char, *s1, s1_len);
memcpy(dest_char + s1_len, *s2, s2_len);
dest_char[s1_len + s2_len] = '\0';
*dest = &(dest_char[0]);
}
Ошибка простая и понятная. Выход за границу буфера в программе на C это вообще типовая проблема. Что тут ещё обсуждать? Ошибка найдена, дело закрыто.
Меня заставил задуматься комментарий читателя о коварности этой ошибки из-за гранулярности выделяемой памяти.
Функция malloc на самом деле выделяет не столько памяти, сколько у неё просят. Она запрашивает у операционной системы большие блоки и нарезает их на куски, добавляя служебную информацию. Точный размер блока зависит от конкретной реализации.
Даже если вы запросите malloc(1), аллокатор все равно вернёт указатель на блок размером, скажем, 32 байта (минус служебные поля — вам достанется 24 полезных байта). Это делается для обеспечения выравнивания памяти (обычно 16-байтного) и упрощения менеджера памяти.
Возвращаемый указатель должен быть выровнен. Поскольку функция malloc ничего не знает о том, какие типы будут храниться в выделенной памяти, она ориентируется на самое большое выравнивание, которое может потребоваться. На практике (x86-64) malloc выдаёт адреса, кратные 16. Поэтому размер блока всегда округляется вверх до следующей границы выравнивания.
Я описал всё очень поверхносно и приблизительно. Важно то, что на практике в рассмотренном коде обычно будет выделяться памяти больше, чем требуется! Если блоки кратны 16 байтам, то запись терминального нуля в соседний блок случится, только если результирующая строка также кратна 16 байтам. Другими словами, вероятность, что ошибка проявит себя, равна 1 к 16 (или не 16 — это число взято как одно из возможных).
Тут следует сразу сделать оговорку про неопределённое поведение. Формально код в любом случае некорректен, так как содержит выход за границу массива. Нельзя рассуждать, как он будет работать, как ошибка проявит себя и т.д.
Однако неопределённое поведение — это в том числе ситуация, когда неправильный код работает так, как ожидалось. Рассматриваемая ситуация, когда из-за особенностей работы менеджера памяти этой памяти выделяется больше, как раз и может привести к видимости, что всё работает хорошо.
Можно написать юнит-тесты типа таких:
void Test1()
{
char *a = "a";
char *b = "";
char *q;
_lfortran_strcat(&a, &b, &q);
int ok = strcmp(q, "a") == 0;
printf("%s+%s=%s %s\n", a, b, q, ok ? "ok" : "err");
free(q);
}
void Test2()
{
char *a = "12";
char *b = "345";
char *q;
_lfortran_strcat(&a, &b, &q);
int ok = strcmp(q, "12345") == 0;
printf("%s+%s=%s %s\n", a, b, q, ok ? "ok" : "err");
free(q);
}
И ничего не заметить. Тесты проходят успешно:
a+=a ok
12+345=12345 ok
Тесты на коротких строках не выявят проблему. В голову может и не прийти мысль попробовать работать с длинными строками. Зачем? На первый взгляд такие тесты ничего не дают. Скорее всего, будут созданы тесты на краевые случаи (пустые строки), но они нерелевантные для поиска обсуждаемого бага.
При этом даже с длинными строками, вероятность заскочить в соседний блок всего 1 к N (где N, например 16). Можно объединить две огромные строки из 111111 символов и всё будет хорошо, ведь конец результирующей строки (222222 символов) не лежит на границе 16-байтного блока.
Только не подумайте, что я критикую юнит-тесты. Это замечательная штука! Однако бывают ошибки, которым легко от этих юнит-тестов спрятаться. И перед нами как раз такой случай.
Дело в том, что легко не заметить, даже когда будет пересекаться тот невидимый рубеж выделенного блока памяти. Следующий тест создаёт не такие уж короткие строки длинной 37 символов. Как думаете, такой тест приведёт к падению программы или ещё чему-то?
void Test3()
{
char *a = "123";
for (unsigned i = 1; i != 35; ++i)
{
char *b = (char *)malloc(i + 1);
memset(b, 'a', i);
b[i] = '\0';
char *q;
_lfortran_strcat(&a, &b, &q);
int ok = strlen(q) == 3 + i;
printf("%u %s %s\n", i, q, ok ? "ok" : "err");
free(b);
free(q);
}
}
Приведёт или нет — неизвестно, ведь тут неопределённое поведение. Но на практике я собираю его gcc с ключом -O2 и не наблюдаю какого-то проявления ошибки, хотя, по идее, блоки памяти уже испорчены. Но по тесту всё ещё кажется, что всё нормально:
1 123a ok
2 123aa ok
3 123aaa ok
4 123aaaa ok
5 123aaaaa ok
6 123aaaaaa ok
7 123aaaaaaa ok
8 123aaaaaaaa ok
9 123aaaaaaaaa ok
10 123aaaaaaaaaa ok
11 123aaaaaaaaaaa ok
12 123aaaaaaaaaaaa ok
13 123aaaaaaaaaaaaa ok
14 123aaaaaaaaaaaaaa ok
15 123aaaaaaaaaaaaaaa ok
16 123aaaaaaaaaaaaaaaa ok
17 123aaaaaaaaaaaaaaaaa ok
18 123aaaaaaaaaaaaaaaaaa ok
19 123aaaaaaaaaaaaaaaaaaa ok
20 123aaaaaaaaaaaaaaaaaaaa ok
21 123aaaaaaaaaaaaaaaaaaaaa ok
22 123aaaaaaaaaaaaaaaaaaaaaa ok
23 123aaaaaaaaaaaaaaaaaaaaaaa ok
24 123aaaaaaaaaaaaaaaaaaaaaaaa ok
25 123aaaaaaaaaaaaaaaaaaaaaaaaa ok
26 123aaaaaaaaaaaaaaaaaaaaaaaaaa ok
27 123aaaaaaaaaaaaaaaaaaaaaaaaaaa ok
28 123aaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
29 123aaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
30 123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
31 123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
32 123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
33 123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
34 123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ok
Падение случится при константе 38 в циклеfor (unsigned i = 1; i != 38; ++i):
free(): invalid pointer
Program terminated with signal: SIGSEGV
Не надо искать какой-то особый смысл в числе 38, просто так получилось. Интересен момент, как долго ошибка пряталась от юнит-тестов!
При этом такую ошибку можно моментально обнаружить с помощью статического или динамического анализа.
Рисунок 1 — Заблаговременное обнаружение ошибки с помощью динамического анализа (AddressSanitizer) или статического (PVS-Studio).
Статический анализатор PVS-Studio сразу предупреждает об аномалии в коде с помощью сообщения: V742 Function receives an address of a 'char' type variable instead of pointer to a buffer. Inspect the first argument.
Или достаточно воспользоваться динамическим анализом. AddressSanitizer (gcc ключ:fsanitize=address). Он сразу покажет проблему уже на первом самом простом тесте Test1.
Юнит-тесты и динамический анализ работают в паре. Санитайзер обнаруживает проблему на этапе запуска юнит-теста. Не будет теста — ошибка всплывёт гораздо позже.
Статический и динамический анализ хорошо дополняют юнит-тесты и другие методы выявления ошибок. При этом нет какого-то лучшего метода или инструмента. Статические и динамические анализаторы имеют свои слабые стороны и дополняют друг друга. В свою очередь, юнит-тесты могут выявить ошибки в логике, где, скорее всего, будут бессильны анализаторы.
Используйте всё. По началу это потребует вложений, но со временем окупит себя ранним выявлением большого процента багов на самых ранних этапах разработки. Чем раньше ошибка обнаружена, тем проще и дешевле её исправление (подход shift-left testing).
0