严格别名规则是单向的吗?

3

我相信C标准中的6.5p7定义了所谓的严格别名规则,具体如下:

一个对象只能通过其有效类型兼容的lvalue表达式访问其储存值,这里有效类型指对象最近一次存储操作的类型,具体规则如下:

  1. 与对象的有效类型兼容的类型
  2. 与对象的有效类型兼容的带限定符修饰的类型
  3. 与对象的有效类型相应的有符号或无符号整型类型
  4. 与对象的有效类型的带限定符修饰的类型相应的有符号或无符号整型类型
  5. 包含其中一种前述类型的聚合体或联合体类型(递归地包括子聚合体或内含联合体的成员)
  6. 字符类型

下面是一个简单例子展示了GCC基于该规则的假设进行的优化。

int IF(int *i, float *f) {
    *i = -1;
    *f = 0;
    return *i;
}

IF:
        mov     DWORD PTR [rdi], -1
        mov     eax, -1
        mov     DWORD PTR [rsi], 0x00000000
        ret

假设intfloat不能别名,因此省略了对return *i的负载。

接下来考虑第6种情况,它说明一个对象可以通过字符类型的左值表达式(char *)被访问。

int IC(int *i, char *c) {
    *i = -1;
    *c = 0;
    return *i;
}

IC:
        mov     DWORD PTR [rdi], -1
        mov     BYTE PTR [rsi], 0
        mov     eax, DWORD PTR [rdi]
        ret

现在针对return *i有一个负载,因为根据规则,ic可能重叠,*c = 0可能会改变*i 中的内容。

那么我们是否也可以通过int *修改char?编译器是否应该考虑这种情况?

char CI(char *c, int *i) {
    *c = -1;
    *i = 0;
    return *c;
}

CI: #GCC
        mov     BYTE PTR [rdi], -1
        mov     DWORD PTR [rsi], 0
        movzx   eax, BYTE PTR [rdi]
        ret

CI: #Clang
        mov     byte ptr [rdi], -1
        mov     dword ptr [rsi], 0
        mov     al, byte ptr [rdi]
        ret

查看汇编输出,GCC和Clang似乎都认为通过 int * 访问可以修改 char

也许重叠的意思很明显,即 A 与 B 重叠时 A 重叠 B,B 重叠 A。然而,我发现这个详细的答案中加粗强调了以下内容:

请注意,像 char* 别名规则一样,may_alias 只能单向传递:使用 int32_t* 读取 __m256 是不安全的。甚至使用 float* 读取 __m256 也可能不安全,就像使用 char buf[1024]; int *p = (int*)buf; 不安全一样。

现在我感到非常困惑。答案还涉及到 GCC 向量类型,它具有 may_alias 属性,因此可以像 char 一样别名。

至少,在以下示例中,GCC似乎认为重叠访问可以双向发生。
int IV(int *i, __m128i *v) {
    *i = -1;
    *v = _mm_setzero_si128();
    return *i;
}

__m128i VI(int *i, __m128i *v) {
    *v = _mm_set1_epi32(-1);
    *i = 0;
    return *v;
}

IV:
        pxor    xmm0, xmm0
        mov     DWORD PTR [rdi], -1
        movaps  XMMWORD PTR [rsi], xmm0
        mov     eax, DWORD PTR [rdi]
        ret
VI:
        pcmpeqd xmm0, xmm0
        movaps  XMMWORD PTR [rsi], xmm0
        mov     DWORD PTR [rdi], 0
        movdqa  xmm0, XMMWORD PTR [rsi]
        ret

https://godbolt.org/z/ab5EMx3bb

但是我可能漏掉了什么?strict aliasing 是否是单向的?


此外,在阅读当前的答案和评论后,我认为这段代码可能不符合标准。

typedef struct {int i;} S;
S s;
int *p = (int *)&s;
*p = 1;

请注意,(int *)&s&s.i 是不同的。 我目前的理解是,以 int 类型的左值表达式访问类型为 S 的对象,并且此情况未列在 6.5p7 中。

规则绝对是单向的,有现实生活中的例子证明编译器会破坏指向实际__m256i *对象* 的代码,比如GCC AVX _m256i强制转换为int数组导致值错误。但你正在使用一个__m128i *指针来指向可以是不同底层类型的内存。请注意,你在我的答案中引用了一个char buf[1024]的示例,这是一个字符数组对象,没有char*参与其中。 (访问它可能涉及到char*,因为buff[i]的工作方式是*(buff+i),所以这种做法可能更安全,不像__m128i) - Peter Cordes
我会更新我的链接答案,包括那个真实世界的破坏例子。 - Peter Cordes
@PeterCordes int成员是一个int对象,但这与struct {int i;}对象不同。通过*(int *)&s = 0;,您正在通过int *访问struct {int i;}。不确定这是否可以接受。 - xiver77
1
__m256i v在该地址上没有int成员子对象,因此严格别名规则的第一点不适用。当然,您必须尊重严格别名以及其他指针派生规则。 - Peter Cordes
1
@PeterCordes:在Dennis Ritchie的语言中使用的抽象模型下,每个可寻址存储区域在其整个生命周期内同时包含每种类型的对象,只要它们符合大小和对齐约束条件。但是当N1570 6.5p6和6.5p7使用“对象”一词时,他们必须指的是其他东西,但不清楚是什么。 - supercat
显示剩余15条评论
3个回答

3

是的,这只是单向的,但从函数的上下文来看,它无法确定是哪一边。

考虑到这个:

char CI(char *c, int *i) {
    *c = -1;
    *i = 0;
    return *c;
}

它本来可以被称为这样:

int a;
char *p = ((char *)&a) + 1;
char b = CI(p,&a);

哪一个是有效使用别名的例子。所以在函数内部,*i = 0正确地将调用函数中的a设置为0,并且*c = -1正确地设置了a中的一个字节。


如果您不介意的话,请看一下我问题中新增的部分。 - xiver77
标准是在编译器不提供这种语义基本上是不可能的时候编写的,因此没有必要强制执行它们。然而,今天,编译器会尽力使用整个程序优化来避免执行标准未规定的任何重新加载操作。 - supercat

2
要理解在任何特定情况下“严格别名规则”如何适用,必须定义两个概念,这些概念在N1570 6.5p7中被引用但实际上没有在标准中定义:
1. 对于N1570 6.5p7,什么情况下将存储区域视为包含任何特定类型的对象?特别是对于您的用例,什么意思是“作为字符类型数组复制”? 2. 对于一个对象来说,“由”特定类型的lvalue访问是什么意思?
关于这些概念如何指定从未达成共识,因此无法让任何人知道规则的含义(*)。 标准似乎旨在明确支持以下场景:通过malloc()或其他方式创建存储区域,然后仅使用字符类型进行写入,然后通过其他类型或仅使用一种非字符类型进行写入并随后通过字符类型进行读取的存储区域,但其他情况则有点模糊。
更重要的是,虽然clang和gcc使用字符类型支持这些场景,但clang和gcc所容纳的场景集不包括一些边角情况,而标准是明确的,但这些情况不符合clang和gcc使用的抽象模型。无论规则如何,程序员应该期望clang和gcc的 -fstrict-aliasing 方言不适用于可能在其生命周期内通过任何非字符类型访问存储,即使始终使用最后一种写入它的类型进行读取。
(*)公正地说,像这样的结构:
unsigned test(float *fp) { return *(unsigned*)fp; }

这段内容的意思是:一个实现可能会忽略指针访问可能会影响到 float 类型的东西,但对于指针目标存储如何在函数外部使用则不加考虑,或者一个实现可能进行更详细的流分析,但注意到被解引用的指针值来自一个 float *。遗憾的是,如果标准认可优秀的实现应该至少像第一种情况一样广泛地回答第二个问题,那么这可能会被视为意味着 clang 和 gcc 的作者一直在要求产生质量低劣的实现。


在什么情况下,存储区域被认为包含任何特定类型的对象?在查看了几种别名情况下GCC的汇编输出后,我认为GCC认为在通过该类型的有效指针访问某个位置之后,该类型(A)的lvalue表达式分配,该位置存在特定类型的对象,并且可以被不同类型(B)的lvalue表达式的另一个赋值覆盖,在此之后,不能安全地假设类型A的对象仍然存在于相同的区域。 - xiver77
据我所知,无论是clang还是gcc都遵循这样一种模式:如果可以证明某个操作会导致存储区域保存先前某个时刻所持有的位模式,则该操作可能会导致存储区域的有效类型恢复为先前的类型,即使该位模式是由不同类型写入的。 - supercat

2
你可以取任何对象的指针,将其转换为char*并使用它来访问该对象底层的位模式。您还可以将通过这种方式获得的char*强制转换回其原始类型。
因此,当编译器看到int *ichar *p时,它无法排除p是通过从i进行转换创建的可能性。因此,它们可能指向相同的原始内存。改变一个可能会改变另一个。它是双向的。但这不是文本所涉及的内容。
这里所说的是从A*char*,然后到B*的强制转换。所指向的对象不会神奇地变成B,通过B*访问它是未定义的行为。也许单向是错误的词。我不知道如何更好地命名它。但对于每个对象,都有只有2个站点的火车:A*char*unsigned char*signed char*const char*,...以及所有变体)。您可以随意前后移动,但永远不能更改轨道并转到B*
这有帮助吗? may_alias属性设置了另一个类似的铁路系统。允许int[4]__m128i*之间的别名,因为这正是编译器需要进行矢量化的重叠部分。但这是您必须在编译器规范中查找的内容。

这个问题也在询问__m128i,它被定义为typedef long long __m128i __attribute__((may_alias,vector_size(16)))。所以除了char*之外,你还可以通过另一种类型进行转换。而且,在我回答中引用的部分中缺少的关键点是,要有一个实际声明的__m128i vec 对象,并将其他指针类型指向它。不仅仅是解引用指针。如果该对象是匿名的,并且只存在于指针解引用中,则如果除了char*__m128i*之外没有其他类型,那么将int*指向它是安全的。 - Peter Cordes
你的回答确实帮助我理解了正在发生的事情,但是我仍然不清楚像 struct {int i;} s = {1}; *(int *)&s = 0; 这样的情况。我知道如果它在内存中,ss.i 必须在同一个内存位置,但规则说只能通过 struct {int i;} * 访问 int,而不能反过来,所以这个例子可能有问题吗? - xiver77
@GoswinvonBrederlow:正如我在第一条评论中所说的,__m128i是一个“may_alias”类型;它明确允许将__m128i*指向int arr[4](或short arr[8]、结构体或其他任何东西)。这种必需的行为是英特尔内置API的一部分;任何提供__m128i*的编译器都必须以某种方式支持它。may_alias属性是GNU C兼容编译器的实现方式;MSVC允许所有别名。请参见Is `reinterpret_cast`ing between hardware SIMD vector pointer and the corresponding type an undefined behavior? - Peter Cordes
@GoswinvonBrederlow:对于所有的__m...向量类型,包括将浮点向量指向整数数据的情况,都是一样的。 - Peter Cordes
@PeterCordes 对不起,你是对的。我选择的类型不好。我已经扩展了我的答案。 - Goswin von Brederlow
显示剩余30条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接