SM4软件实现:常见实现方式与代码结构

各位同学好,这一章我们来聊聊SM4在软件层面是怎么实现的。我个人习惯把软件实现分成两大类:一类是查表法,另一类是比特切片法。你可能会问,为什么要有两种?说白了就是性能和可移植性的取舍。我在前期做国密算法库的时候,这两种都踩过坑,今天把经验分享给你们。

查表法:主流实现,但小心缓存

查表法就是把SM4的轮函数中那些复杂的“合成置换”预计算成查找表。SM4每轮主要由非线性变换(S盒)和线性变换(L)组成。标准做法是这样:

  • 合并S盒与L:将4个并行的S盒输出与L变换合并,生成一张8KB的表(256个4字节值)。
  • 轮函数实现:每轮对4个输入字节分别查表,异或后得到32位输出。
  • 密钥扩展:同样依赖同一套S盒,固定参数FK和CK。
// 典型查表法轮函数(C语言风格)
uint32_t sm4_round(uint32_t x0, uint32_t x1, uint32_t x2, uint32_t x3, uint32_t rk) {
    uint32_t t = x1 ^ x2 ^ x3 ^ rk;
    // 拆成4个字节分别查表
    uint32_t u = (sm4_lut[(t >> 24) & 0xFF] ^
                  sm4_lut[0x100 | ((t >> 16) & 0xFF)] ^
                  sm4_lut[0x200 | ((t >> 8) & 0xFF)] ^
                  sm4_lut[0x300 | (t & 0xFF)]);
    return x0 ^ u;
}

嗯,代码看着简单,但性能瓶颈往往不在算法本身,而在内存访问。我之前在一个ARM平台调SM4吞吐量,查表法每轮要读4次内存,每次读一个32位字。当数据量小的时候还行,一旦处理MB级数据,CPU的L1缓存就被表占满,频繁发生cache miss。

我曾经在一个嵌入式项目里,用查表法加密1MB文件,吞吐量从预期的800Mbps掉到不足300Mbps。后来用perf一看,L1数据缓存miss率超过20%。真相就是:8KB的表加上代码段和其他数据,把32KB的L1D给撑爆了。

解决思路有两种:一是把表拆分,每轮只用一个子表,减少访存压力;二是换方案,用比特切片法。

比特切片法:抗缓存攻击,但实现复杂

比特切片法的核心思想:把一个32位字拆成32个比特,然后对多个数据块并行做按位操作。它尤其适合有S盒的算法——把S盒拆成布尔逻辑电路。好处很明显:

  • 抗侧信道攻击:没有查表,数据流跟密钥无关。
  • 利用SIMD指令:一个128比特寄存器可以同时处理4个32位字(或更多)。
  • 缓存友好:没有大表,只有代码和少量临时变量。

我在做加密硬盘控制器固件时,用的就是比特切片SM4。虽然代码量比查表法大3倍,但吞吐量在NEON上提升了40%。代价是开发和调试周期长了2周。

但注意,比特切片不适合单块小数据加密。如果你只是加密一个TCP报文,初始化开销就划不来。一般在批量加密场景下才值得用。

代码结构:分层设计是基本功

不管用哪种实现方式,代码结构应该分层清晰。我习惯这样组织:

层级 职责 示例文件
API层 提供sm4_encrypt/sm4_decrypt等标准接口 sm4_api.c
轮函数层 实现单轮加密/解密逻辑 sm4_round.c
密钥扩展层 从种子密钥生成32轮子密钥 sm4_keyschedule.c
模式层 ECB/CBC/CTR等工作模式 sm4_mode.c
平台层 NEON/AESNI/查表底层 sm4_arm64.S

这种结构的好处是:换实现方式只改平台层和轮函数层,API和模式层不用动。我曾经在维护一个老项目时,看到有人把查表和比特切片混在一个文件里,`#ifdef` 满天飞。我建议还是按目录组织:

sm4/
├── include/
│   └── sm4.h
├── src/
│   ├── sm4_common.c      // S盒、FK、CK 等常量
│   ├── sm4_keyschedule.c // 密钥扩展
│   ├── sm4_round_lut.c   // 查表法轮函数
│   ├── sm4_round_bitslice.c // 比特切片法轮函数
│   └── sm4_mode.c        // ECB/CBC
├── asm/
│   └── sm4_neon.S        // NEON优化
└── test/
    └── test_sm4.c

你看,每个文件只做一件事。这样就容易定位性能问题——比如吞吐量波动,我可以先用 `perf top` 看热点,如果热点在 `sm4_round_lut.c`,那八成是访存问题;如果在 `sm4_keyschedule.c`,可能是密钥扩展被重复调用太多。

数据存储:对齐和字节序

SM4是32位字操作,但数据在内存中是字节序列。我见过一个很隐蔽的性能问题:非对齐访问。在ARM Cortex-A系列上,非对齐访问会触发异常处理,导致性能骤降。所以,输入和输出缓冲区一定要确保4字节对齐。

小技巧:用 memalign(16, size) 或 posix_memalign 分配缓冲区,而不是直接用 malloc。这样能避免很多莫名其妙的性能抖动。

另外字节序方面,SM4规范本身是大端输入,但大部分处理器和网络协议用大端。如果你在x86小端平台上实现,记得用 `bswap` 或者 `ntohl` 转换。我一般在API入口统一转换,内部都按小端处理,免得轮函数里到处都是 `htonl` 影响性能。

本章结构总览

下面这张图概括了SM4软件实现的几种方式和代码结构层次,方便你直接记住核心要点:

SM4软件实现方式与代码结构 SM4软件实现知识结构 SM4 软件实现 查表法 (LUT) 比特切片法 (Bitslice) 8KB 查找表 轮函数4次访存 密钥扩展同S盒 布尔逻辑S盒 SIMD并行处理 无大表,缓存友好 代码结构:API层 → 模式层 → 轮函数层 → 密钥扩展层 → 平台层 ✔ 查表法:实现简单,适合小数据快速原型,但易受缓存影响。 ✔ 比特切片法:抗侧信道、利用SIMD,但开发成本和调试难度高。 ✔ 项目选型建议:吞吐量敏感且平台统一 → 优选比特切片; 可移植性优先 → 查表法更稳妥。

好了,这一章我们聊了查表法和比特切片法的核心差异,也给出了推荐的代码目录结构。这两种方式我都实际用过,各有优劣。你想想看,如果让你选,会怎么权衡?嗯,这个问题的答案,等你做吞吐量波动排查时自然会明白。