这个结构域混叠代码以什么方式调用未定义行为?< / h1 >



给定代码:

#include <stdlib.h>
#include <stdint.h>
typedef struct { int32_t x, y; } INTPAIR;
typedef struct { int32_t w; INTPAIR xy; } INTANDPAIR;
void foo(INTPAIR * s1, INTPAIR * s2)
{
s2->y++;
s1->x^=1;
s2->y--;
s1->x^=1;
}
int hey(int x)
{
static INTPAIR dummy;
void *p = calloc(sizeof (INTANDPAIR),1);
INTANDPAIR *p1 = p;
INTPAIR *p2a = p;
INTPAIR *p2b = &p1->xy;
p2b->x = x;
foo(p2b,p2a);
int result= p2b->x;
free(p);
return result;
}
#include <stdio.h>
int main(void)
{
for (int i=0; i<10; i++)
printf("%d.",hey(i));
}

行为取决于gcc优化级别,这意味着gcc认为这段代码调用了未定义行为("foo"的定义崩溃为零,但有趣的是,"hey"的定义增加了传入的值)。不过,我不太确定它是否做了违反标准规则的事情。

代码非常故意和邪恶地构造了两个指针,这样s2a->y和s2b->x会产生别名,但是指针被故意构造成这样一种方式,即它们都能识别INTPAIR类型的合法潜在对象。因为代码使用calloc来获取内存,所以所有字段成员都具有合法的初始定义值0。所有对分配内存的访问都是通过INTPAIR*的int32_t成员完成的。

我可以理解为什么标准禁止以这种方式混叠结构字段是有意义的,但是我在标准中找不到任何这样做的东西。gcc是否在这里以标准兼容的方式运行,或者它是否违反了附件J.2没有引用的标准中的某些条款,并且没有使用我搜索的任何术语?

UPDATE:我觉得这个答案还可以,但也不是有点不精确,也不是一成不变的。经过许多非常有趣的讨论和评论,我再次尝试了一个新的答案

这个答案引用了C99标准的右边部分。为了方便,我把它抄在这里。这个问题和几个答案都很全面。

(C99;ISO/IEC 9899:1999 6.5/7:

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

  • 与对象的有效类型兼容的类型,
  • 与有效类型兼容的类型的限定版本对象,
  • 对应的有符号或无符号类型对象的有效类型,
  • a对应的有符号或无符号类型对象有效类型的限定版本,
  • 包含上述类型之一的聚合或联合类型它的成员之间的类型(递归地包括子聚合或包含联合),或
  • 字符类型。

73)或88)此列表的目的是指定对象可以或不可以别名的情况。

那么什么是有效类型?(C99;ISO/IEC 9899:1999 6.5/6:

访问其存储值的对象的有效类型是该对象声明的类型(如果有)。如果一个值通过一个非字符类型的左值被存储到一个没有声明类型的对象中,那么该左值的类型将成为该对象的有效类型,用于该访问以及后续不修改该存储值的访问。如果使用memcpy或memmove将值复制到没有声明类型的对象中,或者将值复制为字符类型的数组,那么对于该访问和后续不修改该值的访问,修改后的对象的有效类型是复制该值的对象的有效类型(如果有)。对于对没有声明类型的对象的所有其他访问,对象的有效类型只是用于访问的左值的类型。

87)已分配对象没有声明类型。

因此在p2b->x = x行,p+4处的对象变为有效类型INTPAIR。对齐正确吗?如果不是,则为未定义行为(UB)。但是为了让它更有趣,假设它在这种情况下是必须的,因为INTANDPAIR的布局。

通过同样的分析有两个8字节的对象,p2a (s2) @(p+4)和p2b @p。正如您的示例所示,p2a的第二个元素和p2b的第一个元素最终被别名。

foo()中,对象p2b @p+4通过s1->x的常规方法被访问。然后是"存储值"对象p2b也通过修改另一个对象p2a @p的副作用来访问。因为它不属于6.5/7的子弹,所以它是UB。注意6.5/7只说,所以对象不能以任何其他方式访问

我认为主要的区别在于"对象"问题是整个结构p2a/s2和p2b/s1,而不是整数成员。如果你改变函数的参数,接受整数并将它们别名,它会工作得很好。因为函数不能知道s1和s2的别名。例如:

void foo2(int *s1, int *s2)
{
(*s2)++;
(*s1)^=1;
(*s2)--;
(*s1)^=1;
}
...
/*foo(p2b,p2a);*/
foo2((int*)p, (int*)p); /* or p+4 or whatever you want */

这或多或少证实了这是GCC选择的解释方式:修改一个成员就是修改整个结构对象,而且由于修改一个对象的副作用不在间接修改另一个对象的合法方法中,因此,当!我们可以做任何我们想做的蠢事。

那么GCC是否解释了标准中的歧义,决定通过不同类型的指针派生s1和s2指针,然后访问它们间接构成通过不同的原始类型通过p1和p访问内存,或者它是否按照我建议的方式解释了标准s2->y修改的不仅仅是整数,而是s2对象,无论如何都是UB。或者GCC只是特别刻薄地指出,如果标准没有非常清楚地指定动态分配的重叠对象的语义,那么它可以自由地做任何它想做的事情,因为根据定义,它是"未定义的"。

我认为在这个微观层面上,除了标准机构之外,任何人都不能明确地回答这是否应该是UB,因为在这个层面上,它需要一些"解释"。GCC的实现者的意见似乎倾向于非常激进的解释。

我喜欢Linus对整件事的反应。这是真的,为什么不只是保守,让程序员告诉编译器什么时候是安全的?非常优秀的Linus Rant

我之前的答案是缺乏的,也许不是完全错误的,但是示例程序被故意设计为避开C99标准规定的每个更明显的显式未定义行为(UB),比如6.5/7。但是对于GCC(和Clang),这个例子展示了严格的混叠失败,就像优化后的症状一样。他们似乎假设s1->y和s2-x不能混叠。那么,是编译器错了吗?这是严格的别名法律术语中的一个漏洞吗?

简短的回答:不。考虑到标准的复杂性,如果标准中存在某种漏洞,我不会感到惊讶。但是在这个例子中,在堆上创建重叠对象是明确未定义的行为,并且还发生了一些标准没有定义的其他事情。

我认为这个例子的重点并不是它失败了——很明显,对指针"玩忽不定"是一个坏主意,如果代码不能工作,依靠角落案例和法律术语来证明编译"错误"是没有什么帮助的。关键问题是:GCC错了吗?标准中的就是这么说的。

首先,让我们看看明显的严格混叠规则,以及这个例子是如何试图避免它们的。

C99 6.5/7:

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

  • 与对象的有效类型兼容的类型,
  • 与对象的有效类型兼容的类型的限定版本,
  • 与对象的有效类型相对应的有符号或无符号类型的类型,
  • 与对象有效类型的限定版本相对应的有符号或无符号类型的类型,
  • 在其成员中包含上述类型之一的聚合或联合类型(递归地包括子聚合或包含联合的成员),或
  • 字符类型。

这是主要的严格混叠部分。这意味着通过两个不同的类型指针访问相同的内存是UB。本例通过在foo()中使用INTPAIR指针访问这两个指针来避开它。

这里的关键问题是,它讨论的是通过两种不同的有效类型(例如指针)访问存储值。它没有讨论通过两个不同的对象访问

正在访问什么?它是整型成员还是整个对象s1/s2?通过"与对象的有效类型兼容的类型"通过s1->y访问s2->x。我认为可以提出这样一个观点:a)作为修改不同对象的副作用的访问不属于6.5/7中允许的方法,b)修改聚合的一个成员也会传递地修改聚合(*s1或*s2)。

由于没有指定,所以它是UB,但它有点手写体。

如何获得指向两个重叠对象的指针?指针强制转换会导致它们吗?第6.3.2.3节包含了转换指针的规则,该示例小心地没有违反任何规则。特别是,因为p2b是指向INTANDPAIR成员xy的指针,所以对齐方式保证是正确的,否则肯定会违反6.3.2.3/7。

此外,&p1->xy不是问题——它不可能有问题——它是一个指向INTPAIR的完全合法的指针。简单地转换指针和/或获取地址是安全的,超出了"访问"(3.1/1)的定义。

很明显,访问两个作为重叠对象的不同部分相互覆盖的整数成员会产生问题。任何试图通过不同类型的指针来实现这一点的尝试都明显会与6.5/7发生冲突。如果通过相同地址的相同类型指针访问,则不会有任何问题。因此,它们可以这样别名的唯一方法是,如果两个位于不同地址的对象以某种方式重叠。

显然,这可能作为union的一部分发生,但在本例中不是这样。通过联合的类型双关语在C99中可能不是UB,但是这个例子的变体是否会通过联合而变得不正常,这将是一个不同的问题。

示例使用动态分配,并将生成的void指针强制转换为两个不同的类型。从指向对象的指针到void *再返回是有效的(6.3.2.3/1)。其他几种获取指向对象的指针的方法可以通过6.3.2.3的指针转换规则、6.5/7的混叠规则和/或6.2.7的兼容类型规则显式地实现。

那么还有什么问题呢?

6.2.4对象的存储时间1对象具有决定其生命周期的存储持续时间。有三种存储持续时间:静态、自动和分配。已分配的存储在7.20.3

中描述。每个对象的存储都是由calloc()分配的,所以我们想要的持续时间是"分配的"。所以我们检查7.20.3:(强调添加)

7.20.3内存管理函数

1连续调用calloc、malloc和realloc函数所分配的存储空间的顺序和连续度是未指定的。如果分配成功,返回的指针将被适当地对齐,以便它可以被分配给指向任何类型对象的指针,然后用于访问所分配空间中的此类对象或此类对象的数组(直到显式释放空间)。已分配对象的生存期从分配开始一直延伸到解除分配。每个这样的分配应该产生一个指向与任何其他对象不相交的对象的指针.

…对象的生命周期是指程序执行过程中保证为对象保留存储空间的部分。对象存在,有一个常量地址25),并在其生命周期内保持其最后存储的值。26)如果一个对象在它的生命周期之外被引用,行为是未定义的.

为了避免UB,对两个不同对象的访问必须是在其生命周期内的有效对象。您可以获得单个具有malloc()/calloc()的有效对象(或数组),但这些保证您将收到与所有其他对象不相交的指针。那么是从calloc() p返回的对象还是p1?不可能两者都有。

通过尝试重用相同的动态分配对象来保存两个不相交的对象来触发UB。虽然calloc()保证它将返回一个指向不相交对象的指针,但如果你开始为第二个重叠的对象使用缓冲区的一部分,没有什么可以保证它仍然工作。事实上,它甚至明确地说,如果你访问一个对象在它的生命周期之外,只有一个分配,因此只有一个生命周期,它是UB。

注意:

4。一致性

  1. 在本国际标准中,"应"被解释为对实施或程序的要求;反之,"不得"被解释为禁止。
  2. 如果违反了出现在约束之外的"应该"或"不应该"要求,则行为为未定义. 未定义行为在本国际标准中以"未定义行为">或省略任何明确定义来表示的行为. 这三者的侧重点并无不同;它们都描述"未定义的行为"。

要成为编译错误,它必须在只使用显式定义的结构的程序上失败。其他任何东西都在安全港之外,并且仍然是未定义的,即使标准没有明确声明它是未定义行为。

最新更新