Каким образом этот код псевдонимов для структурных полей вызывает неопределенное поведение
Учитывая код:
#include <stdlib.h>
#include <stdint.h>
typedef struct { int32_t x, y; } INTPAIR;
typedef struct { int32_t w; INTPAIR xy; } INTANDPAIR;
void foo(INTPAIR * s1, INTPAIR * s2)
{
s2->y++;
s1->x^=1;
s2->y--;
s1->x^=1;
}
int hey(int x)
{
static INTPAIR dummy;
void *p = calloc(sizeof (INTANDPAIR),1);
INTANDPAIR *p1 = p;
INTPAIR *p2a = p;
INTPAIR *p2b = &p1->xy;
p2b->x = x;
foo(p2b,p2a);
int result= p2b->x;
free(p);
return result;
}
#include <stdio.h>
int main(void)
{
for (int i=0; i<10; i++)
printf("%d.",hey(i));
}
Поведение зависит от уровня оптимизации gcc, что подразумевает, что gcc считает, что этот код вызывает неопределенное поведение (определение "foo" сводится на нет, но, что интересно, определение "hey" увеличивает значение, передаваемое в). Я не совсем уверен, что, если что-то делает, противоречит правилам Стандарта.
Код очень преднамеренно и злонамеренно создает два указателя, так что s2a->y и s2b->x будут псевдонимами, но указатели специально создаются таким образом, чтобы оба идентифицировали допустимые потенциальные объекты типа INTPAIR. Поскольку код использовал calloc для получения памяти, все члены поля имеют допустимые начальные значения, равные нулю. Все доступы к выделенной памяти осуществляются через член int32_t INTPAIR*.
Я могу понять, почему в Стандарте имеет смысл запретить псевдонимы структурных полей таким способом, но я не смог найти ничего в Стандарте, который действительно делает это. Работает ли здесь gcc в соответствии со стандартом или нарушает какой-то пункт в стандарте, на который не ссылается приложение J.2 и не использует ни одного из терминов, которые я искал?
2 ответа
Моему предыдущему ответу не хватало, может быть, и не совсем неправильному, но образец программы специально разработан, чтобы обойти каждое из более очевидных явных неопределенных поведений (UB), продиктованных стандартом C99, таких как 6.5/7. Но и с GCC (и с Clang) этот пример демонстрирует строгую ошибку псевдонимов, такую как симптомы при оптимизации. Похоже, они предполагают, что s1->y и s2-x не могут иметь псевдоним. Итак, компилятор не прав? Это лазейка в строгом легализовании псевдонимов?
Короткий ответ: Нет. Я не удивлюсь, если бы в стандарте была какая-то лазейка, учитывая ее сложность. Но в этом примере создание перекрывающихся объектов в куче является явно неопределенным поведением, и происходит несколько других вещей, которые стандарт не определяет.
Я думаю, что смысл этого примера не в том, что он терпит неудачу - очевидно, что "играть быстро и свободно" с указателями - плохая идея, и полагаться на угловые случаи и юридические доказательства того, что компиляция "неправильная", мало поможет, если код не работает Ключевыми вопросами являются: GCC не так? и что в стандарте так говорит.
Во-первых, давайте рассмотрим очевидные строгие правила псевдонимов и то, как этот пример пытается их избежать.
С99 6,5/7:
Объект должен иметь свое сохраненное значение, доступное только через выражение lvalue, которое имеет один из следующих типов: 76)
- тип, совместимый с эффективным типом объекта,
- квалифицированная версия типа, совместимого с эффективным типом объекта,
- тип, который является типом со знаком или без знака, соответствующим действующему типу объекта,
- тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
- тип агрегата или объединения, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегата или автономного объединения), или
- тип персонажа.
Это основной раздел строгого алиасинга. Это означает, что доступ к одной и той же памяти через два указателя разных типов - это UB. Этот пример обходит это, получая доступ к обоим, используя указатели INTPAIR в foo()
,
Основная проблема заключается в том, что речь идет о доступе к сохраненному значению с помощью двух разных эффективных типов (например, указателей). Это не говорит о доступе через два разных объекта.
К чему обращаются? это целочисленный член или весь объект s1 / s2? Имеет доступ к s2->x через доступ s1->y через "тип, совместимый с эффективным типом объекта". Я полагаю, что можно привести аргумент, что a) доступ как побочный эффект изменения другого объекта не подпадает под допустимые методы в 6.5 / 7, и что b) изменение одного члена агрегата транзитивно изменяет агрегат (*s1 или *s2) также.
Поскольку это не указано, это UB, но оно немного волнистое.
Как мы получили указатели на два перекрывающихся объекта? Оправдывает ли приведение указатель к ним? Раздел 6.3.2.3 содержит правила для наведения указателей, и пример тщательно не нарушает ни одного из них. В частности, поскольку p2b является указателем на член INTANDPAIR xy, выравнивание гарантированно будет правильным, в противном случае оно определенно нарушит 6.3.2.3/7.
Кроме того, &p1->xy не является проблемой - это не может быть - это совершенно законный указатель на INTPAIR. Простое приведение указателей и / или получение адресов безопасно находится вне определения "доступа" (3.1/1).
Понятно, что проблема возникает из-за доступа к двум целочисленным элементам, которые накладываются друг на друга как разные части перекрывающихся объектов. Любая попытка сделать это с помощью указателей разных типов явно будет идти вразрез с 6,5 / 7. Если бы доступ к тому же указателю типа по тому же адресу, не было бы никаких проблем вообще. Таким образом, единственный путь, который они могли использовать для псевдонимов, заключается в том, что если два объекта с разными адресами перекрываются каким-либо образом.
Очевидно, что это может произойти как часть союза, но это не так для этого примера. В C99 типовое наказание через союзы может не иметь отношения к UB, но будет другой вопрос, может ли вариант этого примера поступить неправильно через союзы.
В примере используется динамическое распределение и приведенный результирующий указатель void на два разных типа. Переход от указателя на объект к void * и обратно действителен (6.3.2.3/1). Несколько других способов получения указателей на объекты, которые будут перекрываться, являются явным UB с помощью правил преобразования указателей из 6.3.2.3, правил наложения имен из 6.5 / 7 и / или правил совместимых типов 6.2.7.
Так что еще не так?
6.2.4 Длительность хранения объектов
1 У объекта есть срок хранения, который определяет его время жизни. Существует три срока хранения: статический, автоматический и выделенный. Выделенное хранилище описано в 7.20.3
Хранилище для каждого из объектов выделяется функцией calloc(), поэтому желаемая продолжительность "выделяется". Итак, мы проверяем 7.20.3: (выделение добавлено)
7.20.3 Функции управления памятью
1 Порядок и непрерывность памяти, выделенной последовательными вызовами функций calloc, malloc и realloc, не определены. Указатель, возвращаемый в случае успешного выделения, выравнивается соответствующим образом, чтобы его можно было присвоить указателю на объект любого типа, а затем использовать для доступа к такому объекту или массиву таких объектов в выделенном пространстве (пока пространство не будет явно освобождено), Время жизни выделенного объекта простирается от выделения до освобождения. Каждое такое распределение должно давать указатель на объект, не пересекающийся с любым другим объектом.
...
2 Время жизни объекта - это часть выполнения программы, в течение которой для него гарантированно сохраняется память. Объект существует, имеет постоянный адрес 25) и сохраняет свое последнее сохраненное значение в течение всего срока его службы. 26) Если объект упоминается за пределами его времени жизни, поведение не определено.
Чтобы избежать UB, доступ к двум различным объектам должен быть действительным объектом в течение его времени жизни. Вы можете получить один действительный объект (или массив) с помощью malloc () / calloc(), но это гарантирует, что вы получите указатель, не связанный с другими объектами. Таким образом, объект возвращается из calloc () p или это p1? Это не может быть и то и другое.
UB запускается при попытке повторно использовать один и тот же динамически распределенный объект для хранения двух объектов, которые не пересекаются. Хотя calloc () гарантирует, что он вернет указатель на непересекающийся объект, ничто не говорит о том, что он все равно будет работать, если вы затем начнете использовать части буфера для второго перекрывающегося. На самом деле, он даже явно говорит, что это UB, если вы обращаетесь к объекту вне его времени жизни, и существует только одно выделение, то есть за одно время жизни.
Также обратите внимание:
4. Соответствие
- В этом международном стандарте "должен" интерпретироваться как требование к реализации или программе; и наоборот, "не должен" должен интерпретироваться как запрет.
- Если требование '' должен '' или 'не будет' ', которое появляется за пределами ограничения, нарушается, поведение не определено. Неопределенное поведение иначе обозначено в этом международном стандарте словами "неопределенное поведение" или пропуском любого явного определения поведения. Нет разницы в акценте между этими тремя; все они описывают "поведение, которое не определено".
Чтобы это было ошибкой компилятора, она должна завершиться с ошибкой в программе, которая использует только явно определенные конструкции. Все остальное находится за пределами безопасной гавани и все еще не определено, даже если в стандарте прямо не указано, что оно является неопределенным поведением.
ОБНОВЛЕНИЕ: я чувствовал, что этот ответ был в порядке, но не все еще немного неточным, и не точным относительно того, что было UB. После множества очень интересных дискуссий и комментариев я попробовал еще раз с новым ответом
В этом ответе указана правая часть стандарта C99. Я копирую это здесь для удобства. Вопрос и несколько ответов довольно основательны.
(C99; ISO / IEC 9899: 1999 6.5/7:
Объект должен иметь свое сохраненное значение, доступное только через выражение lvalue, которое имеет один из следующих типов 73) или 88):
- тип, совместимый с эффективным типом объекта,
- квалифицированная версия типа, совместимого с эффективным типом объекта,
- тип, который является типом со знаком или без знака, соответствующим действующему типу объекта,
- тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
- тип агрегата или объединения, который включает в себя один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегата или автономного объединения), или
- тип персонажа.
73) или 88) Целью этого списка является определение тех обстоятельств, при которых объект может или не может быть псевдонимом.
Что такое эффективный тип тогда? (C99; ISO/IEC 9899:1999 6.5/6:
Эффективным типом объекта для доступа к его сохраненному значению является объявленный тип объекта, если таковой имеется. 87) Если значение сохраняется в объекте, у которого нет объявленного типа, через lvalue, имеющий тип, который не является символьным типом, то тип lvalue становится эффективным типом объекта для этого доступа и для последующих доступов, которые не изменить сохраненное значение. Если значение копируется в объект, не имеющий объявленного типа, с использованием memcpy или memmove, или копируется как массив символьного типа, тогда эффективный тип измененного объекта для этого доступа и для последующих доступов, которые не изменяют значение, является действующий тип объекта, из которого копируется значение, если оно есть. Для всех других доступов к объекту, не имеющему объявленного типа, эффективный тип объекта - это просто тип lvalue, используемого для доступа.
87) Выделенные объекты не имеют объявленного типа.
Так на линии p2b->x = x
объект с p+4 становится действующим типом INTPAIR. Это выровнено правильно? Если это не так, то неопределенное поведение (UB). Но чтобы это было интересно, предположим, что так и должно быть в этом случае из-за компоновки INTANDPAIR.
По тому же анализу есть два 8-байтовых объекта, p2a (s2) в @(p+4) и p2b @p. Как ваш пример демонстрирует, что 2-й элемент p2a и первый из p2b заканчиваются псевдонимом.
в foo()
объект p2b @p+4 доступен обычным методом через s1->x
, Но затем к "сохраненному значению" объекта p2b также обращаются с побочным эффектом изменения другого объекта p2a @p. Так как это не подпадает ни под одну из пуль 6.5/7, это UB. Обратите внимание, что 6.5/7 говорит только, поэтому доступ к объектам не должен осуществляться другими способами.
Я думаю, что главное отличие состоит в том, что рассматриваемый "объект" - это целая структура p2a/s2 и p2b/s1, а не целочисленные члены. Если вы измените аргумент функции, чтобы взять целые числа и присвоить им псевдоним, он будет работать "отлично", потому что функция не может знать псевдонимы s1 и s2. Например:
void foo2(int *s1, int *s2)
{
(*s2)++;
(*s1)^=1;
(*s2)--;
(*s1)^=1;
}
...
/*foo(p2b,p2a);*/
foo2((int*)p, (int*)p); /* or p+4 or whatever you want */
Это более или менее подтверждает, что именно так GCC решил интерпретировать вещи: изменение члена - это изменение всего объекта структуры, и что поскольку побочные эффекты изменения одного объекта не связаны с перечисленными законными способами косвенного изменения другого объекта, то есть! мы можем делать любые глупости, которые нам нравятся.
Так что, интерпретирует ли GCC неоднозначности в стандарте, чтобы решить, что путем получения указателей s1 и s2 через разные типизированные указатели и последующего доступа к ним будет косвенно обращаться к памяти через различные исходные типы через p1 и p, или же он будет интерпретировать стандарт так, как я предполагая, что "объект" s2->y
модифицирует не только целое число, но и объект s2, это UB в любом случае. Или GCC просто проявляет особую ловкость и указывает, что если стандарт не очень четко определяет семантику динамически размещаемых, но перекрывающихся объектов, он может делать все, что захочет, потому что по определению он "неопределен".
Я не думаю, что на этом микроскопическом уровне кто-либо, кроме органа по стандартизации, может окончательно ответить, должен ли это быть UB или нет, потому что на этом уровне это требует некоторой "интерпретации". Мнение исполнителей GCC, кажется, поддерживает очень агрессивные интерпретации.
Мне нравится реакция Линуса на все это. И это правда, почему бы просто не быть консервативным и позволить программисту сообщить компилятору, когда это безопасно? Очень отлично Линус Рант