“struct hack”是否属于技术上未定义行为?

123
我正在询问的是众所周知的“结构体最后一个成员具有可变长度”的技巧。它大致如下:
struct T {
    int len;
    char s[1];
};

struct T *p = malloc(sizeof(struct T) + 100);
p->len = 100;
strcpy(p->s, "hello world");

由于结构体在内存中的布局方式,我们能够将结构体覆盖到比必要更大的块上,并且将最后一个成员视为大于指定的1 char
因此问题是:这种技术是否属于未定义行为? 我认为它是,但很想知道标准对此有何规定。
PS:我了解C99方法,但希望答案专门针对上述技巧。

36
这个问题似乎很明确、合理,而且最重要的是可以回答。不明白为什么会有关闭投票的理由。 - cHao
2
如果你引入了一个不支持结构体技巧的“ansi c”编译器,我认识的大多数c程序员都不会接受你的编译器“工作正常”。尽管他们会接受标准的严格解释。委员会在这方面简单地忽略了一个问题。 - dmckee --- ex-moderator kitten
4
@james 这个hack的工作原理是通过动态分配一个足够大的对象,以代替你所声明的最小数组。因此,你可以访问在"结构体"定义之外分配的内存。虽然超出你的分配范围是无可争议的错误,但这与在"结构体"内写入但在其定义之外写入是不同的概念。 - dmckee --- ex-moderator kitten
2
@detly:使用指针会更慢(需要额外的解引用操作),并且浪费空间(至少4或8个字节,取决于您是否拥有32/64位机器,如果您将字符串单独malloc而不是在相同分配的块中立即存储它,则会浪费更多空间)。如果您有大量小对象或经常访问它们,则在此处使用指针是愚蠢的。 - R.. GitHub STOP HELPING ICE
6
相较于分配/释放两个物品,分配/释放一个物品更简单,尤其是后者有两种可能的失败方式需要处理。这对我来说比微小的成本/速度节省更重要。 - jamesdlin
显示剩余15条评论
8个回答

55

正如C FAQ所说:

它是否合法或可移植并不清楚,但它相当受欢迎。

另外:

... 官方解释认为它与C标准不严格一致,尽管似乎在所有已知的实现下都能工作。(仔细检查数组边界的编译器可能会发出警告。)

"严格一致"的背后原理在规范的第J.2未定义行为部分,其中包括在未定义行为列表中:

  • 数组下标越界,即使使用给定下标(例如lvalue表达式a [1] [7]在声明int a [4] [5] 的情况下,似乎可以访问对象)(6.5.6)。

6.5.6加性运算符的第8段还提到访问定义数组边界之外的内容是未定义的:

如果指针操作数和结果均指向同一数组对象的元素,或者指向数组对象的最后一个元素之一,则评估不应产生溢出;否则,行为是未定义的。


2
在OP的代码中,p->s从未被用作数组。它被传递给strcpy,此时它会衰减为一个普通的char *,它恰好指向一个可以合法解释为分配对象内的char [100]的对象。 - R.. GitHub STOP HELPING ICE
3
也许另一种看待这个问题的方式是,语言可以限制你如何访问 J.2 中描述的实际数组变量,但它无法对通过 malloc 分配的对象进行限制,当你仅仅将返回的 void * 转换为指向[包含]数组的结构体指针时。仍然可以使用指向 char(或最好是 unsigned char)的指针来访问分配的对象的任何部分。 - R.. GitHub STOP HELPING ICE
1
当然可以!类型和大小信息可以嵌入每个指针中,任何错误的指针算术运算都可以被捕获 - 例如参见CCured。在更深层次上,无论是否有可能的实现可以抓住您,这仍然是未定义的行为(如果我没记错的话,有些未定义的行为需要一个哈尔滨问题的神谕才能确定 - 这正是它们为什么是未定义的原因)。 - zwol
4
该对象不是一个数组对象,因此6.5.6不相关。该对象是由malloc分配的内存块。在你胡说八道之前,请在标准中查找“对象”一词。 - R.. GitHub STOP HELPING ICE
@R..:你认为http://www.open-std.org/Jtc1/sc22/wg14/www/docs/dr_051.html中的缺陷报告意味着什么?该报告暗示结构体Hack在技术上属于未定义行为(虽然被广泛使用,编译器编写者应将其视为具有已定义的语义)。 - supercat
显示剩余15条评论

36

我认为从技术上讲,这是未定义的行为。标准(可以说)没有直接涉及它,因此它落在“或者由于任何明确定义的行为而省略”的条款下(C99的§4/2,C89的§3.16/2),即它是未定义的行为。

上面的“可以说”取决于数组下标运算符的定义。具体来说,它指出:“后跟方括号[]中的表达式的后缀表达式是一个数组对象的下标指定”(C89,§6.3.2.1/2)。

你可以认为这里违反了“数组对象”的规定(因为你正在数组对象的定义范围之外进行下标运算),在这种情况下行为更加明确地是未定义的,而不仅仅是由于没有完全定义而未定义。

理论上,我可以想象一种编译器会进行数组边界检查,然后在尝试使用超出数组范围的下标时中止程序。事实上,我不知道是否有这样的编译器存在,鉴于这种代码风格的流行程度,即使编译器在某些情况下试图强制执行下标,也很难想象任何人会容忍它在这种情况下这样做。


2
我还可以想象一种编译器,如果一个数组恰好是大小为1,那么arr[x] = y;可能会被重写为arr[0] = y;;对于大小为2的数组,arr[i] = 4;可能会被重写为i ? arr[1] = 4 : arr[0] = 4;虽然我从未见过编译器执行这样的优化,在某些嵌入式系统上它们可能非常有效。在使用8位数据类型的PIC18x上,第一条语句的代码将占用16个字节,第二条语句将占用2或4个字节,第三条语句将占用8或12个字节。如果合法,这不是一个坏的优化。 - supercat
如果标准将数组越界访问定义为未定义行为,那么结构体黑客也是如此。然而,如果标准将数组访问定义为指针算术的语法糖(a[2] == a + 2),则不是这样。如果我没错的话,所有的C标准都将数组访问定义为指针算术。 - yyny

17

是的,这是未定义行为。

C语言缺陷报告#051对这个问题给出了明确的答案:

尽管这种习惯用法很普遍,但它并不严格符合规范。

http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_051.html

在C99 Rationale文档中,C委员会补充道:

这种构造的有效性一直存在疑问。在回应一个缺陷报告时,委员会决定它是未定义行为,因为数组p->items只包含一个元素,无论空间是否存在。


2
+1 发现这个问题,但我仍然认为它是矛盾的。两个指向同一对象(在这种情况下,给定字节)的指针是相等的,并且其中一个指向它(通过 malloc 获得的整个对象表示数组中的指针)在加法中是有效的,那么通过另一种方式获得的相同指针如何在加法中无效?即使他们想要声称这是未定义行为,那也没有什么意义,因为计算上没有办法区分明确定义的用法和所谓的未定义用法。 - R.. GitHub STOP HELPING ICE
很遗憾的是,C编译器开始禁止声明零长度数组;如果没有这个限制,许多编译器就不必进行任何特殊处理才能使它们按照“应该”的方式工作,但仍然可以为单元素数组特别处理代码(例如,如果*foo包含一个单元素数组boz,表达式foo->boz[biz()*391]=9;可以简化为biz(),foo->boz[0]=9;)。不幸的是,编译器拒绝零元素数组意味着许多代码使用单元素数组代替,这将破坏该优化。 - supercat

13

那种特定的做法在任何 C 标准中都没有明确定义,但是 C99 将 "struct hack" 作为语言的一部分包含在其中。 在 C99 中,结构体的最后一个成员可以是 "灵活数组成员",声明为 char foo[](在 char 的位置上使用所需的任何类型)。


严谨地说,那不是结构体黑客技巧。结构体黑客技巧使用具有固定大小的数组,而不是可变数组成员。结构体黑客技巧是被问及并且是未定义行为。可变数组成员似乎只是试图取悦在此线程中抱怨这一事实的人们。 - underscore_d

9
无论任何人,无论是官方还是非官方的,都不能说它是未定义行为,因为它已经被标准定义了。除非作为左值使用,否则p->s将计算出一个指针,该指针与(char *)p + offsetof(struct T, s)相同。特别地,在malloc分配的对象内,这是一个有效的char指针,并且在其后立即有100个(或更多,取决于对齐考虑)连续地址,这些地址也是分配对象内的有效char对象。使用->导出指针而不是显式地将偏移量添加到由malloc返回的指针(转换为char *)是不相关的。
技术上讲,p->s[0]是结构体内char数组的单个元素,接下来的几个元素(例如p->s[1]p->s[3])可能是结构体内的填充字节,如果你将整个结构体进行赋值,则可能会损坏,但如果仅访问单个成员,则不会损坏,其余元素是分配对象中的附加空间,您可以随意使用,只要遵守对齐要求(而char没有对齐要求)。
如果您担心与结构体内的填充字节重叠可能会触发Nasal Demon,则可以通过将[1]中的1替换为一个值来避免这种情况,以确保在结构体末尾没有填充。一种简单但浪费的方法是创建一个具有相同成员但末尾没有数组的结构体,并使用s[sizeof struct that_other_struct];作为数组。然后,对于i<sizeof struct that_other_structp->s[i]被明确定义为结构体内数组的元素,对于i>=sizeof struct that_other_struct,它被定义为位于结构体末尾之后的char对象。
编辑:实际上,在获取正确大小的技巧中,您还需要在数组之前放置包含每个简单类型的联合,以确保数组本身从最大对齐开始,而不是在其他元素的填充中间开始。再次说明,我认为这些都是不必要的,但我提供给那些最谨慎的语言律师们。 编辑2: 由于标准的另一部分规定,与填充字节的重叠绝对不是问题。C要求如果两个结构体在其元素的初始子序列上达成一致,则可以通过指向任一类型的指针访问共同的初始元素。因此,如果声明了一个与struct T相同但具有更大的最终数组的结构体,则元素s[0]必须与struct T中的元素s[0]重合,并且这些额外元素的存在不能影响或受到使用指向struct T的指针访问较大结构体的常见元素的影响。

4
你说指针算术的本质不相关,这是正确的,但是你在超出数组声明大小的访问方面是错误的。请参见N1494(最新的公共C1x草案)第6.5.6节第8段 - 即使只是将指针移动一个元素以上,你甚至都不允许进行加法操作,并且即使它只是一个元素之外,也不能对其进行解引用。 - zwol
7
如果malloc没有分配一段可以通过指针算术访问的内存范围,那么它有什么用处呢?而且如果标准将p->s[1]定义为指针算术的语法糖,那么这个答案只是重新确认了malloc的有用性。还剩下什么可以讨论的呢? :) - Daniel Earwicker
3
你可以辩论它是否被明确定义,但这并不改变它实际上没有被定义的事实。标准非常明确地规定了超出数组界限的访问是不行的,而这个数组的边界是1。就是如此简单明了。 - Lightness Races in Orbit
2
我仍然没有看到一个有效的论点,即p->s不是指向分配对象的表示数组char数组元素的指针。如果有这样的论点,那么情况就改变了。有关构建这样一个论点的任何想法? - R.. GitHub STOP HELPING ICE
3
@R..,我认为你的假设是错误的,即两个指针比较相等必须具有相同的行为。考虑 int m[1]; int n[1]; if(m+1 == n) m[1] = 0; 假设进入了 if 分支。根据我所读的6.5.6 p8(最后一句),这是未定义的行为(并且不保证对 n 进行初始化)。相关参考:6.5.9 p6 和脚注109。(引用基于 C11 n1570 版本)[...] - mafso
显示剩余9条评论

9

是的,从技术上讲,这是未定义的行为。

请注意,实现“结构体技巧”的至少有三种方法:

(1)声明大小为0的尾随数组(在旧代码中最常见)。显然,这是UB,因为在C中,声明零大小的数组总是非法的。即使它编译通过,语言也不能保证任何违反约束的代码的行为。

(2)将数组声明为最小合法大小-1(您的情况)。在这种情况下,任何试图获取指向p->s [0]并用于超出p->s [1]范围的指针算术运算的尝试都是未定义的行为。例如,调试实现允许生成带有嵌入式范围信息的特殊指针,每次尝试创建p->s [1]之外的指针时,该指针将触发陷阱。

(3)声明具有“非常大”大小的数组,例如10000。想法是声明的大小应该比实际实践中可能需要的任何东西都要大。就数组访问范围而言,此方法不涉及UB。但是,在实践中,我们当然会分配较小的内存量(只有真正需要的大小)。我不确定这是否合法,即我想知道为对象分配比对象的声明大小少的内存(假设我们从不访问“未分配”的成员)是否合法。


1
在(2)中,s[1] 不是未定义行为。它与 *(s+1) 相同,这与 *((char *)p + offsetof(struct T, s) + 1) 相同,这是指向分配对象中 char 的有效指针。 - R.. GitHub STOP HELPING ICE
如果声明了一个数组的大小(不仅仅是foo[]语法糖表示的*foo),那么无论指针算术如何进行,任何超出其声明大小和分配大小中较小者的访问都是未定义行为。 - zwol
1
@Zack,你在几个方面都错了。结构体中的foo[]不是*foo的语法糖,它是C99的灵活数组成员。其他方面,请参考我的答案和对其他答案的评论。 - R.. GitHub STOP HELPING ICE
6
问题在于,委员会中的一些成员迫切地希望这个“hack”被视为UB,因为他们设想了一个C实现能够强制执行指针边界的童话世界。无论如何,这样做会与标准的其他部分产生冲突,比如能够比较指针是否相等(如果边界被编码在指针本身中),或者任何对象都可以通过想象中的重叠的unsigned char [sizeof object]数组访问的要求。我坚信我所说的Pre-C99的柔性数组成员“hack”具有明确定义的行为。 - R.. GitHub STOP HELPING ICE
如果一个结构体被定义为具有元素"char arr [2]",那么标准中是否有任何要求表达式“n = myptr->arr[i]”处理大于255-offsetof(arr)的i值?在小型嵌入式微控制器上,使用8位算术计算结构体偏移量,然后将其添加到16位指针中,可能比始终使用16位数学运算快得多。 如果i的值不能合法超过1,那么编译器生成地址计算代码以处理较大值的原因是什么? - supercat
显示剩余4条评论

5

标准非常清楚,您不能访问数组末尾以外的内容。(通过指针访问也无济于事,因为您甚至不允许将指针向后递增一个数组末尾后面的位置)。

至于“实际工作中”。我见过gcc/g++优化器使用标准的这部分,从而在遇到此无效C代码时生成错误的代码。


你能举个例子吗? - Tal

2
如果编译器接受像这样的代码:
typedef struct { int len; char dat[]; };
那么很明显,它必须准备好接受 'dat' 上超出其长度的下标。但是,如果有人编写了这样的代码:
typedef struct { int whatever; char dat[1]; } MY_STRUCT;
然后稍后访问 somestruct->dat[x];我认为编译器没有义务使用可以处理大 x 值的地址计算代码。我认为,如果想要真正安全,正确的范例应该更像:
#define LARGEST_DAT_SIZE 0xF000 typedef struct { int whatever; char dat[LARGEST_DAT_SIZE]; } MY_STRUCT;
然后 malloc (sizeof(MY_STRUCT)-LARGEST_DAT_SIZE + desired_array_length) 字节(请注意,如果 desired_array_length 大于 LARGEST_DAT_SIZE,则结果可能是未定义的)。
顺便说一下,我认为禁止零长度数组的决定是不幸的(一些较旧的方言如 Turbo C 支持它),因为零长度数组可以被视为编译器必须生成可处理更大索引的代码的迹象。

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