Почему это недопустимо для типа объединения, объявленного в одной функции, для использования в другой функции?

Когда я читал ISO/IEC 9899: 1999 (см.: 6.5.2.3), я увидел пример, подобный этому (выделение мое):

Ниже приведен неверный фрагмент (поскольку тип объединения не отображается внутри функции f):

struct t1 { int m; };
struct t2 { int m; };
int f(struct t1 * p1, struct t2 * p2)
{
      if (p1->m < 0)
            p2->m = -p2->m;
      return p1->m;
}
int g()
{
      union {
            struct t1 s1;
            struct t2 s2;
      } u;
      /* ... */
      return f(&u.s1, &u.s2);
}

При проверке я не обнаружил ошибок и предупреждений.

Мой вопрос: почему этот фрагмент недействителен?

Ответы

Ответ 1

Пример пытается проиллюстрировать этот параграф заранее 1 (акцент мой):

6.5.2.3 ¶6

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

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

В примере не показано, как инициализируется u, но при условии, что последний записанный член является u.s2.m, функция имеет неопределенное поведение, потому что она проверяет p1->m не имея действующей общей гарантии последовательности.

То же самое происходит, если это u.s1.m которое было записано последним до вызова функции, чем доступ к p2->m является неопределенным поведением.

Обратите внимание: f не является недопустимым. Это совершенно разумное определение функции. Неопределенное поведение связано с переходом в него &u.s1 и &u.s2 качестве аргументов. Это то, что вызывает неопределенное поведение.


1 - Я цитирую n1570, стандартный проект C11.Но спецификация должна быть одинаковой, если только перемещение абзаца или два вверх/вниз.

Ответ 2

Вот строгое правило сглаживания в действии: одно предположение, сделанное компилятором C (или C++), заключается в том, что указатели на разузнавание объектов разных типов никогда не будут ссылаться на одно и то же место в памяти (то есть псевдонимы друг друга).

Эта функция

int f(struct t1* p1, struct t2* p2);

предполагает, что p1 != p2 поскольку они формально указывают на разные типы. В результате оптимизатор может предположить, что p2->m = -p2->m; не влияют на p1->m; он может сначала прочитать значение p1->m в регистре, сравнить его с 0, если оно сравнится с p2->m = -p2->m; меньше 0, тогда p2->m = -p2->m; и, наконец, вернуть значение регистра без изменений!

Объединение здесь - единственный способ сделать p1 == p2 на двоичном уровне, потому что все члены объединения имеют одинаковый адрес.

Другой пример:

struct t1 { int m; };
struct t2 { int m; };

int f(struct t1* p1, struct t2* p2)
{
    if (p1->m < 0) p2->m = -p2->m;
    return p1->m;
}

int g()
{
    union {
        struct t1 s1;
        struct t2 s2;
    } u;
    u.s1.m = -1;
    return f(&u.s1, &u.s2);
}

Что должно g вернуться? +1 соответствии с здравым смыслом (мы меняем -1 на +1 в f). Но если мы посмотрим на сборку gcc с помощью -O1 оптимизации

f:
        cmp     DWORD PTR [rdi], 0
        js      .L3
.L2:
        mov     eax, DWORD PTR [rdi]
        ret
.L3:
        neg     DWORD PTR [rsi]
        jmp     .L2
g:
        mov     eax, 1
        ret

До сих пор все как за исключением. Но когда мы пробуем это с помощью -O2

f:
        mov     eax, DWORD PTR [rdi]
        test    eax, eax
        js      .L4
        ret
.L4:
        neg     DWORD PTR [rsi]
        ret
g:
        mov     eax, -1
        ret

Возвращаемое значение теперь является жестко запрограммированным -1

Это связано с тем, что f в начале кэширует значение p1->m в регистре eax (mov eax, DWORD PTR [rdi]) и не перечитывает его после p2->m = -p2->m; (neg DWORD PTR [rsi]) - он возвращает eax без изменений.


union здесь используется только для всех нестатических данных. Элементы объединения имеют один и тот же адрес. как результат &u.s1 == &u.s2.

кто-то не понимает ассемблерный код, может показать в c/C++, как строгое сглаживание влияет на код f:

int f(struct t1* p1, struct t2* p2)
{
    int a = p1->m;
    if (a < 0) p2->m = -p2->m;
    return a; 
}

кеш компилятора p1->m значение p1->m в локальном var a (фактически в регистре, конечно) и вернуть его, несмотря на p2->m = -p2->m; изменить p1->m. но компилятор предполагает, что память p1 не затронута, поскольку предполагается, что p2 указывает на другую память, которая не перекрывается с p1

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

Ответ 3

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

Хотя большинство кодов, которые полагаются на гарантии Common Initial Sequence, используют несколько легко узнаваемых шаблонов, например

struct genericFoo {int size; short mode; };
struct fancyFoo {int size; short mode, biz, boz, baz; };
struct bigFoo {int size; short mode; char payload[5000]; };

union anyKindOfFoo {struct genericFoo genericFoo;
  struct fancyFoo fancyFoo;
  struct bigFoo bigFoo;};

...
if (readSharedMemberOfGenericFoo( myUnion->genericFoo ))
  accessThingAsFancyFoo( myUnion->fancyFoo );
return readSharedMemberOfGenericFoo( myUnion->genericFoo );

пересмотр союза между вызовами функций, действующих на разных членах профсоюза, авторы Стандарта указали, что видимость типа объединения внутри вызываемой функции должна быть определяющим фактором для того, должны ли функции распознавать возможность того, что доступ к, например, полевому mode FancyFoo может повлиять на mode поля genericFoo. Требование иметь объединение, содержащее все типы структур, адрес которых может быть передан readSharedMemberOfGeneric в одном модуле компиляции в качестве этой функции, делает правило Common Initial Sequence менее полезным, чем это было бы в противном случае, но, по крайней мере, позволяло бы некоторые шаблоны, такие как выше полезной.

Авторы gcc и clang полагали, что рассматривать объявления профсоюзов как указание на то, что вовлеченные типы могут быть задействованы в конструкциях, подобных приведенным выше, было бы нецелесообразным препятствием для оптимизации, и полагал, что, поскольку Стандарт не требует от них поддержки таких строит с помощью других средств, они просто не будут их поддерживать вообще. Следовательно, реальное требование для кода, которое должно было бы использовать общие предупреждения об исходных последовательностях любым значимым образом, заключается не в том, чтобы убедиться, что объявление типа объединения видимо, но для обеспечения того, чтобы clang и gcc вызывались с -fno-strict-aliasing флаг. Также включение видимой декларации профсоюза, когда практическое это не повредит, но это не является ни необходимым, ни достаточным для обеспечения правильного поведения от gcc и clang.