"container_of"宏可以严格遵守吗?

Dre*_*wen 13 c standards pointers

linux内核(和其他地方)中常用的宏container_of是(基本上)定义如下:

#define container_of(ptr, type, member) (((type) *)((char *)(ptr) - offsetof((type), (member))))
Run Code Online (Sandbox Code Playgroud)

在给定指向其中一个成员的指针的情况下,它基本上允许恢复"父"结构:

struct foo {
    char ch;
    int bar;
};
...
struct foo f = ...
int *ptr = &f.bar; // 'ptr' points to the 'bar' member of 'struct foo' inside 'f'
struct foo *g = container_of(ptr, struct foo, bar);
// now, 'g' should point to 'f', i.e. 'g == &f'
Run Code Online (Sandbox Code Playgroud)

但是,并不完全清楚其中包含的减法是否container_of被视为未定义的行为.

一方面,因为barinside struct foo只是一个整数,所以只*ptr应该是有效的(以及ptr + 1).因此,container_of有效地产生一个表达式ptr - sizeof(int),即未定义的行为(即使没有解除引用).

另一方面,C标准的第6.3.2.3节第7段规定,将指针转换为不同类型并再次返回将产生相同的指针.因此,"移动"指向struct foo对象中间的指针,然后返回到开头应该产生原始指针.

主要关注的是允许实现在运行时检查越界索引.我对这个和前面提到的指针等价性要求的解释是必须在指针转换中保留边界(这包括指针衰减 - 否则,你怎么能使用指针迭代数组?).虽然Ergo虽然ptr可能只是一个int指针,但既不是ptr - 1也不*(ptr + 1)是有效的,ptr但仍然应该有一些位于结构中间的概念,因此这(char *)ptr - offsetof(struct foo, bar) 有效的(即使指针ptr - 1在实践中是相等的).

最后,我发现了如果你有类似的事情:

int arr[5][5] = ...
int *p = &arr[0][0] + 5;
int *q = &arr[1][0];
Run Code Online (Sandbox Code Playgroud)

虽然它是取消引用的未定义行为p,但指针本身是有效的,并且需要比较等于q(参见这个问题).这意味着pq 进行比较,但在某些实现定义的方式中可能会有所不同(这样只能q解除引用).这可能意味着给出以下内容:

// assume same 'struct foo' and 'f' declarations
char *p = (char *)&f.bar;
char *q = (char *)&f + offsetof(struct foo, bar);
Run Code Online (Sandbox Code Playgroud)

p并且q比较相同,但可能有不同的边界与它们相关联,因为转换(char *)来自指向不兼容类型的指针.


总而言之,C标准并不完全清楚这种行为,并且试图应用标准的其他部分(或者至少我对它们的解释)会导致冲突.那么,是否有可能以container_of严格一致的方式进行定义?如果是这样,上面的定义是否正确?


在对这个问题的回答发表评论之后,这里讨论这一点

Mat*_*ong 10

总长DR

\n

container_of关于使用的程序是否严格符合,这是语言律师们争论的问题,但使用该container_of习惯用法的实用主义者有很好的伙伴,并且不太可能在主流硬件上运行用主流工具链编译的程序时遇到问题。换句话说:

\n
    \n
  • 严格遵守:有争议
  • \n
  • 符合:是的,在大多数情况下,出于所有实际目的
  • \n
\n

今天可以说什么

\n
    \n
  1. 标准 C17 标准中没有语言明确要求支持该container_of习惯用法。
  2. \n
  3. 缺陷报告container_of表明该标准旨在通过跟踪对象和指针的“来源”(即有效边界)来允许实现空间来禁止该习惯用法。然而,仅这些并不规范。
  4. \n
  5. C 内存对象模型研究组最近开展了一项活动,旨在为这个问题和类似问题提供更严格的要求。请参阅澄清 C 内存对象模型 - 2016 年的N2012、2018 年的指针比您预期的更抽象,以及2021 年的C 来源感知内存对象模型 - N2676
  6. \n
\n

根据您阅读本文的时间, WG14 文档日志中可能有更新的文档。此外,Peter Sewell 在此处收集了相关参考资料: https: //www.cl.cam.ac.uk/~pes20/cerberus/。这些文件不会改变严格遵守的内容程序(2021 年,对于 C17 及更早版本),但它们表明答案可能会在新版本的标准中发生变化。

\n

背景

\n

成语是什么container_of

\n

此代码通过扩展通常用于实现该习惯用法的宏的内容来演示该习惯用法:

\n
#include <stddef.h>\n\nstruct foo {\n    long first;\n    short second;\n};\n\nvoid container_of_idiom(void) {\n    struct foo f;\n\n    char* b = (char*)&f.second;        /* Line A */\n    b -= offsetof(struct foo, second); /* Line B */\n    struct foo* c = (struct foo*)b;    /* Line C */\n}\n
Run Code Online (Sandbox Code Playgroud)\n

在上述情况下,container_of宏通常会采用一个short*旨在指向second的字段的参数struct foo。它还需要struct foo和 的参数second,并将扩展为返回 的表达式struct foo*。它将采用上面 AC 行中看到的逻辑。

\n

问题是:这段代码严格遵守吗?

\n

首先,我们来定义“严格符合”

\n
\n

C17 4 (5-7) 一致性

\n
    \n
  1. 严格遵守的程序应仅使用本国际标准中指定的语言和库的那些功能。它不应产生依赖于任何未指定、未定义或实现定义的行为的输出,并且不应超过任何最小实现限制。

    \n
  2. \n
  3. [...] 合格的托管实施应接受任何严格合格的程序。[...] 一致的实现可以具有扩展(包括附加的库函数),只要它们不改变任何严格一致的程序的行为。

    \n
  4. \n
  5. 合格程序是合格实现可接受的程序。

    \n
  6. \n
\n
\n

(为了简洁起见,我省略了“独立”实现的定义,因为它涉及对此处不相关的标准库的限制。)

\n

由此我们可以看出,严格一致性是相当严格的,但是只要不改变严格一致性程序的行为,一致性实现就可以定义额外的行为。实际上,几乎所有的实现都这样做;这是大多数 C 程序编写时所依据的“实用”定义。

\n

为了这个答案的目的,我将包含我对严格符合程序的答案,并讨论仅仅符合程序的答案,并在最后

\n

缺陷报告

\n

语言标准本身在这个问题上有些不清楚,但一些缺陷报告更清楚地说明了这个问题。

\n

博士51

\n

博士51询问该计划的问题:

\n
#include <stdlib.h>\n\nstruct A {\n    char x[1];\n};\n\nint main() {\n    struct A *p = (struct A *)malloc(sizeof(struct A) + 100);\n    p->x[5] = \'?\'; /* This is the key line */\n    return p->x[5];\n}\n
Run Code Online (Sandbox Code Playgroud)\n

对 DR 的回应包括(强调我的):

\n
\n

第 6.3.2.1 节描述了与数组下标相关的指针算术的限制。(另请参见第 6.3.6 节。)基本上,它允许实现定制如何表示指向它们所指向的对象的大小的指针p->x[5]因此,即使 malloc 调用确保该字节存在,表达式也可能无法指定预期的字节。该习语虽然常见,但并不严格遵循

\n
\n

在这里,我们第一个迹象表明该标准允许实现根据指向的对象“定制”指针表示,并且“保留”指向的原始对象的有效范围的指针算术并不严格符合要求。

\n

DR 72询问该计划的问题:

\n
#include <stddef.h>\n#include <stdlib.h>\n\ntypedef double T;\nstruct hacked {\n    int size;\n    T data[1];\n};\n\nstruct hacked *f(void)\n{\n    T *pt;\n    struct hacked *a;\n    char *pc;\n\n    a = malloc(sizeof(struct hacked) + 20 * sizeof(T));\n    if (a == NULL) return NULL;\n    a->size = 20;\n\n    /* Method 1 */\n    a->data[8] = 42; /* Line A /*\n\n   /* Method 2 */\n    pt = a->data;\n    pt += 8; /* Line B /*\n    *pt = 42;\n\n    /* Method 3 */\n    pc = (char *)a;\n    pc += offsetof(struct hacked, data);\n    pt = (T *)pc; /* Line C */\n    pt += 8;      /* Line D */\n    *pt = 6 * 9;\n    return a;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

精明的读者会注意到/* Method 3 */上面的内容很像这个container_of习语。即,它需要一个指向结构类型的指针,将其转换为char*,执行一些指针算术,获取char*原始结构范围之外的内容,然后使用该指针。

\n

委员会回应称/* Line C */严格遵守,但/* Line D */并不严格遵守上述 DR 51 的相同论点。T此外,委员会表示,答案“如果有类型,则不受影响char”。

\n

结论:container_of不严格符合(可能)

\n

container_of习惯用法采用指向结构体子对象的指针,将指针转换为char*,并执行将指针移至子对象外部的指针算术。这与 DR 51 和 72 中讨论的操作相同。委员会有明确的意图。他们认为该标准“允许实现定制如何表示指向它们所指向的对象大小的指针”,因此“该习惯用法虽然常见,但并不严格遵守。

\n

有人可能会争辩说,container_of通过在指针域中进行指针算术来回避这个问题char*,但委员会表示答案“如果有类型则不受影响Tchar

\n

这个container_of习语可以运用到实际中吗?

\n

不,如果您想要严格并仅使用不明确严格符合当前语言标准的代码。

\n

是的,如果你是一个实用主义者,并且相信在 Linux、FreeBSD、Microsoft Windows C 代码中广泛使用的习惯用法足以标记在实践中符合的习惯用法。

\n

如上所述,允许实现以标准未要求的方式保证行为。实际上,该container_of惯用语被用在 Linux 内核和许多其他项目中。在现代硬件上支持实现很容易。各种“消毒剂”系统,例如 Address Sanitizer、Undefined Behaviour Sanitizer、Purify、Valgrind 等,都允许这种行为。在具有平面地址空间甚至分段地址空间的系统上,各种“指针游戏”很常见(例如,转换为整数值并屏蔽低位以查找页边界等)。这些技术在当今的 C 代码中非常常见,因此这些习惯用法现在或将来不太可能在任何普遍支持的系统上停止运行。

\n

事实上,我发现边界检查器的一种实现在其论文中给出了对 C 语义的不同解释。引用来自以下论文:Richard WM Jones 和 Paul HJ Kelly。C 程序中数组和指针的向后兼容边界检查。第三届自动调试国际研讨会(M. Kamkarand D. Byers 编辑),第 2 卷(1997 年),Link\xc3\xb6ping Electronic Articles in Computer and Information Science 第 009 期。Link\xc3\xb6ping 大学电子出版社,Link\xc3\xb6ping,瑞典。ISSN 1401-9841,1997 年 5 月,第 13\xe2\x80\x9326 页。网址http://www.ep.liu.se/ea/cis/1997/009/02/

\n
\n

ANSI C 允许我们方便地定义一个对象作为内存分配的基本单位。[...] 允许操作对象内的指针,但不允许指针操作在两个对象之间交叉。对象之间没有定义顺序,并且永远不应该允许程序员对对象在内存中的排列方式做出假设。

\n
\n
\n

使用强制转换(即类型强制)不会阻止或削弱边界检查。强制转换可以正确地用于更改指针所引用的对象的类型,但不能用于将指向一个对象的指针转换为指向另一个对象的指针。一个推论是,边界检查不是类型检查:它不会阻止使用一种数据结构声明存储并与另一种数据结构一起使用。更微妙的是,请注意,由于这个原因,C 中的边界检查无法轻松验证structs依次包含数组的数组的使用。

\n
\n
\n

C 中的每个有效的指针值表达式都从一个原始存储对象中派生出其结果。如果指针计算的结果引用了不同的对象,则它是无效的。\n这种语言是相当明确的,但请注意该论文是在 1997 年发表的,在上面的 DR 报告撰写和回复之前。解释本文中描述的边界检查系统的最佳方法是作为C 的一致实现,但不能检测所有非严格一致的程序。然而,我确实看到这篇论文和2021 年的A Provenance-aware Memory Object Model for C - N2676之间有相似之处,因此将来与上面引用的类似的想法可能会被编入语言标准。

\n
\n

C内存对象模型研究小组container_of是与许多其他密切相关的问题相关讨论的宝库。从他们的邮件列表档案中,我们提到了这个container_of习语:

\n

2.5.4 Q34 可以使用表示指针算术和强制转换在结构体的成员之间移动吗?

\n
\n

该标准对于允许的指针算术(在无符号 char* 表示指针上)和子对象之间的交互是不明确的。例如,考虑:

\n

例子cast_struct_inter_member_1.c

\n
\n
#include <stdio.h>\n#include <stddef.h>\ntypedef struct { float f; int i; } st;\nint main() {\n  st s = {.f=1.0, .i=1};\n  int *pi = &(s.i);\n  unsigned char *pci = ((unsigned char *)pi);\n  unsigned char *pcf = (pci - offsetof(st,i))\n    + offsetof(st,f);\n  float *pf = (float *)pcf;\n  *pf = 2.0;  // is this free of undefined behaviour?\n  printf("s.f=%f *pf=%f  s.i=%i\\n",s.f,*pf,s.i);\n}\n
Run Code Online (Sandbox Code Playgroud)\n
\n

这形成了一个指向结构体第二个成员 (i) 的 unsigned char* 指针,使用 offsetof 对其进行算术运算以形成指向第一个成员的 unsigned char* 指针,将其转换为指向第一个成员类型的指针 (f ),并用它来编写。

\n

在实践中,我们相信大多数编译器都支持这一切,并且在实践中使用它,例如 Chisnall 等人的 Container 惯用法。[ASPLOS 2015],其中他们讨论了容器宏,这些容器宏采用指向结构成员的指针并计算指向整个结构的指针。他们发现他们研究的示例程序之一大量使用了它。我们被告知 Intel 的 MPX 编译器不支持容器宏习惯用法,而 Linux、FreeBSD 和 Windows 都依赖它。

\n

标准规定(6.3.2.3p7):“...当指向对象的指针转换为指向字符类型的指针时,结果指向该对象的最低寻址字节。结果的连续增量,最多可达对象的大小,yield 指向对象剩余字节的指针。”。这将无符号 char* 指针 pci 的构造许可到 si 表示的开头(假设结构成员本身是一个“对象”,其本身在标准中是不明确的),但允许它仅用于访问si 的表示

\n

stddef.h 中的 offsetof 定义,7.19p3,“ offsetof(type,member-designator) 扩展为类型为 size_t 的整型常量表达式,其值是以字节为单位的偏移量,到结构体成员(由 member 指定) -designator,从其结构的开始(由类型指定”,意味着pcf的计算得到正确的数字地址,但并不表示它可以被使用,例如访问sf的表示正如我们在讨论中看到的毫无疑问,在 DR260 后的世界中,指针具有正确的地址这一事实并不一定意味着它可以用来访问该内存而不会引起未定义的行为。

\n

最后,如果认为 pcf 是指向 sf 表示的合法 char* 指针,那么标准表示,如果充分对齐,它可以转换为指向任何对象类型的指针,对于 float* 来说就是这样。6.3.2.3p7:“指向对象类型的指针可以转换为指向不同对象类型的指针。如果生成的指针未针对引用类型正确对齐 (68),则行为未定义。否则,当转换回来时再次,结果应等于原始指针......”。但该指针是否具有正确的值以及是否可用于访问内存尚不清楚。

\n

这个例子在我们事实上的语义中应该是允许的,但在 ISO 文本中却没有明确允许。

\n

为了澄清这一点,需要对 ISO 文本进行哪些更改?

\n

更一般地说,ISO 文本对“对象”的使用不清楚:它是指分配,还是结构成员、联合成员和数组元素也是“对象”?

\n
\n

关键短语是“这个例子应该在我们事实上的语义中被允许,但在 ISO 文本中没有明确允许。 ”即我认为这意味着像N2676这样的组文档希望看到container_of支持。

\n

然而,在后来的消息中:

\n
\n

2.2 出处和子对象:容器类型转换

\n

一个关键问题是是否可以从指向结构体第一个成员的指针转换为整个结构体,然后使用它来访问其他成员。我们之前在 N2222 Q34 可以使用表示指针算术和强制转换在结构的成员之间移动吗?、N2222 Q37 指向结构及其第一个成员的可用指针是否可以相互转换?、N2013 和 N2012 中讨论过。我们中的一些人认为,ISO C 6.7.2.1p15 中毫无争议地允许这样做...指向结构对象的指针,经过适当转换,指向其初始成员...,反之亦然...,但其他人不同意。实际上,这在实际代码中、“容器”习惯用法中似乎很常见。

\n

尽管有人建议 IBM XL C/C++ 编译器不支持它。WG14 和编译器团队的澄清对于这一点非常有帮助。

\n
\n

对此,该小组很好地总结了这一点:该习语被广泛使用,但对于标准的说法存在分歧。

\n