Размышления над разыменованием нулевого указателя

    Оказывается, что вопрос, корректен или нет вот такой код &((T*)(0)->x), весьма непрост. Решил написать про это маленькую заметку.

    В недавней статье о проверке ядра Linux с помощью анализатора PVS-Studio, я написал, что нашел вот такой фрагмент кода:
    static int podhd_try_init(struct usb_interface *interface,
            struct usb_line6_podhd *podhd)
    {
      int err;
      struct usb_line6 *line6 = &podhd->line6;
    
      if ((interface == NULL) || (podhd == NULL))
        return -ENODEV;
      ....
    }

    Также в статье я написал, что такой код, на мой взгляд, некорректен. Подробности можно посмотреть в статье.

    После этого мне посыпались в почту письма о том, что я неправ, и этот код совершенно корректен. Многие указали, что если podhd == 0, то этот код по сути реализует идиому «offsetof», и ничего плохого произойти не может. Чтобы не писать множество ответов я решил оформить ответ в виде маленького поста в блоге.

    Естественно я решил изучить данную тему подробнее. Но, если честно, в результате я только ещё больше запутался. Поэтому я не дам вам точный ответ, можно так писать или нет. Я только предоставлю некоторые ссылки и поделюсь своим мнением.

    Когда я писал статью о проверке Linux, я размышлял так.

    Любое разыменование нулевого указателя — это неопределённое поведение. Одним из проявлений неопределённого поведения может стать такая оптимизация кода, когда проверка (podhd == NULL) исчезнет. Именно такой вариант развития событий я и описал в статье.

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

    Некоторые также написали, что именно так устроен макрос ffsetof():
    #define offsetof(st, m) ((size_t)(&((st *)0)->m))

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

    Более того, здесь компилятор явно видит 0 и может угадать, что от него хочет программист. Когда 0 хранится в переменной это совсем другое дело, и компилятор может повести себя неожиданным образом.

    Вот, что про offsetof сказано в Wikipedia:

    The «traditional» implementation of the macro relied on the compiler being not especially picky about pointers; it obtained the offset of a member by specifying a hypothetical structure that begins at address zero:

    #define offsetof(st, m) ((size_t)(&((st *)0)->m))

    This works by casting a null pointer into a pointer to structure st, and then obtaining the address of member m within said structure. While this works correctly in many compilers, it has undefined behavior according to the C standard, since it involves a dereference of a null pointer (although, one might argue that no dereferencing takes place, because the whole expression is calculated at compile time). It also tends to produce confusing compiler diagnostics if one of the arguments is misspelled. Some modern compilers (such as GCC) define the macro using a special form instead, e.g.

    #define offsetof(st, m) __builtin_offsetof(st, m)

    Как видите, согласно Wikipedia я прав. Так писать нельзя. Это undefined behavior. Так же считают некоторые на сайте StackOverflow: Address of members of a struct via NULL pointer.

    Однако, меня смущает то, что, хотя все говорят про неопределённое поведение, нигде нет точного разъяснения на этот счёт. Например, в Wikipedia стоит пометка, что утверждение требует подтверждения [citation needed].

    На форумах много раз обсуждались похожие вопросы, но нигде я не увидел однозначного объяснения, подтверждённого ссылками на стандарт Си или Си++.

    Есть ещё вот такое старое обсуждение стандарта, которое тоже не добавило ясности: 232. Is indirection through a null pointer undefined behavior?

    Итак, на данный момент окончательно данный вопрос мне не ясен. Однако, я по-прежнему считаю, что этот код плох и его следует отрефакторить.

    Если кто-то пришлёт мне хорошие примечания на эту тему, то я добавлю их в конец этой статьи.

    UPDATE: Продолжение: habrahabr.ru/company/pvs-studio/blog/250701
    PVS-Studio
    Static Code Analysis for C, C++, C# and Java

    Comments 22

      +1
      Ожидаемая правильная работа программы — это просто один из вариантов неопределённого поведения

      А какие бывают варианты неопределенного поведения?
        +2
        Ой, ну разные. Например, запустится игра:
        Ещё один пример неопределенного поведения: курьёз с ANSI-директивой «#pragma». Согласно спецификации языка компиляторам предоставлена полная свобода при обработке этой конструкции. До версии 1.17 компилятор GCC при нахождении в исходном коде этой директивы пытался запустить Emacs с игрой «Ханойские башни».
          0
          То есть, неопределенное поведение — это набор вариантов, один из которых — попытка запустить Emacs?

          Насколько мне известно, это называется unspecified behaviour.

          А неопределенное поведение потому и названо так, что оно не определено. И никаких вариантов нет.
            0
            Определение undefined behavior из Стандарта C++03 (1.3.12)
            behavior, such as might arise upon use of an erroneous program construct or erroneous data, for which this International Standard imposes no requirements.

            [...], на которое этот Стандарт не налагает никаких требований — допускается любое поведение, в том числе и запуск Emacs. В принципе, никто не мешает разработчикам компилятора специально сделать неопределенное поведение разнообразным — например, по пятницам программа будет запускать Emacs, а по остальным — выполнять действие, приводящее к аварийному завершению программы, но пользователи не очень это оценят.
              0
              Да, это я и имел ввиду. При этом ни о каких вариантах речи нет. Спасибо за ссылку на пункт стандарта.
                0
                В Стандарте действительно не сказано ничего о вариантах неопределенного поведения. В статье, очевидно, речь идет о возможных вариантах работы конкретного результата компиляции программы конкретной версией конкретного компилятора с конкретными настройками. Одни компиляторы вообще неспособны к описанной оптимизации, у других она включается в зависимости от настроек (в достаточно новом gcc она включается автоматически, начиная с уровня -O2). В зависимости от этих факторов и возможны разные варианты работы программы.
        +1
        Однако, меня смущает то, что, хотя все говорят про неопределённое поведение, нигде нет точного разъяснения на этот счёт.


        В стандарте C++03 есть разъяснение. Пункты 1.9/4 и 5.2.5/3.

          –2
          Это я видел. Было 1.9/4: Certain other operations are described in this International Standard as undefined (for example, the effect of dereferencing the null pointer).
          А что с новым то стандартом? Почему они застеснялись прежнего описания и теперь там иной пример 1.9/4: Certain other operations are described in this International Standard as undefined (for example, the effect of attempting to modify a const object).
            +4
            А как изменение примера могло повлиять на поведение? Пример могли вообще удалить, его присутствие необязательно.

            Какого-то нового описания разыменования нулевого указателя не появилось, поэтому по данному вопросу все так же, как и было. Пункт 1.9/4 устанавливает неопределенное поведение.
              0
              Но мне так и не понятно, что ответить. :) Мне продолжат писать письма, что в статье ошибка и я/анализатор не прав. :)
              Прошу помощи. Кто сможет написать бронебойный текст вида: данный код корректен/не корректен, так как согласно современному стандарту C++11 бла… бла… см. пункты такой и такой. Буду благодарен.
            0
            Это не те пункты. В Стандарте C++03 нужное определение дано в 1.3.12
            undefined behavior [defns.undefined]
            behavior, such as might arise upon use of an erroneous program construct or erroneous data, for which this International Standard imposes no requirements.

            imposes no requirements означает «не налагает никаких требований» — допускается любое поведение.
            +3
            Я думаю, стоит задать вопрос в gcc-help. Там живые разработчики компилятора могут ответить, что они думают на этот счёт.
              +2
              Прислали ещё следующее мнение:
              Я прочитал вашу статью «Размышления над разыменованием нулевого указателя» и предыдущую «PVS-Studio покопался во внутренностях Linux (3.18.1)».

              Обнаружил два заблуждения:

              · При оптимизации компилятор рассуждает так. Вот здесь указатель разыменовывается: podhd->line6. Ага, программист знает что делает. Значит указатель здесь точно не равен нулю. Отлично, запомним это.

              · Любое разыменование нулевого указателя — это неопределённое поведение.


              Собака здесь зарыта в том, что разыменование указателя a->b это две последовательных операции:

              · Вычисление значения lvalue;

              · Использование объекта lvalue.

              В случае с NULL ничто не мешает вычислить lvalue, и согласно стандарту его вычислят здесь нет неопределенного поведения. А вот в момент использования объекта lvalue компилятор уже с полным основанием может полагать, что в функцию передан ненулевой указатель, либо порадовать нас неопределенным поведением в противном случае. Если lvalue используется в выражении указателей, то нет основания полагать, что исходный указатель был NULL.

              Похоже, что вы просто столкнулись с багом программы PVS-Studio, когда она некорректно выдает предупреждение V595 в случае вычисления lvalue без реального использования объекта.

                0
                Хотел уже гуглить, чтобы это написать. Тоже читал, что только использование результата разыменования нулевого указателя влечёт UB. Не помню, приводились ли в том пояснении ссылки на стандарт.
                0
                в статье же есть комментарий

                Прежде всего вспомним, что сишные компиляторы используются в мире эмбеддеров, там struct и union — весьма частые гости. Конструкция offsetof в указанном виде нормально работает типами struct и union. Если коротко — макрос offsetof успешно выдает смещение элементов структур вида offsetof(POINT,str_my_point.x). Вот пример разжевывающей статьи и ее перевод на русский.

                Если эта реализация макроса offsetof вас смущает — покажите как без нее обойтись в случаях со struct'ами и union'ами. Просто чтоб вычисляло смещение и компилировалось без ошибок — как?
                  0
                  Спасибо за ссылку на перевод, полезная информация.
                  После прочтения вспомнил что несколько лет назад набрел как-то на статью где описывалась очень странная по их мнению поведение gcc и способы обхода этой баги. То есть проблема там была не надуманная а реальная, взятая с какой-то либы. За точность не ручаюсь, но с учетом того что прочитал теперь понял для чего там использовался этот offset, точнее использовался для объяснения проблемы. Итак.

                  Была у них структура, для упрощения приведу ключевые элементы которые влияли на результат, да и не вспомню какие там точно поля другие были:

                  typedef struct A_
                  {
                  bool t;
                  long long s;
                  } A;

                  Тут имеем структура sizeof(A) == 12. В одной из функции в библиотеке структура инициализировалась последовательно присваивая ее поля. Потом эта другому экземпляру этой структуры присваивалась та которая была уже заполненная. После чего по какой-то логике, зачем и почему не помню и не хочу :), для сравнения структур, точнее идентичности полей, использовалась «memcmp()».
                  Внимательные сишные эмбедерские комрады сразу заподозрили неладное с этой структурой, и не зря. Для тех кто считает битики и байтики экономя на всем, видят тут что в структуре будет использовано выравнивание из-за «bool».
                  Написал примерчик, как раз было очень удобно использовать этот «offsetof()»:

                  GCC 32 — ideone.com/qc3G4B
                  A struct size: 12 — Paddind A struct: 3 — memcmp res: 1
                  B struct size: 12 — Paddind B struct: 0 — memcmp res: 0

                  MSVC 2013 x64:
                  A struct size: 16 — Paddind A struct: 7 — memcmp res: -1
                  B struct size: 16 — Paddind B struct: 4 — memcmp res: 0

                  Но у меня еще остался вопрос если выравнивание идет по 4 байт, в случае как с MSVC, memcmp все равно выдает 0, то есть едентичность, тут я пока не разобрался, специфика реализации? Хотя GCC в этом случае так же вернет 0.

                  Ну и разрабам PVS. Можете записать в свой «todo list». Баг очень специфический, но это баг из реального кода библиотеки. Навряд такое в ядре найдешь, но кто знает.

                  А ну и как разрабы те решили проблему? написали макрос по типу "#define our_super__struct_memcmp(a, b)", где сравнивали поля структуры по отдельности.
                    0
                    А вот в случае когда структура копируется в другую посредством простого присваивания, а не инициализации полями, memcmp() возвращает ожидаемый 0.

                    ideone.com/64eCVm

                    GCC:
                    A struct size: 12 — Paddind A struct: 3 — memcmp res: 0
                    B struct size: 12 — Paddind B struct: 0 — memcmp res: 0

                    MSVC 2013 x64:
                    A struct size: 16 — Paddind A struct: 7 — memcmp res: 0
                    B struct size: 16 — Paddind B struct: 4 — memcmp res: 0

                    Вот так.
                      0
                      Ну и про сам «offset». MSVC версия прям вообще со всеми предосторожностями если используется С++…

                      #ifdef __cplusplus

                      #ifdef _WIN64
                      #define offsetof(s,m) (size_t)( (ptrdiff_t)&reinterpret_cast((((s *)0)->m)) )
                      #else /* _WIN64 */
                      #define offsetof(s,m) (size_t)&reinterpret_cast((((s *)0)->m))
                      #endif /* _WIN64 */

                      #else /* __cplusplus */

                      #ifdef _WIN64
                      #define offsetof(s,m) (size_t)( (ptrdiff_t)&(((s *)0)->m) )
                      #else /* _WIN64 */
                      #define offsetof(s,m) (size_t)&(((s *)0)->m)
                      #endif /* _WIN64 */

                      #endif /* __cplusplus */
                      –1
                      На всякий случай добавлю замечание.

                      Тут проблему можно еще решить обнулением участка памяти созданного объекта структуры, в простонародье:
                      memset(&r1, 0, sizeof(A));
                      memset(&r2, 0, sizeof(A));

                      ideone.com/OaipoS
                        0
                        Что за жесть? Почему бы просто не проинициализировать при создании:

                        structTypeName r1 = { 0 };
                        structTypeName r2 = { 0 };
                          0
                          memset() гарантировано зануляет память структуры включая неиспользуемое место из-за выравнивания(padding). А вот скобочки до принятия стандарта С11(6.7.9-10) НЕ гарантировало обнуление padding и тогда memcmp() могло работать не как ожидалось, а в случае если структуру отправлять по сети или запиcывать в файл — возможно будет включен мусор.

                          Вот поэтому во всех, почти во всех, сишных библиотеках, ядрах..., структуры заполняют нулями с помощью memset().
                    0

                    Only users with full accounts can post comments. Log in, please.