“挥发性”的定义是不稳定的,还是GCC有一些标准的兼容性问题?
我需要一个函数(像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从不忽略易失性。
- GCC 4.6.4和4.7.3忽略数组大小为1,2和4的volatile。
- GCC 4.8.1直到4.9.2忽略数组大小1和2的易失性。
- GCC 5.1直到5.3忽略数组大小为1,2,4,8的易失性。
- GCC 6.1只是忽略了它的任何数组大小(一致性加分)。
我testing过的任何其他编译器(clang,icc,vc)都会根据任何编译器版本和任何数组大小生成所期望的存储器。 所以在这一点上,我想知道,这是一个(相当古老和严重?)GCC编译器错误,或者在标准中的挥发性的定义不精确,这实际上符合行为,使得写一个可移植的“ SecureZeroMemory“function?
编辑:一些有趣的观察。
#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()可能的写法将使所有GCC版本,除了6.1生成预期的商店。 在内存栏中注释也将使GCC 6.1生成商店,尽pipe只有与可能的callMeMaybe()写入相结合。
有人还build议刷新caching。 微软不会在“SecureZeroMemory”中尝试刷新caching。 无论如何,caching可能会很快失效,所以这可能不是什么大问题。 此外,如果另一个程序试图探测数据,或者如果它将被写入页面文件,它将始终是归零版本。
在独立函数中使用memset()的GCC 6.1也有一些问题。 Godbolt上的GCC 6.1编译器可能会破坏构build,因为GCC 6.1似乎为某些人的独立函数生成一个正常的循环(就像godbolt中的5.3一样)。 (阅读zwol的回答。)
海湾合作委员会的行为可能是一致的,即使不是这样,你也不应该依靠volatile
做你想做的事情。 C委员会为存储器映射的硬件寄存器和exception控制stream程中修改的variables(例如信号处理程序和setjmp
)devise了volatile
。 那些是唯一可靠的东西。 作为一般的“不优化”注释是不安全的。
特别是标准不明确的一个关键点。 (我已经将你的代码转换为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; }
内存清除循环通过一个volatile限定的左值来访问arr
,但是arr
本身没有被声明为volatile
。 因此,至less可以让C编译器推断循环所做的存储是“死的”,并且完全删除循环。 C理论中的文字暗示委员会意味着要保存这些商店,但是标准本身实际上并没有这样的要求,就像我读到的那样。
有关标准要求或不要求的更多讨论,请参阅为什么是一个易变的本地variables与volatilevariables进行了不同的优化,为什么优化程序会从后者生成一个无操作的循环? , 是否通过一个易失的引用/指针访问一个声明的非易失性对象在所述访问时赋予volatilevariables规则? 和GCC bug 71793 。
有关委员会认为 volatile
更多信息,请查阅C99理由 “易变”一词。 John Regehr的论文“ 挥发性成分被编译错误 ”详细说明了编程人员对volatile
期望如何不能被生产编译器满足。 LLVM团队的一系列散文“ 每个C程序员应该了解未定义的行为 ”都没有涉及到volatile
但会帮助您理解现代C编译器是如何以及为什么不是 “可移植的汇编程序”。
关于如何实现一个函数来实现volatileZeroMemory
所要做的事情的实际问题:无论标准要求什么或要求什么,最好假设你不能使用volatile
。 有一个可以依赖的替代scheme,因为如果它不起作用,它会打破太多其他的东西:
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
在任何情况下memory_optimization_fence
被内联。 它必须在它自己的源文件中,并且不能经受链路时间优化。
还有其他的select,依赖于编译器扩展,在某些情况下可以使用,并且可以生成更严格的代码(其中一个出现在以前版本的答案中),但没有一个是通用的。
(我build议调用函数explicit_bzero
,因为它可以在多个C库中以该名称使用,至less有另外四个名字竞争者,但是每个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_key
和encrypt_with_ek
是内联的,那么编译器可能会完全保留在向量寄存器文件中 – 直到调用explicit_bzero
,这迫使它将敏感数据复制到堆栈上以进行擦除而且,更糟的是,对于仍然位于vector寄存器中的密钥没有做任何事情!
我需要一个函数(像WinAPI的SecureZeroMemory一样)总是零内存并且不会被优化,
这是memset_s
标准函数的memset_s
。
至于这种具有易失性的行为是否符合要求,这有点难以言明,并且说长期以来一直困扰着易变的错误。
其中一个问题是规范说“访问volatile对象严格按照抽象机器的规则进行评估”。 但是,这只是指“易失性对象”,而不是通过添加了易失性的指针来访问非易失性对象。 所以显然,如果一个编译器可以告诉你没有真正访问一个volatile对象,那么不需要把这个对象当作volatile来处理。
我提供这个版本作为可移植的C ++(尽pipe语义是微妙的不同):
void volatileZeroMemory(volatile void* const ptr, unsigned long long size) { volatile unsigned char* bytePtr = new (ptr) volatile unsigned char[size]; while (size--) { *bytePtr++ = 0; } }
现在,您已经对volatile对象进行了写访问,而不仅仅是访问通过volatile对象的非易失性对象。
语义上的区别在于它现在正式结束了任何对象占据内存区域的时间,因为内存已被重用。 因此,在对内容进行调零之后访问对象现在肯定是未定义的行为(以前,在大多数情况下,它将是未定义的行为,但确实存在一些exception)。
要在对象的生命期内而不是在最后使用这个调零,调用者应该使用placement new
将原始types的新实例再次返回。
通过使用值初始化,代码可以缩短(尽pipe不太清楚):
void volatileZeroMemory(volatile void* const ptr, unsigned long long size) { new (ptr) volatile unsigned char[size] (); }
在这一点上,它是一个单线,几乎不需要辅助function。
应该可以通过在右侧使用易失性对象来编写该函数的可移植版本,并强制编译器将存储保存到数组中。
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
,确保编译器不会对其值做任何假设,即使它总是评估为零。
最后的赋值expression式从数组中的易失性索引中读取并将该值存储在易失性对象中。 由于此读取无法优化,因此确保编译器必须生成循环中指定的存储区。