[Перевод] Объекты нулевого размера

В чём разница между следующими парами длин и указателей?

size_t len1 = 0;
char *ptr1 = NULL;

size_t len2 = 0;
char *ptr2 = malloc(0);

size_t len3 = 0;
char *ptr3 = (char *)malloc(4096) + 4096;

size_t len4 = 0;
char ptr4[0];

size_t len5 = 0;
char ptr5[];

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

Первый случай интересный, но слишком сильно отличается от других, поэтому его пока отложим.

malloc(0)


Поведение malloc(0) определено стандартами. Можно вернуть нулевой или уникальный указатель. Второй вариант во многих реализациях выполняется внутренним увеличением длины на единицу (которая затем обычно округляется до 16). По правилам, разыменовать такой указатель нельзя, но обычно несколько байт всё-таки размещаются, и поэтому такая программа не упадёт.

Возврат NULL приводит к возможности возникновения интересного бага. Часто возврат NULL из malloc расценивается как ошибка.

if ((ptr = malloc(len)) == NULL)
        err(1, "out of memory");

Если len равна нулю, это приведёт к неправомерному сообщению об ошибке – если не добавить дополнительную проверку && len != 0. Также можно вступить в секту адептов «непроверки malloc».

В OpenBSD malloc обрабатывает ноль по-другому. Размещение данных нулевого размера возвращает куски страниц, которые были защищены через mprotect() с ключом PROT_NONE. Попытка разыменовать такой указатель приведёт к падению.

Отметим, что требования к уникальным указателям запрещают «мухлевать» при их использовании.

int thezero;

void *
malloc(size_t len)
{
        if (len == 0) return &thezero;
}
void
free(void *ptr)
{
        if (ptr == &thezero) return;
}

Такая реализация не соответствует правилам, поскольку последовательные вызовы будут возвращать одно и то же значение. Поэтому второй случай похож как на первый, так и на третий – в зависимости от реализации.

Другие случаи


Если malloc не выдаст ошибку, то варианты 3, 4 и 5 в большинстве случаев работают идентично. Основное отличие будет в использовании sizeof(ptr) / sizeof(ptr[0]), например в цикле. Это приведёт к неверному ответу, правильному ответу или вообще ни к чему не приведёт, обломавшись на этапе компиляции. 4-й вариант не разрешён стандартом, но компиляторы, скорее всего, его проглотят.

Наибольше отличие этих вариантов от первого состоит в том, что они проходят проверку на null. Это будет похоже на разницу между пустым массивом и отсутствующим массивом. И точно так же пустая строка не равна null-строке, хотя занимает один байт в памяти.

null-объекты


Вернёмся к первому варианту и нулевым объектам. Рассмотрим следующий вызов:

memset(ptr, 0, 0);

0 байт ptr присваиваем 0. Какие из пяти перечисленных указателей позволят сделать такой вызов? 3, 4 и 5. 2-й – если это уникальный указатель. Но что, если ptr это NULL?

В стандарте С в разделе «Использование библиотечных функций» сказано:

Если у аргумента функции недопустимое значение (значение находится вне домена функции, указатель указывает на память вне области доступной программе, или же указатель является нулевым), то поведение не будет определено.


В разделе «Соглашения по функциям работы со строками» уточняется:

Если аргумент, объявленный как size_t n, определяет длину массива в функции, значение n может быть равным нулю при вызове этой функции. Если только в описании конкретной функции не указано обратное, значения аргументов-указателей должны быть допустимыми.


Судя по всему, результат memset'а 0 байт на NULL будет неопределённым. В документации по memset, memcpy и memmove не указано, что они могут принимать нулевые указатели. В качестве контрпримера можно привести описание snprintf, в котором сказано: «Если n равен нулю, ничего не записывается, и s может быть нулевым указателем». Документация к функции read из POSIX похожим образом описывает, что чтение нулевой длины не считается ошибкой, но реализация может проверить другие параметры на ошибку – например, на недопустимые буферные указатели.

А что на практике? Самый простой способ обработки нулевой длины в функциях типа memset или memcpy – не входить в цикл и ничего не делать. Обычно в С неопределённое поведение вызывает какую-либо реакцию, но в данном случае уже определено, что с нормальными указателями ничего не происходит. Проверка на ненормальность указателей была бы лишней работой.

Проверка на ненулевые, но недопустимые указатели, довольно сложна. memcpy этим вообще не занимается, позволяя программе просто упасть. Вызов read тоже ничего не проверяет. Он делегирует проверку функции copyout, которая заводит хэндлер для обнаружения ошибок. И хотя можно добавить проверку на null, такие указатели не более недопустимы для этих функций, нежели 0x1 или 0xffffffff, для которых нет никакой особой обработки.

Облом


На практике это означает наличие большого количества кода, подразумевающего (специально или случайно), что нулевые указатели и нулевая длина допустимы. Я решил провести эксперимент, добавив в memcpy вывод ошибок и выход, в случае, если указатель оказывается NULL, и установил новую libc.

Feb 11 01:52:47 carbolite xsetroot: memcpy with NULL
Feb 11 01:53:18 carbolite last message repeated 15 times

Нда, это не отняло много времени. Интересно, что он там делает:

Feb 11 01:53:18 carbolite gdb: memcpy with NULL
Feb 11 01:53:19 carbolite gdb: memcpy with NULL

Ясно, понятно. Эти сообщения, похоже, очень быстро надоедят. Возвращаем всё, как было.

Последствия


Я занялся эти вопросом, поскольку на пересечении областей «не определено, но должно работать» и оптимизации компиляторов С не происходит ничего хорошего. Умный компилятор может увидеть вызов memcpy, отметить оба указателя, как допустимые, и убрать проверки на null.

int backup;
void
copyint(int *ptr)
{
        size_t len = sizeof(int);
        if (!ptr)
                len = 0;
        memcpy(&backup, ptr, len);
}

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

Этот вопрос волнует меня, потому что в прошлом уже сталкивался со случаями, когда подобная оптимизация разыменований и проверок приводила к пробелам в безопасности. Для софта, не готового к такому высокому уровню соответствия стандартам, это достаточно грустные новости.

Поначалу мне не удавалось убедить компилятор удалять проверку на ноль после «разыменования» memcpy, но это не значит, что этого не может случиться. gcc 4.9 говорит, что эта проверка будет удалена оптимизацией. В OpenBSD пакет gcc 4.9 (содержащий множество патчей) не удаляет по умолчанию проверку, даже при –O3, но если разрешить "-fdelete-null-pointer-checks", это приводит к удалению проверок. Не знаю, что насчёт clang – первые тесты показывают, что не удаляет, но гарантий нет. В теории он тоже сможет провести такую оптимизацию.

© Habrahabr.ru