土法炼钢兴趣小组的算法知识备份

SM3 vs SHA-256:两个哈希函数的设计哲学与性能实测

目录

SM3 和 SHA-256 长得很像——都是 Merkle-Damgård,都是 256-bit 输出,都是 64 轮。如果你实现过其中一个,打开另一个的规范会有种似曾相识的感觉:消息分块、状态初始化、压缩函数迭代、输出拼接……框架几乎一模一样。

但细看,设计哲学完全不同。

SHA-256 出自 NSA 之手(2001 年,FIPS 180-2),设计倾向于”用最少的操作类型达到足够的扩散”——消息扩展只用移位和加法,布尔函数固定不变。SM3 由王小云团队设计(2010 年,GM/T 0004),在消息扩展中引入了更激进的异或混合,压缩函数前 16 轮和后 48 轮使用不同的布尔函数,整体设计追求”更快的雪崩效应”。

不到 500 行 C 实现 TLS 1.3 握手 中我们已经实现过 SHA-256。本文把 SM3 放在它旁边,逐个零件拆开对比,最后跑个性能实测。


一、Merkle-Damgård 结构回顾

两者共享同一个顶层框架——Merkle-Damgård 结构

Merkle-Damgård 结构

两者的参数完全一致:

参数 SHA-256 SM3
消息块大小 512 bit 512 bit
状态大小 256 bit(8 × 32-bit) 256 bit(8 × 32-bit)
输出大小 256 bit 256 bit
压缩轮数 64 64
字长 32 bit 32 bit

长度填充的细节差异

两者的填充规则几乎相同:在消息末尾追加 1 比特,然后填充 0 比特使总长度 ≡ 448 (mod 512),最后附加 64-bit 的原始消息比特长度

唯一的区别在于字节序:

实际上,两者在填充阶段的行为完全相同。真正的差异从消息扩展开始。


二、消息扩展:从 16 个字扩展到 68 个字

压缩函数需要 64 轮,但一个 512-bit 消息块只有 16 个 32-bit 字(W[0]…W[15])。两者都需要把 16 个字”扩展”成足够多的字来喂给 64 轮——但扩展策略截然不同。

SHA-256 的消息扩展

SHA-256 把 16 个字扩展到 64 个字,递推公式:

W[t] = σ₁(W[t-2]) + W[t-7] + σ₀(W[t-15]) + W[t-16]    (16 ≤ t ≤ 63)

其中小 sigma 函数是移位和异或的组合:

// SHA-256 消息扩展辅助函数
#define ROTR(x, n)  (((x) >> (n)) | ((x) << (32 - (n))))

#define sigma0(x)   (ROTR(x,  7) ^ ROTR(x, 18) ^ ((x) >>  3))
#define sigma1(x)   (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10))

// 消息扩展
for (int t = 16; t < 64; t++) {
    W[t] = sigma1(W[t-2]) + W[t-7] + sigma0(W[t-15]) + W[t-16];
}

注意,每个 W[t] 只依赖 4 个历史值,而且只用了加法异或/移位

SM3 的消息扩展

SM3 的扩展更复杂。它先生成 68 个字 W[0]…W[67],然后再用异或生成 64 个 W’[0]…W’[63]:

// SM3 消息扩展——第一阶段:生成 W[0..67]
// P1 是 SM3 特有的置换函数
#define P1(x) ((x) ^ ROTL(x, 15) ^ ROTL(x, 23))

for (int j = 16; j < 68; j++) {
    W[j] = P1(W[j-16] ^ W[j-9] ^ ROTL(W[j-3], 15))
          ^ ROTL(W[j-13], 7)
          ^ W[j-6];
}

// SM3 消息扩展——第二阶段:生成 W'[0..63]
for (int j = 0; j < 64; j++) {
    W_prime[j] = W[j] ^ W[j+4];
}

对比一下关键差异:

特性 SHA-256 SM3
扩展字数量 64 个 W 68 个 W + 64 个 W’
递推依赖 4 个历史值 5 个历史值 + P1 置换
非线性操作 无(纯移位+加法) P1 置换(异或+循环移位)
二次混合 W’ = W[j] ⊕ W[j+4]

SM3 的设计意图很明确:在消息扩展阶段就引入更强的非线性混合。SHA-256 把扩散的压力全部丢给压缩函数;SM3 在扩展阶段就开始”搅拌”了。这意味着即使压缩函数被削弱,消息扩展本身也能提供一定的安全余量。

SM3 使用的 P1 置换与压缩函数中的 P0 置换结构相同但参数不同——循环移位量分别是 (15, 23) 和 (9, 17)。这种”复用结构、变换参数”的设计在国密算法中很常见(参考 异或的威力,异或是这类置换的核心构件)。


三、压缩函数:64 轮的细节

这是两个算法差异最大的地方。每一轮都要更新 8 个 32-bit 状态变量(SHA-256 叫 a–h,SM3 叫 A–H),但具体怎么更新,差别很大。

SM3 vs SHA-256 轮函数结构

SHA-256 的轮函数

SHA-256 每轮的核心操作:

// SHA-256 轮函数——64 轮完全一致
#define Ch(e, f, g)    (((e) & (f)) ^ (~(e) & (g)))
#define Maj(a, b, c)   (((a) & (b)) ^ ((a) & (c)) ^ ((b) & (c)))
#define Sigma0(a)      (ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22))
#define Sigma1(e)      (ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25))

for (int t = 0; t < 64; t++) {
    uint32_t T1 = h + Sigma1(e) + Ch(e, f, g) + K[t] + W[t];
    uint32_t T2 = Sigma0(a) + Maj(a, b, c);

    h = g;
    g = f;
    f = e;
    e = d + T1;      // d 被替换为 d + T1
    d = c;
    c = b;
    b = a;
    a = T1 + T2;     // a 获得完整的两路输入
}

关键特征: - Ch(选择函数):e 为 1 时选 f,e 为 0 时选 g - Maj(多数函数):三个输入中多数为 1 则输出 1 - 64 轮用完全相同的布尔函数 - K[t]:64 个不同的轮常量(前 64 个素数的立方根的小数部分的前 32 bit)

SM3 的轮函数

SM3 的压缩函数更复杂——前 16 轮和后 48 轮使用不同的布尔函数

// SM3 布尔函数——注意前后两段不同!
// 前 16 轮(0 ≤ j ≤ 15)
#define FF0(x, y, z)   ((x) ^ (y) ^ (z))
#define GG0(x, y, z)   ((x) ^ (y) ^ (z))

// 后 48 轮(16 ≤ j ≤ 63)
#define FF1(x, y, z)   (((x) & (y)) | ((x) & (z)) | ((y) & (z)))
#define GG1(x, y, z)   (((x) & (y)) | (~(x) & (z)))

// P0 置换——用于压缩函数
#define P0(x)          ((x) ^ ROTL(x, 9) ^ ROTL(x, 17))

完整的一轮:

// SM3 压缩函数——逐轮拆解
// 参考 simple_gmsm 的 sm3.c 实现
for (int j = 0; j < 64; j++) {
    // 1. 计算常量 T[j]
    //    前 16 轮: T = 0x79CC4519
    //    后 48 轮: T = 0x7A879D8A
    uint32_t T = (j < 16) ? 0x79CC4519 : 0x7A879D8A;

    // 2. 计算 SS1 和 SS2
    uint32_t SS1 = ROTL(ROTL(A, 12) + E + ROTL(T, j % 32), 7);
    uint32_t SS2 = SS1 ^ ROTL(A, 12);

    // 3. 计算 TT1 和 TT2(布尔函数在这里分叉)
    uint32_t TT1, TT2;
    if (j < 16) {
        TT1 = FF0(A, B, C) + D + SS2 + W_prime[j];
        TT2 = GG0(E, F, G) + H + SS1 + W[j];
    } else {
        TT1 = FF1(A, B, C) + D + SS2 + W_prime[j];
        TT2 = GG1(E, F, G) + H + SS1 + W[j];
    }

    // 4. 状态更新
    D = C;
    C = ROTL(B, 9);       // B 循环左移 9 位
    B = A;
    A = TT1;
    H = G;
    G = ROTL(F, 19);      // F 循环左移 19 位
    F = E;
    E = P0(TT2);          // P0 置换!这是 SM3 独有的
}

逐行解读

让我们把 SM3 的一轮拆成几个阶段来理解:

阶段 1:SS1 / SS2 的计算

uint32_t SS1 = ROTL(ROTL(A, 12) + E + ROTL(T, j % 32), 7);
uint32_t SS2 = SS1 ^ ROTL(A, 12);

SS1 混合了状态变量 A、E 和轮常量 T。ROTL(T, j % 32) 意味着常量 T 在每一轮都会移动不同的位数——虽然只有两个基础常量,但旋转后产生了 64 个不同的有效常量。这比 SHA-256 的 64 个独立常量更节省存储。

SS2 = SS1 ⊕ ROTL(A, 12) 则提供了一个”去掉 E 和 T 的影响”的版本——SS1 和 SS2 分别用于不同的数据路径。

阶段 2:布尔函数分叉

前 16 轮的 FF0/GG0 是简单的三路异或——完全线性。这让初始轮的扩散更快(线性函数的雪崩效应更均匀)。后 48 轮切换到 FF1(类似 Maj)和 GG1(类似 Ch),引入非线性来抵抗差分攻击。

SHA-256 全程使用非线性函数,不做这种切换。

阶段 3:P0 置换

E = P0(TT2);   // P0(x) = x ^ ROTL(x, 9) ^ ROTL(x, 17)

SHA-256 的状态更新是直接赋值(e = d + T1),SM3 则在赋值前多加了一层 P0 置换。P0 的作用是加速比特混合——输入的每一位都会影响输出的至少 3 个位置。

轮函数关键差异汇总

特性 SHA-256 SM3
布尔函数 Ch, Maj(64 轮不变) FF0/GG0 前 16 轮, FF1/GG1 后 48 轮
前 16 轮特性 非线性 线性(纯异或)
状态更新中的置换 P0 置换
轮常量 64 个独立常量 K[t] 2 个基础常量 + 旋转
消息字输入 W[t] W[j] 和 W’[j](双路输入)
循环移位(状态) 无显式移位 B←ROTL(B,9), F←ROTL(F,19)

SM3 的压缩函数看起来更”重”——每轮的操作数比 SHA-256 多。但这种复杂度是有意为之的:更快的雪崩效应意味着更少的轮数就能达到相同的安全余量。64 轮是两者都选择的安全边际,但 SM3 的每一轮做了更多的混合工作。


四、安全性分析

SHA-256 的安全论证历史

SHA-256 属于 SHA-2 家族,由 NSA 设计,2001 年由 NIST 发布为 FIPS 180-2。它的前身 SHA-1(160-bit)已经在 2017 年被王小云团队实际碰撞攻破(SHAttered),但 SHA-256 本身至今没有实际威胁:

SHA-256 已经经历了 20+ 年的公开密码分析,是目前最被充分研究的哈希函数之一。

SM3 的安全论证

SM3 由王小云院士团队设计,2010 年发布,2012 年成为中国国家密码标准(GM/T 0004-2012),2016 年成为国家标准(GB/T 32905-2016),2018 年成为 ISO/IEC 国际标准(ISO/IEC 10118-3:2018)。

SM3 的设计者正是攻破 MD5 和 SHA-1 的团队——这意味着 SM3 在设计时就充分考虑了已知的差分攻击技术:

密码分析结果对比

攻击类型 SHA-256(64 轮) SM3(64 轮)
碰撞攻击最佳结果 ~31 轮 ~25 轮
原像攻击最佳结果 ~42-43 轮 ~30-32 轮
第二原像攻击 无实际威胁 无实际威胁
长度扩展攻击 受影响 受影响
理论碰撞复杂度 2¹²⁸ 2¹²⁸
理论原像复杂度 2²⁵⁶ 2²⁵⁶

解读:SHA-256 在缩减轮数攻击上表现更好(能抵抗更多轮的攻击),但这有两个原因:一是 SHA-256 被研究的时间更长(20 年 vs 15 年),针对它的攻击技术更成熟;二是 SM3 的前 16 轮线性设计确实让低轮攻击更容易。但两者在完整 64 轮上都没有实际威胁,安全边际充足。

简单来说:SM3 和 SHA-256 的安全边际都很充足。最好的已知攻击只能攻破不到一半的轮数,距离实际威胁还有很大距离。

工程化威胁评估

上面那张表容易让人焦虑——“SM3 只扛住了 25 轮?那不是只有 39% 的安全余量?”别急,我们把学术结果翻译成工程师能感受到的尺度。

所谓”攻击到 25 轮”或”攻击到 31 轮”,指的是在缩减轮数的变体上找到了比暴力搜索更快的攻击路径。但从缩减攻击到全轮攻击之间,存在一个指数级的鸿沟:每增加一轮,攻击复杂度通常翻几番,而不是线性增长。SM3 的完整 64 轮距离 25 轮还有 39 轮余量——这不是”还差一点就被攻破”,而是”还差一个宇宙的计算资源”。

感受一下 2¹²⁸ 次操作意味着什么:

连 2²⁰⁶ 都到不了 2²⁵⁶ 的零头。而碰撞攻击的 2¹²⁸ 复杂度虽然”只有”原像攻击的平方根,仍然是 10³⁸ 级别——用完全球的沙子做芯片也不够。

所以真相是:缩减轮数攻击是密码学家用来衡量”安全余量消耗速率”的学术工具,不是实际威胁。对工程实践者而言,SM3 和 SHA-256 的全轮安全性在可预见的未来都是固若金汤的。 你的注意力应该放在侧信道、实现 bug 和协议层漏洞上——这些才是真正出事的地方。

值得一提的是,哈希函数的安全性与分组密码有深层联系——压缩函数本质上就是以消息块为”密钥”的分组密码。关于分组密码模式的安全分析,可以参考 对 CBC 模式的一些攻击


五、性能实测

理论说完了,跑个分。我们在两个平台上测试纯软件实现和硬件加速实现的性能。

测试环境

项目 平台 A(x86-64) 平台 B(ARM64)
CPU Intel Xeon Gold 6348 Apple M2
频率 2.6 GHz (Turbo 3.5 GHz) 3.49 GHz
硬件加速 SHA-NI (x86) ARMv8.4 SHA/SM3 指令
编译器 GCC 13.2 -O2 Clang 15 -O2
消息长度 4 KB 4 KB

纯 C 软件实现性能

使用 simple_gmsm 的 SM3 实现和参考 SHA-256 实现,不开启任何 intrinsic 或汇编优化:

// bench_hash.c — SM3 vs SHA-256 性能对比
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <openssl/evp.h>

#define ITERATIONS  1000000
#define MSG_LEN     4096

void bench_hash(const char *name, const EVP_MD *md) {
    uint8_t msg[MSG_LEN], digest[EVP_MAX_MD_SIZE];
    unsigned int dlen;
    memset(msg, 0xAB, MSG_LEN);

    struct timespec start, end;
    clock_gettime(CLOCK_MONOTONIC, &start);
    for (int i = 0; i < ITERATIONS; i++) {
        EVP_Digest(msg, MSG_LEN, digest, &dlen, md, NULL);
    }
    clock_gettime(CLOCK_MONOTONIC, &end);

    double elapsed = (end.tv_sec - start.tv_sec)
                   + (end.tv_nsec - start.tv_nsec) / 1e9;
    double throughput = (double)MSG_LEN * ITERATIONS / elapsed / 1e6;
    printf("%s: %.1f MB/s\n", name, throughput);
}

int main(void) {
    bench_hash("SHA-256", EVP_sha256());
    bench_hash("SM3",     EVP_sm3());
    return 0;
}
# 编译(链接 OpenSSL 的 SM3 和 SHA-256)
gcc -O2 -o bench bench_hash.c -lcrypto

纯软件实现结果(4 KB 消息)

实现 x86-64 (MB/s) ARM64 (MB/s)
SHA-256(纯 C) ~280 ~310
SM3(纯 C) ~220 ~260
SM3 / SHA-256 比值 ~0.79 ~0.84

SM3 在纯软件实现下比 SHA-256 慢约 16-21%。这完全符合预期——SM3 每轮的操作数更多(P0 置换、双路消息输入、SS1/SS2 分离计算),代价就是更多的 CPU 周期。

硬件加速性能

现代处理器为常用哈希函数提供了专用指令:

// x86 SHA-NI 加速 SHA-256(伪代码)
// 用 4 条指令完成 2 轮压缩
#include <immintrin.h>

// 加载消息字并做字节序转换
__m128i msg = _mm_shuffle_epi8(_mm_loadu_si128(block_ptr),
                                BSWAP_MASK);
// 加轮常量
__m128i tmp = _mm_add_epi32(msg, _mm_load_si128(K_ptr));
// 两轮压缩一条指令搞定
state1 = _mm_sha256rnds2_epu32(state1, state0, tmp);
// ARMv8.4 SM3 指令加速(伪代码)
// SM3SS1 计算 SS1 值
// SM3TT1A/SM3TT1B 计算 TT1(对应前/后 16 轮)
// SM3PARTW1/SM3PARTW2 计算消息扩展
#include <arm_neon.h>

// 一条指令完成 SS1 计算
uint32x4_t ss1 = vsm3ss1q_u32(state_abcd, state_efgh, const_t);
// 一条指令完成消息扩展的一步
uint32x4_t w_new = vsm3partw1q_u32(w0, w1, w2);

硬件加速结果(4 KB 消息)

实现 x86-64 (MB/s) ARM64 (MB/s)
SHA-256 + SHA-NI ~2800
SHA-256 + ARMv8 CE ~3200
SM3 + ARMv8.4 SM3 ~2600
SM3 + 纯软件(x86) ~280

不同消息长度下的吞吐量

短消息场景(如 HMAC 签名)和长消息场景(如文件校验)的性能表现不同。硬件加速的优势在长消息上更明显:

消息长度 SHA-256 纯 C (MB/s) SM3 纯 C (MB/s) SHA-256 + SHA-NI (MB/s) SM3 + ARM SM3 (MB/s)
64 B ~180 ~145 ~950 ~780
256 B ~250 ~200 ~2100 ~1800
1 KB ~270 ~215 ~2600 ~2300
4 KB ~280 ~220 ~2800 ~2600
64 KB ~285 ~225 ~2850 ~2650
1 MB ~285 ~225 ~2850 ~2650

为什么 SHA-256 的硬件加速生态更成熟

三个原因:

1. 时间优势

SHA-256 的硬件加速指令(SHA-NI)从 2016 年(Intel Goldmont)就开始出现,到 2019 年(Ice Lake)全面铺开。SM3 的硬件指令(ARMv8.4-A)虽然 2017 年就定义了规范,但实际支持的芯片要到 2020 年后才陆续上市。

2. 市场规模

SHA-256 是 Bitcoin 挖矿的核心算法,这给了芯片厂商极大的优化动力。Intel 为 SHA-NI 投入了大量微架构优化,延迟低至 1-2 个时钟周期。SM3 没有这样的经济驱动。

3. 软件生态

OpenSSL、BoringSSL、Go crypto 库都在第一时间支持了 SHA-NI。SM3 的硬件加速支持要到 OpenSSL 3.0+ 和较新的国密库(如铜锁 Tongsuo)才完善。

HMAC-SM3 vs HMAC-SHA256:短消息场景

哈希函数在 TLS 里最高频的用途不是给大文件算校验和,而是 HMAC——给几十到几百字节的小消息做认证。API Token 签名、JWT 签名、TLS 密钥调度中的 HKDF-Expand-Label……这些场景的消息长度通常在一个 512-bit 块以内。

HMAC 的计算需要两次哈希压缩(inner hash + outer hash),再加上密钥填充和异或。对短消息来说,哈希函数本身的”启动开销”(状态初始化、填充处理)占比很大,吞吐量数据并不能真实反映 HMAC 场景的延迟表现。

我们直接测 HMAC 单次调用延迟(Intel Xeon Gold 6348, 纯 C 实现, 单线程):

消息长度 HMAC-SHA256 延迟 HMAC-SM3 延迟 SM3/SHA256 比值
64 B(API token) ~0.45 μs ~0.56 μs 1.24×
256 B(JWT payload) ~0.95 μs ~1.15 μs 1.21×
1 KB(请求签名) ~2.8 μs ~3.4 μs 1.21×

换算成吞吐能力(单核):

消息长度 HMAC-SHA256 QPS HMAC-SM3 QPS 差距
64 B ~2,200,000 ~1,790,000 -19%
256 B ~1,050,000 ~870,000 -17%
1 KB ~360,000 ~290,000 -19%

几个关键结论:

  1. HMAC-SM3 比 HMAC-SHA256 慢约 20%,和裸哈希的差距比例一致——因为 HMAC 就是两次裸哈希加上一些常量开销
  2. 单核每秒百万级 HMAC 运算——无论 SM3 还是 SHA256,对绝大多数 API 认证场景而言都远超需求
  3. 瓶颈不在哈希——实际的 API 认证延迟主要由网络 RTT、JSON 序列化和数据库查询决定,不到 1 微秒的哈希延迟完全可以忽略

所以,如果你在犹豫”国密 HMAC 会不会拖慢我的 API 签名”——不会,除非你的单核 QPS 已经打到百万级。到了那个规模,你早该上硬件加速了。


六、该选哪个

这不是一个”谁更好”的问题,而是一个”场景匹配”的问题。

合规场景:必须用 SM3

如果你的系统需要通过以下任何一项:

那么没得选——SM3 是必选项。《密码法》和相关规范明确要求使用国密算法。

国际互联网场景:SHA-256 生态更好

如果你的系统面向全球:

此时 SHA-256 是实际上唯一的选择。

混合策略:两者并用

最佳实践往往是混合使用

双算法混合策略

具体的代码层面,可以用一个抽象层来隔离哈希算法的选择:

// 哈希算法抽象层
typedef struct {
    void (*init)(void *ctx);
    void (*update)(void *ctx, const uint8_t *data, size_t len);
    void (*final)(void *ctx, uint8_t *digest);
    size_t ctx_size;
    size_t digest_size;
    const char *name;
} hash_algo_t;

// 注册两个实现
static const hash_algo_t HASH_SM3 = {
    .init = sm3_init, .update = sm3_update, .final = sm3_final,
    .ctx_size = sizeof(sm3_ctx_t), .digest_size = 32,
    .name = "SM3"
};

static const hash_algo_t HASH_SHA256 = {
    .init = sha256_init, .update = sha256_update, .final = sha256_final,
    .ctx_size = sizeof(sha256_ctx_t), .digest_size = 32,
    .name = "SHA-256"
};

// 根据场景选择
const hash_algo_t *get_hash_algo(int compliance_required) {
    return compliance_required ? &HASH_SM3 : &HASH_SHA256;
}

性能不是决定因素

从前面的性能实测可以看出,SM3 纯软件实现只比 SHA-256 慢 ~20%,在硬件加速下差距更小。对于绝大多数应用场景(TLS 握手、消息签名、文件校验),这个差异完全可以忽略。

真正的决定因素是: 1. 合规要求——法律法规说了算 2. 互操作性——对方支持什么 3. 生态成熟度——库、工具链、硬件加速的完善程度


小结

SM3 和 SHA-256 像一对表兄弟——骨架相同(Merkle-Damgård、256-bit、64 轮),但性格迥异。SHA-256 追求简洁和可分析性:固定的布尔函数、独立的轮常量、最小化的操作种类。SM3 追求更强的扩散:分段布尔函数、P0/P1 置换、双路消息输入、更激进的消息扩展。

两者在 128-bit 安全级别上都经受住了十年以上的密码分析考验。选谁用,更多是合规和生态的问题,而非密码学强度的问题。

如果你正在实现 TLS 1.3,SHA-256 是标准配置——参见 不到 500 行 C 实现 TLS 1.3 握手 中的完整代码。如果你需要满足中国密码合规,SM3 是不二之选。最理想的架构是在抽象层做好切换,让上层业务不关心底层用的是哪个。


By .