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

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 – первые тесты показывают, что не удаляет, но гарантий нет. В теории он тоже сможет провести такую оптимизацию.

Комментарии ()


  1. maaGames
    17.10.2015 17:44
    -14

    Ин Совет Раша программисты не только говнокодят, но и пишут об этом статьи на хабре.

    1, 2, 4 и 5 случай как-то ещё можно понять и простить, но случай три…


    1. fshp
      17.10.2015 18:12

      С таким же успехом можно было просто рандомное число в указатель записать.


      1. xaizek
        17.10.2015 18:46
        +2

        Это разные вещи. Тут ptr3 является указателем, про который стандарт говорит, что он «points one past the last element of the array object» (или «past the end of the array» в других местах). Такие указатели нельзя разименовывать, но они могут быть использованы для вычисления размеров (end - begin) либо как граница массива (p != end). Исходя из этого имеет смысл рассматривать их как отдельный случай в контексте данной статьи, а не просто как «рандомное число в указателе».


        1. fshp
          17.10.2015 22:56

          Т.е. malloc не может выделить байт, следующий за уже выделенным куском памяти?


          1. xaizek
            17.10.2015 23:48

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


    1. mapron
      17.10.2015 18:34
      +6

      Какой раша? перевод же!


      1. maaGames
        17.10.2015 18:44
        -20

        Флажок перевода такой неприметный… Прошу прощения, исправляюсь:

        Ин Совет Раша не только переводят пиндостанские статьи о говнокоде, но и пишут об этом статьи на хабре.


      1. pravic
        18.10.2015 11:11
        +1

        Перевод: tedu

        В оформлении косяки: выглядит, будто переводчик tedu, а автор SLY_G.


        1. mapron
          18.10.2015 13:01

          Да вообще не говорите. Раньше уж насколько плохо было с дизайном перевода, все путались, но авторы хабра решили все исправить. Я теперь всегда стараюсь плашки все читать, на них одна надежда. Найти ссылку на источник до сих пор с первого раза не могу, зная что она даже должна быть.


  1. dmitrmax
    17.10.2015 22:24

    Сразу прошу простить, если я нарушу чувство гармонии тех, кто знает стандарт наизусть, но вот вывод gcc 4.9 на 5-й случай:
    main.c:2:6: warning: array ‘a’ assumed to have one element
    char a[];

    Значит ли это, что gcc отступает от стандарта в данном месте?

    P.S.: При этом sizeof(a) не компилится с ошибкой, что тип недоопределен.


    1. xaizek
      17.10.2015 22:48
      +1

      Это связано с типом видимости и недоопределённостью символа, там даже пример такой приводится (C99, 6.9.2):

      EXAMPLE 2 If at the end of the translation unit containing

      int i[];

      the array i still has incomplete type, the implicit initializer causes it to have one element, which is set to zero on program startup.
      Так что GCC скорее сообщает о том, что реально происходит, так как это может быть следствием ошибки программиста (если добавить static или extern, то поведение более очевидное и предупреждение пропадает). Вообще, добавляйте флаг -pedantic, если хотите отключить расширения GNU-C и получить больше предупреждений об отклонении от буквы стандарта.


  1. Dark_Purple
    18.10.2015 02:10
    +1

    Открыл для себя слово надоедят, богат и могуч…