C - 用于算术的不兼容指针是否违反严格别名?

C - Do incompatible pointers used for arithmetic violate strict aliasing?

这个问题是我之前问过的问题的延伸。但是,经过一段时间后,我发现自己对的一些概念还是有歧义。

为了方便讨论,我先对主机实现做如下假设:


问题一:

void *ptr = malloc(4096);        // (A)

*(int *) ptr = 10;               // (B)               

/*
 * Does the following line have undefined behavior
 * or violate strict aliasing rules?
 */
*(((double *) ptr) + 2) = 1.618; // (C)

// now, can still read integer value with (*(int *) ptr)

以我目前的理解,答案是

根据 C11 的 [6.3.2.3 #7]:

A pointer to an object type may be converted to a pointer to a different object type. If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined. ...

和 C11 的 [6.5 #7]:

An object shall have its stored value accessed only by an lvalue expression that has one of the following types:

  • a type compatible with the effective type of the object,
  • ...

因此,据我所知,

我上面说的是不是有什么误解?


问题二:

void *ptr = malloc(4096);        // (A)

*(int *) ptr = 10;               // (B)

/*
 * Does the following line have undefined behavior
 * or violate strict aliasing rules?
 */
*(double *) ptr = 1.618;        // (C)

// now, shall not read value with (*(int *) ptr)

以我目前的理解,答案也是

根据 C11 的 [6.5 #6]:

If a value is stored into an object having no declared type through an lvalue having a type that is not a character type, then the type of the lvalue becomes the effective type of the object for that access and for subsequent accesses that do not modify the stored value.

因此,据我所知,(C) 行是后续访问,它修改了存储值并将前 8 个字节的有效类型更新为 double。我对上面说的有什么误解吗?

主要困惑不确定是否违反了[6.5#7]规则:

An object shall have its stored value accessed only by an lvalue expression that has one of the following types:

  • a type compatible with the effective type of the object,
  • ...

对于问题 1,没有问题,因为您访问了一个没有声明类型的不同对象。在 intdouble 的情况下,“左值的类型变成 该访问的对象的有效类型。

对于问题2,它说:

If a value is stored into an object having no declared type through an lvalue having a type that is not a character type, then the type of the lvalue becomes the effective type of the object for that access and for subsequent accesses that do not modify the stored value.

分配的存储没有声明类型,您可以通过 int 访问它,但稍后您可以通过 double 进行 修改 *((double *) ptr) = 1.618; 不太可能是一些读-修改-写 - 它只是一个写(这样的概念甚至没有被 C 定义)。

一个非常明智的解释是“对于不修改的后续访问”不适用,我们应该将其视为具有不同有效类型的新左值访问。如果从字面上看,就不会有任何严格的别名违规。

但这一切都模棱两可;您不妨将其理解为:编译器应该在内部跟踪所有有效类型,以及当您通过不兼容类型进行访问或在没有声明类型的对象先前获得有效类型,那就是UB。

这部分标准6.5/6和/7根本就不清楚


实际上,无论标准怎么说,我们还可以看到,当我们尝试启用以下优化的代码时,主流编译器 运行 进入了未定义行为森林:

#include <stdlib.h>
#include <stdio.h>

int main (void)
{
    void *ptr = malloc(4096);        // (A)

    *((int *) ptr) = 10;             // (B)

    /*
    * Does the following line have undefined behavior
    * or violate strict aliasing rules?
    */
    *((double *) ptr) = 1.618;       // (C)

   if( *((int *) ptr) == 10  )
     puts("Value didn't change.");
}

https://godbolt.org/z/jhxj7WqKW

  • gcc x86 说“值没有改变。”直到我们删除 -O3 然后行为改变。
  • clang x86 不会生成程序,因为它认为值已更改。
  • icc 生成 mov 指令,尽管进行了优化并检查了内容,然后不打印任何内容。

来自 3 个编译器的 3 种不同行为,使用相同的代码和相同的编译器选项...所以在实践中,我们必须简单地引用像这样的可疑指针转换,因为在 C99 之后的 22 年,编译器仍在执行严格的以错误的方式使用别名,我不怪他们,因为标准写得很含糊。

To facilitate the discussion, I first make the following assumptions about the host implementation [...]

这些假设几乎完全无关紧要。对于所提出的特定问题,唯一重要的限制是 sizeof(int) <= 2 * sizeof(double).

特别是,malloc() 保证分配一个适合任何内置类型对齐的块。

Question One:

您的分析是正确的:没有违反严格的别名。

Question Two:

According to [6.5 #6] of C11:

If a value is stored into an object having no declared type through an lvalue having a type that is not a character type, then the

type of the lvalue becomes the effective type of the object for that access and for subsequent accesses that do not modify the stored value.

因此,据我所知,行 (C) 是修改的后续访问 存储的值并更新前 8 字节的有效类型 加倍。

是的,第 (C) 行修改了 *(double *) ptr 的存储值,尽管 ptr 具有声明的类型,但 *(double *) ptr 指定的对象是动态分配的一部分块,不。因此,到第 6.5/6 段,*(double *) ptr 指定的对象的有效类型成为表达式 *(double *) ptr 的类型(即 double 包括访问自身。该段末尾的例外用于避免该例外与您 (B) 的访问效果之间的冲突。

因此,在 (C) 处不存在严格的别名违规。用于访问的左值是 *(double *)ptr。它的类型是 double,根据 6.5/6,这也是被访问对象的有效类型,尽管该对象或其任何部分可能具有任何其他有效类型。这就满足了SAR的第一种选择。

虽然其他答案在描述标准似乎要说的内容方面做了合理的工作,但 clang 和 gcc 似乎都将短语“不修改存储值的后续访问”解释为好像它说的是“后续访问确实不要以稍后将被观察到的方式改变存储的位模式”。两个编译器都容易取序列:

  1. 使用引用 1 写入值为 X 的 T 的存储
  2. 使用引用 2 写入 U 值为 Y 的存储
  3. 使用引用 3 将存储读取为类型 U
  4. 可选地使用某个任意值的 T 写入存储,使用参考 3
  5. 使用参考文献 3,使用位模式与步骤 #3 中读取的内容匹配的 T 写入存储
  6. 使用引用 1 将存储读取为类型 T

如代码所示:

typedef long long longish;
__attribute((noinline))
long test(long *p, int index, int index2, int index3)
{
    if (sizeof (long) != sizeof (longish))
        return -1;

    p[index] = 1;                          // Step 1
    ((longish*)p)[index2] = 2;             // Step 2
    longish temp2 = ((longish*)p)[index3]; // Step 3
    p[index3] = 5;                         // Step 4
    p[index3] = temp2;                     // Step 5
    return p[index];                       // Step 6
}
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    long *arr = malloc(sizeof (long));
    long temp = test(arr, 0, 0, 0);
    printf("%ld should equal %ld\n", temp, arr[0]);
    free(arr);
}

并优化步骤#4 中的写入(这里写入的位模式将永远不会被观察到,因为它已被步骤#5 覆盖),以及步骤#5 中的写入(一旦在步骤# 中写入4 被删除,步骤#5 中的写入将不再更改位模式)。一旦这些写入被删除,编译器将假设由于没有使用类型 T 的对象来修改对象,它们可能会在步骤 #6 中优化读取。即使引用应该被识别为在每个使用点从一个公共指针新派生的,他们也会这样做。

我在标准的术语中看不到任何暗示这种解释有效或合理的内容,但 clang 和 gcc 的维护者多年来一直知道他们不处理这种极端情况,据我所知如果第 3 步将该位模式读取为 U 而第 5 步将其写入为 T,则第 2 步可能会合法地覆盖第 1 步中写入的值,但没有尝试考虑这种可能性。