首页 文章

是否错误地指定了严格别名规则?

提问于
浏览
29

作为previously established,形式的联合

union some_union {
    type_a member_a;
    type_b member_b;
    ...
};

n个成员在重叠存储中包含n个对象:一个对象用于union本身,一个对象用于每个union成员 . 很明显,您可以按任何顺序自由地读取和写入任何工会成员,即使读取的工会成员不是最后写入的工会成员 . 永远不会违反严格别名规则,因为访问存储的左值具有正确的有效类型 .

这是脚注95的further supported,它解释了类型双关语是否是联盟的预期用途 .

严格别名规则启用的优化的典型示例是此函数:

int strict_aliasing_example(int *i, float *f)
{
    *i = 1;
    *f = 1.0;
    return (*i);
}

编译器可能会优化到类似的东西

int strict_aliasing_example(int *i, float *f)
{
    *i = 1;
    *f = 1.0;
    return (1);
}

因为它可以安全地假设写入 *f 不会影响 *i 的值 .

但是,当我们将两个指针传递给同一个联盟的成员时会发生什么?考虑这个例子,假设一个典型的平台,其中 float 是IEEE 754单精度浮点数, int 是32位二进制补码整数:

int breaking_example(void)
{
    union {
        int i;
        float f;
    } fi;

    return (strict_aliasing_example(&fi.i, &fi.f));
}

如前所述, fi.ifi.f 指的是重叠的存储区域 . 阅读和写作是无条件的合法(一旦工会初始化,写作只是合法的) . 在我看来,所有主要编译器执行的先前讨论的优化产生了错误的代码,因为不同类型的两个指针合法地指向相同的位置 .

我莫名其妙地无法相信我对严格别名规则的解释是正确的 . 由于前面提到的拐角情况,严格混叠的优化设计是不可能的,这似乎是不合理的 .

请告诉我为什么我错了 .

在研究过程中出现了related question .

请在添加自己的答案之前阅读所有现有答案及其评论,以确保您的答案添加了新的参数 .

9 回答

  • 3

    C11标准(§6.5.2.3.9例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;
    返回p1-> m;
    }
    int g()
    {
    联盟{
    struct t1 s1;
    struct t2 s2;

    / * ...... * /
    return f(&u.s1,&u.s2);
    }

    但我无法对此发现更多澄清 .

  • 5

    从您的示例开始:

    int strict_aliasing_example(int *i, float *f)
    {
        *i = 1;
        *f = 1.0;
        return (*i);
    }
    

    让我们首先承认,如果没有任何联合,如果 if 都指向同一个对象,这将违反严格的别名规则;假设对象没有有效类型,则 *i = 1 将有效类型设置为 int ,然后将 *f = 1.0 设置为 float ,最后 return (*i) 然后通过类型为 int 的左值访问有效类型为 float 的对象,这显然是不允许的 .

    问题是,如果 if 指向同一联盟的成员,这是否仍会构成严格别名违规 . 在通过"."成员访问运算符访问联合成员时,规范说(6.5.2.3):

    后缀表达式后跟 . 运算符和标识符指定结构或联合对象的成员 . 该值是指定成员(95)的值,如果第一个表达式是左值,则该值是左值 .

    上面提到的脚注95说:

    如果用于读取union对象内容的成员与上次用于在对象中存储值的成员不同,则该值的对象表示的相应部分将重新解释为新类型中的对象表示形式如6.2.6所述(有时称为''punning''的过程) . 这可能是陷阱表示 .

    这显然是为了允许通过联合进行类型惩罚,但应该注意的是(1)脚注是非规范性的,也就是说,它们不应该禁止行为,而是应该澄清某些部分的意图 . 根据规范的其余部分的文本,以及(2)编译器供应商认为通过联合进行类型惩罚的这种限制仅适用于通过联盟成员访问操作员的访问 - 因为否则严格的别名是毫无意义的,因为任何潜在的别名访问也可能是同一联盟的潜在成员 .

    您的示例通过指向不存在或至少非活动的联合成员的指针存储,从而提交严格的别名冲突(因为它使用不合适类型的左值访问活动的成员)或使用左值不表示一个对象(因为对应于非活动成员的对象不存在) - 它可以被论证,并且标准不是特别清楚,但是任何一种解释都意味着你的例子有未定义的行为 .

    (一世可能会补充一点,我看不出脚注允许通过联合打字的脚注如何描述规范中固有的行为 - 也就是说,它似乎打破了不禁止行为的ISO规则;规范中的任何其他内容似乎都不允许通过联合进行类型惩罚 . 此外,阅读规范性文本是一种延伸,因为要求这种形式的惩罚要求必须通过联合类型立即进行访问 .

    但是,规范的另一部分经常会引起混淆,但在6.5.2.3中也是如此:

    为了简化联合的使用,我们做了一个特别的保证:如果一个联合包含几个共享一个共同初始序列的结构(见下文),并且如果联合对象当前包含这些结构中的一个,则允许检查它们中任何一个的共同初始部分,可以看到完整类型的联合声明 .

    虽然这不适用于您的示例,因为没有共同的初始序列,我看到人们将此视为管理类型惩罚的一般规则(至少在涉及共同的初始序列时);他们认为,这意味着只要完整的联合声明可见,就应该可以使用两个指向不同联盟成员的类型惩罚(因为在上面引用的段落中出现了这种效果的词) . 但是,我要指出上面的段落仍然只适用于通过"."运算符访问联盟成员 . 在这种情况下,协调这种理解的问题是,完整的联合声明必须是可见的,否则你将无法引用工会成员 . 我认为这是措辞中的这个小故障,加上示例3中类似的错误措辞(以下不是有效的片段(因为联合类型不可见......),当联合可见性不是真正的决定因素时) ,这使得一些人认为共同初始序列异常旨在全局应用,而不仅仅是通过"."运算符进行成员访问,作为严格别名规则的例外;并且,在得出这个结论之后,读者可能会将关于类型惩罚的脚注解释为全局应用,并且有些人会这样做:例如,参见this GCC bug上的讨论(注意该bug已经处于SUSPENDED状态很长一段时间了) .

    (顺便说一句,我知道有几个编译器没有实现"global common initial sequence"规则 . 我没有特别注意任何实现"global common initial sequence"规则的编译器,同时也没有允许任意类型的惩罚,但是没有't mean such compilers don'存在 . 委员会对Defect Report 257的回应表明他们打算将规则视为全局规则,但是,我个人认为仅仅是一种类型的可见性应该改变代码的语义而不是指那种类型的想法存在严重缺陷,我知道其他人也同意这一观点 .

    此时,您可以很好地质疑如何通过成员访问运算符读取非活动的联合成员不会违反严格的别名,如果通过指针执行相同操作 . 这又是一个规范有些朦胧的领域;关键在于决定哪个左值负责访问 . 例如,如果一个联合对象 u 有一个成员 a 并且我通过表达式 u.a 读取它,那么我们可以将其解释为对成员对象的访问( a )或仅仅是对联合对象( u )的访问 . 然后从中提取成员值 . 在后一种情况下,没有别名冲突,因为它特别允许通过包含合适成员(6.5¶7)的聚合类型的左值来访问对象(即活动成员对象) . 实际上,6.5.2.3中成员访问运算符的定义确实支持这种解释,如果有点弱:值是指定成员的值 - 虽然它可能是左值,但是没有必要访问由此引用的对象 . lvalue是为了获取成员的值,因此避免了严格的别名冲突 . 但这再次拉伸了一点 .

    (对我而言,似乎是指定不足,通常,只是当一个对象按照6.5左右的“左右表达式访问它的存储值”时;我们当然可以为自己做出合理的决定,但是我们必须小心允许通过工会按照上面的方式进行打字,或者以其他方式愿意无视脚注95.尽管经常有不必要的措辞,但规范有时缺乏必要的细节 .

    关于union语义的争论总是在某个时候引用DR 236 . 实际上,您的示例代码表面上与该缺陷报告中的代码非常相似 . 我会注意到:

    • "Committee believes that Example 2 violates the aliasing rules in 6.5 paragraph 7" - 这与我上面的推理并不矛盾;

    • "In order to not violate the rules, function f in example should be written as" - 这支持了我上面的推理;您必须使用union对象(和"."运算符)来更改活动成员类型,否则您正在访问不存在的成员(因为union一次只能包含一个成员);

    • DR 236中的示例与类型惩罚无关 . 它是否可以通过指向该成员的指针分配给非活动的联合成员 . 有问题的代码与此处的问题略有不同,因为它在写入第二个成员后不会再次尝试访问"original" union成员 . 因此,尽管示例代码中存在结构相似性,但缺陷报告与您的问题基本无关 .

    • 委员会在DR 236中的答复声称"Both programs invoke undefined behavior" . 然而,讨论不支持这一点,该讨论仅显示示例2调用未定义的行为 . 我认为反应是错误的 .

  • 3

    根据§6.5.2.3中工会成员的定义:

    3后缀表达式后跟 . 运算符和标识符指定结构或联合对象的成员 . ... 4后缀表达式后跟 - >运算符,标识符指定结构或联合对象的成员 . ...

    另见§6.2.3¶1:

    结构或工会的成员;每个结构或联合为其成员都有一个单独的名称空间(通过 . 或 - >运算符用于访问成员的表达式的类型消除歧义);

    很明显,脚注95引用了工会成员与范围内的联合的访问并使用 .-> 运算符 .

    由于对包含联合的字节的赋值和访问不是通过联合成员而是通过指针进行的,因此您的程序不会调用联合成员的别名规则(包括脚注95所阐明的那些) .

    此外,由于 *f = 1.0 之后的对象的有效类型是 float ,因此违反了正常的别名规则,但其存储的值由 int 类型的左值访问(参见§6.5¶7) .

    注意:所有参考文献都引用了this C11标准草案 .

  • 1

    严格别名规则禁止通过两个没有兼容类型的指针访问同一个对象,除非一个是指向字符类型的指针:

    7对象的存储值只能由具有以下类型之一的左值表达式访问:88)与对象的有效类型兼容的类型,与对象的有效类型兼容的类型的限定版本,对应于对象的有效类型的有符号或无符号类型的类型,对应于对象的有效类型的限定版本的有符号或无符号类型的类型,包含其中一个的聚合或联合类型其成员中的上述类型(包括递归地,子聚合或包含联合的成员)或字符类型 .

    在您的示例中, *f = 1.0; 正在修改 fi.i ,但类型不兼容 .

    我认为错误在于认为联合包含n个对象,其中n是成员数 . 在程序执行期间,§6.7.2.1¶16中的union在任何时候只包含一个活动对象

    最多其中一个成员的值可以随时存储在union对象中 .

    支持这种解释,即联合不同时包含其所有成员对象,可以在§6.5.2.3中找到:

    并且如果union对象当前包含这些结构中的一个

    最后,2006年defect report 236提出了一个几乎相同的问题 .

    示例2 //如果“qi”没有别名“qd”,则优化机会
    void f(int * qi,double * qd){
    int i = * qi 2;

    • QD= 3.1; //将此赋值提升到函数顶部???
    • qd * = i;
      返回;
      }

    main(){
    联合标签{
    int mi;
    双md;

    u.mi = 7;
    f(&u.mi,&u.md);
    }
    委员会认为,示例2违反了6.5第7段中的别名规则:“在其成员中包括上述类型之一的聚合或联合类型(包括递归地,属于子分类或包含的联盟的成员) . ”为了不违反规则,示例中的函数f应写为:union tag {
    int mi;
    双md;

    void f(int * qi,double * qd){
    int i = * qi 2;
    u.md = 3.1; //更改有效类型时必须使用union类型

    • qd * = i;
      返回;
      }
  • 16

    本质上,严格别名规则描述了允许编译器假定(或者,相反地,不允许假设)两个不同类型的指针不指向存储器中的相同位置的情况 .

    在此基础上,允许在 strict_aliasing_example() 中描述的优化,因为允许编译器假定 fi 指向不同的地址 .

    breaking_example() 导致传递给 strict_aliasing_example() 的两个指针指向同一地址 . 这打破了允许 strict_aliasing_example() 进行的假设,因此导致该函数表现出不确定的行为 .

    因此,您描述的编译器行为是有效的 . 事实是 breaking_example() 导致传递给 strict_aliasing_example() 的指针指向导致未定义行为的相同地址 - 换句话说, breaking_example() 打破了允许编译器在 strict_aliasing_example() 内进行的假设 .

  • 2

    让我们暂时退出标准,并考虑编译器实际可行的内容 .

    假设 strict_aliasing_example()strict_aliasing_example.c 中定义, breaking_example()breaking_example.c 中定义 . 假设这两个文件分别编译然后链接在一起,如下所示:

    gcc -c -o strict_aliasing_example.o strict_aliasing_example.c
    gcc -c -o breaking_example.o breaking_example.c
    gcc -o breaking_example strict_aliasing_example.o breaking_example.o
    

    当然我们必须将函数原型添加到 breaking_example.c ,如下所示:

    int strict_aliasing_example(int *i, float *f);

    现在考虑 gcc 的前两次调用是完全独立的,除了函数原型之外不能共享信息 . 编译器在生成 strict_aliasing_example() 的代码时,不可能知道 ij 将指向同一个union的成员 . 链接或类型系统中没有任何内容可以指定这些指针在某种程度上是特殊的,因为它们来自一个联合 .

    这支持了其他答案提到的结论:从标准的角度来看,与解除引用任意指针相比,通过 .-> 访问联合服从不同的别名规则 .

  • 4

    在C89标准之前,绝大多数实现将写入解除引用的行为定义为特定类型的指针,以按照为该类型定义的方式设置底层存储的位,并定义读取 - 解引用指针的行为特定类型的,以为该类型定义的方式读取底层存储的位 . 虽然这些能力对于所有实现都不是有用的,但是存在许多实现,其中热循环的性能可以通过例如大大提高来实现 . 使用32位加载和存储一次操作四个字节的组 . 此外,在许多这样的实现中,支持这样的行为并没有花费任何成本 .

    C89标准的作者声明他们的目标之一是避免无可挽回地破坏现有代码,并且有两种基本方法可以解释规则与此一致:

    • C89规则原本只适用于类似于基本原理中给出的情况(通过该类型直接访问具有声明类型的对象并通过指针间接访问),以及编译器没有理由期望的情况走样 . 跟踪每个变量是否当前缓存在寄存器中非常简单,并且能够在访问其他类型的指针时将这些变量保存在寄存器中是一种简单而有用的优化,并且不会妨碍对使用更常见的代码的支持别名模式(让编译器将 float* 解释为 int* 强制转换为必须刷新任何寄存器缓存的 float 值是简单而直接的;这种强制转换非常罕见,以至于这种方法不太可能对性能产生负面影响) .

    • 鉴于标准对于为给定平台提供高质量实现的内容而言,这些规则通常是不可知的,规则可以解释为允许实现破坏使用别名的代码,这些代码既有用又显而易见,而不建议优质实现不应该'尽量避免这样做 .

    如果标准定义了一种允许就地混叠的实用方法,这种方法在任何方面都不会明显低于其他方法,那么除了定义的方法之外的方法可能被合理地视为已弃用 . 如果不存在标准定义的方法,那么为了获得良好性能而需要别名的平台的质量实现应该努力有效地支持这些平台上的公共别名平台,无论标准是否要求它们这样做 .

    遗憾的是,由于标准要求的内容不明确,导致某些人认为不存在替代品的被弃用的结构 . 存在涉及两个基本类型的完整联合类型定义被解释为指示通过一种类型的指针的任何访问应被视为对另一种类型的可能访问将使得可以调整依赖于就地别名的程序 . 这样做没有未定义的行为 - 鉴于本标准,这是任何其他实际方法无法实现的 . 不幸的是,这样的解释也会限制99%无害的情况下的许多优化,从而使解释标准的编译器无法以尽可能高效的方式运行现有代码 .

    至于规则是否正确指定,这取决于它应该是什么意思 . 可能有多种合理的解释,但将它们结合起来会产生一些相当不合理的结果 .

    PS - 关于指针比较和 memcpy 的规则的唯一解释,如果没有赋予术语"object"与其在别名规则中的含义不同的含义,那将是有意义的,这表明没有分配的区域可用于保存多于一种对象 . 虽然某些类型的代码可能能够遵守这样的限制,但是如果没有过多的malloc / free调用,程序就无法使用自己的内存管理逻辑来回收存储 . 标准的作者可能已经打算说,实现不需要让程序员创建一个大区域并将其划分为更小的混合类型块本身,但这并不意味着它们意图通用实现将无法做到所以 .

  • 5

    这是注释95及其上下文:

    后缀表达式后跟 . 运算符和标识符指定结构或联合对象的成员 . 该值是指定成员的值(95),如果第一个表达式是左值,则该值是左值 . 如果第一个表达式具有限定类型,则结果具有指定成员类型的限定版本 . (95)如果用于读取union对象内容的成员与上次用于在对象中存储值的成员不同,则该值的对象表示的适当部分将被重新解释为对象表示形式 . 6.2.6中描述的新类型(有时称为“类型双关”的过程) . 这可能是陷阱表示 .

    注释95明确适用于通过工会成员进行的访问 . 您的代码不会这样做 . 通过指向2个不同类型的指针访问两个重叠对象,其中没有一个是字符类型,并且没有一个是与类型双关语相关的后缀表达式 .

    这不是一个明确的答案......

  • 12

    标准不允许使用成员类型的左值访问结构或联合的存储值 . 由于您的示例使用类型不是union的类型的lvalues访问union的存储值,也没有任何包含该union的类型,因此仅在此基础上行为将是Undefined .

    有一件事变得棘手的是,在严格阅读标准的情况下,即使是如此简单的事情

    int main(void)
    {
      struct { int x; } foo;
      foo.x = 1;
      return 0;
    }
    

    也违反N1570 6.5p7,因为 foo.xint 类型的左值,它用于访问 struct foo 类型的对象的存储值,类型 int 不满足该部分的任何条件 .

    标准甚至可以远程使用的唯一方法就是一个认识到在涉及从其他左值衍生的左值的情况下,N1570 6.5p7需要有例外 . 如果标准描述了编译器可能或必须认识到这种推导的情况,并指定N1570 6.5p7仅适用于在函数或循环的特定执行中使用多种类型访问存储的情况,那么这将消除很多复杂性,包括对“有效类型”概念的任何需求 .

    不幸的是,一些编译器甚至在一些明显的情况下忽略了左值和指针的推导,例如:

    s1 *p1 = &unionArr[i].v1;
    p1->x ++;
    

    如果涉及 unionArr[i] 的其他操作分离了p1的创建和使用,则编译器无法识别 p1unionArr[i].v1 之间的关联可能是合理的,但是即使在使用指针的简单情况下,gcc和clang都不能始终如一地识别这种关联 . 紧接着采取工会成员地址的行动 .

    同样,由于标准不要求编译器识别派生左值的任何用法,除非它们是字符类型,gcc和clang的行为不会使它们不符合 . 另一方面,他们合规的唯一原因是因为标准中的一个缺陷是如此离谱,以至于没有人读标准说它实际上做了什么 .

相关问题