简介:面向8051等8位单片机开发者的国密SM4算法C语言实现源码,聚焦资源受限嵌入式环境下的对称加密落地需求。包内共2个文件,以c源文件与h头文件为主,分别承载SM4核心加解密逻辑与类型定义、接口声明,压缩包整体约3KB,体量轻巧便于直接嵌入现有工程。源码同时支持ECB与CBC两种工作模式,适合需要在单片机侧完成数据加密、身份认证或安全通信的开发者参考移植。已有1370人学习下载,具备一定的实践参考基础。读者可从中获得可编译的SM4基础实现、分组加解密流程与模式调用方式,并据此理解国密算法在8位平台上的资源占用特点与移植要点,为后续裁剪优化与工程集成提供起点。
1. 从一块只有 256 字节 RAM 的单片机说起
去年帮一个做工业采集终端的朋友收拾烂摊子,他们的板子上挂着一颗 8051 内核的 MCU,RAM 只有 256 字节,Flash 也就 32KB,却要在本地把上行数据做一次对称加密再发出去。原本的方案是外挂一颗专用加密芯片,结果 BOM 成本和交期都顶不住,最后改成软件实现。翻了一圈资料,能直接在 Keil C51 下编译、还同时给出 ECB 和 CBC 两种模式的国密 SM4 源码并不多,这份 C51 版本的 SM4 源码就是在这种场景下被翻出来的。它面向的就是 8051 这类资源极度受限的平台,用纯 C 实现 SM4 分组密码算法,支持 ECB 和 CBC 两种工作模式,适合做嵌入式加密模块、协议栈底层加解密、以及教学场景下理解分组密码在 8 位机上的落地方式。如果你手上正好有 8051 项目要做国密改造,或者想搞清楚一个 128 位分组算法怎么塞进 8 位机,这份源码值得拆开看。
2. 技术选型与目录结构:为什么是纯 C 的 C51 实现
2.1 选型逻辑:8 位机上做 SM4 的取舍
SM4 是分组长度和密钥长度都为 128 位的分组密码,32 轮迭代,每轮用一个 32 位轮密钥做异或,再过 S 盒和线性变换。放到 8051 上,最大的矛盾是 128 位数据要拆成 4 个 32 位字,而 8051 原生只有 8 位累加器,32 位运算全靠软件模拟。所以这份源码的核心取舍很明确:不追求极致速度,优先保证代码体积和 RAM 占用可控,用unsigned long承载 32 位字,靠 Keil C51 的库函数完成移位和异或。
常见做法是把 S 盒做成code段常量表放在 Flash 里,而不是放 RAM,这一点在 8051 上非常关键,因为 256 字节的 idata 根本放不下 256 字节的 S 盒。源码里 S 盒、FK 常量、CK 常量都应该是code修饰的常量数组,编译器会把它们放到程序存储区,运行时只读不占 RAM。轮密钥扩展需要缓存 32 个 32 位轮密钥,也就是 128 字节,这部分是 RAM 的大头,通常放在xdata或者由调用方传入缓冲区,避免和栈抢空间。
另一个取舍是 ECB 和 CBC 共用同一套轮函数和密钥扩展,只在分组处理的外层做模式区分。ECB 每个分组独立加密,CBC 需要维护一个 16 字节的 IV 并与前一个密文分组异或。这样拆的好处是核心算法只写一遍,模式逻辑薄薄一层,代码复用率高,也方便后续加 CFB、OFB 之类的模式。
2.2 目录结构:一个典型的最小工程
这类 C51 加密源码包通常不会很复杂,目录结构大致如下,具体文件名可能略有差异,但职责划分是固定的:
sm4_c51/ ├── inc/ │ ├── sm4.h // 对外接口:密钥扩展、ECB/CBC 加解密声明 │ └── sm4_port.h // 平台相关宏:code/xdata 修饰、类型定义 ├── src/ │ ├── sm4.c // 核心:S 盒、轮函数、密钥扩展、单分组加解密 │ ├── sm4_ecb.c // ECB 模式封装 │ └── sm4_cbc.c // CBC 模式封装 ├── test/ │ └── main.c // 测试入口:标准测试向量验证 └── keil/ └── sm4.uvproj // Keil C51 工程文件sm4.h是对外契约,里面一般会定义SM4_KEY结构体或者直接用unsigned long rk[32]存轮密钥,以及SM4_Encrypt、SM4_Decrypt、SM4_SetKey这类函数原型。sm4_port.h是移植层,把code、xdata、idata这些 Keil 关键字用宏包一层,方便换编译器时只改这一个文件。sm4.c是算法本体,sm4_ecb.c和sm4_cbc.c是模式封装,main.c用国密标准测试向量做自检。这个划分方式很朴素,但对 8 位机项目来说够用,编译单元少,链接后代码段紧凑。
提示:拿到源码后先看
sm4_port.h,确认code和xdata宏是否和你用的编译器匹配。IAR 8051 和 Keil C51 的关键字不完全一样,直接编译可能报错。
2.3 数据类型与内存布局
8051 上int是 16 位,long是 32 位,SM4 的 32 位字正好用unsigned long。但要注意 Keil C51 默认的unsigned long运算会调用库函数,代码体积会增加,这是没办法的事。源码里通常会用typedef unsigned long u32;和typedef unsigned char u8;统一类型,避免不同编译器下位宽不一致。
内存布局上,输入输出缓冲区一般由调用方提供,放在xdata里,因为 16 字节的分组加上可能的链式缓冲,放 idata 容易把栈挤爆。轮密钥数组也是xdata,128 字节。S 盒 256 字节放code。这样算下来,RAM 占用主要是轮密钥 128 字节加调用方缓冲区,对 8051 来说是可以接受的。
3. 核心模块拆解:轮函数、密钥扩展与两种模式
3.1 S 盒与线性变换:SM4 的心脏
SM4 的轮函数由三部分组成:异或轮密钥、S 盒代换、线性变换 L。S 盒是一个 256 字节的查找表,输入一个字节输出一个字节。线性变换 L 对 32 位字做循环左移和异或。先看 S 盒和基本宏的定义:
#include "sm4_port.h" /* S 盒,放 code 段,不占 RAM */ static const u8 SBOX[256] = { 0xd6,0x90,0xe9,0xfe,0xcc,0xe1,0x3d,0xb7, /* 仅示意前 8 字节 */ /* ... 其余 248 字节 ... */ }; /* 32 位循环左移 n 位,8051 上靠库函数完成 */ #define ROTL32(x, n) (((x) << (n)) | ((x) >> (32 - (n)))) /* 线性变换 L,用于轮函数 */ static u32 sm4_L(u32 x) { return x ^ ROTL32(x, 2) ^ ROTL32(x, 10) ^ ROTL32(x, 18) ^ ROTL32(x, 24); } /* 线性变换 L',用于密钥扩展 */ static u32 sm4_Lp(u32 x) { return x ^ ROTL32(x, 13) ^ ROTL32(x, 23); }SBOX用const加code修饰(这里为了可读性省略了code,实际源码里会有),编译器把它放到程序存储区。ROTL32是宏,避免函数调用开销,但 8051 上移位运算本身也是库函数,所以这个宏展开后代码量不小。sm4_L和sm4_Lp的区别在于移位位数不同,轮函数用 2/10/18/24,密钥扩展用 13/23,这是 SM4 标准规定的,写错一位整个算法就废了。
轮函数本身是把输入四个字中的后三个做非线性变换再异或:
/* 轮函数:输入 x0..x3 和轮密钥 rk,输出新的 x0 */ static u32 sm4_round(u32 x0, u32 x1, u32 x2, u32 x3, u32 rk) { u32 t = x1 ^ x2 ^ x3 ^ rk; /* 字节代换:把 t 拆成 4 个字节分别查 S 盒 */ t = ((u32)SBOX[(t >> 24) & 0xff] << 24) | ((u32)SBOX[(t >> 16) & 0xff] << 16) | ((u32)SBOX[(t >> 8) & 0xff] << 8) | ((u32)SBOX[ t & 0xff]); return x0 ^ sm4_L(t); }这里有个细节:t先和三个状态字、轮密钥异或,然后拆字节查 S 盒再拼回去,最后过线性变换 L 再和x0异或。参数x0..x3是当前四个 32 位状态字,rk是当前轮密钥。返回值是下一轮的新状态字。注意字节拼接时的移位和掩码,8051 上unsigned long右移 24 位是合法的,但要注意编译器是否支持,Keil C51 是支持的。
3.2 密钥扩展:32 个轮密钥怎么来
SM4 的密钥扩展用初始密钥 128 位拆成 4 个 32 位字,和系统参数 FK 异或,然后迭代 32 次生成轮密钥。系统参数 FK 和固定参数 CK 都是常量,放code段:
static const u32 FK[4] = {0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc}; /* CK 共 32 个,这里只列前 4 个示意 */ static const u32 CK[32] = { 0x00070e15,0x1c232a31,0x383f464d,0x545b6269, /* ... 其余 28 个 ... */ }; /* 密钥扩展:key 为 16 字节初始密钥,rk 输出 32 个轮密钥 */ void SM4_SetKey(u32 *rk, const u8 *key) { u32 k[4]; u8 i; /* 初始密钥按大端拆成 4 个 32 位字,再与 FK 异或 */ k[0] = ((u32)key[0] << 24) | ((u32)key[1] << 16) | ((u32)key[2] << 8) | (u32)key[3]; k[1] = ((u32)key[4] << 24) | ((u32)key[5] << 16) | ((u32)key[6] << 8) | (u32)key[7]; k[2] = ((u32)key[8] << 24) | ((u32)key[9] << 16) | ((u32)key[10] << 8) | (u32)key[11]; k[3] = ((u32)key[12] << 24) | ((u32)key[13] << 16) | ((u32)key[14] << 8) | (u32)key[15]; k[0] ^= FK[0]; k[1] ^= FK[1]; k[2] ^= FK[2]; k[3] ^= FK[3]; for (i = 0; i < 32; i++) { u32 t = k[1] ^ k[2] ^ k[3] ^ CK[i]; t = ((u32)SBOX[(t >> 24) & 0xff] << 24) | ((u32)SBOX[(t >> 16) & 0xff] << 16) | ((u32)SBOX[(t >> 8) & 0xff] << 8) | ((u32)SBOX[ t & 0xff]); rk[i] = k[0] ^ sm4_Lp(t); /* 状态字滑动:k0<-k1<-k2<-k3<-新值 */ k[0] = k[1]; k[1] = k[2]; k[2] = k[3]; k[3] = rk[i]; } }SM4_SetKey的参数rk是输出缓冲区,长度 32 个u32,调用方负责分配,通常放xdata。key是 16 字节初始密钥。函数内部先把密钥按大端序拼成 4 个字,和 FK 异或,然后循环 32 次,每次用 CK[i] 参与运算,过 S 盒和 L' 变换,得到轮密钥后滑动状态字。这里的大端拼接顺序不能错,SM4 标准规定密钥和明文都按大端解释,8051 是小端架构,所以必须手动拼字节,不能直接memcpy到u32数组。
3.3 单分组加解密与 ECB 模式
单分组加解密是 32 轮迭代,加密和解密的区别在于轮密钥使用顺序相反,以及解密时线性变换要用 L 而不是 L'。先看加密:
/* 单分组加密:input 16 字节明文,output 16 字节密文 */ void SM4_EncryptBlock(const u32 *rk, const u8 *input, u8 *output) { u32 x[4]; u8 i; /* 明文按大端拆成 4 个字 */ x[0] = ((u32)input[0] << 24) | ((u32)input[1] << 16) | ((u32)input[2] << 8) | (u32)input[3]; x[1] = ((u32)input[4] << 24) | ((u32)input[5] << 16) | ((u32)input[6] << 8) | (u32)input[7]; x[2] = ((u32)input[8] << 24) | ((u32)input[9] << 16) | ((u32)input[10] << 8) | (u32)input[11]; x[3] = ((u32)input[12] << 24)| ((u32)input[13] << 16) | ((u32)input[14] << 8) | (u32)input[15]; for (i = 0; i < 32; i++) { u32 t = sm4_round(x[0], x[1], x[2], x[3], rk[i]); x[0] = x[1]; x[1] = x[2]; x[2] = x[3]; x[3] = t; } /* 反序输出,注意最后一轮后要交换 x0 和 x3 */ x[0] = x[3]; x[3] = x[0]; /* 实际实现用临时变量,这里示意 */ /* 按大端写回 output */ output[0] = (x[0] >> 24) & 0xff; /* ... 其余字节 ... */ }上面这段为了说明逻辑做了简化,实际源码里反序输出会用临时变量正确交换,并且把 4 个字完整写回 16 字节。关键点是:32 轮后状态字顺序要反一下,这是 SM4 标准规定的输出变换。解密时轮密钥从rk[31]倒着用到rk[0],其余结构一样。
ECB 模式就是循环调用单分组加解密,每个分组独立处理:
/* ECB 加密:len 必须是 16 的倍数 */ void SM4_ECB_Encrypt(const u32 *rk, const u8 *in, u8 *out, u16 len) { u16 i; for (i = 0; i < len; i += 16) { SM4_EncryptBlock(rk, in + i, out + i); } }参数len是字节长度,必须是 16 的整数倍,源码里一般不做填充,填充逻辑交给上层协议。这一点在嵌入式场景很常见,因为填充策略往往和具体协议绑定,加密库只负责裸分组运算。
3.4 CBC 模式:IV 与前一个密文分组的异或
CBC 比 ECB 多了一个 16 字节 IV 和链式异或。加密时每个明文分组先和上一个密文分组(第一个是 IV)异或,再加密;解密时先解密再异或。看加密实现:
/* CBC 加密:iv 为 16 字节初始向量,会被内部复制,不修改原值 */ void SM4_CBC_Encrypt(const u32 *rk, const u8 *iv, const u8 *in, u8 *out, u16 len) { u8 chain[16]; u8 blk[16]; u16 i, j; /* 复制 IV 到链式缓冲 */ for (j = 0; j < 16; j++) chain[j] = iv[j]; for (i = 0; i < len; i += 16) { /* 明文与链式值异或 */ for (j = 0; j < 16; j++) { blk[j] = in[i + j] ^ chain[j]; } SM4_EncryptBlock(rk, blk, out + i); /* 更新链式值为当前密文分组 */ for (j = 0; j < 16; j++) chain[j] = out[i + j]; } }chain是 16 字节栈上缓冲,在 8051 上要小心栈空间,如果编译器把局部数组放 idata 可能吃紧,常见做法是把它改成static或者由调用方传入。iv是输入参数,函数内部复制,不修改调用方的 IV。len同样要求 16 的倍数。解密逻辑对称,先SM4_DecryptBlock再和chain异或,然后chain更新为当前密文分组(注意解密时更新的是输入密文,不是输出明文)。
注意:CBC 的 IV 每次会话应该随机且不可预测,嵌入式里如果没有硬件随机源,至少用计数器加盐的方式生成,不要固定写死一个常量 IV,否则相同明文开头会产生相同密文,安全性打折扣。
4. 跑起来:Keil C51 工程配置与测试向量验证
4.1 工程配置要点
把源码放进 Keil 后,几个配置项必须确认。第一是 Memory Model,建议用 Large 模式,让变量默认放xdata,避免 idata 不够用。第二是 Code Rom Size,选 Large 或者根据芯片实际 Flash 选,SM4 的 S 盒加 CK 常量大概 300 多字节,加上代码本身,32KB Flash 绰绰有余。第三是优化等级,建议从 Level 4 开始试,如果发现算法结果不对再降到 Level 2 排查,因为高优化等级下 8051 编译器对unsigned long移位的处理偶尔会有坑。
编译选项里还要确认code关键字可用,Keil C51 默认支持。如果用的是 SDCC,code要换成__code,xdata换成__xdata,这就是sm4_port.h存在的意义。链接时注意看 map 文件,确认 SBOX 落在 CODE 段而不是 DATA 段,轮密钥数组落在 XDATA 段。
4.2 用标准测试向量自检
国密标准给了一组测试向量,密钥和明文都是0123456789abcdeffedcba9876543210,加密结果应该是681edf34d206965e86b3e94f536e4246。main.c里应该有这么一段:
#include "sm4.h" #include <stdio.h> /* 标准测试向量 */ static const u8 test_key[16] = { 0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef, 0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10 }; static const u8 test_pt[16] = { 0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef, 0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10 }; static const u8 test_ct[16] = { 0x68,0x1e,0xdf,0x34,0xd2,0x06,0x96,0x5e, 0x86,0xb3,0xe9,0x4f,0x53,0x6e,0x42,0x46 }; void main(void) { u32 rk[32]; u8 out[16]; u8 i, ok = 1; SM4_SetKey(rk, test_key); SM4_EncryptBlock(rk, test_pt, out); for (i = 0; i < 16; i++) { if (out[i] != test_ct[i]) { ok = 0; break; } } /* ok 为 1 表示加密结果与标准一致 */ while (1); }这段代码先扩展密钥,再加密一个分组,逐字节比对标准密文。ok变量在调试器里观察,或者点亮一个 LED 表示通过。如果对不上,先查大端拼接顺序,再查 S 盒是否完整,最后查轮密钥滑动逻辑。解密验证同理,用标准密文解出明文比对。
4.3 在真实链路上跑通
测试向量过了只是第一步,真实项目里还要处理分组边界。比如上行数据长度不是 16 的倍数,需要上层做填充,常见的是 PKCS#7 填充,补n个值为n的字节。CBC 模式下还要保证 IV 和密文一起传输,接收方用同一个 IV 解密。如果协议里已经有长度字段,也可以约定零填充加长度校验,省掉填充字节。这些逻辑不建议塞进 SM4 源码里,保持加密库纯粹,填充和组帧放在协议层,后续换算法时改动面小。
5. 避坑与排查:8051 上跑 SM4 的五个血泪经验
5.1 现象:测试向量对不上,密文完全随机
原因通常是 S 盒没放对段,或者大端拼接写成了小端。8051 是小端,如果直接把 16 字节memcpy到u32数组,拼出来的字和标准大端解释相反,轮函数输入就错了。解决方法是老老实实按字节移位拼接,别图省事用指针强转。另外检查 S 盒数组是否完整 256 字节,少一个字节后面全错。
5.2 现象:加密前几个分组对,后面开始错
CBC 模式下出现这种问题,八成是chain缓冲被覆盖了。8051 上局部数组如果放 idata,函数调用层次一深就可能和栈冲突。把chain改成static或者xdata修饰,问题消失。ECB 模式出现类似现象则要查输入输出缓冲区是否重叠,in和out指向同一块内存时,加密过程中会互相踩踏,必须用临时分组缓冲。
5.3 现象:编译通过但运行跑飞,看门狗复位
SM4 的 32 轮循环里unsigned long运算很重,如果 MCU 主频低,单次加密耗时可能到几十毫秒,看门狗没喂就复位了。解决方法是加密前后喂狗,或者把加密拆成分组间让出 CPU。另外检查栈大小,Keil 默认栈很小,sm4_round里临时变量多,栈溢出也会跑飞,把 Stack Size 调大一点。
5.4 现象:换编译器后code关键字报错
Keil C51 的code、xdata、idata是编译器扩展关键字,换到 SDCC 或 IAR 就不认。这就是为什么要有sm4_port.h,把平台相关修饰用宏包起来。移植时只改这个头文件,算法源码不动。如果源码里关键字是散落各处的,移植工作量会大很多,拿到手先做这一步归一化。
5.5 现象:相同明文每次加密结果不同(ECB 下)
ECB 模式下相同明文应该产生相同密文,如果结果不同,说明轮密钥没初始化或者被意外修改。检查SM4_SetKey是否在每次加密前都调用了,以及rk数组是否被其他模块越界写。CBC 模式下结果不同是正常的,因为 IV 或链式值在变,但同一 IV 同一明文应该稳定,不稳定就查chain更新逻辑。
6. 二次开发:把 SM4 接进你的协议栈
拿到这份源码后,最常见的改造方向有三个。第一是接口封装,把SM4_EncryptBlock和模式函数包成统一的crypto_encrypt(mode, key, iv, in, out, len)形式,方便上层协议切换算法。第二是性能优化,8051 上unsigned long移位是瓶颈,如果 Flash 有余量,可以把ROTL32展开成查表或者用汇编重写关键轮函数,实测能快不少,但代码可读性会下降,建议先用 C 版本跑通再优化。第三是内存布局调整,如果项目 RAM 特别紧张,轮密钥可以不缓存,每轮现算,代价是速度慢一倍,但省 128 字节 xdata。
改造时我一般会先写一个跨平台的测试桩,在 PC 上用 gcc 编译同一份sm4.c,跑标准向量和随机向量对比,确认算法逻辑没问题再烧到板子上。这样能把算法错误和平台问题分开,排查效率高很多。下面这个表格是我常用的验证清单:
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 标准向量 | 固定 key/pt 加密 | 密文与标准一致 |
| 解密回环 | 加密再解密 | 还原出原文 |
| CBC 链式 | 两个分组相同明文 | 密文不同 |
| 边界长度 | len=16/32/48 | 无越界、结果正确 |
| 内存占用 | 看 map 文件 | SBOX 在 CODE,rk 在 XDATA |
还有一个容易忽略的点:密钥管理。源码只负责算法,密钥怎么存、怎么下发是另一回事。8051 项目里常见做法是把密钥放 Flash 的受保护区域,或者每次上电由上位机下发,不要硬编码在源码里,否则固件一泄露密钥就没了。如果板子上有唯一 ID,可以用 ID 派生密钥,增加一点提取难度。
最后说个我自己的教训。早些年做第一个 8051 加密项目时,测试向量过了就直接量产,结果现场反馈偶发解密失败。查了两天才发现是 CBC 的chain缓冲放在了 idata,中断里调用了另一个函数把栈压深,把chain覆盖了。从那以后我每次在 8 位机上做加密,都强制把链式缓冲和轮密钥放xdata,并且在 map 文件里逐个确认段归属,再也不敢凭感觉。希望这份拆解能帮你在自己的板子上少走点弯路,源码拿过去先跑标准向量,再按上面的清单过一遍,基本就能稳稳接进项目里。
本文还有配套的精品资源,点击获取