使用offsetof访问结构体成员。

14

我有以下的代码:

#include <stddef.h>

int main() {
  struct X {
    int a;
    int b;
  } x = {0, 0};

  void *ptr = (char*)&x + offsetof(struct X, b);

  *(int*)ptr = 42;

  return 0;
}

最后一行代码执行了对x.b的间接访问。

这段代码是否符合任何C标准?

我知道:

  • *(char*)ptr = 42; 虽然只定义了实现,但是是被定义的。
  • ptr == (void*)&x.b

我猜想通过int*访问ptr指向的数据并不违反严格别名规则,但我不能完全确定标准保证了这一点。


x.b是一个具有有效(和声明的)类型为int的对象,其存储的值通过类型为int的lvalue表达式访问,因此这是完全合法的。 - Ian Abbott
3
offsetof宏的目的不就在于此吗? - Eugene Sh.
@EugeneSh。问题是它是否适用于通过int类型访问,而不仅仅是char。 - tstanisl
2个回答

14
是的,这是完全被定义了的,并且正是offsetof预期被使用的方式。你对字符类型的指针进行指针运算,因此它以字节为单位完成,然后转换回成员的实际类型。
例如,可以参见6.3.2.3 p7(所有引用均指C17草案N2176):
当将一个对象的指针转换为指向字符类型的指针时,结果指向对象的最低地址字节。结果的连续增量,直到对象的大小为止,产生指向对象剩余字节的指针。
因此,(char *)&x 是指向 x 转换为指向char类型的指针,因此它指向x的最低地址字节。当我们添加 offsetof (struct X, b) 时(假设它是4),那么我们就有了指向 x 的第四个字节的指针。现在,offsetof(struct X, b)被定义为返回结构的开头到其结构成员的偏移量(7.19p3),所以4实际上是从 x 开始到 x.b 的偏移量。因此,x的第4个字节是x.b的最低字节,这就是 ptr 指向的内容;换句话说,ptr 是一个指向 x.b 的指针,但是它的类型是 char *。当我们将其转换回 int * 时,我们获得了一个指向 x.b 的指针,它的类型是 int *,与表达式 &x.b 得到的完全相同。因此,解引用这个指针访问了 x.b
在评论中出现了一个问题:当将 ptr 转换回 int * 时,我们如何知道我们确实拥有指向 intx.b 的指针?标准中没有太明确的说明,但我认为这是显然的意图。然而,我认为我们也可以间接地推导出来。希望我们同意上面提到的 ptr 是指向 x.b 最低地址字节的指针。现在,根据6.3.2.3 p7 引用的相同段落,将指向 x.b 的指针转换为 char *,例如 (char *)&x.b,也会产生指向 x.b 的最低地址字节的指针。因为它们是指向相同字节的相同类型的指针,所以它们是相同的指针:ptr == (char *)&x.b。然后我们看看6.3.2.3 p7 的前几句话:
如果将对象类型的指针转换为指向不同对象类型的指针,则结果指针未对齐以引用的类型,则行为未定义。否则,再次转换的结果应与原始指针相等。
这里没有对齐问题,因为 char 具有最弱的对齐要求(6.2.8p6)。因此,将 (char *)&x.b 转换回 int * 必须恢复指向 x.bint arr[100]; *(int *)((char *)arr + (17 * sizeof(int))) = 42;

那么这等价于 arr[17] = 42;

这就是像 qsortbsearch 这样的通用程序是如何实现的。如果我们尝试对 int 数组进行 qsort,那么在 qsort 内部,所有指针算术都以字节为单位,在字符类型的指针上进行,通过手动按比例缩放作为参数传递的对象大小的偏移量来完成(这里将是 sizeof(int))。当 qsort 需要比较两个对象时,它将它们强制转换为 const void * 并将它们作为参数传递给比较函数,该函数将它们转换回 const int * 来进行比较。

这一切都很好运行,显然也是语言中预期的特性。因此我认为我们不必怀疑当前问题中使用 offsetof 同样是一种预期的特性。


1
你引用了一个非常重要的规则:“当将一个指向对象的指针转换为指向字符类型的指针时……”,你能引用一下关于反向转换的规则吗?即从指向字节的字符类型指针转换为指向其他类型的指针,以确保指针将指向指定类型的对象?如果没有这个规则,offsetof将没有意义。 - Language Lawyer
1
@LanguageLawyer,不是完全无用的,offsetof仍然可以通过使用char*访问对象或通过memcpy来使用。但是,它的有用性将受到严重限制。 - tstanisl
我添加了一个部分来解决这个问题。 - Nate Eldredge
我不确定这个论点:如果两个指针ab相等(即a == b),那么在其他表达式中可以用b替换a。例如,假设没有填充,则C标准保证&x.a + 1 == &x.b。然而,只有&x.b可以被解引用,而解引用&x.a + 1会调用UB - tstanisl
关于“But ptr is the same pointer as (char *)&x.b”:我同意@tstanisl的观点:我们知道ptr == (char *) &x.b,但这并不意味着它们是相同的。例如,在浮点运算中,-0. == +0.,但signbit(-0.) != signbit(+0.)。从理论上讲,一个指针可以是一千个字节,并且不仅包含内存地址,还包含来源信息。我希望任何合理的编译器都支持使用ptr来访问x.b,但我不能说C标准的超级严格解释规定了它。 - Eric Postpischil
显示剩余4条评论

3
我相信这是完全合法的;实际上,我刚刚在一本我正在阅读的书中遇到了类似的技巧(不过这并不重要)。
以下是我认为这是合法的原因:
void *ptr = (char*)&x + offsetof(struct X, b);

首先,将x解引用为指向结构体的指针,但如果我们使用它的原始类型进行指针算术运算,每次将&x增加1时,实际上增加的值等于sizeof(struct X)。由于offsetof返回的值是从结构体开头开始的距离(以字节为单位),因此我们需要将&x转换为兼容的字节大小类型的指针,在这种情况下是char *。由于char总是定义为1个字节,因此当我们将char *增加1时,我们将前进1个字节。这就是为什么在第6.5节表达式中特别提到的原因:

一个对象的存储值只能通过具有以下类型之一的lvalue表达式访问:88)

  • 与对象的有效类型兼容的类型,
  • 与对象的有效类型兼容的类型的限定版本,
  • 与对象的有效类型相应的已签名或未签名类型,
  • 与对象的有效类型的限定版本相应的已签名或未签名类型,
  • 包括上述类型之一的聚合或联合类型(包括递归地子聚合或包含的联合的成员),或
  • 字符类型。
现在的结果是x.b开头的指向char *类型的指针,它是完全对齐的,因此这里没有引发未定义的行为。为什么?因为offsetof返回从开头开始的距离,并且我们通过char *转换对指针进行了按字节计算,结果应该准确地指向b的开头。
既然我们已经到达了所需对象的开头,我们就不再需要将结果保留在char *类型中。现在将结果强制转换为通用指针void * ptr,以便稍后将其转换为int *,然后解引用它以访问x.b
由于b是一个int,而我们最终有一个*(int*),它评估为int类型,因此我们遵循上面的"与对象有效类型兼容的类型"条款标准(或其他条款之一;如果我错了,请纠正我)。

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