“易失性”的定义是易失性的,还是GCC存在一些标准合规性问题?


89

我需要一个函数(例如WinAPI中的SecureZeroMemory)始终将内存归零,并且不会被优化,即使编译器认为此后再也不会访问内存了。似乎是挥发物的理想选择。但是我在将其与GCC一起使用时遇到了一些问题。这是一个示例函数:

void volatileZeroMemory(volatile void* ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = (volatile unsigned char*)ptr;

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

很简单。但是,如果您调用GCC实际生成的代码,则随着编译器版本以及您实际上试图将其设置为零的字节数而大不相同。https://godbolt.org/g/cMaQm2

  • GCC 4.4.7和4.5.3永远不会忽略挥发物。
  • 对于数组大小1、2和4,GCC 4.6.4和4.7.3忽略了volatile。
  • GCC 4.8.1至4.9.2会忽略数组大小1和2的volatile。
  • GCC 5.1直到5.3会忽略数组大小1、2、4、8的volatile
  • 对于任何数组大小(一致性的加分点),GCC 6.1都将忽略它。

我测试过的任何其他编译器(clang,icc,vc)都可以生成期望的存储,并且具有任何编译器版本和任何数组大小。所以在这一点上,我想知道,这是一个(相当老而严重的)GCC编译器错误,还是该标准中的volatile定义不精确地表明这实际上是符合规范的行为,因此根本不可能编写可移植的“ SecureZeroMemory”功能?

编辑:一些有趣的观察。

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <atomic>

void callMeMaybe(char* buf);

void volatileZeroMemory(volatile void* ptr, std::size_t size)
{
    for (auto bytePtr = static_cast<volatile std::uint8_t*>(ptr); size-- > 0; )
    {
        *bytePtr++ = 0;
    }

    //std::atomic_thread_fence(std::memory_order_release);
}

std::size_t foo()
{
    char arr[8];
    callMeMaybe(arr);
    volatileZeroMemory(arr, sizeof arr);
    return sizeof arr;
}

从callMeMaybe()进行的可能写入操作将使除6.1以外的所有GCC版本均生成预期的存储。尽管仅与callMeMaybe()的可能写入功能结合使用,但在内存保护区中进行注释也将使GCC 6.1生成存储。

有人还建议刷新缓存。Microsoft根本尝试刷新“ SecureZeroMemory”中的缓存。无论如何,缓存可能很快就会失效,因此这可能没什么大不了的。同样,如果另一个程序试图探查数据,或者要将其写入页面文件,则该数据将始终为零版本。

在独立功能中使用memset()的GCC 6.1也存在一些问题。Godbolt上的GCC 6.1编译器可能会破坏构建,因为GCC 6.1似乎为某些人的独立功能生成了一个普通循环(就像5.3在Godbolt上一样)。(阅读zwol答案的评论。)


4
恕我直言,volatile除非另有证明,否则使用是错误。但是很可能是一个错误。volatile太少了,以至于很危险-只是不要使用它。
Jesper Juhl

19
@JesperJuhl:否,volatile在这种情况下合适。
Dietrich Epp

9
@NathanOliver:这行不通,因为即使编译器使用,它们也可以优化死存储memset。问题在于编译器确切地知道该做什么memset
Dietrich Epp

8
@PaulStelian:那将成为一个volatile指针,我们想要一个指针volatile(我们不在乎是否++严格,但是是否*p = 0严格)。
Dietrich Epp

7
@JesperJuhl:没有任何关于volatile的不足。
GManNickG '16

Answers:


82

GCC的行为可能是合规的,即使事实并非如此,volatile在这种情况下,您也不应依靠自己去做。C委员会专volatile为内存映射的硬件寄存器和异常控制流期间修改的变量(例如,信号处理程序和setjmp)而设计。 这些是它唯一可靠的东西。 将其用作一般的“不要优化”注释是不安全的。

特别是,该标准在关键点上尚不清楚。(我已经将您的代码转换为C;这里在C和C ++之间不应有任何区别。我还手动完成了可疑优化之前发生的内联操作,以显示此时编译器“看到”的内容)

extern void use_arr(void *, size_t);
void foo(void)
{
    char arr[8];
    use_arr(arr, sizeof arr);

    for (volatile char *p = (volatile char *)arr;
         p < (volatile char *)(arr + 8);
         p++)
      *p = 0;
}

内存结算循环访问arr通过volatile限定左值,但arr本身没有宣布volatile。因此,至少可以说,C编译器可以推断出循环所造成的存储是“死的”,并完全删除了循环。C语言中的文字暗示该委员会打算保留这些存储,但正如我所阅读的那样,标准本身并未真正提出该要求。

有关标准要求或不要求的内容的更多讨论,请参见为什么优化了易失性局部变量的方法不同于易失性参数,以及为什么优化程序会从后者中产生无操作循环?通过易失性引用/指针访问声明的非易失性对象是否会在所述访问中赋予易失性规则?GCC错误71793

有关委员会的想法的 更多信息volatile,请在C99基本原理中搜索“ volatile”一词。John Regehr的论文“ Volatiles is Miscompiled ”详细说明volatile了生产编译器可能无法满足程序员的期望。LLVM团队的系列文章“每个C程序员应该了解的未定义行为”没有特别涉及,volatile但是可以帮助您了解现代C编译器不是“便携式汇编器”的方式和原因。


对于如何实现一个可以完成您想要做的功能的实际问题volatileZeroMemory:无论标准有什么要求或打算要有什么要求,最明智的做法是假设您不能使用volatile此功能。还有就是可以上依赖于工作的选择,因为它会打破太多其他的东西,如果它不工作:

extern void memory_optimization_fence(void *ptr, size_t size);
inline void
explicit_bzero(void *ptr, size_t size)
{
   memset(ptr, 0, size);
   memory_optimization_fence(ptr, size);
}

/* in a separate source file */
void memory_optimization_fence(void *unused1, size_t unused2) {}

但是,您必须绝对确保memory_optimization_fence在任何情况下都不要内联。它必须位于其自己的源文件中,并且不得进行链接时优化。

还有其他选项,依赖于编译器扩展,在某些情况下可能可用,并且可以生成更严格的代码(其中一个出现在此答案的上一版本中),但没有一个是通用的。

(我建议调用该函数explicit_bzero,因为该名称可以在多个C库中以该名称使用。该名称至少有四个其他竞争者,但每个竞争者仅被单个C库采用。)

您还应该知道,即使您可以使用它,但这可能还不够。特别要考虑

struct aes_expanded_key { __uint128_t rndk[16]; };

void encrypt(const char *key, const char *iv,
             const char *in, char *out, size_t size)
{
    aes_expanded_key ek;
    expand_key(key, ek);
    encrypt_with_ek(ek, iv, in, out, size);
    explicit_bzero(&ek, sizeof ek);
}

假设具有AES加速指令的硬件(如果expand_keyencrypt_with_ek是内联的),则编译器可能会ek完全保留在向量寄存器文件中-直到对的调用explicit_bzero,这迫使它将敏感数据复制到堆栈中只是为了擦除它,并且更糟糕的是,对于仍然位于向量寄存器中的键,不会做得很糟糕!


6
有趣的是……我很想看到对委员会意见的引用。
Dietrich Epp

10
unesdoc.unesco.org unesdoc.unesco.org如何用6.7.3(7)的volatileas定义平方。因此,必须严格按照抽象机器的规则评估引用该对象的任何表达式,如5.1.2.3中所述。此外,在每个序列点上,对象中最后存储的值应与抽象机指定的值一致,除非之前提到的未知因素对其进行了修改。实现定义是构成对具有volatile限定类型的对象的访问。
Iwillnotexist Idonotexist

15
@IwillnotexistIdonotexist该段落中的关键字是objectvolatile sig_atomic_t flag;是一个易失的对象*(volatile char *)foo只是通过限定volatile的左值进行的访问,该标准不要求它具有任何特殊效果。
zwol

3
该标准说明了什么才是“合规”实施必须满足的标准。它不去描述给定平台上的实现是“良好”实现还是“可用”实现所必须满足的标准。GCC对的处理volatile可能足以使其成为“合规”实现,但这并不意味着成为“良好”或“有用”就足够了。对于许多类型的系统编程,在这些方面都应视为严重不足。
超级猫

3
在C时的参数也相当直接说“一个实际实现不必评估表达式的一部分,如果它可以推断其值未被使用且没有需要的副作用产生(包括任何引起调用一个函数或访问的易失性对象) 。” (强调我的)。
约翰内斯·绍布

15

我需要一个函数(例如WinAPI中的SecureZeroMemory)始终将内存归零,并且不会被优化,

这就是标准功能的作用memset_s


至于这种行为与挥发性是否合格,不合格,这是一个有点难以启齿,震荡已经早就一直困扰着错误。

一个问题是规范说“严格按照抽象机的规则评估对易失对象的访问”。但这仅指“易失性对象”,而不是通过已添加易失性的指针访问非易失性对象。因此,显然,如果编译器可以告诉您您不是真正在访问易失性对象,则根本不需要将对象视为易失性。


4
注意:这是C11标准的一部分,并且并非在所有工具链中都可用。
Dietrich Epp

5
应当注意,有趣的是,此函数是针对C11标准化的,而不是针对C ++ 11,C ++ 14或C ++ 17的。因此,从技术上讲,这不是C ++的解决方案,但我同意,从实用的角度来看,这似乎是最好的选择。在这一点上,我确实想知道GCC的行为是否符合要求。编辑:实际上,VS 2015没有memset_s,因此还不是所有可移植的。
cooky451 '16

2
@ cooky451我以为C ++ 17通过引用引入了C11标准库(请参阅第二杂项)。
nwp '16

14
同样,描述memset_s为C11标准也是一种夸大其词。它是附件K的一部分,在C11中是可选的(因此在C ++中也是可选的)。基本上,包括微软在内的所有实施者都拒绝接受它;他们最初的想法是(!)。最后我听说他们正在谈论将其废弃在C-next中。
zwol

8
@ cooky451在某些圈子中,Microsoft臭名昭著,因为他们基本上将其他人的反对意见强加到C标准中,然后又不愿自己去实现它。(这方面最突出的例子是为了什么的基本类型的规则C99的放松size_t被允许。Win64中的ABI与C90不符合的。那将是...没有确定,但并不可怕......如果MSVC实际上已经拿起C99之类的东西uintmax_t,并%zu及时,但他们并没有)。
zwol

2

我将此版本作为可移植的C ++提供(尽管语义上有细微的差别):

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = new (ptr) volatile unsigned char[size];

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

现在您具有对易失性对象的写访问权,而不仅仅是访问通过该对象的易失性视图创建的非易失性对象。

语义上的差异是,由于内存已被重用,因此它现在正式结束了占用内存区域的任何对象的生命周期。因此,将对象的内容清零后访问对象现在肯定是未定义的行为(以前,在大多数情况下,它本来是未定义的行为,但是肯定存在某些例外)。

要在对象的生命周期中而不是在结束时使用此调零,调用方应使用放置 new再次放回原始类型的新实例。

通过使用值初始化,可以使代码更短(虽然不太清晰):

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    new (ptr) volatile unsigned char[size] ();
}

在这一点上,它是单线的,几乎不保证有辅助功能。


2
如果函数执行后对对象的访问将调用UB,则意味着此类访问可以产生“清除”对象之前保存的值。这与安全性相反吗?
超级猫

0

应该可以通过使用右侧的易失性对象并强制编译器将存储保存到数组中来编写该函数的可移植版本。

void volatileZeroMemory(void* ptr, unsigned long long size)
{
    volatile unsigned char zero = 0;
    unsigned char* bytePtr = static_cast<unsigned char*>(ptr);

    while (size--)
    {
        *bytePtr++ = zero;
    }

    zero = static_cast<unsigned char*>(ptr)[zero];
}

zero对象被声明volatile可确保即使编译器始终将其评估为零,也不会对其值作任何假设。

最终赋值表达式从数组中的易失性索引中读取并将值存储在易失性对象中。由于无法优化此读取,因此可以确保编译器必须生成循环中指定的存储。


1
这根本行不通...只看正在生成的代码。
cooky451

1
更好地阅读了我生成的ASM之后,似乎可以内联函数调用并保留循环,但是*ptr在该循环期间不进行任何存储,或者实际上根本不做任何事情……只是循环。wtf,有我的大脑。
underscore_d

3
@underscore_d这是因为它在优化存储的同时保留了volatile的读取。
D Krueger

1
是的,它将结果转储为不变edx:我明白了:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d

1
如果我更改功能以允许传递任意 volatile unsigned char const填充字节,则它甚至不会读取它。生成的内联调用volatileFill()为just [load RAX with sizeof] .L9: subq $1, %rax; jne .L9。为什么优化器(A)不重新读取填充字节,而优化器(B)为什么在不执行任何操作的地方保留循环?
underscore_d
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.