通过偏移指针从其他结构成员访问结构成员是否合法?

7
在这两个示例中,通过从其他成员偏移指针来访问结构体成员是否会导致未定义/未指定/实现定义的行为?
struct {
  int a;
  int b;
} foo1 = {0, 0};

(&foo1.a)[1] = 1;
printf("%d", foo1.b);


struct {
  int arr[1];
  int b;
} foo2 = {{0}, 0};

foo2.arr[1] = 1;
printf("%d", foo2.b);

C11 § 6.7.2.1的第14段似乎表明这应该是实现定义:

结构体或联合体对象的每个非位域成员都以适合其类型的实现定义方式进行对齐。

后面又继续说道:

结构体对象内部可能存在未命名的填充,但不会出现在开头。

然而,像下面这样的代码似乎相当普遍:

union {
  int arr[2];
  struct {
    int a;
    int b;
  };
} foo3 = {{0, 0}};

foo3.arr[1] = 1;
printf("%d", foo3.b);

(&foo3.a)[1] = 2; // appears to be illegal despite foo3.arr == &foo3.a
printf("%d", foo3.b);

这个标准似乎保证了foo3.arr&foo3.a相同,但从一个角度引用它是合法的,而从另一个角度引用却不合法,这是没有道理的。同样地,将外部结合体与数组相加后,使(&foo3.a)[1]变得合法也是没有意义的。
所以我认为第一个例子也一定是合法的:
  1. foo3.arr保证与&foo.a相同
  2. foo3.arr + 1&foo3.b指向同一内存位置
  3. &foo3.a + 1&foo3.b因此必须指向同一内存位置(来自1和2)
  4. 结构布局必须是一致的,因此&foo1.a&foo1.b应该与&foo3.a&foo3.b完全相同
  5. &foo1.a + 1&foo1.b因此必须指向同一内存位置(来源于3和4)
我发现一些外部资源表明foo3.arr[1](&foo3.a)[1]这两个例子都是不合法的,但是我无法找到标准中明确说明的语句来证明其不合法。即使它们都是不合法的,也可以使用灵活的数组指针构造相同的情况,这在我看来具有标准定义的行为。
union {
  struct {
    int x;
    int arr[];
  };
  struct {
    int y;
    int a;
    int b;
  };
} foo4;

原始应用程序正在考虑一个结构体字段的缓冲区溢出是否严格符合标准:

struct {
  char buffer[8];
  char overflow[8];
} buf;
strcpy(buf.buffer, "Hello world!");
println(buf.overflow);

我希望这段代码在几乎所有真实的编译器上输出"rld!",但是这种行为是否由标准保证,还是未定义或实现定义的行为?

@M.M 第二部分的原因是,联合代码的假定有效性似乎意味着第一个样本也应该是有效的。不过,我想单独提出另一个问题,只问联合代码的有效性可能更有意义。 - AJMansfield
@AJMansfield,在标准中没有这样的暗示;联合体有特殊规则。 - M.M
1
foo.arr[1] = 1; 是未定义行为,没有规范说明下一个成员是 foo.arr[1] - chux - Reinstate Monica
@chux 那个反对意见可以通过使用“assert”来解决;或者注意到写入填充字节是合法的。 - M.M
@M.M 向填充区写入数据是否合法?嗯,我怀疑向填充区写入任何位模式都不是可以的。有时候校验位就藏在那里。也许这是一个好问题。 - chux - Reinstate Monica
2个回答

10
介绍:在这个领域,标准是不充分的,并且存在着数十年关于此主题和严格别名的争论,没有令人信服的解决方案或修复建议。

此答案反映了我的观点,而不是标准的强制规定。


首先:通常认为你第一个代码示例中的代码是未定义行为,因为通过直接指针算术访问数组范围之外。规则是C11 6.5.6/8 。它说索引从指针必须保持在“数组对象”内(或在其结束处)。它没有说清楚是哪个“数组对象”,但通常认为在情况int *p = &foo.a;下,“数组对象”是foo.a,而不是foo.a是子对象的任何更大的对象。

相关链接:onetwo


其次:通常认为你的两个union示例都是正确的。标准明确规定,联合体的任何成员都可以被读取;任何相关内存位置的内容都将被解释为正在读取的联合体成员的类型。


您暗示union是正确的,这意味着第一个代码也应该是正确的,但实际上并不是这样。问题不在于指定要读取的内存位置,而在于我们如何到达指定内存位置的表达式。

即使我们知道&foo.a + 1&foo.b是相同的内存地址,仅通过第一个来访问int是无效的,而通过第二个访问int则是有效的。

通常认为,可以通过其他不违反6.5.6/8规则的方式计算其地址来访问int,例如:

((int *)((char *)&foo + offsetof(foo, b))[0]

或者

((int *)((uintptr_t)&foo.a + sizeof(int)))[0]

相关链接: one, two


关于((int *)&foo)[1]是否有效还没有得到普遍认可。一些人认为它基本上与您的第一段代码相同,因为标准规定:"经过适当转换的对象指针指向元素的第一个对象"。其他人认为它基本上与我上面的(char *)示例相同,因为它遵循指针转换的规范。甚至有些人声称它是严格别名违规,因为它将结构体别名为数组。

也许值得注意的是N2090 - 指针来源提案。这并没有直接解决这个问题,并且也没有提出废除 6.5.6/8 的建议。


1
好的回答,我只想补充一点,C委员会最近成立了一个“内存模型工作组”来讨论这类问题,并为C2x提出一个更具决定性的模型。 - Jens Gustedt
@JensGustedt 很酷,期待看到他们的成果 :) - M.M
认为第一个示例正确的原因是,由于foo.arr保证与&foo.a相同,并且foo.arr + 1指向与&foo.b相同的位置,因此将foo.arr替换为&foo.a,如&foo.a + 1,也应该保证指向与&foo.b相同的位置;并且由于将内部结构与数组合并不应更改结构体的内存布局,因此这个表达式也应该是合法的。我会编辑我的问题,使推理链更加明确。 - AJMansfield
3
直到C99之前,几乎普遍认为,用于不同目的的编译器需要支持超出标准规定的类型使用模式,并且支持这些模式被认为是实现质量问题。在仅针对高端数字运算进行配置或设计的编译器中适用的限制会使其对处理底层内存管理代码无用。如果不愿意承认不同种类的实现,任何试图确定单一规则的尝试都将失败。 - supercat
3
如果所有的东西都要这样做,那么很可能会破坏很多代码并不必要地影响许多优化。标准的某个部分“允许”编译器以任意方式处理本应在其他地方定义行为的操作,并不意味着质量良好、用于特定目的的编译器应该这样做。 - supercat

2
根据C11草案N1570 6.5p7,试图使用除字符类型lvalue、结构体或联合体类型或包含结构体或联合体类型以外的任何东西访问结构体或联合体对象的存储值,即使行为在其他标准部分完全描述,也会引发UB。这一节没有任何规定,允许使用非字符成员类型(或任何非字符数值类型)的lvalue用于访问结构体或联合体的存储值。
然而,根据已发布的解释文档,标准的作者认识到,在标准不强制要求的情况下,不同的实现提供了不同的行为保证,并将这些“流行扩展”视为好的和有用的事情。他们判断,这些扩展应该得到市场的回应,而不是由委员会来回答何时以及如何支持此类扩展。虽然标准允许一个晦涩的编译器忽略someStruct.array[i]可能会影响someStruct的存储值的可能性似乎很奇怪,但是标准的作者认识到,任何编写者不是故意晦涩的编译器都将支持这样一种结构,无论标准是否强制执行,并且任何尝试从晦涩设计的编译器中强制执行任何有用行为都是徒劳的。
因此,编译器对与结构体或联合体有关的基本上任何东西的支持水平是实现质量问题。专注于与广泛程序兼容的编译器编写者将支持广泛范围的结构。而那些专注于最大化仅具备语言完全无用的构造的代码性能的编译器则会支持一个更窄的集合。然而,标准在这些问题上缺乏指导。
PS--配置为与MSVC风格的volatile语义兼容的编译器将解释该限定符作为表示可能存在与对象交互的副作用,且未受到restrict保护的指针访问,即使没有任何其他原因预计该可能性也是如此。在“不寻常”方式下访问存储时使用此类限定符可能会使人类读者更明显地意识到代码正在做一些“奇怪”的事情,同时还将确保与使用此类语义的编译器兼容,即使这样的编译器否则不会识别该访问模式。不幸的是,一些编译器编写者拒绝以非标准语法支持此类语义,除了使用需要它的程序以外的优化级别0。

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