【问题标题】:C - Do incompatible pointers used for arithmetic violate strict aliasing?C - 用于算术的不兼容指针是否违反严格的别名?
【发布时间】:2021-07-10 14:40:04
【问题描述】:

这个问题是我之前提出的问题的延伸。但是,一段时间后,我发现我对Conversion Behavior between Two Pointers 的一些概念仍然模棱两可。

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

  • malloc:8 对齐
  • sizeof(int):4,_Alignof(int):4
  • sizeof(double):8,_Alignof(double):8

问题一:

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]:

指向对象类型的指针可以转换为指向不同对象类型的指针。如果结果指针未正确对齐引用类型,则行为未定义。 ...

和 C11 的 [6.5 #7]:

对象的存储值只能由具有以下类型之一的左值表达式访问:

  • 与对象的有效类型兼容的类型,
  • ...

因此,据我所知,

  • 在 (A) 行之后,我分配了一个没有声明类型且还没有有效类型的对象。
  • 在 (B) 行之后,分配对象的前 4 个字节已经具有有效类型:int
  • 对于 (C) 行,ptrdouble 类型正确对齐,指针转换和指针算术是合法的。因为它没有访问前 4 个字节,所以它没有违反 6.5 #7 规则。

我对上面提到的内容有什么误解吗?


问题二:

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]:

如果通过具有非字符类型类型的左值将值存储到没有声明类型的对象中,则左值的类型将成为该访问和后续对象的有效类型不修改存储值的访问

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

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

对象的存储值只能由具有以下类型之一的左值表达式访问

  • 与对象的有效类型兼容的类型,
  • ...

【问题讨论】:

  • 有效类型规则被严重破坏了。因为它确实说“如果通过具有非字符类型类型的左值将值存储到没有声明类型的对象中,则左值的类型将成为该访问和后续访问的对象的有效类型不修改存储值。”但是你在这里做了一个写访问,所以:“对于没有声明类型的对象的所有其他访问,对象的有效类型就是用于访问的左值的类型。”这意味着您也可以通过 double 访问原始对象。
  • 我不认为他们打算让标准按字面意思阅读,但这部分语言只是被破坏了。
  • @Lundin:回复“意味着您也可以通过 double 访问原始对象”:“所有其他访问”部分不适用于此处。当他们在做* (double *) ptr = 时,它被“如果一个值通过一个非字符类型的左值存储到一个没有声明类型的对象中”覆盖,而不是“所有其他”子句。如果他们在写了int 之后正在阅读它,它会被“……用于不修改存储值的后续访问”所涵盖,并且再次不属于“所有其他”子句。
  • @EricPostpischil 是的,我同意,我提到了 OP 的 Q2。
  • 考虑[6.5 #6]和[6.5 #7]顺序应用可能会有所帮助,这样如果对象的有效类型被#6改变,那么它已经兼容#7中左值表达式的类型。

标签: c pointers language-lawyer strict-aliasing


【解决方案1】:

为了便于讨论,我首先对主机实现做出以下假设 [...]

这些假设几乎完全不相关。对于提出的特定问题,唯一重要的约束是sizeof(int) <= 2 * sizeof(double)

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

问题一:

您的分析是正确的:不存在严格混叠违规。

问题二:

根据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

左值的类型成为该对象的有效类型 访问和不修改存储的后续访问 价值

所以,据我所知,行 (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的第一个备选方案。

【讨论】:

  • 虽然对标准文本的直接解释可能意味着将类型 T 的值存储到对象将允许将其作为类型 T 读回,而不管它之前可能具有的任何有效类型,我认为clang或gcc处理的方言不是这样。我的答案中包含的示例中是否有任何内容不符合书面标准?
  • @supercat,我没有在您的示例中看到任何不符合规定的内容,但我也没有遵循您的投诉。您似乎反对 GCC 和 Clang 执行的优化,但标准并没有说明这一点。让我们假设一个符合要求的托管实现,其中sizeof(long) == sizeof(long long) 接受程序,并且malloc 成功运行。如果我正确地遵循代码,那么标准只要求程序打印“2 应该等于 2”,然后是换行符。它不需要实际执行所有内存更新。
  • 其实__attribute是一个扩展,它确实使程序不符合,但上面的分析将适用于删除属性声明修改的程序。
  • 在我的示例中,当启用优化时,clang 和 gcc 都会输出“1 should be 2”。 __attribute 确实意味着该程序不是一个严格符合 C 程序,但它的行为取决于 longlong long 是否相同大小的事实无论如何都意味着。关键是要关注编译器在不知道索引是否相等的情况下如何处理test() 函数; clang 和 gcc 似乎都进行了两种优化,其中任何一种都可以单独使用,但是以一种无效的方式组合它们。
  • 如果 Godbolt 测试器可以编写一个组合两个编译单元的程序,我只需在一个编译单元中编写 test(),在另一个编译单元中编写 main()__attribute 的存在是为了提供基本相同的效果,尽管使用 volatile 函数指针可能更清晰。对配方而言重要的是编译器认识到index 的两种使用将产生相同的值,index3 的所有三种使用将产生相同的值,但它不知道index 和@ 987654345@ 匹配,index3 是否匹配 indexindex2
【解决方案2】:

虽然其他答案可以合理地描述标准似乎所说的内容,但 clang 和 gcc 似乎都将“不修改存储值的后续访问”这一短语解释为“不改变的后续访问”以稍后将被观察到的方式存储的位模式”。两种编译器都倾向于采用这种序列:

  1. 使用引用 1 写入 T 值为 X 的存储
  2. 使用引用 2 写入 U 值为 Y 的存储
  3. 使用参考 3 将存储读取为 U 型
  4. 可以选择使用参考 3 写入具有任意值 T 的存储
  5. 使用 T 写入存储,其位模式与步骤 #3 中读取的内容相匹配,使用参考 3
  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 中写入的值的可能性。

【讨论】:

    【解决方案3】:

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

    对于问题 2,它说:

    如果一个值通过 左值的类型不是字符类型,则左值的类型变为 该访问和不修改的后续访问的对象的有效类型 存储的值。

    分配的存储没有声明的类型,您可以通过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 年,编译器仍在实现严格的别名坏的方式,我不怪他们,因为标准写得模棱两可。

    【讨论】:

    • 示例代码不能反映 OP 的问题;它将以double 写入的内存读取为int。 OP 的问题不涉及读取与写入类型不同的任何类型的内存。
    • @Eric Postpischil,对,我将改进我的问题,使其更清晰。
    • @Lundin:感谢您的回答。我会问为什么“对于不修改的后续访问”不适用于 Q2 的 (C) 行?据我所知,C11 (3.1) 中的术语access 包括读取和“修改”,其含义更接近于“写入”而不是基于先前读取修改值的 R"M"W 操作。
    • @Lundin:对!你准确地指出了我的困惑。我不知道我是否可以用新的有效类型覆盖没有声明类型的旧有效类型的对象。如果可以,一旦一个对象被新的有效类型覆盖,只要不使用旧有效类型的左值访问该对象,是否合法?
    • @rici:访问除最后一个存储的联合成员之外的联合成员是 C(不是 C++)中定义的行为。 C 2018 6.5.2.3 3 表示该值是“指定成员的值”,脚注 99 明确指出:“如果用于读取联合对象内容的成员与上次用于存储对象中的值,该值的对象表示的适当部分被重新解释为新类型中的对象表示,如 6.2.6 中所述(有时称为“类型双关语”的过程)......”
    猜你喜欢
    • 2016-10-09
    • 1970-01-01
    • 2018-09-03
    • 2020-04-11
    • 1970-01-01
    • 2016-02-24
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多