“container_of”宏可以严格遵守吗?

Posted

技术标签:

【中文标题】“container_of”宏可以严格遵守吗?【英文标题】:Can a "container_of" macro ever be strictly-conforming? 【发布时间】:2014-10-07 09:28:25 【问题描述】:

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

#define container_of(ptr, type, member) (((type) *)((char *)(ptr) - offsetof((type), (member))))

这基本上允许在给定一个指向其成员之一的指针的情况下恢复“父”结构:

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'

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

一方面,因为struct foo 中的bar 只是一个整数,所以只有*ptr 应该是有效的(以及ptr + 1)。因此,container_of 有效地生成了类似ptr - sizeof(int) 的表达式,这是未定义的行为(即使没有取消引用)。

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

主要问题是允许实现在运行时检查越界索引。我对此和前面提到的指针等效要求的解释是,必须在指针强制转换中保留边界(这包括指针衰减 - 否则,您如何使用指针遍历数组?)。因此,虽然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];

虽然取消引用p 是未定义的行为,但指针本身是有效的,并且需要与q 进行比较(请参阅this question)。这意味着pq 比较 相同,但在某些实现定义的方式上可能不同(这样只有q 可以被取消引用)。这可能意味着考虑到以下情况:

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

pq 比较相同,但可能有不同的边界关联,因为对 (char *) 的强制转换来自指向不兼容类型的指针。


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


这是在 cmets 在 my answer 到 this 问题之后讨论的 here。

【问题讨论】:

这似乎是一个好问题。我会赞成的。 好像应该没问题:由于原来的指针指向一个大对象的中间,所以把它转换成char指针,当成指向对象表示的一个元素就OK了大对象并对其执行算术运算。 当您有数组的数组(或包含数组的结构,因为结构对象是大小为 1 的隐式数组)时,C 规范对于术语“数组对象”的含义是完全模棱两可的 - - 它可能意味着内部数组或包含数组。将此与规范要求实现以允许将任何对象视为可以复制的字节序列(字符)的事实相结合,并且您的情况似乎必须允许所有此类指针操作,但规范并没有明确说明。 “[…] 将指针转换为不同类型并再次返回将产生相同的指针”——准确地说,一个“应与原始指针比较相等”的指针。当我读到它时,这并不一定意味着关于边界信息的“相同”。 该标准也不清楚如何通过转换后的指针访问对象——它只提到了对齐要求。 【参考方案1】:

TLDR

语言律师之间争论的问题是使用container_of 的程序是否严格符合,但使用container_of 成语的实用主义者相处得很好,不太可能遇到问题在主流硬件上运行使用主流工具链编译的程序。换句话说:

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

今天可以说什么

    标准 C17 标准中没有明确要求支持 container_of 成语的语言。 有defect reports 表明该标准打算允许实现空间通过跟踪对象的“providence”(即有效边界)以及指针来禁止container_of 成语。然而,这些都不是规范的。 C memory object model study group 最近有一些活动,旨在为这个问题和类似问题提供更严谨的回答。请参阅 2016 年的 Clarifying the C memory object model - N2012、2018 年的 Pointers are more abstract than you might expect 和 2021 年的 A Provenance-aware Memory Object Model for C - N2676。

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

背景

container_of 成语是什么?

这段代码通过扩展通常看到的实现习语的宏的内容来演示习语:

#include <stddef.h>

struct foo 
    long first;
    short second;
;

void container_of_idiom(void) 
    struct foo f;

    char* b = (char*)&f.second;        /* Line A */
    b -= offsetof(struct foo, second); /* Line B */
    struct foo* c = (struct foo*)b;    /* Line C */

在上述情况下,container_of 宏通常会采用一个short* 参数,该参数旨在指向struct foosecond 字段。它还将接受struct foosecond 的参数,并将扩展为返回struct foo* 的表达式。它将采用上面 A-C 行中的逻辑。

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

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

C17 4 (5-7) 一致性

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

    [...] 符合要求的托管实现应接受任何严格符合要求的程序。 [...] 符合标准的实现可能有扩展(包括额外的库函数),只要它们不改变任何严格符合标准的程序的行为。

    符合标准的程序是符合标准的实现可接受的程序。

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

从这里我们看到严格的一致性是相当严格的,但是一个符合的实现可以定义额外的行为,只要它不改变一个严格符合的程序的行为。在实践中,几乎所有的实现都这样做;这是大多数 C 程序所依据的“实用”定义。

出于本答案的目的,我将包含我对严格符合程序的回答,并在最后仅讨论符合程序。

缺陷报告

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

DR 51

DR 51对本程序提问:

#include <stdlib.h>

struct A 
    char x[1];
;

int main() 
    struct A *p = (struct A *)malloc(sizeof(struct A) + 100);
    p->x[5] = '?'; /* This is the key line */
    return p->x[5];

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

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

这里我们有第一个迹象表明该标准允许实现基于指向的对象“定制”指针表示,并且“离开”原始对象的有效范围的指针算法指向to 不严格符合。

DR 72对本程序提问:

#include <stddef.h>
#include <stdlib.h>

typedef double T;
struct hacked 
    int size;
    T data[1];
;

struct hacked *f(void)

    T *pt;
    struct hacked *a;
    char *pc;

    a = malloc(sizeof(struct hacked) + 20 * sizeof(T));
    if (a == NULL) return NULL;
    a->size = 20;

    /* Method 1 */
    a->data[8] = 42; /* Line A /*

   /* Method 2 */
    pt = a->data;
    pt += 8; /* Line B /*
    *pt = 42;

    /* Method 3 */
    pc = (char *)a;
    pc += offsetof(struct hacked, data);
    pt = (T *)pc; /* Line C */
    pt += 8;      /* Line D */
    *pt = 6 * 9;
    return a;

精明的读者会注意到上面的/* Method 3 */ 很像container_of 成语。 IE。它需要一个指向结构类型的指针,将其转换为char*,执行一些指针运算,使char* 超出原始结构的范围,然后使用该指针。

委员会回应说/* Line C */ 严格符合但/* Line D */ 并没有严格符合上面为 DR 51 给出的相同论点。此外,委员会表示“如果T 具有char 类型,则答案不会受到影响。”

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

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

有人可能会争辩说container_of 通过在char* 指针的域中进行指针算术来解决问题,但委员会表示答案是“如果T 具有char 类型则不受影响.

可以在实践中使用container_of 成语吗?

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

是的,如果你是一个实用主义者,并且认为在 Linux、FreeBSD、Microsoft Windows C 代码中广泛使用的成语足以在实践中标记成语符合

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

事实上,我在其论文中发现了一种边界检查器的实现,它对 C 语义给出了不同的解释。引用来自以下论文:Richard W. M. Jones 和 Paul H. J. Kelly。向后兼容的边界检查 C 程序中的数组和指针。在第三届自动调试国际研讨会上(编辑 M. Kamkarand D. Byers),第 2 卷(1997 年),林雪平计算机和信息科学电子文章第 009 期。林雪平大学电子出版社,瑞典林雪平。 ISSN 1401-9841,1997 年 5 月,第 13-26 页。网址http://www.ep.liu.se/ea/cis/1997/009/02/

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

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

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

C memory object model study group 是与container_of 和许多其他密切相关的问题相关的讨论宝库。从他们的邮件列表存档中,我们提到了container_of 成语:

2.5.4 Q34 Can one move among the members of a struct using representation-pointer arithmetic and casts?

该标准在允许的指针算术(在无符号字符*表示指针上)和子对象之间的交互上是模棱两可的。例如,考虑:

例如cast_struct_inter_member_1.c

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

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

在实践中,我们相信大多数编译器都支持这一点,并且在实践中使用它,例如就像 Chisnall 等人的 Container 成语一样。 [ASPLOS 2015],他们讨论了容器宏,这些宏接受指向结构成员的指针并计算指向整个结构的指针。他们看到他们研究的一个示例程序大量使用了它。听说Intel的MPX编译器不支持container macro idiom,Linux、FreeBSD、Windows都依赖它。

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

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

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

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

为了澄清这一点,ISO 文本中需要更改哪些内容?

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

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

但是,在later message:

2.2 出处和子对象:container-of casts

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

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

小组对此进行了很好的总结:该成语被广泛使用,但对于该标准的说法存在分歧。

【讨论】:

【参考方案2】:

我认为它严格符合或标准存在很大缺陷。参考您的最后一个示例,关于指针运算的部分没有给编译器任何回旋余地来区别对待pq。不以指针值如何获取为条件,只以它指向的对象为条件。

pq 可以在指针算术中以不同方式处理的任何解释都需要解释 pq 不指向同一个对象。由于在您获得pq 的方式中没有依赖于实现的行为,因此这意味着它们在任何实现上都不指向同一个对象。这反过来又要求 p == q 在所有实现中为 false,因此会使所有实际实现不符合要求。

【讨论】:

是的,它指向什么对象是关键。辩论似乎围绕着这个问题。标准只是说“对象”,但在char* p = (char *)&amp;f.bar; 的情况下,对象可能是f 或其子对象f.bar。一些人认为该标准允许但不要求实现将p 指针的规定限制为f.bar 中的字节范围。如果p 递减超过f.bar 的第一个字节,则有人认为p 具有未定义的值,因此相等比较未定义,如果是这样,您的矛盾证明将不成立。【参考方案3】:

我只想回答这个问题。

int arr[5][5] = ...
int *p = &arr[0][0] + 5;
int *q = &arr[1][0];

这不是 UB。可以确定 p 是指向数组元素的指针,前提是它在边界内。在每种情况下,它都指向 25 个元素数组的第 6 个元素,并且可以安全地取消引用。它也可以递增或递减以访问数组的其他元素。

请参阅 n3797 S8.3.4 了解 C++。 C的措辞不同,但含义相同。实际上,数组具有标准布局,并且在指针方面表现良好。


让我们暂时假设情况并非如此。有什么影响?我们知道数组 int[5][5] 的布局与 int[25] 相同,不能有填充、对齐或其他无关信息。我们还知道,一旦 p 和 q 已经形成并被赋予了一个值,它们在各个方面都必须相同。

唯一的可能性是,如果标准说它是 UB 并且编译器编写者实现了该标准,那么足够警惕的编译器可能 (a) 基于分析数据值发出诊断或 (b) 应用优化这取决于不偏离子数组的范围。

我不得不承认(b)至少是一种可能性。我得到了一个相当奇怪的观察结果,即如果您可以向编译器隐藏您的真实意图,则该代码可以保证产生定义的行为,但如果您公开执行它可能不会。

【讨论】:

你读过the question linked in this question吗?内存布局和边界检查是两件事,前者是保证,后者是这里的问题。 是的,我读过,是的。这个答案很旧,这个问题在 cmets 中没有得到妥善解决,我不想添加它们。但是您的评论使我产生了不同的思路……请参阅编辑。 您的 (b) 是另一个好点,而主要关注的是可靠的运行时边界检查(而不是您在 (a) 中建议的编译时检查)。阅读链接帖子中给出的标准引用(尤其是强调部分和脚注)让我相信标准的意图是不允许这种越界访问。是的,这必然意味着,比较相等的指针可能在其关联的边界信息中有所不同,因此算术/取消引用可能会为其中一个抛出异常并为另一个成功。两个例子: int p[1]; int q; if(p+1 == &amp;q) /* look if they happen to be contiguous in stack */ p[1] = 1; /* Does this initialize q? */ 假设输入了if 分支。我认为最好允许边界检查实现来捕捉这种无意义的构造。一个不太人为的例子:struct char str1[16], str2[16]; foo; fgets(str1, 32, stdin);(假设没有填充,我们可以静态断言sizeof foo == 32。)应该允许这抛出异常吗?我们可以在聊天中进一步讨论,如果你愿意,我对这个话题很感兴趣。 @david.pfx 标准中没有规定指向数组元素的指针不能携带原始数组的信息。否则,像int x[5], p = &amp;x[2], q = p[3]; 这样的东西,就无法知道p[3] 指的是x[5],这是越界的。

以上是关于“container_of”宏可以严格遵守吗?的主要内容,如果未能解决你的问题,请参考以下文章

offsetof宏与container_of宏

offsetof与container_of宏分析

linux内核中的offsetof与container_of宏

container_of 和 offsetof宏

Linux内核中的常用宏container_of

container_of宏解析 && 为什么需要使用中间变量__mptr?