为什么严格的别名规则不适用于int *和unsigned *?

时间:2018-09-15 23:56:20

标签: c language-lawyer compiler-optimization undefined-behavior strict-aliasing

在C语言中,我们无法使用左值表达式访问对象,该左值表达式的类型与该对象的有效类型不兼容,因为这会产生不确定的行为。基于此事实,严格的别名规则规定,如果两个指针具有不兼容的类型,则它们不能互为别名(引用内存中的同一对象)。但是在C11标准的 p6.2.4 中,允许访问具有签名版本左值的未签名有效类型,反之亦然。

由于最后一段,两个指针int *aunsigned *b可能互为别名,并且其中一个指针所指向的对象的值的更改可能导致该对象的值的更改。指向另一个对象(因为它是同一个对象)。

让我们在编译器级别进行演示:

int f (int *a, unsigned *b)
{
    *a = 1;
    *b = 2;

    return *a;
}

在具有 -O2 的GCC 6.3.0上,上述函数的生成程序集如下所示:

0000000000000000 <f>:
   0:   movl   $0x1,(%rdi)
   6:   movl   $0x2,(%rsi)
   c:   mov    (%rdi),%eax
   e:   retq  

这是可以预料的,因为GCC不会优化返回值,并且在写入*a之后仍会再次读取值*b(因为更改*b可能会导致*a的更改)。

但是具有其他功能:

int ga;
unsigned gb;

int *g (int **a, unsigned **b)
{
    *a = &ga;
    *b = &gb;

    return *a;
}

生成的程序集非常令人惊讶(GCC -O2):

0000000000000010 <g>:
  10:   lea    0x0(%rip),%rax        # 17 <g+0x7>
  17:   lea    0x0(%rip),%rdx        # 1e <g+0xe>
  1e:   mov    %rax,(%rdi)
  21:   mov    %rdx,(%rsi)
  24:   retq 

返回值已优化,写入*b后不会再次读取。我知道int *aunsigned *b不是兼容类型,但是 P6.2.4 段中的规则又如何呢?(允许访问带有签名版本左值的未签名有效类型反之亦然)?为什么在这种情况下不适用?在这种情况下,编译器为什么要进行这种优化?

关于兼容类型和严格别名的整个故事,我有些不了解。有人可以启发我们吗? (并请解释为什么两个指针具有不兼容的类型,但可以互相别名,请考虑int *aunsigned *b)。

2 个答案:

答案 0 :(得分:4)

鉴于int **aunsigned **b*a的类型不是与*b的有效类型相对应的有符号或无符号类型,也不是*b*a的有效类型相对应的有符号或无符号类型。因此,该规则不允许通过相应的有符号或无符号类型进行别名处理。由于没有其他允许别名的规则也适用,因此编译器有权假定对*b的写操作不会修改*a,因此,编译器在{{1}中写入*a的值} *a = &ga;语句仍在*a中出现。

return *a;指向已签名的int *的事实并不使它成为已签名的类型。它是一个指针。 intint *是指向不同类型的指针。即使它们被认为是有符号的或无符号的,它们也将是指向不同类型的有符号的或无符号的指针:如果unsigned *是有符号的指针,它将是指向int *的有符号的指针,并且相应的无符号版本将是指向int的无符号指针,而不是指向int的任何指针。

答案 1 :(得分:3)

要了解已签名/未签名豁免的预期含义,必须首先了解这些类型的背景。 C语言最初没有“无符号”整数类型,而是专为在二进制补码计算机上使用而在溢出时进行安静环绕而设计。尽管有一些操作,最值得注意的是关系运算符,除法,余数和右移,其中有符号和无符号行为会有所不同,但对有符号类型执行大多数操作将产生与对无符号类型执行相同操作的位模式相同的位模式,从而最大限度地减少了对后者的需求。

尽管无符号类型即使在静默环绕的二进制补码机器上肯定也很有用,但在不支持静默环绕的二进制补码语义的平台上,它们是必不可少的。但是,由于C最初并不支持这样的平台,因此编写了许多逻辑上“应该”使用了无符号类型的代码,并且如果它们早已存在,便会使用它们,而是改为使用有符号类型。该标准的作者不希望类型访问规则在使用有符号类型的代码之间创建任何困难,因为在编写时无符号类型不可用,而使用无符号类型的代码则因为它们可用并且可以使用而使用无符号类型。有道理。

交替使用intunsigned的历史原因同样适用于允许使用int*类型的左值访问unsigned*类型的对象,反之亦然,{ {1}}将使用int**等进行访问。尽管标准没有明确规定应允许使用任何此类用法,但它也忽略了显然应允许的其他一些用法,因此不能被合理地视为完全而完整地描述了实现应支持的一切。

该标准无法区分两种情况,即涉及基于指针的类型修剪的情况-涉及别名的情况和不涉及别名的情况-在非规范脚注中指出,规则的目的是表明什么时候可能出现别名。区别如下所示:

unsigned**

如果int *x; unsigned thing; int *usesAliasingUnlessXandPDisjoint(unsigned **p) { if (x) *p = &thing; return x; } x标识相同的存储,则*p*p之间将出现别名,因为x的创建和通过{的写入使用左值p对存储的冲突访问将分隔{1}}。但是,给出如下所示:

*p

x参数和unsigned thing; unsigned writeUnsignedPtr(unsigned **p) { *p = &thing; } int *x; int *doesNotUseAliasing(void) { if (x) writeUnsignedPtr((unsigned**)&x); return x; } 之间将没有别名,因为在传递的指针*p的生存期内,x和任何其他指针或左值都不不是从p派生的,用于访问与x相同的存储。我认为很明显,该标准的作者希望允许使用后一种模式。我认为尚不清楚他们是否想允许前者甚至使用p*p类型的左值[相对于signedunsigned],还是没有意识到将规则应用于实际涉及别名的案例就足以允许后者了。

gcc和clang解释别名规则的方式不会将signed*unsigned*之间的兼容性扩展到intunsigned,这是给定的限制。标准的措词,但至少在不涉及别名的情况下,我认为这与标准的既定目的背道而驰。

您的特定示例确实涉及int*unsigned*重叠的情况下的别名,因为首先创建了*a,并且在创建和复制之间发生了通过*b的冲突访问。首先创建了a*b的最后一次使用,并且在创建和最后使用*a之间发生了通过b的冲突访问。我不确定标准的作者是否打算允许这种用法,但是可以证明允许*ab的相同理由同样适用于int和{{ 1}}。另一方面,gcc和clang的行为似乎不是由标准的作者根据已发布的《原理》所表明的那样决定的,而是由他们未能要求编译器这样做的。