Почему неопределенное поведение настолько последовательно?

#c #pointers #printf #undefined-behavior

#c #указатели #printf #неопределенное поведение

Вопрос:

Я играл с указателями и случайно ввел неправильный аргумент в printf

 #include <stdio.h>

int
main (void)
{
  double * p1;
  double * p2; 
  double d1, d2;

  d1 = 1.2345;
  d2 = 2.3456;
  p1 = amp;d1;
  p2 = amp;d2;

  printf ("p1=%pn, *p1=%gn", (void *)p1, *p1);
  printf ("p2=%pn, *p2=%gn", (void *)p2,  p2); /* third argument should be *p2 */

  return 0;
}
  

Вывод был

предупреждение: формат ‘%g’ ожидает аргумент типа ‘double’, но аргумент 3 имеет тип ‘double *’
p1= 0x7ffc9aec46b8, * p1=1.2345
p2=0x7ffc9aec46c0, *p2=1.2345

Почему в этом случае вывод p2 всегда равен выводу *p1 ?

Я использую компилятор gcc (v5.4.0) со стандартом по умолчанию для C (gnu11).

Комментарии:

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

2. Оно не определено только потому, что оно не меняется при каждом запуске программы.

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

4. Этот пример — еще одна очень веская причина для компиляции со всеми включенными предупреждениями, а затем исправить эти предупреждения перед попыткой запуска кода.

Ответ №1:

Код, который вызывает неопределенное поведение, может делать что угодно — вот почему он неопределенный.

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

В любом случае, на вашей платформе аргументы с плавающей запятой, вероятно, передаются в выделенных регистрах с плавающей запятой (или в выделенном стеке с плавающей запятой), а не в основном стеке. printf(«%g») ожидает аргумент с плавающей запятой, поэтому он выглядит в регистре с плавающей запятой. Но вы ничего не передавали в регистре с плавающей запятой; все, что вы передали, это два аргумента указателя, которые оба отправились в стек (или куда идут аргументы указателя; это также выходит за рамки стандарта C). Таким образом, второй вызов printf получает весь мусор, который был в этом конкретном регистре с плавающей запятой при последней загрузке. Так получилось, что последнее, что вы загрузили в этот регистр, было значением *p1 в последнем вызове printf , поэтому это значение используется повторно.

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

Ответ №2:

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

Комментарии:

1. Спасибо, это так. Я попытался передать указатель на другое значение с плавающей запятой (не 1.2345) до последнего оператора printf, и значение p2 в выходных данных изменилось с 1.2345 на это другое значение.

Ответ №3:

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

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

Компилятор использует различные соглашения о передаче (области памяти, стеки, регистры) для передачи различных типов аргументов. Указатели передаются одним способом (скажем, в стек процессора), в то время double как значения передаются другим способом (скажем, в стек регистров FPU). Вы передали указатель, но сказали printf , что это a double . printf зашел в область для передачи double s (например, в верхнюю часть стека регистров FPU) и прочитал «мусорное» значение, которое было оставлено там предыдущим printf вызовом.

Ответ №4:

неопределенное поведение — нет никаких ограничений на поведение программы. Примерами неопределенного поведения являются обращения к памяти за пределами границ массива, переполнение целых чисел со знаком, разыменование нулевого указателя, модификация одного и того же скаляра более одного раза в выражении без точек последовательности, доступ к объекту через указатель другого типа и т.д. Компиляторы не обязаны диагностировать неопределенное поведение (хотя многие простые ситуации диагностируются), и скомпилированная программа не обязана делать что-либо значимое.1

Неопределенное поведение означает не случайное поведение, а «не охватываемое стандартным» поведением. Так что это может быть все, что разработчик решит с этим сделать.

Стандарт определяет UBs, поскольку он допускает оптимизацию компиляции, которая в противном случае была бы невозможна.

Ответ №5:

Другие ответы касались того, что такое неопределенное поведение. Вот интересная статья, в которой описывается, почему в C так много неопределенного поведения и какие могут быть преимущества. Это не потому, что K amp; R были ленивы или им было все равно.

http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

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

Комментарии:

1. В этой статье описывается ошибочная философия, которая заставила авторов компиляторов требовать, чтобы вещи оставались неопределенными, но think don’t think at точно описывает, почему они остались неопределенными в C89. До C89 многие модели поведения были определены на некоторых платформах, но не на других, и я не видел никаких доказательств того, что авторы C89 намеревались это изменить. Для многих действий, которые вызывают UB, разрешение реализации делать абсолютно все, дает мало преимуществ для оптимизации по сравнению с предоставлением ей свободы произвольного выбора из множества возможных вариантов поведения.

2. Знаете ли вы какие-либо доказательства того, что авторы C89 намеревались разрешить виды «оптимизации», которые современные компиляторы используют для оправдания принуждения программистов к написанию менее эффективного кода?

3. @supercat: Я не уверен, что понимаю ваш вопрос. Поведение, определяемое реализацией, по-прежнему является четко определенным поведением. Это означает, что автор компилятора должен учитывать и обрабатывать этот конкретный случай. В некоторых случаях это то, что вы хотите, но во многих случаях это может противоречить эффективности. Если вы хотите подчеркнуть, что до 1989 года все было по-другому, возможно, вы могли бы привести пример?

4. Читая обоснование для C89, я думаю, довольно ясно, что решение о том, чтобы короткие значения без знака повышались до «signed int», в некоторой степени основывалось на том факте, что это не повлияет на поведение кода, такого как [предположим, 16-разрядный «short» и 32-разрядный «int»] unsigned mul(unsigned short x, unsigned short y) { return x*y; } на современных платформах даже в тех случаях, когда продукт находится между INT_MAX 1 и UINT_MAX (они явно упоминают такие случаи). Как вы думаете, авторы предполагали, что подобная функция должна иметь совершенно непредсказуемые побочные эффекты?

5. Кроме того, в приложениях, где допустимо, чтобы программа выполняла практически все, когда вводится неверный ввод, включая получение бессмысленных результатов или аварийное завершение, но где некоторые возможные варианты поведения (выполнение произвольного кода, предоставленного создателем злонамеренно искаженного ввода) были бы неприемлемыми, имеет ли смысл, что код C, необходимый длядля этого выполнение таких свободных требований должно быть более громоздким, чем машинный код? Если бы переполнение определялось реализацией, это потребовало бы, чтобы int1*int2>long1 поведение было согласованным в выборе получения 1 или 0 в случае переполнения.

Ответ №6:

Язык C широко использовался задолго до публикации стандарта C89, и авторы Стандарта не хотели требовать, чтобы соответствующие компиляторы не могли выполнять все, что существующие компиляторы могли делать так же эффективно, как они это уже делали. Если требование, чтобы все компиляторы реализовывали какое-либо поведение, сделало бы какой-то компилятор где-то менее подходящим для его задачи, это было бы оправданием для того, чтобы оставить поведение неопределенным. Даже если поведение было полезным и широко использовалось на 99% платформ, авторы Стандарта не видели причин полагать, что оставление неопределенного поведения должно повлиять на это. Если разработчики компиляторов считали практичным и полезным поддерживать поведение в те дни, когда ни один стандарт ничего не предписывал, не было никаких оснований ожидать, что им понадобится мандат для поддержания такой поддержки. Доказательства этой точки зрения можно найти в обосновании о продвижении коротких целочисленных типов без знака к signed .

Каким-то образом сложилось странное мнение, что все должно быть либо предписано Стандартом, либо непредсказуемо. Стандарт описывает общие последствия неопределенного поведения, и одним из наиболее распространенных в 1989 году было то, что реализация будет вести себя документированным образом, характерным для реализации.

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