为什么gcc允许从结构中投机加载?

显示可能会出错的gcc优化和用户代码示例

下面代码片段中的函数“foo”只会加载其中一个结构成员A或B; 至less这是未经优化的代码的意图。

typedef struct { int A; int B; } Pair; int foo(const Pair *P, int c) { int x; if (c) x = P->A; else x = P->B; return c/102 + x; } 

以下是gcc -O3给出的内容:

 mov eax, esi mov edx, -1600085855 test esi, esi mov ecx, DWORD PTR [rdi+4] <-- ***load P->B** cmovne ecx, DWORD PTR [rdi] <-- ***load P->A*** imul edx lea eax, [rdx+rsi] sar esi, 31 sar eax, 6 sub eax, esi add eax, ecx ret 

所以看来gcc允许推测性地加载两个结构成员以消除分支。 但是,那么下面的代码是否被认为是未定义的行为呢,还是上面的gcc优化是非法的?

 #include <stdlib.h> int naughty_caller(int c) { Pair *P = (Pair*)malloc(sizeof(Pair)-1); // *** Allocation is enough for A but not for B *** if (!P) return -1; P->A = 0x42; // *** Initializing allocation only where it is guaranteed to be allocated *** int res = foo(P, 1); // *** Passing c=1 to foo should ensure only P->A is accessed? *** free(P); return res; } 

如果在上述情况下会发生负载猜测,则加载P> B将导致exception,因为P-> B的最后一个字节可能位于未分配的内存中。 如果closures优化,则不会发生此exception。

问题

上面显示的gcc优化负载猜测合法吗? 规范在哪里说或暗示没关系? 如果优化是合法的,那么'naughtly_caller'中的代码如何变成未定义的行为?

读取variables(未被声明为volatile )不被认为是C标准中规定的“副作用”。 所以程序可以自由地读取一个位置,然后放弃结果,就C标准而言。

这是非常普遍的。 假设你从一个4字节的整数请求1个字节的数据。 如果速度更快(alignment读取),编译器可以读取整个32位,然后丢弃除了请求的字节之外的所有内容。 你的例子与此类似,但编译器决定读取整个结构。

正式地,这在“抽象机器”C11章节5.1.2.3的行为中可以find。 考虑到编译器遵循那里指定的规则,它可以随心所欲地执行。 列出的唯一规则是关于volatile对象和指令sorting。 读取一个volatile结构中的不同的结构成员是不行的。

至于为整个结构分配内存太less的情况,这是未定义的行为。 因为结构的内存布局通常不是程序员决定的 – 例如编译器最后可以添加填充。 如果没有足够的内存分配,即使您的代码只能用于结构的第一个成员,也可能会访问禁止的内存。

不,如果*P分配正确, P->B永远不会在未分配的内存中。 它可能不会被初始化,就是这样。

编译器完全有权做他们所做的事情。 唯一不允许的是以“未初始化”的借口喋喋不休地访问P->B 但是,他们做什么和怎么做都是在实施的自由裁量之下,而不是你所关心的。

如果将一个指向由malloc返回的块的指针指向Pair* ,但不能确保其宽度足以容纳Pair则程序的行为是不确定的。

这是完全合法的,因为在一般情况下读取一些内存位置不被视为可观察的行为( volatile会改变这一点)。

您的示例代码确实是未定义的行为,但我无法在标准文档中find任何明确指出这一点的文章。 但是,我认为只要看看有效types的规则就足够了…从N1570,第6.5页p6:

如果通过types不是字符types的左值将值存储到没有声明types的对象中,则左值的types成为该访问的对象的有效types,并且对于不修改储值。

所以,你对*P写访问实际上给了这个对象typesPair – 因此它只是扩展到你没有分配的内存中,结果是出界限访问。

后缀expression式后跟->运算符,标识符指定结构或联合对象的成员。 该值是第一个expression式指向的对象的指定成员的值

如果调用expression式P->A是明确定义的,则P必须实际指向一个struct Pairtypes的对象,因此P->B也是明确定义的。

Pair *上的A ->运算符意味着完全分配了一个完整的Pair对象。 ( @Hurkyl引用标准 )

x86(与任何常规体系结构一样)没有访问正常分配内存的副作用,因此x86内存语义与C抽象机器的非易失volatile内存语义兼容 。 如果编译器认为在任何特定情况下他们正在调优的目标微体系结构都会取得性能上的胜利,那么编译器可以投机加载。

请注意,在x86上,内存保护以页面粒度运行。 编译器可以展开循环或使用SIMD进行向量化,只要所有触摸的页面包含对象的一些字节即可。 在x86和x64的同一页面内读取缓冲区的末尾是否安全? 。 libc strlen()实现手工编写在汇编中,但是AFAIK gcc不会,而是在自动vector化循环的末尾使用标量循环来处理剩余的元素,即使它已经将指针与(完全展开的)启动循环。 (也许是因为它会使valgrind运行时边界检查变得困难。)


为了得到你期望的行为,使用const int * arg

数组是单个对象,但是指针与数组不同。 (即使内联到两个数组元素已知可访问的上下文中,我也无法让gcc发出类似于结构体的代码,所以如果它的结构代码是一个胜利,那么这是一个错过的优化当它也是安全的时候,在数组上进行操作)。

在C中,只要c不为零,就可以将这个函数传递给一个int 。 在为x86编译时,gcc必须假定它可能指向页面中的最后一个int ,而下一页未映射。

在Godbolt编译器资源pipe理器上的源代码+ gcc和clang输出以及其他变体

 // exactly equivalent to const int p[2] int load_pointer(const int *p, int c) { int x; if (c) x = p[0]; else x = p[1]; // gcc missed optimization: still does an add with c known to be zero return c + x; } load_pointer: # gcc7.2 -O3 test esi, esi jne .L9 mov eax, DWORD PTR [rdi+4] add eax, esi # missed optimization: esi=0 here so this is a no-op ret .L9: mov eax, DWORD PTR [rdi] add eax, esi ret 

在C中, 可以传递一个数组对象(通过引用)给一个函数 ,保证函数允许它触及所有的内存,即使C抽象机没有。 语法是int p[static 2]

 int load_array(const int p[static 2], int c) { ... // same body } 

但gcc没有利用,并发出相同的代码load_pointer。


Off topic:clang以同样的方式编译所有版本(struct和array),使用cmov计算加载地址。

  lea rax, [rdi + 4] test esi, esi cmovne rax, rdi add esi, dword ptr [rax] mov eax, esi # missed optimization: mov on the critical path ret 

这不一定是好的:它比gcc的结构代码有更高的延迟,因为加载地址依赖于几个额外的ALU uop。 如果两个地址都不安全,分支预测不好,那么这是相当不错的。

我们可以用gcc和clang的相同策略,使用setcc (除了一些真正古老的CPU之外的所有CPU上的1c延迟)代替cmovcc (在Skylake之前的Intel上2个cmovcc ),我们可以得到更好的代码。 xor zeroing总是比LEA便宜。

 int load_pointer_v3(const int *p, int c) { int offset = (c==0); int x = p[offset]; return c + x; } xor eax, eax test esi, esi sete al add esi, dword ptr [rdi + 4*rax] mov eax, esi ret 

海湾合作委员会和铛声都把最后的关键path。 而在英特尔Sandybridge系列上,索引寻址模式并不保持与add微融合。 所以这会更好,就像它在分支版本中所做的一样:

  xor eax, eax test esi, esi sete al mov eax, dword ptr [rdi + 4*rax] add eax, esi ret 

[rdi][rdi+4]这样简单的寻址模式比Intel SnB系列CPU的其他寻址模式的延迟时间要低1c,所以这可能实际上是Skylake( cmov便宜)的延迟。 testlea可以并行运行。

内联后,最终的mov可能不会存在,它可以addesi

如果没有一致的程序能够区分这一点,那么在“假设”规则下总是允许的。 例如,一个实现可以保证,在每个分配malloc的块之后,至less有八个字节可以被访问而没有副作用。 在这种情况下,编译器可以生成代码,如果您将代码写入代码,那么这些代码将会是未定义的。 因此,只要P [0]被正确地分配,即使在你自己的代码中这是未定义的行为,编译器也会阅读P [1]。

但是在你的情况下,如果你没有为结构分配足够的内存,那么读取任何成员都是未定义的行为。 所以在这里,编译器可以做到这一点,即使读P-> B崩溃。