Ошибка разыменование пустого указателя си

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

Время на прочтение
6 мин

Количество просмотров 52K

Silent NULL (Разыменовывание нулевого указателя приводит к неопределённому поведению)
Ненароком я породил большую дискуссию, касающуюся того, допустимо ли использовать в Си/Си++ выражение &P->m_foo, если P является нулевым указателем. Программисты разделились на два лагеря. Одни уверенно доказывали, что так писать нельзя, другие столь же уверенно утверждали, что можно. Приводились различные аргументы и ссылки. И я понял, что нужно внести окончательную ясность в этот вопрос. Для этого я обратился к экспертам Microsoft MVP и разработчикам Visual C++, общающимся через закрытый список рассылки. Они помогли подготовить эту статью, и я представляю её всем желающим. Для нетерпеливых: этот код не корректен.

Напомню историю обсуждений

Все началось со статьи о проверке ядра Linux с помощью анализатора PVS-Studio. Но сама проверка ядра тут ни причём. Дело в том, что в статье я привёл следующий фрагмент из кода Linux:

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;
  ....
}

Я назвал этот код опасным, так как посчитал, что здесь имеет место неопределённое поведение.

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

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

Здесь имеет место разыменование нулевого указателя, но код успешно работает. Были и другие письма с рассуждениями того, что раз нет доступа по нулевому указателю, то нет и проблемы.

Хотя я и доверчивый, но стараюсь проверять информацию. Я начал разбираться с этой темой и в результате написал небольшую статью: «Размышления над разыменованием нулевого указателя».

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

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

О языке Си

Выражение ‘&podhd->line6’ является неопределенным поведением в языке C в том случае, если ‘podhd’ — нулевой указатель.

Вот что говорится про оператор взятия адреса ‘&’ в стандарте C99 (Раздел 6.5.3.2 «Операторы взятия адреса и разыменовывания»):

Операнд унарного оператора & должен быть либо указателем функции, либо результатом оператора [] или унарного оператора *, либо lvalue-выражением, указывающим на объект, который не является битовым полем и не содержит в объявлении спецификатора регистрового класса памяти.

Выражение ‘podhd->line6’ однозначно не является указателем функции, результатом оператора [] или *. Это как раз lvalue-выражение. Однако, когда указатель ‘podhd’ равен нулю, выражение не указывает на объект, поскольку в Разделе 6.3.2.3 «Указатели» сказано следующее:

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

Если «lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение» (Стандарт C99, Раздел 6.3.2.1 «Lvalue-выражения, массивы и указатели функций»):

lvalue — это выражение объектного типа или неполного типа, отличного от void; если lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение.

Ещё раз кратко:

Когда оператор -> был применен к указателю, его результатом стало lvalue-выражение, для которого не существует объекта, и в результате мы имеем дело с неопределенным поведением.

О языке Си++

В языке С++ всё обстоит точно также. Выражение ‘&podhd->line6’ является неопределенным поведением в языке C++ в том случае, если ‘podhd’ — нулевой указатель.

С толку немного сбивает дискуссия на WG21 (232. Is indirection through a null pointer undefined behavior?), на которую я ссылался в предыдущей статье. Там настаивают, будто бы такое выражение не является неопределенным поведением. Однако никто так и не нашел никаких правил в стандартах C++, которые разрешали бы использовать «poldh->line6», когда «polhd» — нулевой указатель.

Указатель «polhd» нарушает основное ограничение (Раздел 5.2.5/4, второй пункт в списке) о том, что он должен указывать на объект. Ни один объект в C++ не может иметь адреса nullptr.

Итого

struct usb_line6 *line6 = &podhd->line6;

Этот код является некорректным в языке Си и Си++, если указатель podhd равен 0. Если указатель равен 0, то возникает неопределённое поведение.

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

Так писать нельзя. Указатель должен быть проверен до разыменования.

Разное в дополнение

  • При рассмотрении идиоматической реализации offsetof() следует учитывать, что компилятору разрешено использовать непереносимые приемы для реализации этой функциональности. Тот факт, что в реализации библиотеки в компиляторе используется константа нулевого указателя при реализации offsetof(), вовсе не означает, что в пользовательском коде можно без опаски применять ‘&podhd->line6′ в случае, когда’podhd’ является нулевым указателем.
  • GCC может (и делает это) проводить оптимизацию, основываясь на предположении, что никакого неопределенного поведения возникнуть не может, и убрать в данном случае проверки указателей на ноль — поэтому ядро компилируется с набором ключей, указывающих компилятору не делать этого. Например, эксперты в качестве примера ссылаются на статью «What Every C Programmer Should Know About Undefined Behavior #2/3».
  • Возможно, вам также будет интересно узнать, что подобным образом нулевой указатель был задействован в эксплойте ядра с помощью TUN/TAP-драйва. Подробности можно посмотреть по ссылке «Fun with NULL pointers». Некоторые могут решить, будто эти два примера имеют мало общего, поскольку во втором случае есть существенное отличие: в баге TUN/TAP-драйвера вместо простого взятия адреса поля структуры, к которому обращался нулевой указатель, это поле было явно взято в качестве значения для инициализации переменной. Однако с точки зрения стандарта C взятие адреса поля с помощью нулевого указателя также является неопределенным поведением.
  • А есть ли какая-та ситуация, когда при P == nullptr мы напишем &P->m_foo и всё будет хорошо? Да, например это может быть аргументом оператора sizeof: sizeof(&P->m_foo).

Благодарности

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

  • Майкл Бёрр — горячий поклонник языка C/C++ и специалист по системному и встроенному ПО, в том числе службам Windows, работе с сетями и драйверам устройств. Активно участвует в жизни сообщества StackOverflow, отвечая на вопросы программистов по C и C++ (а иногда и на некоторые простые вопросы по C#). Имеет 6 наград Microsoft MVP в номинации Visual C++.
  • Билли О’Нил — разработчик ПО на C++ (преимущественно) и активный участник сообщества StackOverflow. Является инженером-разработчиком ПО в подразделении по совершенствованию систем безопасности Microsoft (Trustworthy Computing Team). До этого работал в нескольких компаниях, занимающихся безопасностью ПО, в числе которых — Malware Bytes и PreEmptive Solutions.
  • Джованни Диканио — программист, специализирующийся на разработке ОС Windows. Автор статей для программистов по C++, OpenGL и другим темам в ряде итальянских компьютерных журналов. Также писал код для некоторых открытых проектов. Джованни помогает коллегам, давая советы по решению программистских проблем, связанных с C и C++, на форумах Microsoft MSDN, а с некоторых пор — и на StackOverflow. Имеет 8 наград Microsoft MVP в номинации Visual C++.
  • Габриэль Дус Рейс — главный инженер-разработчик ПО Microsoft. Также является исследователем и долгосрочным участником C++-сообщества. Одно из направлений его научных интересов и исследований — средства разработки надежного ПО. До того, как прийти в Microsoft, работал старшим преподавателем в Техасском Университете A&M (Texas A&M University). В 2012 году Доктор Дус Рейс был отмечен премией Национального Научного Фонда (National Science Foundation CAREER Award) за проведенное им исследование компиляторов надежного ПО в области вычислительной математики и за образовательную деятельность. Является членом комитета по стандартизации языка C++.

Дополнительные ссылки

  1. Wikipedia. Неопределённое поведение.
  2. A Guide to Undefined Behavior in C and C++. Part 1, 2, 3.
  3. Wikipedia. offsetof.
  4. LLVM Blog. What Every C Programmer Should Know About Undefined Behavior #2/3.
  5. LWN. Fun with NULL pointers. Part 1, 2.

0 / 0 / 0

Регистрация: 07.09.2019

Сообщений: 23

1

Предупреждение «Разыменование пустого указателя»

07.02.2020, 14:38. Показов 4224. Ответов 3


Студворк — интернет-сервис помощи студентам

Доброго времени суток! Я в процессе написания функции столкнулся с таким предупреждением:
«C6011 Разыменование пустого указателя mostfreqch». На самом деле таких предупреждений 5 штук, так что я просто размещу метки в коде, где именно я получил эти предупреждения.
Функция должна находить самые часто встречаемые символы в строке и возвращать эти символы также в виде одной строки.
И функция работает, но эти предупреждения меня как-то напрягают. Если что, я пишу в Visual Studio 2019.
Если не трудно, объясните, что значат предупреждения и как их можно исправить.
Вот код:

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
char* MostFreqChar(char* str)
{
//N - число букв в английском алфавите
    const int N = 26;
//Выделение памяти под массив, который будет хранить количество каждой буквы в строке                                                                    
    int* mostfreqch = (int *)calloc(N, sizeof(char));
//Обнуление массива                                 
    for (int i = 0; i < N; i++)
//В строке ниже предупреждение                                                         
        *(mostfreqch + i) = 0;
                                                            
    for (int i = 0; *(str + i) != ''; i++)                                            
    {
//Если текущий символ - заглавная буква - добавить 1 к соответствующему элементу массива
        if (*(str + i) >= 'A' && *(str + i) <= 'Z')
//В строке ниже предупреждение                                     
            *(mostfreqch + *(str + i) - 'A') = *(mostfreqch + *(str + i) - 'A') + 1;
//Если текущий символ - прописная буква - добавить 1 к соответствующему элементу массива 
        else if (*(str + i) >= 'a' && *(str + i) <= 'z')
//В строке ниже предупреждение                             
            *(mostfreqch + *(str + i) - 'a') = *(mostfreqch + *(str + i) - 'a') + 1;    
    }
//В строке ниже предупреждение 
    int max = *(mostfreqch), max_num = 0;
//Цикл по поиску самой встречаемой и близкой к началу алфавита буквы                                                
    for (int i = 0; i < N; i++)                                                         
        if (*(mostfreqch + i) > max)
        {
            max = *(mostfreqch + i);
//max_num - позиция этой буквы в алфавите ('a' - 0, 'b' - 1 и т. д.)
            max_num = i;                                                                
        }
    char* output_str = (char*)calloc(N + 1, sizeof(char));
//В строке ниже предупреждение
    *(output_str) = max_num + 97;
//Цикл по поиску остальных букв, количество которых также максимальное                                                       
    for (int i = 0, k = 1; i < N; i++)                                                                  
        if (*(mostfreqch + i) == max && i != max_num)
        {
//Составление выходной строки из таких букв
            *(output_str + k) = i + 97;                                                 
            k++;
        }
 
    return output_str;



0



Очень интересное решение. Итак, у вас есть указатель на выделенную память (которую потом можно будет удалить через free) order[1].

Вы перезаписываете этот указатель

 order[1] = temp;

и теперь он (как и все остальные, по окончании работы) указывает на память, выделенную в стеке массиву temp. Еще раз и медленно — все указатели указывают на одну и ту же память в стеке (к которой применять free нельзя). Т.е. все указывают на одну и ту же последнюю строку.

Видимо, вы просто прогуляли лекцию, где рассказывалось, что для копирования строк используются функции наподобие strcpy()

Еще — вам точно хватит места под вводимые слова? Все же в temp может поместиться 49-символьная строка, а в order[i] — только четырехсимвольная…

И еще — судя по вашим (char*) перед malloc вы все же компилируете в режиме C++, а не С — в С это приведение не требуется.

P.S. VC++ ни о каком разыменовании в указанном вами месте не говорит. Только о том, что после temp[50] вы точку с запятой забыли.

0 / 0 / 0

Регистрация: 07.09.2019

Сообщений: 23

1

Предупреждение «Разыменование пустого указателя»

07.02.2020, 14:38. Показов 3561. Ответов 3


Доброго времени суток! Я в процессе написания функции столкнулся с таким предупреждением:
«C6011 Разыменование пустого указателя mostfreqch». На самом деле таких предупреждений 5 штук, так что я просто размещу метки в коде, где именно я получил эти предупреждения.
Функция должна находить самые часто встречаемые символы в строке и возвращать эти символы также в виде одной строки.
И функция работает, но эти предупреждения меня как-то напрягают. Если что, я пишу в Visual Studio 2019.
Если не трудно, объясните, что значат предупреждения и как их можно исправить.
Вот код:

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
char* MostFreqChar(char* str)
{
//N - число букв в английском алфавите
    const int N = 26;
//Выделение памяти под массив, который будет хранить количество каждой буквы в строке                                                                    
    int* mostfreqch = (int *)calloc(N, sizeof(char));
//Обнуление массива                                 
    for (int i = 0; i < N; i++)
//В строке ниже предупреждение                                                         
        *(mostfreqch + i) = 0;
                                                            
    for (int i = 0; *(str + i) != ''; i++)                                            
    {
//Если текущий символ - заглавная буква - добавить 1 к соответствующему элементу массива
        if (*(str + i) >= 'A' && *(str + i) <= 'Z')
//В строке ниже предупреждение                                     
            *(mostfreqch + *(str + i) - 'A') = *(mostfreqch + *(str + i) - 'A') + 1;
//Если текущий символ - прописная буква - добавить 1 к соответствующему элементу массива 
        else if (*(str + i) >= 'a' && *(str + i) <= 'z')
//В строке ниже предупреждение                             
            *(mostfreqch + *(str + i) - 'a') = *(mostfreqch + *(str + i) - 'a') + 1;    
    }
//В строке ниже предупреждение 
    int max = *(mostfreqch), max_num = 0;
//Цикл по поиску самой встречаемой и близкой к началу алфавита буквы                                                
    for (int i = 0; i < N; i++)                                                         
        if (*(mostfreqch + i) > max)
        {
            max = *(mostfreqch + i);
//max_num - позиция этой буквы в алфавите ('a' - 0, 'b' - 1 и т. д.)
            max_num = i;                                                                
        }
    char* output_str = (char*)calloc(N + 1, sizeof(char));
//В строке ниже предупреждение
    *(output_str) = max_num + 97;
//Цикл по поиску остальных букв, количество которых также максимальное                                                       
    for (int i = 0, k = 1; i < N; i++)                                                                  
        if (*(mostfreqch + i) == max && i != max_num)
        {
//Составление выходной строки из таких букв
            *(output_str + k) = i + 97;                                                 
            k++;
        }
 
    return output_str;

__________________
Помощь в написании контрольных, курсовых и дипломных работ, диссертаций здесь

0

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

Silent NULL (Разыменовывание нулевого указателя приводит к неопределённому поведению)
Ненароком я породил большую дискуссию, касающуюся того, допустимо ли использовать в Си/Си++ выражение &P->m_foo, если P является нулевым указателем. Программисты разделились на два лагеря. Одни уверенно доказывали, что так писать нельзя, другие столь же уверенно утверждали, что можно. Приводились различные аргументы и ссылки. И я понял, что нужно внести окончательную ясность в этот вопрос. Для этого я обратился к экспертам Microsoft MVP и разработчикам Visual C++, общающимся через закрытый список рассылки. Они помогли подготовить эту статью, и я представляю её всем желающим. Для нетерпеливых: этот код не корректен.

Напомню историю обсуждений

Все началось со статьи о проверке ядра Linux с помощью анализатора PVS-Studio. Но сама проверка ядра тут ни причём. Дело в том, что в статье я привёл следующий фрагмент из кода Linux:

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;
  ....
}

Я назвал этот код опасным, так как посчитал, что здесь имеет место неопределённое поведение.

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

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

Здесь имеет место разыменование нулевого указателя, но код успешно работает. Были и другие письма с рассуждениями того, что раз нет доступа по нулевому указателю, то нет и проблемы.

Хотя я и доверчивый, но стараюсь проверять информацию. Я начал разбираться с этой темой и в результате написал небольшую статью: «Размышления над разыменованием нулевого указателя».

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

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

О языке Си

Выражение ‘&podhd->line6’ является неопределенным поведением в языке C в том случае, если ‘podhd’ — нулевой указатель.

Вот что говорится про оператор взятия адреса ‘&’ в стандарте C99 (Раздел 6.5.3.2 «Операторы взятия адреса и разыменовывания»):

Операнд унарного оператора & должен быть либо указателем функции, либо результатом оператора [] или унарного оператора *, либо lvalue-выражением, указывающим на объект, который не является битовым полем и не содержит в объявлении спецификатора регистрового класса памяти.

Выражение ‘podhd->line6’ однозначно не является указателем функции, результатом оператора [] или *. Это как раз lvalue-выражение. Однако, когда указатель ‘podhd’ равен нулю, выражение не указывает на объект, поскольку в Разделе 6.3.2.3 «Указатели» сказано следующее:

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

Если «lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение» (Стандарт C99, Раздел 6.3.2.1 «Lvalue-выражения, массивы и указатели функций»):

lvalue — это выражение объектного типа или неполного типа, отличного от void; если lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение.

Ещё раз кратко:

Когда оператор -> был применен к указателю, его результатом стало lvalue-выражение, для которого не существует объекта, и в результате мы имеем дело с неопределенным поведением.

О языке Си++

В языке С++ всё обстоит точно также. Выражение ‘&podhd->line6’ является неопределенным поведением в языке C++ в том случае, если ‘podhd’ — нулевой указатель.

С толку немного сбивает дискуссия на WG21 (232. Is indirection through a null pointer undefined behavior?), на которую я ссылался в предыдущей статье. Там настаивают, будто бы такое выражение не является неопределенным поведением. Однако никто так и не нашел никаких правил в стандартах C++, которые разрешали бы использовать «poldh->line6», когда «polhd» — нулевой указатель.

Указатель «polhd» нарушает основное ограничение (Раздел 5.2.5/4, второй пункт в списке) о том, что он должен указывать на объект. Ни один объект в C++ не может иметь адреса nullptr.

Итого

struct usb_line6 *line6 = &podhd->line6;

Этот код является некорректным в языке Си и Си++, если указатель podhd равен 0. Если указатель равен 0, то возникает неопределённое поведение.

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

Так писать нельзя. Указатель должен быть проверен до разыменования.

Разное в дополнение

  • При рассмотрении идиоматической реализации offsetof() следует учитывать, что компилятору разрешено использовать непереносимые приемы для реализации этой функциональности. Тот факт, что в реализации библиотеки в компиляторе используется константа нулевого указателя при реализации offsetof(), вовсе не означает, что в пользовательском коде можно без опаски применять ‘&podhd->line6′ в случае, когда’podhd’ является нулевым указателем.
  • GCC может (и делает это) проводить оптимизацию, основываясь на предположении, что никакого неопределенного поведения возникнуть не может, и убрать в данном случае проверки указателей на ноль — поэтому ядро компилируется с набором ключей, указывающих компилятору не делать этого. Например, эксперты в качестве примера ссылаются на статью «What Every C Programmer Should Know About Undefined Behavior #2/3».
  • Возможно, вам также будет интересно узнать, что подобным образом нулевой указатель был задействован в эксплойте ядра с помощью TUN/TAP-драйва. Подробности можно посмотреть по ссылке «Fun with NULL pointers». Некоторые могут решить, будто эти два примера имеют мало общего, поскольку во втором случае есть существенное отличие: в баге TUN/TAP-драйвера вместо простого взятия адреса поля структуры, к которому обращался нулевой указатель, это поле было явно взято в качестве значения для инициализации переменной. Однако с точки зрения стандарта C взятие адреса поля с помощью нулевого указателя также является неопределенным поведением.
  • А есть ли какая-та ситуация, когда при P == nullptr мы напишем &P->m_foo и всё будет хорошо? Да, например это может быть аргументом оператора sizeof: sizeof(&P->m_foo).

Благодарности

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

  • Майкл Бёрр — горячий поклонник языка C/C++ и специалист по системному и встроенному ПО, в том числе службам Windows, работе с сетями и драйверам устройств. Активно участвует в жизни сообщества StackOverflow, отвечая на вопросы программистов по C и C++ (а иногда и на некоторые простые вопросы по C#). Имеет 6 наград Microsoft MVP в номинации Visual C++.
  • Билли О’Нил — разработчик ПО на C++ (преимущественно) и активный участник сообщества StackOverflow. Является инженером-разработчиком ПО в подразделении по совершенствованию систем безопасности Microsoft (Trustworthy Computing Team). До этого работал в нескольких компаниях, занимающихся безопасностью ПО, в числе которых — Malware Bytes и PreEmptive Solutions.
  • Джованни Диканио — программист, специализирующийся на разработке ОС Windows. Автор статей для программистов по C++, OpenGL и другим темам в ряде итальянских компьютерных журналов. Также писал код для некоторых открытых проектов. Джованни помогает коллегам, давая советы по решению программистских проблем, связанных с C и C++, на форумах Microsoft MSDN, а с некоторых пор — и на StackOverflow. Имеет 8 наград Microsoft MVP в номинации Visual C++.
  • Габриэль Дус Рейс — главный инженер-разработчик ПО Microsoft. Также является исследователем и долгосрочным участником C++-сообщества. Одно из направлений его научных интересов и исследований — средства разработки надежного ПО. До того, как прийти в Microsoft, работал старшим преподавателем в Техасском Университете A&M (Texas A&M University). В 2012 году Доктор Дус Рейс был отмечен премией Национального Научного Фонда (National Science Foundation CAREER Award) за проведенное им исследование компиляторов надежного ПО в области вычислительной математики и за образовательную деятельность. Является членом комитета по стандартизации языка C++.

Дополнительные ссылки

  1. Wikipedia. Неопределённое поведение.
  2. A Guide to Undefined Behavior in C and C++. Part 1, 2, 3.
  3. Wikipedia. offsetof.
  4. LLVM Blog. What Every C Programmer Should Know About Undefined Behavior #2/3.
  5. LWN. Fun with NULL pointers. Part 1, 2.

Andrey Karpov

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

  • Напомню историю обсуждений
  • О языке Си
  • О языке Си++
  • Итого
  • Разное в дополнение
  • Благодарности
  • Дополнительные ссылки

Ненароком я породил большую дискуссию, касающуюся того, допустимо ли использовать в Си/Си++ выражение &P->m_foo, если P является нулевым указателем. Программисты разделились на два лагеря. Одни уверенно доказывали, что так писать нельзя, другие столь же уверенно утверждали, что можно. Приводились различные аргументы и ссылки. И я понял, что нужно внести окончательную ясность в этот вопрос. Для этого я обратился к экспертам Microsoft MVP и разработчикам Visual C++, общающимся через закрытый список рассылки. Они помогли подготовить эту статью, и я представляю её всем желающим. Для нетерпеливых: этот код не корректен.

0306_Reflections_Null_Pointer_Dereferencing2_ru/image1.png

Напомню историю обсуждений

Все началось со статьи о проверке ядра Linux с помощью анализатора PVS-Studio. Но сама проверка ядра тут ни причём. Дело в том, что в статье я привёл следующий фрагмент из кода Linux:

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;
  ....
}

Я назвал этот код опасным, так как посчитал, что здесь имеет место неопределённое поведение.

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

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

Здесь имеет место разыменование нулевого указателя, но код успешно работает. Были и другие письма с рассуждениями того, что раз нет доступа по нулевому указателю, то нет и проблемы.

Хотя я и доверчивый, но стараюсь проверять информацию. Я начал разбираться с этой темой и в результате написал небольшую статью: «Размышления над разыменованием нулевого указателя».

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

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

О языке Си

Выражение ‘&podhd->line6’ является неопределенным поведением в языке C в том случае, если ‘podhd’ — нулевой указатель.

Вот что говорится про оператор взятия адреса ‘&’ в стандарте C99 (Раздел 6.5.3.2 «Операторы взятия адреса и разыменовывания»):

Операнд унарного оператора & должен быть либо указателем функции, либо результатом оператора [] или унарного оператора *, либо lvalue-выражением, указывающим на объект, который не является битовым полем и не содержит в объявлении спецификатора регистрового класса памяти.

Выражение ‘podhd->line6’ однозначно не является указателем функции, результатом оператора [] или *. Это как раз lvalue-выражение. Однако, когда указатель ‘podhd’ равен нулю, выражение не указывает на объект, поскольку в Разделе 6.3.2.3 «Указатели» сказано следующее:

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

Если «lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение» (Стандарт C99, Раздел 6.3.2.1 «Lvalue-выражения, массивы и указатели функций»):

lvalue — это выражение объектного типа или неполного типа, отличного от void; если lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение.

Ещё раз кратко:

Когда оператор -> был применен к указателю, его результатом стало lvalue-выражение, для которого не существует объекта, и в результате мы имеем дело с неопределенным поведением.

О языке Си++

В языке С++ всё обстоит точно также. Выражение ‘&podhd->line6’ является неопределенным поведением в языке C++ в том случае, если ‘podhd’ — нулевой указатель.

С толку немного сбивает дискуссия на WG21 (232. Is indirection through a null pointer undefined behavior?), на которую я ссылался в предыдущей статье. Там настаивают, будто бы такое выражение не является неопределенным поведением. Однако никто так и не нашел никаких правил в стандартах C++, которые разрешали бы использовать «podhd->line6», когда «podhd» — нулевой указатель.

Указатель «podhd» нарушает основное ограничение (Раздел 5.2.5/4, второй пункт в списке) о том, что он должен указывать на объект. Ни один объект в C++ не может иметь адреса nullptr.

Итого

struct usb_line6 *line6 = &podhd->line6;

Этот код является некорректным в языке Си и Си++, если указатель podhd равен 0. Если указатель равен 0, то возникает неопределённое поведение.

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

Так писать нельзя. Указатель должен быть проверен до разыменования.

Разное в дополнение

  • При рассмотрении идиоматической реализации offsetof() следует учитывать, что компилятору разрешено использовать непереносимые приемы для реализации этой функциональности. Тот факт, что в реализации библиотеки в компиляторе используется константа нулевого указателя при реализации offsetof(), вовсе не означает, что в пользовательском коде можно без опаски применять ‘&podhd->line6′ в случае, когда’podhd’ является нулевым указателем.
  • GCC может (и делает это) проводить оптимизацию, основываясь на предположении, что никакого неопределенного поведения возникнуть не может, и убрать в данном случае проверки указателей на ноль — поэтому ядро компилируется с набором ключей, указывающих компилятору не делать этого. Например, эксперты в качестве примера ссылаются на статью «What Every C Programmer Should Know About Undefined Behavior #2/3».
  • Возможно, вам также будет интересно узнать, что подобным образом нулевой указатель был задействован в эксплойте ядра с помощью TUN/TAP-драйва. Подробности можно посмотреть по ссылке «Fun with NULL pointers». Некоторые могут решить, будто эти два примера имеют мало общего, поскольку во втором случае есть существенное отличие: в баге TUN/TAP-драйвера вместо простого взятия адреса поля структуры, к которому обращался нулевой указатель, это поле было явно взято в качестве значения для инициализации переменной. Однако с точки зрения стандарта C взятие адреса поля с помощью нулевого указателя также является неопределенным поведением.
  • А есть ли какая-та ситуация, когда при P == nullptr мы напишем &P->m_foo и всё будет хорошо? Да, например это может быть аргументом оператора sizeof: sizeof(&P->m_foo).

Благодарности

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

  • Майкл Бёрр — горячий поклонник языка C/C++ и специалист по системному и встроенному ПО, в том числе службам Windows, работе с сетями и драйверам устройств. Активно участвует в жизни сообщества Stack Overflow, отвечая на вопросы программистов по C и C++ (а иногда и на некоторые простые вопросы по C#). Имеет 6 наград Microsoft MVP в номинации Visual C++.
  • Билли О’Нил — разработчик ПО на C++ (преимущественно) и активный участник сообщества Stack Overflow. Является инженером-разработчиком ПО в подразделении по совершенствованию систем безопасности Microsoft (Trustworthy Computing Team). До этого работал в нескольких компаниях, занимающихся безопасностью ПО, в числе которых — Malware Bytes и PreEmptive Solutions.
  • Джованни Диканио — программист, специализирующийся на разработке ОС Windows. Автор статей для программистов по C++, OpenGL и другим темам в ряде итальянских компьютерных журналов. Также писал код для некоторых открытых проектов. Джованни помогает коллегам, давая советы по решению программистских проблем, связанных с C и C++, на форумах Microsoft MSDN, а с некоторых пор — и на Stack Overflow. Имеет 8 наград Microsoft MVP в номинации Visual C++.
  • Габриэль Дус Рейс — главный инженер-разработчик ПО Microsoft. Также является исследователем и долгосрочным участником C++-сообщества. Одно из направлений его научных интересов и исследований — средства разработки надежного ПО. До того, как прийти в Microsoft, работал старшим преподавателем в Техасском Университете A&M (Texas A&M University). В 2012 году Доктор Дус Рейс был отмечен премией Национального Научного Фонда (National Science Foundation CAREER Award) за проведенное им исследование компиляторов надежного ПО в области вычислительной математики и за образовательную деятельность. Является членом комитета по стандартизации языка C++.

Дополнительные ссылки

  • Wikipedia. Неопределённое поведение.
  • A Guide to Undefined Behavior in C and C++. Part 1, 2, 3.
  • Wikipedia. offsetof.
  • LLVM Blog. What Every C Programmer Should Know About Undefined Behavior #2/3.
  • LWN. Fun with NULL pointers. Part 1, 2.
  • Дискуссия на сайте Stack Overflow. Is dereferencing a pointer that’s equal to nullptr undefined behavior by the standard?

Присылаем лучшие статьи раз в месяц

The answer to this question is: it depends which language standard you are following :-).

In C90 and C++, this is not valid because you perform indirection on the null pointer (by doing *p), and doing so results in undefined behavior.

However, in C99, this is valid, well-formed, and well-defined. In C99, if the operand of the unary-& was obtained as the result of applying the unary-* or by performing subscripting ([]), then neither the & nor the * or [] is applied. For example:

int* p = 0;
int* q = &*p; // In C99, this is equivalent to int* q = p;

Likewise,

int* p = 0;
int* q = &p[0]; // In C99, this is equivalent to int* q = p + 0;

From C99 §6.5.3.2/3:

If the operand [of the unary & operator] is the result of a unary * operator, neither that operator nor the & operator is evaluated and the result is as if both were omitted, except that the constraints on the operators still apply and the result is not an lvalue.

Similarly, if the operand is the result of a [] operator, neither the & operator nor the unary * that is implied by the [] is evaluated and the result is as if the & operator were removed and the [] operator were changed to a + operator.

(and its footnote, #84):

Thus, &*E is equivalent to E (even if E is a null pointer)

The answer to this question is: it depends which language standard you are following :-).

In C90 and C++, this is not valid because you perform indirection on the null pointer (by doing *p), and doing so results in undefined behavior.

However, in C99, this is valid, well-formed, and well-defined. In C99, if the operand of the unary-& was obtained as the result of applying the unary-* or by performing subscripting ([]), then neither the & nor the * or [] is applied. For example:

int* p = 0;
int* q = &*p; // In C99, this is equivalent to int* q = p;

Likewise,

int* p = 0;
int* q = &p[0]; // In C99, this is equivalent to int* q = p + 0;

From C99 §6.5.3.2/3:

If the operand [of the unary & operator] is the result of a unary * operator, neither that operator nor the & operator is evaluated and the result is as if both were omitted, except that the constraints on the operators still apply and the result is not an lvalue.

Similarly, if the operand is the result of a [] operator, neither the & operator nor the unary * that is implied by the [] is evaluated and the result is as if the & operator were removed and the [] operator were changed to a + operator.

(and its footnote, #84):

Thus, &*E is equivalent to E (even if E is a null pointer)

Обнаружение в коде дефекта «разыменование нулевого указателя» +18

C++, Информационная безопасность, JAVA, C, PHP, Проектирование и рефакторинг, Совершенный код, Блог компании «ЗАО «НПО „Эшелон“»


Рекомендация: подборка платных и бесплатных курсов Java — https://katalog-kursov.ru/

Этой статьей мы открываем серию публикаций, посвященных обнаружению ошибок и уязвимостей в open-source проектах с помощью статического анализатора кода AppChecker. В рамках этой серии будут рассмотрены наиболее часто встречающиеся дефекты в программном коде, которые могут привести к серьезным уязвимостям. Сегодня мы остановимся на дефекте типа «разыменование нулевого указателя».

Разыменование нулевого указателя (CWE-476) представляет собой дефект, когда программа обращается по некорректному указателю к какому-то участку памяти. Такое обращение ведет к неопределенному поведению программы, что приводит в большинстве случаев к аварийному завершению программы.

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

#include <iostream>
class A {
        public:
            void bar() {
                std::cout << "Test!n";
            }
};

int main() {
    A* a = 0;
    a->bar();
    return 0;
}

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

#include <iostream>
class A {
        int x;
        public:
            void bar() {
                std::cout << x << "Test!n";
            }
};

int main() {
    A* a = 0;
    a->bar();
    return 0;
}

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

Рассмотрим следующий фрагмент кода на C++:

if( !pColl )
   pColl->SetNextTxtFmtColl( *pDoc->GetTxtCollFromPool( nNxt ));

Нетрудно заметить, что если pColl == NULL, выполнится тело этого условного оператора. Однако в теле оператора происходит разыменование указателя pColl, что вероятно приведет к краху программы.

Обычно такие дефекты возникают из-за невнимательности разработчика. Чаще всего блоки такого типа применяются в коде для обработки ошибок. Для выявления таких дефектов можно применить различные методы статического анализа, например, сигнатурный анализа или symbolic execution. В первом случае пишется сигнатура, которая ищет в абстрактном синтаксическом дереве (AST) узел типа «условный оператор», в условии которого есть выражение вида! а, a==0 и пр., а в теле оператора есть обращение к этому объекту или разыменование этого указателя. После этого необходимо отфильтровать ложные срабатывания, например, перед разыменованием этой переменной может присвоиться значение:

if(!a) {
  a = new A();
  a->bar();
}

Выражение в условии может быть нетривиальным.

Во втором случае во время работы анализатор «следит», какие значения могут иметь переменные. После обработки условия if (!a) анализатор понимает, что в теле условного оператора переменная a равна нулю. Соответственно, ее разыменование можно считать ошибкой.

Приведенный фрагмент кода взят из популярного свободного пакета офисных приложений Apache OpenOffice версии 4.1.2. Дефект в коде был обнаружен при помощи статического анализатора программного кода AppChecker. Разработчики были уведомлены об этом дефекте, и выпустили патч, в котором этот дефект был исправлен ).

Рассмотрим аналогичный дефект, обнаруженный в Oracle MySQL Server 5.7.10:

bool sp_check_name(LEX_STRING *ident)
{
  if (!ident || !ident->str || !ident->str[0] ||
      ident->str[ident->length-1] == ' ')
  {
    my_error(ER_SP_WRONG_NAME, MYF(0), ident->str);
    return true;
  }
..
}

В этом примере если ident равен 0, то условие будет истинным и выполнится строка:

my_error(ER_SP_WRONG_NAME, MYF(0), ident->str);

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

Нетрудно догадаться, что разыменование нулевого указателя – это дефект, не зависящий от языка программирования. Предыдущие два примера демонстрировали код на языке C++, однако с помощью статического анализатора AppChecker можно находить подобные проблемы в проектах на языках Java и PHP. Приведем соответствующие примеры.

Рассмотрим фрагмент кода системы управления и централизации информации о строительстве BIM Server версии bimserver 1.4.0-FINAL-2015-11-04, написанной на языке Java:

if (requestUri.equals("") || requestUri.equals("/") || requestUri == null) {
     requestUri = "/index.html";
}

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

Теперь рассмотрим фрагмент кода популярной коллекции веб-приложений phabricator, написанной на php:

if (!$device) {
    throw new Exception(
      pht(
        'Invalid device name ("%s"). There is no device with this name.',
        $device->getName()));
}

В данном случае условие выполняется только если $device = NULL, однако затем происходит обращение к $device->getName(), что приведет к fatal error.

Подобные дефекты могут оставаться незамеченными очень долго, но в какой-то момент условие выполнится, что приведет к краху программы. Несмотря на простоту и кажущуюся банальность такого рода дефектов, они встречаются достаточно часто, как в open-source, так и в коммерческих проектах.

Update:

Ссылка на бесплатную версию AppChecker: https://file.cnpo.ru/index.php/s/o1cLkNrUX4plHMV

Разыменование нулевых указателей.

Самая крутая ошибка с самыми жуткими последствиями. null вообще называют ошибкой на миллиард долларов.
От них страдает куча кода, на самых разных языках программирования. Но если в условной Java при обращении по null-ссылке вы получите исключение с вполне предсказуемыми последствиями (ну, упало и упало), то в великом и ужасном C++, а также в C за вами придет неопределенное поведение. И оно будет действительно неопределенным!

Но для начала, конечно, надо отметить, что, после всех обсуждений туманных формулировок стандарта, в настоящее время есть некоторое соглашение, что все-таки не сама по себе конструкция *p, где p — нулевой указатель, вызывает неопределенное поведение. А lvalue-to-rvalue преобразование. Ну или менее формально, кратко и не совсем правильно: пока нет чтения или записи значения по этому самому нулевому адресу — все нормально.

Так, сейчас совершенно законно вы можете вызвать статические методы класса через nullptr.

struct S {
    static void foo() {};
};

S *p = nullptr;
p->foo();

А также можно писать вот такую ерунду

Причем эту ерунду можно писать только в C++. В C это безобразие все-таки запретили (см. 6.5.3.2, сноска 104). И в C применять оператор разыменования к невалидным и нулевым указателями нельзя нигде. А у C++ свой особый путь. И эти странные примеры собираются в constexpr контексте (напоминаю, в нем запрещено UB и компилятор проверяет).

Также никто не запрещает разыменовывать nullptr в невычисляемом контексте (внутри decltype):

#define LVALUE(T) (*static_cast<T*>(nullptr))

struct S {
    int foo() { return 1; };
};

using val_t = decltype(LVALUE(S).foo());

Но, несмотря на то что так делать можно, совершенно не значит, что так делать нужно.
Потому что последствия от разыменования nullptr там, где этого делать нельзя, могут быть печальными.
Лезвие тонкое, острое, можно легко оступиться и что-нибудь взорвать.

Если разыменовать nullptr, может быть исполнен код, который никак не вызывался:

#include <cstdlib>

typedef int (*Function)();

static Function Do = nullptr;

static int EraseAll() {
  return system("rm -rf /");
}

void NeverCalled() {
  Do = EraseAll;  
}

int main() {
  return Do();
}

Компилятор обнаруживает разыменование nullptr (вызов функции Do). Это неопределенное поведение. Такого быть не может. Компилятор находит, что есть одно место, где этому указателю присваивается ненулевое значение. И раз нуля быть не может, то, значит, именно это значение он и использует. Как результат — исполняется код функции, которую мы не вызывали.

Или вот совершенно дурная программа.

void run(int* ptr) {
    int x = *ptr;
    if (!ptr) {
        printf("Null!n");
        return;
    }
    *ptr = x;
}

int main() {
  int x = 0;
  scanf("%d", &x);  
  run(x == 0 ? nullptr : &x);
}

Из-за разыменования указателя ptr, проверка на nullptr после разыменования может быть удалена.

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

void run(int* ptr) {
    try_do_something(ptr); // если функция разыменует указатель, 
                           // и оптимизатор это увидит, проверка ниже
                           // может быть удалена
    if (!ptr) {
        printf("Null!n");
        return;
    }
    *ptr = x;
}

Такая ситуация уже куда ближе к реальности.

В стандартной библиотеке C, например, есть функции, от которых можно было бы, по неопытности, ожидать проверки на nullptr, но они этого не делают.

strlen, strcmp, другие строковые функции, а в C++ еще конструктор std::string(const char*) — их вызов с nullptr в качестве аргумента ведет к неопределенному поведению (и удалению нижерасположенных проверок, если вам не повезет).

Еще есть особо мерзкие в этом смысле memcpy и memmove. Которые, несмотря на принимаемые в аргументах размеры буферов, все равно приводят к неопределенному поведению, если передать в них nullptr и нулевой размер!
И точно также это может проявиться в удалении ваших проверок.

int main(int argc, char **argv) {
      char *string = NULL;
      int length = 0;
      if (argc > 1) {
          string = argv[1];
          length = strlen(string);
          if (length >= LENGTH) exit(1);
      }

      char buffer[LENGTH];
      memcpy(buffer, string, length); // при передаче nullptr
                                      // length будет нулевым,
                                      // но это не спасает от UB
      buffer[length] = 0;

      if (string == NULL) {
          printf("String is null, so cancel the launch.n");
      } else {
          printf("String is not null, so launch the missiles!n");
      }
}

На одних и тех же входных данных (вернее, их отсутствии), этот код завершается с разными результатами
в зависимости от компилятора и уровня оптимизаций.

Если вы еще недостаточно напуганы, то вот еще замечательная история о том, как весело и задорно падала функция вида

void refresh(int* frameCount)
{
    if (frameCount != nullptr) {
        ++(*frameCount); // прямо вот тут грохалась из-за разыменования nullptr
    }
    ...
}

просто потому что где-то совершенно в не связанном с ней классе написали:

class refarray {
public:
    refarray(int length)
    {
        m_array = new int*[length];
        for (int i = 0; i < length; i++) {
            m_array[i] = nullptr;
        }
    }

    int& operator[](int i)
    {
        // разыменование указателя без проверки на null
        return *m_array[i];
    }
private:
    int** m_array;
};

И вызвали функцию так:

refresh(&(some_refarray[0]));

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

Не забывайте проверять на nullptr. Иначе оно взорвется.

Полезные ссылки

  1. https://habr.com/ru/company/pvs-studio/blog/250701/
  2. https://habr.com/ru/post/513058/
  3. https://news.ycombinator.com/item?id=12002746

В этом материале для новичков мы рассуждаем про обнаружение в коде C++ распространенного дефекта «разыменование нулевого указателя», попутно объясняя его скрытую коварность.

Помогаем

Unrecognizable

NULL

Содержание:
1. Разыменование нулевого указателя
2. Нулевое значение и нулевые указатели
3. NULL и nullptr
4. Тип данных nullptr
Заключение

1. Разыменование нулевого указателя

Сегодня рассмотрим причину дефекта в коде С++, который получается, если программа обращается по некорректному указателю к какому-то участку памяти. Такое обращение ведет к неопределенному поведению программы, что приводит в большинстве случаев к аварийному завершению. Данный дефект получил название разыменование нулевого указателя (CWE-476). Мы поговорим о том, что такое NULL и nullptr и для чего они нужны.

По сути, это почти одинаковые вещи, но есть нюансы.

analiz

Рассмотрим код.

#include <iostream>
#include <string>

using namespace std;

/*
* Работа с динамической памятью. Нулевые указатели
*/

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
}

Язык С++ не имеет автоматического сборщика мусора, как, например, в Java или C#. Если мы выделяем область под данные, то никто кроме нас не позаботится о том, чтобы область памяти была очищена. Если в памяти находится одно число, это не является проблемой.

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

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

Итак, в нашем коде (пример выше) с помощью оператора new мы выделили место для нашей оперативной памяти. После очистки места, которое мы выделяли под данные в динамической части оперативной памяти, сами данные исчезают. В следствии действия оператора delete данные уничтожаются и система может выделять память, которую мы уже не используем, для любых других своих нужд.

Однако, у нас остается проблема! В нашем указателе *pa все еще сохранен адрес на тот участок памяти, где у нас лежали данные и, в принципе, нам никто не запрещает туда обращаться.

NULL

Мы можем туда что-нибудь записать или получить данные, которые там находятся. А можем что-нибудь повредить либо получить некорректные данные, далее невольно начать с ними работать если у нас есть подобная ошибка в логике. В нашем случае, например, мы можем по указателю получить вот такое число — 842150451.

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

2. Нулевое значение и нулевые указатели

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

Для этого и существуют NULL и nullptr. Обратите внимание, что если у нас сейчас вызывается оператор delete на нашем указателе (мы очищаем находящуюся по нему память), то оттуда данные теряются.

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

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
            cout << *pa << endl;
}

Но, если мы еще раз возьмем и вызовем оператор delete pa, то все закончится очень плохо — мы увидим на экране сообщение об ошибке. Она говорит о том, что возникла проблема при работе с кучей, то есть с динамической памятью.

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
            cout << *pa << endl;
            delete pa;
}

Для того, чтобы избежать такой проблемы мы можем использовать NULL.

NULL

3. NULL и nullptr

В таких языках программирования как Java или C#, NULL является отдельным типом данных и там ситуация несколько иная. В случае С++ мы имеем дело с NULL и nullptr.

nullptr — это более новая разработка, добавленная в С++ 11, и она уже работает аналогично тому как это реализовано в Java или C#. nullptr это отдельный тип данных, с которым компилятор ничего спутать не может. Что же касается NULL, то это просто эквивалент записи 0 (ноль).

Если мы напишем pa = 0, то это значит, что наш указатель pa теперь не будет хранить адрес в памяти, а будет нулевым указателем, указывать на ноль. Вместо pa = 0, мы можем записать pa = NULL — эти записи абсолютно равнозначны. Все дело в том, что NULL — это просто макрос.

Если мы наведем на него мышку, поставим курсор и нажимаем f12, то увидим #define NULL 0.

NULL

Строка pa = NULL говорит указателю, который до момента выполнения данной строки (где мы уже почистили память) указывает на определенный адрес оперативной памяти, чтобы он этот адрес забыл, чтобы мы к этому адресу впоследствии случайно не обратились. После того как мы присвоили NULL, у нас одни нули, т.е. нулевой указатель.

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

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

NULL

Добавим проверку if pa != 0 или if pa != NULL с возможностью выводить наш адрес указателя. В данном случае адрес не вывелся, поскольку указатель указывает на NULL. А раз он указывает на NULL, то он в принципе ничего не может хранить.

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

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = NULL;
    if (pa != NULL)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Если мы уберем запись pa = NULL, то не сможем знать, куда указывает указатель, мы не можем перебрать все возможные адреса и знать что там лежит. Поэтому мы получим вывод нашего адреса и ошибку.

4. Тип данных nullptr

Возникает вопрос — для чего нужен отдельный тип данных nullptr? В принципе, мы можем использовать и NULL. В данном случае работать это будет точно так же.

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = nullptr;
    if (pa != nullptr)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Это уже не просто макрос и не просто нолик, а целочисленный тип Int. Это уже — отдельный тип данных. Мы его присваиваем и, на первый взгляд, разницы никакой нет. Однако для компилятора разница есть, он никогда не перепутает указатель nullptr с целочисленным типом данных.

К примеру, если у вас будет какая-то функция, она будет перегружена для типа Int и для указателя. И вы захотите передать в вашу функцию указатель с целочисленным нулем pa = 0:

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = 0;
    if (pa != 0)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Ваша функция принимает либо указательную Int, либо просто целочисленный тип Int. В таком случае у компилятора могут быть проблемы какую функцию вызвать — с реализацией получается неоднозначность.

В С++ 11 nullptr это отдельный тип данных и компилятор никогда не перепутает его с обычным int. Поэтому в случаях когда вы будете работать с указателями, рекомендуется использовать именно его.

Если вы встретите где-то старый код, вы можете увидеть запись с присвоением нуля pa = NULL; (pa = 0;). Теперь вы будете знать, что это такое и какие могут быть проблемы. Справедливости ради нужно сказать, что на самом деле проблемы возникают редко, но чтобы исключить их вообще, лучше использовать nullptr. Это хоть и редкий тип проблем, но очень коварный и трудно вычислимый.

Также стоит обратить внимание на еще один тип ошибок. Если вам нужно очистить динамическую память, в которой выделено место под ваши данные, то обязательно сначала нужно вызвать delete, ну, а затем, если нужно затереть адрес — присваивать нашему указателю nullptr.

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = nullptr;
    if (pa != nullptr)
    {
        cout << *pa << endl;
    }
    delete pa;
}

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

void main()

{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    
    pa = nullptr;
            delete pa;

    if (pa != nullptr)
    {
        cout << *pa << endl;
    }
    delete pa;
}

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

Если мы вызываем сначала delete, а затем присваиваем NULL, то сначала убиваются данные, а затем теряется и адрес, который хранил указатель. Однако, если вы сначала используете nullptr, тогда вы просто убираете адрес, но данные никуда не деваются, они так и остаются висеть в оперативной памяти.

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

NULL

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

Заключение

Надеемся, что наш материал поможет вам избежать частых проблем при работе с памятью в C++. В заключение рекомендуем посмотреть видео, в котором рассказывается про указатели в С++

а также про работу с динамической памятью при работе с массивами

This is the bug report from facebook infer.

error: NULL_DEREFERENCE
  pointer `stack` last assigned on line 24 could be null and is dereferenced at line 25, column 5.
  22. struct string_stack* create_String_Stack(unsigned capacity)
  23.   {
  24.       struct char_stack* stack = calloc(1,sizeof(struct char_stack));
  25. >     stack-> capacity = capacity;
  26.       stack->top = -1;
  27.       stack->array = (char*)malloc(stack->capacity * sizeof(char));
struct char_stack
{
    int top;
    unsigned capacity;
    char* array;
};

How can get rid of this warning?

asked Dec 10, 2020 at 9:12

Kelly Ding's user avatar

2

I believe the problem is at struct char_stack* stack = calloc(1,sizeof(struct char_stack));. If you just simply say struct char_stack* stack= malloc(sizeof(struct char_stack); as you want only 1 item to save space and then I believe that it probably solve it. If it does not then I would suggest checking if you correctly pronounce sizeof(struct char_stack).In the end, you always must check if (stack==NULL) because the program might not find a space to allocate space for the pointer. Also, I would recommend using typedef struct char_stack Char_stack; so you don’t need to write all the time the struct char_stack but only Char_stack. I hope that this will help you to find the problem.

answered Dec 10, 2020 at 9:22

Akis Lionis's user avatar

4

  • Ошибка распаковки пакета не найден файл описания пакета пфр осп xml или packagedescription xml
  • Ошибка разума фильм смотреть
  • Ошибка распаковки пакета андроид
  • Ошибка разрыв соединения фифа мобайл
  • Ошибка распаковки невозможно записать данные на диск