Никто не спорит о пользе статического или динамического анализа кода. Но некоторые разработчики воспринимают эту пользу как абстрактную и останавливаются на уровне написания юнит-тестов. Сейчас вспомнился один пример из мира 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. В результате терминальный ноль будет записан уже за пределами выделенного буфера.Ошибка простая и понятная. Выход за границу буфера в программе на C это вообще типовая проблема. Что тут ещё обсуждать? Ошибка найдена, дело закрыто.
Меня заставил задуматься комментарий читателя о коварности этой ошибки из-за гранулярности выделяемой памяти.
Функция
malloc на самом деле выделяет не столько памяти, сколько у неё просят. Она запрашивает у операционной системы большие блоки и нарезает их на куски, добавляя служебную информацию. Точный размер блока зависит от конкретной реализации.Даже если вы запросите
malloc(1), аллокатор все равно вернёт указатель на блок размером, скажем, 32 байта (минус служебные поля — вам достанется 24 полезных байта). Это делается для обеспечения выравнивания памяти (обычно 16-байтного) и упрощения менеджера памяти.Возвращаемый указатель должен быть выровнен. Поскольку функция
malloc ничего не знает о том, какие типы будут храниться в выделенной памяти, она ориентируется на самое большое выравнивание, которое может потребоваться. На практике (x86-64) malloc выдаёт адреса, кратные 16. Поэтому размер блока всегда округляется вверх до следующей границы выравнивания.Я описал всё очень поверхносно и приблизительно. Важно то, что на практике в рассмотренном коде обычно будет выделяться памяти больше, чем требуется! Если блоки кратны 16 байтам, то запись терминального нуля в соседний блок случится, только если результирующая строка также кратна 16 байтам. Другими словами, вероятность, что ошибка проявит себя, равна 1 к 16 (или не 16 — это число взято как одно из возможных).
Тут следует сразу сделать оговорку про неопределённое поведение. Формально код в любом случае некорректен, так как содержит выход за границу массива. Нельзя рассуждать, как он будет работать, как ошибка проявит себя и т.д.
Однако неопределённое поведение — это в том числе ситуация, когда неправильный код работает так, как ожидалось. Рассматриваемая ситуация, когда из-за особенностей работы менеджера памяти этой памяти выделяется больше, как раз и может привести к видимости, что всё работает хорошо.
