У нас, разработчиков статического анализатора кода PVS-Studio, специфическое представление о красоте. О красоте багов. Нам нравится находить изящество в ошибках, разбираться в них, пытаться угадать, как они появились. Сейчас как раз интересный случай, когда в коде спутались понятия длины и размера.
Ошибка из проекта LFortran
Услышав о выходе очередного CppCast на тему LFortran, мы решили проверить этот самый LFortran. Проект небольшой, поэтому не знаем, наберется ли материал для классической статьи о проверке открытого проекта. Однако на глаза сразу попала ошибка, которая заслуживает написания отдельной маленькой заметки. На наш вкус, ошибка очень милая.
В проекте 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 байт меньше, чем требуется. В результате терминальный ноль будет записан уже за пределами выделенного буфера.
Программист, писавший код, увлёкся использованием функции strlen и использовал её даже для того, чтобы определить размер терминального нуля. Произошла путаница между размером объекта (терминального нуля) и длиной пустой строки. Это странный и неверный код. Но на наш взгляд, это красивая необычная ошибка.
Пояснение:
char trmn = '\0';
int trmn_size = strlen(&trmn);
Здесь символ trmn интерпретируется как пустая строка. Её длина нулевая. Соответственно, переменная trmn_size, название которой означает размер терминального нуля, всегда будет равна 0.
Нужно было не считать длину пустой строки, а посчитать с помощью оператора sizeof, сколько байт занимает терминальный символ. Правильный код:
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 = sizeof(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]);
}
Обнаружение ошибки
Как уже было сказано, ошибка была найдена с помощью статического анализатора кода PVS-Studio. К сожалению, он не смог обнаружить ошибку, именно как выход за границу массива. Это сделать достаточно сложно. Анализ потока данных не смог сопоставить, как связан размер буфера dest_char и значение переменной cntr, увеличивающейся в циклах. Ошибка была обнаружена косвенным путём.
PVS-Studio выдал предупреждение: V742 [CWE-170, CERT-EXP37-C] Function receives an address of a 'char' type variable instead of pointer to a buffer. Inspect the first argument. lfortran_intrinsics.c 550
Очень странно считать длину строки с помощью strlen, передав ей указатель на одиночный символ. И действительно, в процессе изучения обнаруженная анализатором аномалия оказалась серьёзным багом. Статический анализ — это круто!
Продолжаем улучшать код
Выше уже было показано, как исправить ошибку в коде. Однако у кода есть и другие недостатки, на которые указывает анализатор. И было бы полезно провести дополнительный рефакторинг.
Во-первых, анализатору не нравится, что нет проверки указателя, который возвращает функция malloc. Это важно. Предупреждение: V522 [CWE-690, CERT-MEM52-CPP] There might be dereferencing of a potential null pointer 'dest_char'. Check lines: 553, 551. lfortran_intrinsics.c 553
Во-вторых, анализатор выдаёт несколько предупреждений на тему 64-битных ошибок. Код не готов к работе со строками, длина которых может быть больше INT_MAX символов. Понятно, что это экзотика, но всё равно некрасиво и потенциально опасно. Лучше использовать тип size_t вместо int.
Улучшенный вариант функции:
void _lfortran_strcat(const char** s1, const char** s2, char** dest)
{
if (s1 == NULL || *s1 == NULL ||
s2 == NULL || *s2 == NULL || dest == NULL)
{
// Какая-то обработка ошибки, уместная в данном проекте.
....
}
size_t cntr = 0;
const char trmn = '\0';
const size_t s1_len = strlen(*s1);
const size_t s2_len = strlen(*s2);
char* dest_char = (char*)malloc((s1_len+s2_len+1)*sizeof(char));
if (dest_char == NULL)
{
// Какая-то обработка ошибки, уместная в данном проекте.
....
}
for (size_t i = 0; i < s1_len; i++) {
dest_char[cntr] = (*s1)[i];
cntr++;
}
for (size_t i = 0; i < s2_len; i++) {
dest_char[cntr] = (*s2)[i];
cntr++;
}
dest_char[cntr] = trmn;
*dest = dest_char;
}
Новый код тоже не претендует на идеальность, но он стал явно лучше. Спасибо за внимание. И приходите попробовать PVS-Studio для проверки собственных проектов.
Дополнительные ссылки
- Начало коллекционирования ошибок в функциях копирования.
- Теперь PVS-Studio ещё лучше знает, что за зверь такой – strlen.
- Разработка 64-битных приложений.
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Karpov. A Beautiful Error in the Implementation of the String Concatenation Function.
Комментарии (26)
neolp
22.07.2021 10:57а копирование данных лучше делать memcpy(), часто это существенно эффективнее
qw1
22.07.2021 16:45Эффективнее только для длинных строк.
Зачастую в memcpy проверяется куча граничных случаев: насколько выровнен источник, насколько приёмник, середину копируем 16-байтным кусками через SSE2. При копировании типичной строки из 5 символов, эти проверки сожрут всю оптимизацию.
picul
22.07.2021 11:04Думаю в библиотечной функции добавленные "улучшающие" проверки неуместны, так как теперь они будут выполняться даже тогда, когда они не нужны (а чаще всего они действительно не нужны). Разве что проверку результата malloc'а можно оставить, хотя и она в обычной ситуации никогда не сработает.
kunix
22.07.2021 11:26+6Во-вторых, анализатор выдаёт несколько предупреждений на тему 64-битных ошибок.
Ну по-хорошему, надо еще детектить целочисленное переполнение, в результате которого, теоретически, будет выдан буфер меньшего размера со всеми вытекающими.
char* dest_char = (char*)malloc(s1_len+s2_len+trmn_size);
Когда-то в Android было много багов при вызовах вида new T[count], где count контролировался извне. Местечковый урезанный libc (bionic) честно вызывал malloc(sizeof(T)*count), умножение переполнялась, на выходе красивое переполнение кучи.
atd
22.07.2021 15:04+2Меня другое смущает: если мы делаем +sizeof('\0') вместо +1, то значит мы не уверены, что у нас sizeof(char) == 1, а если мы в этом не уверены, то маллок нам выделит недостаточно памяти даже в «исправленном» случае. Верно было бы писать что-то вроде malloc((len1+len2+1)*sizeof(char))
tyomitch
22.07.2021 16:57Стандарт гарантирует, что
sizeof(char)==1
на любой архитектуре, независимо от числа битов вchar
.
Andrey2008 Автор
22.07.2021 16:59Согласен. Поменял в статье.
tyomitch
22.07.2021 17:04+4И зря.
Пункт 6.5.3.4 стандарта Си:4. When
sizeof
is applied to an operand that has typechar
,unsigned char
, orsigned char
, (or a qualified version thereof) the result is 1.Andrey2008 Автор
22.07.2021 17:14Почему зря? Мне кажется, такой вариант смотрится лучше:
(len1+len2+1)*sizeof(char)
. А до этого смешивалось понятие длины строк и размера терминально символа.tyomitch
22.07.2021 17:41+1Лучше чем
len1+len2+sizeof(char)
- согласен.Чем
len1+len2+1
- разве лучше?Andrey2008 Автор
22.07.2021 17:48+1Чисто теоретически, легче будет переделать на wchar_t.
thesanone
24.07.2021 12:15Писать код на будущее, очень частая ошибка, а если потом понадобиться функция конкатенации wchar_t, то лучше новую функцию для этого написать
gameplayer55055
22.07.2021 17:47А зачем считать размер нуль терминатора?
Типо если подсунут жирный char?
Andrey2008 Автор
22.07.2021 19:27Да. Но как уже отметили, тогда уж надо ещё и умножение длины на sizeof для красоты писать.
GCU
24.07.2021 01:13+2Очень коварная ошибка.
Учитывая что malloc выровнен блоками по 8 байт, вероятность что вылезут из блока лишь 1/8. Но поскольку память распределяется страницами по 4КБ, ошибка доступа будет лишь при выходе из последнего блока страницы, вероятность что блок последний 1/512.
Итого у пользователя в среднем вылетит одна ошибка на 4096 вызовов и то лишь при условии что следующая страница всегда закрыта. Но каждые 8 вызовов может сломаться что-то другое, не столь заметное.
qrdl
Автор кода хотел поумничать, и нагородил лажу вместо того, чтобы идиоматично написать +1 в аргументе malloc'а. Что тут красивого?
foto_shooter
Притаившаяся ошибка. :)
motoroller95
много ли ума надо чтобы считать длинну однобайтовой строки?)
Jogger
Да ладно если бы это была строка! Но объевлен-то char! Внимание вопрос - сколько же char-ов влезет в один char? Не отвечайте сразу дети, подумайте!!!
Я уж молчу что зачем-то копируют символы в цикле вместо пары memcpy, мы же здесь про ошибки, а не про оптимизацию... но блин.
mSnus
Ну, вообще-то, всё правильно, если терминатор будет из двух char-ов, этот код (с sizeof) сработает верно
А заранее предусмотреть возможность не исследовать и переписывать код, просто поменяв константу -- очень правильное решение.
tyomitch
Что вы имеете в виду?
const char trmn = '\1\2';
— это ошибка компиляции.amarao
А что такое char? Вот в Rust - я знаю. А в Си - я, наверное, не буду умничать. Потому что мы не знаем какой именно char, и 8 там бит, или 6, или там, кто-то прихерачил половинку юникода в 16 битах...
So подсказывает:
picul
A byte is the smallest addressable unit, так что по идее не важно, 8 в нем бит или 42 - sizeof будет 1.
amarao
В этом месте особых проблем нет. А вот каким образом char связан с буквой... Ой, давайте не будем.
Ritan
Какая разница как char связан с буквой. sizeof(char) == 1 по определению, а соответсвенно malloc(sizeof(char)) == malloc(1). Спорить тут можно разве что с тем, что
new char[1] != malloc(1)