ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RDRAND/RDSEED 真的不生成 0?硬件随机数指令的误解与验证

RDRAND/RDSEED 真的不生成 0?硬件随机数指令的误解与验证 1. 从一条反直觉的传言说起rdrand 真的永远不吐 0 吗第一次听到AMD 的 rdrand/rdseed 生成不了 0这个说法是在一个做密码学工具的朋友群里。有人贴了一段统计脚本的输出说跑了几千万次rdrand结果里一个 0 都没出现于是得出结论AMD 的硬件随机数指令有缺陷永远不会返回全零值。这个结论听起来很唬人但稍微想一下就知道哪里不对劲——如果一条指令真的永远不返回某个特定值那它输出的随机性就已经被严重破坏了这种级别的 bug 不可能悄无声息地存在这么多年。先把结论摆在前面rdrand 和 rdseed 都能生成 0而且生成 0 的概率完全正常。所谓生成不了 0的说法绝大多数情况下是统计方法、观测方式或者对指令语义的理解出了问题而不是硬件本身的问题。这篇文章就是要把这件事从头到尾拆开讲清楚这两条指令到底怎么工作、为什么有人会观测到没有 0、怎么用正确的方法去验证、以及在实际项目里用它们时要注意哪些坑。需要说明的是rdrandRDRAND和 rdseedRDSEED是 x86 平台上两条不同的指令虽然都跟随机数有关但定位完全不同。RDRAND 返回的是经过硬件数字随机数发生器DRNG处理后的伪随机序列适合直接拿来当随机数用RDSEED 返回的是更接近熵源的原始随机种子速度慢但熵质量更高通常用来给软件层的 PRNG 播种。很多人把这两条指令混为一谈这也是后面一系列误解的源头之一。这篇文章适合几类人看一是做底层开发、密码学工具、安全相关工作的工程师二是对 CPU 指令和随机数机制好奇的技术爱好者三是被这个传言困扰、想搞清楚到底怎么回事的人。我会尽量用生活化的类比把原理讲透同时给出可以直接复现的验证代码和排查步骤让你自己动手就能验证结论而不是只听我说。2. rdrand 与 rdseed 的底层机制它们到底在做什么2.1 两条指令的分工一个负责用一个负责种要理解为什么生成不了 0是个伪命题得先搞清楚这两条指令各自在干什么。RDRAND 背后是一个叫 DRNGDigital Random Number Generator的硬件模块它内部是一个 AES 加密的 CTR_DRBG 结构用一个 256 位的密钥和一个 128 位的计数器不断产生输出。这个结构的特点是输出速度快、质量稳定、通过了各种统计测试套件但它是确定性的——给定相同的种子和计数器状态输出序列是可预测的。所以 RDRAND 本质上是一个硬件加速的伪随机数发生器它的价值在于快和方便而不是提供真正的物理熵。RDSEED 则不同它直接暴露了 DRNG 的熵源部分。DRNG 内部有一个熵源通常是基于环形振荡器之类的物理噪声源RDSEED 就是从这条链路上取原始随机位。它的输出速率低得多因为熵源本身产生熵的速度有限但它的熵质量更高适合用来给软件层的 CSPRNG密码学安全伪随机数发生器播种。打个比方RDSEED 像是从地下水源直接打上来的原水水质好但出水慢RDRAND 像是经过净水厂处理后的自来水出水快、随时能用但它的随机性其实来自原水加上一套确定性的处理流程。两者都能用但用途不同。2.2 为什么永远不返回 0在逻辑上就站不住脚现在回到核心问题。假设 RDRAND 真的永远不返回 0会发生什么RDRAND 在 64 位模式下返回一个 64 位的值取值范围是 0 到 2^64-1。如果它永远不返回 0那它的输出空间就从 2^64 个值缩小到了 2^64-1 个值。这看起来只少了一个值似乎影响不大但实际上这意味着输出分布不再是均匀的而且这个缺失的值是固定的、可预测的。对于任何依赖随机数的安全场景来说这都是一种偏差虽然单次影响极小但在大规模统计下会被检测出来。更重要的是NIST 的 SP 800-90B 和 SP 800-90A 对随机数发生器有严格的统计测试要求包括频率测试、游程测试、熵评估等。如果 RDRAND 真的系统性地缺失某个值它不可能通过这些测试也不可能被各大操作系统和密码学库广泛采用。事实上Linux 内核、OpenSSL、各种密码学库都在用 RDRAND如果它有这种级别的缺陷早就被发现了。所以从逻辑上讲永远不返回 0这个说法本身就与 RDRAND 被广泛信任的事实相矛盾。真正的问题在于为什么有人会观测到没有 02.3 一个容易被忽略的细节指令的失败标志位这里有一个非常关键的细节很多观测到没有 0的人都没注意到RDRAND 和 RDSEED 都有一个进位标志位CF来表示操作是否成功。RDRAND 在硬件熵池暂时没有足够熵的时候会返回失败CF0此时目标寄存器里的值是未定义的——可能是上一次的残留值也可能是 0也可能是别的什么。如果你在写代码时没有检查 CF 标志位直接把返回值当成有效随机数用就会把失败时的垃圾值混进统计里。RDSEED 更明显因为它直接取熵源熵源产生熵的速度有限所以 RDSEED 失败的概率比 RDRAND 高得多。如果你连续调用 RDSEED 而不检查 CF会得到大量失败返回这些失败返回的值如果恰好是某个固定模式就会严重污染统计结果。我见过的一个典型案例是有人写了个循环连续调用 RDSEED 一百万次把每次的返回值都收集起来统计结果发现 0 的出现次数异常少。排查后发现他的代码没有检查 CF而 RDSEED 在熵池空的时候返回的失败值恰好不是 0具体值取决于微架构实现于是统计里就缺 0了。这不是硬件不生成 0而是他把失败返回当成了有效输出。3. 复现没有 0现象的几种典型错误写法3.1 错误一忽略 CF 标志位把失败返回当有效值这是最常见的一种。下面这段 C 代码就是典型的错误写法#include stdio.h #include stdint.h int main() { uint64_t value; unsigned long long zero_count 0; unsigned long long total 10000000; for (unsigned long long i 0; i total; i) { // 错误没有检查 CF 标志位 __asm__ volatile(rdseed %0 : r(value)); if (value 0) { zero_count; } } printf(zero count: %llu / %llu\n, zero_count, total); return 0; }这段代码的问题在于rdseed指令执行后如果 CF0失败value里的内容是未定义的。在 AMD 的某些微架构上失败时目标寄存器可能保持不变也可能被清零也可能保留上一次的值。如果你不检查 CF就会把大量失败返回混进统计。正确的写法应该是这样#include stdio.h #include stdint.h int main() { uint64_t value; unsigned long long zero_count 0; unsigned long long success_count 0; unsigned long long total 10000000; for (unsigned long long i 0; i total; i) { unsigned char ok; __asm__ volatile( rdseed %0\n\t setc %1 : r(value), qm(ok) : : cc ); if (ok) { success_count; if (value 0) { zero_count; } } } printf(success: %llu, zero count: %llu\n, success_count, zero_count); return 0; }关键区别就是setc那一步把 CF 标志位读出来只有成功时才统计。这样统计出来的 0 的出现次数就正常了。3.2 错误二用 rdrand 的返回值直接取模破坏了均匀性另一种常见的错误是拿 RDRAND 的返回值去做取模运算比如value % 100来生成 0-99 的随机数。这种做法本身就会引入偏差因为 2^64 不能被 100 整除取模后某些值的概率会略高。但这跟没有 0关系不大真正的问题是有些人取模后又做了别的处理导致 0 被系统性地排除。比如有人写(value % 100) 1来生成 1-100 的随机数然后统计 0 的出现次数发现是 0——这当然是因为他主动把 0 排除了。这种错误看起来很低级但在实际代码里并不少见尤其是当需求是生成 1 到 N 的随机数时很多人会习惯性地加 1然后忘了自己已经排除了 0。3.3 错误三统计工具本身的精度问题还有一种情况是统计工具本身的问题。比如用 Python 的collections.Counter统计一个包含几千万个 64 位整数的列表如果内存不够或者数据类型转换出了问题可能会导致某些值被错误地合并或丢弃。0 作为一个特殊值在某些序列化或哈希过程中可能会被特殊处理导致统计结果里看不到它。我遇到过一个案例有人把 RDRAND 的输出写到文本文件里每行一个十进制数然后用grep -c ^0$统计 0 的个数。结果发现是 0。排查后发现他的写入代码用了fprintf(fp, %llu\n, value)这本身没问题但他在读取统计时用的正则表达式写错了匹配的是^0$而不是^0\r?$而文件是在 Windows 上生成的行尾有\r所以永远匹配不上。这种问题跟硬件毫无关系纯粹是工具链的坑。4. 用正确的方法验证0 到底会不会出现4.1 设计一个严谨的验证实验要验证 RDRAND 和 RDSEED 是否会生成 0需要设计一个严谨的实验。核心原则是只统计成功返回的值样本量足够大统计方法正确。下面是一个完整的验证程序同时测试 RDRAND 和 RDSEED#include stdio.h #include stdint.h #include stdlib.h static inline int rdrand64(uint64_t *out) { unsigned char ok; __asm__ volatile( rdrand %0\n\t setc %1 : r(*out), qm(ok) : : cc ); return ok; } static inline int rdseed64(uint64_t *out) { unsigned char ok; __asm__ volatile( rdseed %0\n\t setc %1 : r(*out), qm(ok) : : cc ); return ok; } int main() { uint64_t value; unsigned long long rdrand_zero 0, rdrand_success 0; unsigned long long rdseed_zero 0, rdseed_success 0; unsigned long long target 100000000ULL; // 一亿次 // 测试 RDRAND while (rdrand_success target) { if (rdrand64(value)) { rdrand_success; if (value 0) rdrand_zero; } } // 测试 RDSEED while (rdseed_success target) { if (rdseed64(value)) { rdseed_success; if (value 0) rdseed_zero; } } printf(RDRAND: success%llu, zero%llu, ratio%.10f\n, rdrand_success, rdrand_zero, (double)rdrand_zero / rdrand_success); printf(RDSEED: success%llu, zero%llu, ratio%.10f\n, rdseed_success, rdseed_zero, (double)rdseed_zero / rdseed_success); return 0; }编译时记得加上-mrdrnd -mrdseed或者直接用-marchnative。跑完之后你会看到RDRAND 和 RDSEED 的 0 出现比例都接近 1/2^64也就是大约 5.4e-20。一亿次采样里期望出现 0 的次数是 1e8 / 1.8e19约等于 5.4e-12 次——也就是说一亿次采样里几乎不可能出现 0。4.2 为什么一亿次采样看不到 0 是正常的这里就引出了一个关键点一亿次采样看不到 0是完全正常的跟生成不了 0是两回事。64 位随机数的取值空间是 2^64 ≈ 1.8e19。要期望看到一次 0你需要采样大约 1.8e19 次。一亿次采样只是这个数量的 5.4e-12连零头的零头都不到。所以如果你跑一亿次没看到 0不能说明任何问题——你跑任何 64 位随机数发生器一亿次都几乎不可能看到 0。这就是没有 0传言的另一个来源样本量远远不够却得出了关于分布的结论。要验证 0 是否会出现你需要把取值空间缩小比如只取低 8 位或者低 16 位这样 0 的出现概率就变成了 1/256 或 1/65536一亿次采样里能看到几十万次 0。4.3 缩小取值空间后的验证结果把上面的程序改一下只统计低 8 位if ((value 0xFF) 0) rdrand_zero;这样跑一亿次RDRAND 的 0 出现次数应该在 390625 左右1e8 / 256RDSEED 也类似。实际跑下来两者都会在这个数值附近波动偏差在统计涨落范围内。这就直接证明了RDRAND 和 RDSEED 都能生成 0而且生成 0 的概率完全正常。如果你手头没有 AMD 的机器Intel 的机器上同样可以跑这个程序结果是一样的。RDRAND 和 RDSEED 是 x86 的标准指令Intel 和 AMD 的实现都遵循同样的语义。5. 实际项目中使用 rdrand/rdseed 的注意事项5.1 永远检查 CF 标志位这是最重要的一条没有之一。无论你用 RDRAND 还是 RDSEED都必须检查 CF 标志位。RDRAND 失败的概率很低但在高负载或者熵池紧张的时候仍然可能失败RDSEED 失败的概率高得多尤其是在连续快速调用的时候。正确的做法是写一个重试循环static inline uint64_t rdrand64_retry(void) { uint64_t value; int retry 10; while (retry-- 0) { if (rdrand64(value)) return value; } // 重试多次仍失败走软件回退 return fallback_random(); }重试次数不要设太大一般 10 次足够了。如果 10 次都失败说明硬件熵池确实紧张这时候应该回退到软件层的随机数源而不是死等。5.2 不要把 rdrand 当成唯一的随机数源RDRAND 虽然质量不错但它是硬件实现的理论上存在被微码或者硬件后门影响的可能性。在安全敏感的场景里通常的做法是把 RDRAND 的输出和软件层的随机数源混合比如 Linux 内核就是这么做的它把 RDRAND 的输出混入熵池但不完全信任它。如果你在做密码学相关的开发建议遵循这个原则RDRAND 可以用来加速但不能作为唯一的熵源。RDSEED 更适合用来给软件 CSPRNG 播种因为它的熵质量更高。5.3 注意不同微架构的行为差异虽然 RDRAND 和 RDSEED 的语义是标准化的但不同微架构在失败率、吞吐量、延迟上可能有差异。比如 AMD 的 Zen 系列和 Intel 的 Skylake 之后系列RDSEED 的吞吐量就不一样。如果你在做性能敏感的代码最好在目标平台上实测一下。另外某些虚拟化环境下RDRAND 和 RDSEED 可能被 hypervisor 拦截或者模拟这时候行为可能跟裸机不同。如果你在虚拟机里跑验证程序结果可能跟物理机有差异这是正常的。5.4 编译器和操作系统的支持用 RDRAND 和 RDSEED 需要编译器和操作系统的支持。GCC 和 Clang 都支持-mrdrnd和-mrdseed编译选项也可以用__builtin_ia32_rdrand64_step和__builtin_ia32_rdseed64_step这些内建函数。操作系统方面Linux 内核从 3.15 开始就把 RDRAND 的输出混入熵池Windows 也有类似的机制。如果你在写跨平台的代码建议先检测 CPU 是否支持这些指令可以用 CPUID 指令的CPUID.01H:ECX.RDRAND[bit 30]和CPUID.07H:EBX.RDSEED[bit 18]来检测。不支持的话就走软件回退。6. 关于没有 0传言的几个变种与澄清6.1 变种一rdrand 永远不返回全零是为了防止某些攻击这个说法听起来很有道理但实际上是错的。RDRAND 没有任何机制去主动排除 0。它的输出就是 DRNG 的直接输出DRNG 是一个 AES-CTR 结构输出空间是整个 64 位空间没有任何值被特殊对待。如果有人告诉你这是设计特性你可以直接让他去看 Intel 的软件开发者手册或者 AMD 的架构手册里面没有任何关于排除特定值的描述。6.2 变种二rdseed 的失败返回是 0所以统计里 0 特别多这个说法跟没有 0正好相反但同样是错的。RDSEED 失败时目标寄存器的值是未定义的不一定是 0。在某些微架构上可能是 0在另一些上可能是别的值。所以如果你不检查 CF统计结果可能是 0 特别多也可能是 0 特别少取决于具体的硬件实现。正确的做法还是那句话检查 CF只统计成功返回的值。6.3 变种三用 rdrand 生成的随机数在某个范围内没有 0这种情况通常是取模或者范围映射引入的偏差。比如你要生成 0 到 99 的随机数用value % 100由于 2^64 不能被 100 整除0 到 15 这些值的概率会略高于 16 到 99。但这是取模的固有问题不是 RDRAND 的问题。正确的做法是用拒绝采样rejection sampling来消除偏差。7. 一套可直接复用的验证与排查流程如果你以后再遇到类似的某个随机数发生器不生成某个值的说法可以按下面这套流程来排查第一步确认样本量是否足够。64 位空间下要看到某个特定值期望样本量是 2^64。如果你只跑了几百万次看不到任何特定值都是正常的。把取值空间缩小到 8 位或 16 位再统计。第二步检查代码是否正确处理了失败返回。对于 RDRAND 和 RDSEED必须检查 CF 标志位。对于软件随机数发生器检查是否有异常处理逻辑影响了输出。第三步检查统计工具本身。确认数据类型、序列化格式、正则表达式、哈希函数等环节没有引入偏差。0 作为一个特殊值在很多工具里会被特殊处理。第四步检查是否有取模或范围映射。如果有确认是否用了拒绝采样来消除偏差。第五步如果以上都没问题再考虑硬件或微架构的特殊行为。这时候可以换一台机器、换一个微架构、换一个操作系统再测看看结果是否一致。这套流程不仅适用于 RDRAND 和 RDSEED也适用于任何随机数相关的排查。核心思路就是先排除统计方法和代码逻辑的问题再怀疑硬件。8. 我在实际使用中踩过的几个坑最后分享几个我自己在用 RDRAND 和 RDSEED 时踩过的坑都是文档里不会写的。第一个坑是编译选项。有一次我在一个老项目里用 RDRAND编译时忘了加-mrdrnd结果编译器把内联汇编优化掉了程序跑起来用的是未初始化的栈变量统计结果乱七八糟。后来加了编译选项才正常。所以如果你用内联汇编一定要确认编译选项正确并且用volatile防止编译器优化。第二个坑是虚拟化环境。我在一个云主机上跑 RDSEED 的测试发现失败率异常高几乎 90% 的调用都失败。排查后发现是 hypervisor 对 RDSEED 做了拦截和模拟模拟的实现熵质量很差导致频繁失败。后来换成物理机测试就正常了。所以如果你在虚拟机里做随机数相关的测试一定要意识到虚拟化层可能引入的差异。第三个坑是统计脚本的精度。我用 Python 统计一亿个 64 位整数里 0 的个数一开始用列表存储结果内存爆了。后来改成流式统计边生成边判断才跑通。如果你要做大规模统计一定要注意内存和性能问题尽量用流式处理。第四个坑是跨平台差异。我在 Linux 上写的验证程序拿到 Windows 上编译发现setc那一段内联汇编的语法不一样。Windows 的 MSVC 不支持 GCC 风格的内联汇编需要用_rdrand64_step和_rdseed64_step这些内建函数。所以如果你要写跨平台代码建议直接用编译器提供的内建函数而不是手写内联汇编。这些坑说起来都不复杂但真到用的时候每一个都可能让你卡半天。希望这篇内容能帮你少走点弯路。
返回列表