考虑以下简单示例:
struct __attribute__ ((__packed__)) {
int code[1];
int place_holder[100];
} s;
void test(int n)
{
int i;
for (i = 0; i < n; i++) {
s.code[i] = 1;
}
}
for-循环正在写入大小为1的字段code
。在code
之后的下一个字段是place_holder
。
我预计,在n > 1
的情况下,对code
数组的写将溢出,而1
将被写入place_holder
。
然而,在用-O2
编译时(在gcc 4.9.4上,但也可能在其他版本上),会发生一些有趣的事情。
编译器标识代码可能溢出数组code
,限制循环展开为1迭代。
在使用-fdump-tree-all
编译和查看最后一个树传递(“t.optimized”)时,很容易看出这一点:
;; Function test (test, funcdef_no=0, decl_uid=1366, symbol_order=1)
Removing basic block 5
test (int n)
{
<bb 2>:
# DEBUG i => 0
# DEBUG i => 0
if (n_4(D) > 0)
goto <bb 3>;
else
goto <bb 4>;
<bb 3>:
s.code[0] = 1;
# DEBUG i => 1
# DEBUG i => 1
<bb 4>:
return;
}
因此,在本例中,编译器将循环完全展开为一个迭代。
我的问题是:
code
数组时我在做什么。我知道编写这样的代码从一个字段溢出到另一个字段是一种不好的做法,我不打算编写这样的代码。
我还知道将数组(可能为零大小)作为最后一个结构字段以允许其溢出的做法,编译器很好地支持这一点,而在这种情况下,数组code
不是最后一个字段。
所以这不是一个“如何修复代码”的问题,而是一个理解编译器假设并影响它们的问题。
当我观察到已经以这种方式编写的现有代码时,出现了这些问题,并对其进行了调试,以找出它为什么不像最初的开发人员所期望的那样运行。
风险在于代码中还有其他地方存在这样的问题。静态分析工具可以帮助查找,但我也想知道是否有一种方法可以让编译器容忍这样的代码,并仍然生成我们期望的结果。
更新
我对上面的问题(1)有了明确的答案,但问题(2)没有得到明确的回答。
发布于 2020-07-02 01:23:56
从C规范的角度来看,从一个struct成员到另一个结构成员的溢出(故意)是非法的还是未定义的行为?
这是未定义的行为。arr[i]
运算符是*(arr + i)
周围的语法糖。因此,数组访问归结为指针算术的二进制+
操作符,C17 6.5.6加法运算符,从§7和§8开始:
为了这些运算符的目的,指向非数组元素的对象的指针的行为与指向长度为1的数组的第一个元素的指针相同,其元素类型为对象的类型。 将具有整数类型的表达式添加到指针或从指针中减去时,结果具有指针操作数的类型。/-/ 如果指针操作数和结果都指向同一个数组对象的元素,或者指向数组对象的最后一个元素,则计算值不会产生溢出;否则,行为将未定义。如果结果指向数组对象的最后一个元素,则不应将其用作求值的一元*操作符的操作数。
正如您注意到的,优化编译器可能利用这些规则来生成更快的代码。
在这种情况下,有没有办法阻止gcc展开循环?
有一个特殊的例外规则可以使用,C17 6.3.2.3/7:
当指向对象的指针转换为指向字符类型的指针时,结果指向对象的最低寻址字节。结果的连续增量,直到对象的大小,会产生指向对象剩余字节的指针。
此外,严格的混叠不适用于字符类型,因为C17 6.5§7中的另一条特殊规则
对象的存储值只能由具有下列类型之一的lvalue表达式访问:字符类型。
这两条特殊规则和谐共存。因此,假设我们在指针转换过程中没有弄乱对齐等,这意味着我们可以这样做:
unsigned char* i;
for(i = (unsigned char*)&mystruct; i < (unsigned char*)(&mystruct + 1); i++)
{
do_something(*i);
}
然而,这可能读取填充字节等,因此它是“实现定义的”。但从理论上讲,您可以访问每个字节的struct字节,并且只要结构偏移量是按字节计算的,就可以以这种方式遍历结构的多个成员(或任何其他对象)。
据我所知,这个看上去很可疑的代码应该有很好的定义:
#include <stdint.h>
#include <string.h>
#include <stdio.h>
struct __attribute__ ((__packed__)) {
int code[1];
int place_holder[100];
} s;
void test(int val, int n)
{
for (unsigned char* i = (unsigned char*)&s;
i < (unsigned char*)&s + n*sizeof(int);
i += _Alignof(int))
{
if((uintptr_t)i % _Alignof(int) == 0) // not really necessary, just defensive prog.
{
memcpy(i, &val, sizeof(int));
printf("Writing %d to address %p\n", val, (void*)i);
}
}
}
int main (void)
{
test(42, 3);
printf("%d %d %d\n", s.code[0], s.place_holder[0], s.place_holder[1]);
}
这在gcc和clang (x86)上很好。效率有多高,那又是另外一个故事了。不过,请不要写这样的代码。
发布于 2020-07-02 01:11:02
发布于 2020-07-02 03:12:23
1.问题:
“从C规范的角度来看,是否(故意)从一个结构成员溢出到下一个非法或未定义的行为?”
是未定义行为。C标准规定(强调我的标准):
“后缀表达式后面加上方括号中的表达式
[]
是数组对象元素的下标指定。下标运算符[]
的定义是E1[E2]
与(*((E1)+(E2)))
相同。由于适用于二进制+
运算符的转换规则,如果E1
是数组对象(相当于数组对象的初始元素的指针),E2
是整数,则E1[E2]
指定E1
的E2
-th元素(从零计数)。” 来源: ISO/IEC 9899:2018 (C18),§6.5.2.1/2“当一个具有整数类型的表达式添加到指针或从指针中减去时,结果具有指针操作数的类型。如果指针操作数指向数组对象的一个元素,并且数组足够大,则结果指向与原始元素偏移的元素,从而使结果数组元素和原始数组元素下标的差值等于整数表达式。换句话说,如果表达式P
指向数组对象的i
-th元素,则表达式(P) + N
(等效地,N + (P)
)和(P) - N
(其中N
的值为n
)分别指向数组对象的i+n
-th和i−n
-th元素,只要它们存在。此外,如果表达式P
指向数组对象的最后一个元素,则表达式(P) + 1
指向数组对象的最后一个元素,如果表达式Q
指向数组对象的最后一个元素,则表达式(Q) - 1
指向数组对象的最后一个元素。如果指针操作数和结果都指向同一个数组对象的元素,或者指向数组对象的最后一个元素,则计算值不会产生溢出;否则,行为将未定义。如果结果指向数组对象的最后一个元素,则不应将其用作计算的一元*
运算符的操作数。 资料来源: ISO/IEC 9899:2018 (C18),§6.5.6/8。
非规范性附件J还就规范标准第6.5.6节规定:
J.2未定义行为 1在下列情况下,行为未予界定: ……
a[1][7]
的lvalue表达式int a[4][5]
) (6.5.6)。2.问题(加最新情况):
“在这种情况下,有没有办法阻止gcc展开循环?” “gcc能允许通过一些编译选项将其作为扩展吗?” “当gcc确认时,有没有办法至少得到警告?这对于在现有的大型代码库中识别此类情况非常重要。”
您可以尝试将像asm("");
这样的空汇编代码函数放入循环中,如DenilsonáMaia,F.E.的这个答案所示:
for (i = 0; i < n; i++) {
s.code[i] = 1;
asm("");
}
或者#pragma
在test
函数周围,如这里,F.E.所示:
#pragma GCC push_options
#pragma GCC optimize ("O0")
void test(int n)
{
int i;
for (i = 0; i < n; i++) {
s.code[i] = 1;
}
}
#pragma GCC pop_options
以防止对该特定程序部件的优化,并使循环展开。
相关信息:
它并不能阻止循环展开,但是您可以使用AddressSanitizer,它也集成了LeakSanitizer,并且从4.8版就内置到GCC中,用来检测循环展开不工作的时间/访问非关联内存。
有关这方面的更多信息,您可以找到这里。
编辑:正如您所说的目标实现是MIPS,您仍然可以使用瓦兰来检测内存泄漏。
https://stackoverflow.com/questions/62692609
复制