函数调用是否是内存屏障?

22

考虑以下 C 代码:

extern volatile int hardware_reg;

void f(const void *src, size_t len)
{
    void *dst = <something>;

    hardware_reg = 1;    
    memcpy(dst, src, len);    
    hardware_reg = 0;
}
memcpy()的调用必须出现在这两个赋值之间。一般来说,由于编译器可能不知道所调用的函数将做什么,它无法对函数调用进行重排序以出现在或之后赋值操作。但是,在这种情况下,编译器知道函数将要执行的操作(甚至可以插入内联内置替代程序),并且可以推断memcpy()永远不会访问hardware_reg。在我看来,如果编译器想这样做的话,它似乎不会在移动memcpy()调用时遇到任何问题。

所以,问题是:一个函数调用单独足以发出内存屏障以防止重排序吗?还是说,在此情况下需要在memcpy()调用之前和之后显式地使用内存屏障?

如果我理解有误,请您纠正。


1
我猜你不担心CPU重新排序内存访问? - aib
3
既不是volatile访问也不是序列点是内存屏障。 - Mat
编译器肯定知道memcpy的作用。它在标准中有明确规定。 - R.. GitHub STOP HELPING ICE
@aib:我想我是。只是在这个领域不是专家。 :-) - Andrey Vihrov
5个回答

10
编译器不能将memcpy()操作在hardware_reg = 1之前或hardware_reg = 0之后重新排序,这就是volatile所确保的——至少在编译器生成的指令流方面。函数调用不一定是“内存屏障”,但它是序列点。
C99标准对volatile(5.1.2.3/5“程序执行”)有如下规定:
在序列点上,volatile对象是稳定的,以前的访问已经完成,后续的访问还没有发生。
因此,在由memcpy()表示的序列点处,必须已经发生了写入1的volatile访问,并且尚未发生写入0的volatile访问。
但是,我想指出两件事情:
  1. 根据<something>的内容,如果目标缓冲区未被访问,则编译器可能会完全删除memcpy()操作。这是Microsoft推出SecureZeroMemory()函数的原因。SecureZeroMemory()针对volatile限定指针进行操作,以防止优化写入。

  2. volatile并不一定意味着内存屏障(这是一个硬件问题,不仅仅是代码顺序问题),因此,如果您正在运行多处理器机器或某些类型的硬件,则可能需要显式调用内存屏障(例如,在Linux上使用wmb())。

    从MSVC 8(VS 2005)开始,微软记录了volatile关键字意味着适当的内存屏障,因此可能不需要单独的特定内存屏障调用:

    另外,在进行优化时,编译器必须在访问易失性对象以及其他全局对象时保持顺序。特别地,

    • 对易失性对象的写入(易失性写入)具有释放语义;在指令序列中写入易失性对象之前发生的对全局或静态对象的引用将在编译后二进制文件中先于该易失性写入出现。

    • 对易失性对象的读取(易失性读取)具有获取语义;在从易失性内存读取后发生的对全局或静态对象的引用将在编译后二进制文件中在该易失性读取之后发生。


1
感谢您详细的回答。不过我还有一个问题想问。根据C99中的附录C“序列点”,表达式语句的结尾是一个序列点。结合您的引用,我们得出易失性访问必须恰好发生在其他赋值之间,就像源代码中一样,即使这些其他赋值作用于非易失性对象。但是Bo Persson此前在这里曾经声明过,“...它可以重新排序其他非易失性赋值到易失性赋值的任一侧”。我以前也看到过这种说法。这两种说法哪个是正确的? - Andrey Vihrov
1
除此之外,理论上可以对memcpy()调用进行优化,因为它不作用于易失性数据。从这个角度来看,更安全的做法是编写一种内联memcpy替代方案,该方案可作用于易失性的目标地址。这样也可以避免考虑内存屏障的问题 :-) - Andrey Vihrov
1
@Andrey:抱歉回复晚了。我将编辑(或者可能删除)我的答案,因为我认为它对标准的解读过于严格。虽然我认为volatile对象应该是一个“序列点屏障”,我相信标准确实是这样说的,但现实是编译器可能不会这样做(而我不是编译器作者或语言律师,尽管有时在SO上扮演这样的角色)。请参见http://gcc.gnu.org/onlinedocs/gcc/Volatiles.html和http://drdobbs.com/high-performance-computing/212701484?pgno=2。 - Michael Burr
@Andrey:所以Bo Persson是正确的,至少就GCC而言(可能也适用于其他编译器)。 - Michael Burr
你的答案解释得很好,只有添加你评论的内容才能进一步完善(事实上,GCC链接的第二段几乎就是我的问题)。不过看起来有点奇怪,因为GCC会做与标准相违背的事情。你答案中的引用是否只适用于C99? - Andrey Vihrov

4
据我所见,你的推理导致编译器在移动memcpy调用时没有任何问题是正确的。你的问题无法通过语言定义来回答,只能参考特定的编译器来解决。很抱歉我没有更有用的信息。

如果 srcdest 等于 &hardware_reg 会怎样? - Oliver Charlesworth
1
@Oli:编译器应该知道这是不可能的,因为hardware_reg是易失性的,而*src*dst则不是。 (据我所记,将易失对象作为非易失对象访问是UB。) - Andrey Vihrov

0

这里是一个稍作修改的示例,使用gcc 7.2.1在x86-64上编译:

#include <string.h>
static int temp;
extern volatile int hardware_reg;
int foo (int x)
{
    hardware_reg = 0;
    memcpy(&temp, &x, sizeof(int));
    hardware_reg = 1;
    return temp;
}

gcc知道memcpy()与赋值相同,并知道temp在其他地方没有被访问,因此tempmemcpy()完全从生成的代码中消失:

foo:
    movl    $0, hardware_reg(%rip)
    movl    %edi, %eax
    movl    $1, hardware_reg(%rip)
    ret

据我所知,编译器实际上不应该知道memcpy()的具体作用。在链接时,它应该是一个未定义的符号,因为程序员可以提供自己的memcpy()函数。这个汇编代码是编译的结果还是与某些启用了链接时优化的链接器相关的反汇编程序的结果? - oromoiluig
@oromoiluig 这只是gcc -O2。它知道memcpy()的作用。 引用gcc手册的说法:ISO C规范区分“托管”和“独立”环境,区别在于“托管”环境具有标准函数(如memcpy)和标准行为。 gcc默认为“托管”。 如果您希望gcc忽略诸如“memcpy”之类的函数的标准规范,请使用-ffreestanding命令行选项。如果您使用“gcc -O2 -ffreestanding”编译上述内容,则确实将memcpy()视为任意函数,并且不会删除temp&memcpy。 - user1998586

0

我的假设是编译器从不重新排序易失性赋值,因为它必须假定它们必须在代码中出现的确切位置执行。


2
可以,但是它可以重新排列其他非易失性的分配到易失性的分配两侧。 - Bo Persson
这就像重新排序易失性变量一样 - 所以我会假设对易失性变量的任何访问都是重新排序的屏障。 - ThiefMaster
1
不,它不是。从语言角度来看,易失性写入必须同时出现,并按给定顺序排列。一些编译器承诺不会在易失性访问中移动代码,但这是一种扩展功能。 - Bo Persson

0

它可能会被优化,因为编译器会内联mecpy调用并消除第一个赋值,或者因为它被编译为RISC代码或机器代码并在那里进行了优化。


1
不能消除任何赋值 - 这就是 'volatile' 的作用。 - mlp

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