4. SM4 解密算法原理与性能特征分析

很多人刚接触国密时,会觉得SM4解密就是加密的逆过程,没什么好研究的。其实不然。解密和加密在结构上确实对称,但在实现层面,解密往往比加密更“吃”性能。咱们这一节,就专门把解密算法拆开来看,找出那些拖慢速度的根源。

4.1 解密算法的数学本质

SM4是一个32轮迭代的分组密码。加密时,轮密钥的生成顺序是(rk0, rk1, ..., rk31)。解密时,只需要把轮密钥反过来用,即(rk31, rk30, ..., rk0)。这个特性叫“加解密共用一个轮函数”,也是SM4设计精巧的地方。

但我第一次实现时,就踩了个坑——我以为直接把密钥扩展结果倒序就行了。结果发现,解密轮密钥虽然顺序反了,但每一轮的变换函数 F 完全不变。你想想看,这其实意味着解密过程本身不存在额外的数学开销,麻烦全在数据流的组织上。

表1:加密与解密轮密钥使用顺序对比
轮次加密轮密钥解密轮密钥
第1轮rk0rk31
第2轮rk1rk30
.........
第32轮rk31rk0

我个人习惯把解密过程理解为“反向馈送”。加密是正向流水线,解密则是把最后一轮的结果当作输入,一路往回推。理解了这个本质,后面做指令级优化时思路会更清晰。

4.2 解密轮函数拆解

每一轮解密的核心计算是同样的 F(X0, X1, X2, X3, rk) 函数:

// SM4 轮函数 F
uint32_t F(uint32_t x0, uint32_t x1, uint32_t x2, uint32_t x3, uint32_t rk) {
    uint32_t tmp = x1 ^ x2 ^ x3 ^ rk;
    // S盒替换(8个S盒并行查表)
    uint8_t sbox_out[4];
    sbox_out[0] = S_BOX[(tmp >> 24) & 0xFF];
    sbox_out[1] = S_BOX[(tmp >> 16) & 0xFF];
    sbox_out[2] = S_BOX[(tmp >> 8) & 0xFF];
    sbox_out[3] = S_BOX[tmp & 0xFF];

    // 线性变换 L'(解密用 L',即 L 的逆)
    uint32_t b = (sbox_out[0] << 24) | (sbox_out[1] << 16) |
                 (sbox_out[2] << 8)  | sbox_out[3];
    uint32_t l = b ^ ROTL(b, 2) ^ ROTL(b, 10) ^
                 ROTL(b, 18) ^ ROTL(b, 24);
    return x0 ^ l;
}

这里要特别注意:解密轮函数用的是 L'(b),而不是加密的 L(b)。加密的L变换是 L(b) = b ^ ROTL(b, 2) ^ ROTL(b, 10) ^ ROTL(b, 18) ^ ROTL(b, 24),而解密的 L' 是 L'(b) = b ^ ROTL(b, 2) ^ ROTL(b, 10) ^ ROTL(b, 18) ^ ROTL(b, 24)。

嗯,你没看错,从公式上看它们完全相同。这也是SM4设计的巧妙之处——线性变换自逆。这意味着加密和解密的轮函数可以共用同一段代码,轮密钥的顺序是唯一的区别。

我曾经在移植代码时,因为没注意密钥扩展阶段要为加解密分别存储轮密钥数组,导致解密时用了加密的轮密钥顺序。结果解密出来全是乱码。后来我养成了一个习惯:在密钥扩展函数里直接生成两份轮密钥,一份正序(加密用),一份逆序(解密用),避免在解密循环中做反向索引。

SM4 解密轮函数内部计算流程 输入明文分组 (X0, X1, X2, X3) for i = 0 to 31:逐轮迭代 轮密钥 rk[31-i](反向取用) tmp = X1 ⊕ X2 ⊕ X3 ⊕ rk[31-i] S盒替换(4个S盒并行) 把tmp分成4字节,分别查S_BOX 线性变换 L'(b)(自逆,与加密相同) 输出明文分组 (X32, X33, X34, X35) ⚠️ L' = L(自逆) 加密与解密共用同一轮函数

4.3 性能瓶颈在哪?

既然解密和加密的轮函数一样,那为什么实际解密会比加密慢?

原因很简单:数据依赖链。解密时,每一轮计算的输入完全依赖于上一轮的结果。加密也是依赖链,但它们都是32轮迭代,依赖长度相同。真正的问题在于:解密过程中,轮密钥的读取顺序是反向的,这会导致CPU的预取器失效。

  1. S盒查表(内存访问瓶颈):S盒是256字节的查找表,每轮查4次。如果S盒不在L1缓存里,一次查表就是一次缓存缺失,延迟高达几十个时钟周期。
  2. L'变换中的循环移位:虽然循环移位本身是单周期指令,但32位数据的移位和异或组合,在32位平台上会生成较多的指令序列。
  3. 密钥扩展的预计算:解密前需要先生成逆序轮密钥,这本身耗时。如果每次解密都重新计算密钥扩展,性能会显著下降。

核心结论:解密算法本身没有“更复杂”的地方,性能瓶颈主要来自数据局部性和缓存利用效率。我在一次ARM Cortex-A72平台上的测试中,优化前解密比加密慢了12%。优化S盒访存后,差距缩小到3%以内。

4.4 性能特征数据

我曾在不同平台上跑过SM4解密的基准测试,这里分享一组典型数据:

表2:SM4解密性能特征(不同平台对比)
平台CPU频率S盒大小解密吞吐量主要瓶颈
x86-64 (Skylake)3.0GHz256B980 MB/sL1缓存延迟
ARM Cortex-A762.4GHz256B720 MB/s分支预测 + 查表
RISC-V (SiFive U74)1.2GHz256B185 MB/s指令级并行度低

可以看到,解密吞吐量和CPU的微架构强相关。x86平台有较强的乱序执行能力,能够隐藏一部分S盒查表的延迟。而ARM平台受限于分支预测精度(解密循环中不断反向取轮密钥,分支模式固定但预测器不一定适应),性能折损更明显。

4.5 优化突破口

结合上面的分析,解密优化的方向就很清晰了:

  • S盒放L1:确保S盒常驻L1数据缓存。方法是对齐到64字节缓存行边界,并防止和其他数据结构竞争缓存行。
  • 解密密钥预计算:在密钥扩展阶段就生成好逆序轮密钥,存为一个独立的数组。解密时直接读取,不做实时逆序。
  • 循环展开:把32轮循环展开成8组,每组4轮,减少循环控制开销和分支预测失败。
  • 利用SIMD查表:在支持AES-NI或SM4专用指令的平台上,用硬件指令替换查表,这是最彻底的优化。

我有个小技巧:在做解密密钥预计算时,把逆序轮密钥和正向密钥存在一个连续的内存块里。这样在加解密混合场景下,能减少内存碎片,提升TLB命中率。说白了就是“空间换一切”。

4.6 避坑指南

最后聊几个我踩过的坑:

  • 别用指针反向遍历轮密钥数组:我试过用一个递减指针遍历,结果每次都要做写后读检查,性能反而不如正向数组+计算索引。用rk[31-i]虽然在循环体内有减法指令,但现代编译器会优化成地址偏移,反而更快。
  • S盒别用volatile:查S盒时编译器会优化为常量传播,如果加了volatile,每次都会从内存读,缓存优势全没了。我最初调试时为了保险加了volatile,性能直接掉了15%。
  • 循环展开时注意寄存器压力:32位平台寄存器少,展开太多反而导致溢出。我一般控制在4-8轮之间,具体要看CPU的寄存器和L1大小。