简介:面向8051等8位单片机开发者的国密SM4算法C语言实现源码,聚焦资源受限嵌入式环境下的对称加密落地需求,提供ECB与CBC两种工作模式,适合已具备单片机C编程基础、需要在嵌入式项目中集成国密加密能力的开发者参考移植。压缩包共2个文件,以c源文件与h头文件为主,分别承载SM4加解密核心逻辑与类型定义、接口声明,整体约3KB,体量精简便于直接嵌入工程。已有1370人学习下载,具备一定实践参考价值。源码针对C51编译环境编写,读者可据此理解SM4轮函数、密钥扩展在8位平台上的实现取舍,并对照ECB与CBC两种模式的调用差异,快速完成加密模块的裁剪与适配,减少从零实现国密算法的时间成本。
1. 国密SM4落到8051上:一份能直接烧进片子的C51实现
手里有个老项目要过国密改造,主控还是那颗跑了好多年的8051,Flash 只剩几KB,RAM 掰着指头数。这种场景下你去找SM4实现,网上大多是 OpenSSL 那种吃内存的写法,或者干脆是跑在 Cortex-M 上的库,挪到 Keil C51 里一编译,idata溢出、xdata访问慢得离谱,直接翻车。这份 C51 版 SM4 源码解决的正是这个痛点:它把 ECB 和 CBC 两种模式都做了进去,密钥扩展、轮函数、S 盒全部按 8051 的存储模型重写,目标就是让一颗资源紧张的 8051 也能跑完一次完整的分组加解密。适合谁?做嵌入式国密改造的、维护老工控设备的、以及需要在无操作系统裸机上做数据保护的从业者。下面我按「它怎么组织、怎么接进工程、参数怎么调、哪里会踩坑」拆开讲。
2. SM4算法在8051上的存储与轮函数设计:为什么不能照搬PC实现
2.1 分组密码的四个基本件与8051的存储现实
SM4 是 128 位分组、128 位密钥的分组密码,32 轮迭代,每轮做一次非线性变换 τ(4 个 S 盒并行)、一次线性变换 L,再和轮密钥异或。放到 PC 上这些都是寄存器操作,但 8051 是 8 位机,一个 128 位分组就是 16 字节,密钥又是 16 字节,轮密钥 32 组共 128 字节——光轮密钥表就吃掉 128 字节 RAM,这对只有 128 或 256 字节内部 RAM 的经典 8051 是致命的。
所以这份源码的第一个设计取舍就在存储模型上。常见做法是把轮密钥表放进xdata(外部数据区),代价是每次访问要走MOVX指令,比idata慢,但胜在容量大;如果芯片内部 RAM 够(比如增强型 8051 有 1KBidata),也可以把轮密钥放idata换速度。源码里用宏把存储区抽象出来,改一处就能切换,这点比那些写死xdata的实现友好得多。
/* 存储区抽象:按芯片资源改这两个宏即可 */ #define SM4_RK_AREA xdata /* 轮密钥表存放区,RAM 紧张时用 xdata */ #define SM4_BUF_AREA data /* 分组缓冲区,16 字节放 data 最快 */ SM4_RK_AREA unsigned char sm4_rk[32][4]; /* 32 轮 × 4 字节轮密钥 */ SM4_BUF_AREA unsigned char sm4_block[16]; /* 当前分组 */这段声明的逻辑很直白:轮密钥是「一次扩展、多次使用」的数据,放慢速区可以接受;分组缓冲区是每轮都要读写的热点,必须放最快的data区。参数上要注意,sm4_rk占 128 字节,如果你的芯片xdata也很紧张,就得考虑「边算边用」的流式密钥扩展——但那样每轮都要重算,速度会掉一半,属于用时间换空间的取舍,源码默认没这么做。
2.2 轮函数与S盒:查表还是算?
SM4 的 S 盒是固定的 256 字节常量表。8051 的代码空间(code区)通常有 4KB 到 64KB,放一张 256 字节的表毫无压力,所以源码选择查表,而不是像某些极限压缩实现那样用数学公式现算 S 盒——现算虽然省 256 字节 Flash,但每轮多出几十条指令,32 轮下来慢得没法看。这是典型的「Flash 换速度」,在 8051 上几乎总是划算的。
/* S 盒放 code 区,不占 RAM */ code unsigned char sm4_sbox[256] = { 0xd6,0x90,0xe9,0xfe,0xcc,0xe1,0x3d,0xb7, /* 前 8 项示意,完整表见源码 */ /* ... 共 256 项 ... */ }; /* 单轮:τ 变换 + L 线性变换 */ static void sm4_round(unsigned char *x, unsigned char *rk) { unsigned char t[4]; unsigned int b; /* τ:4 字节并行过 S 盒 */ t[0] = sm4_sbox[x[0] ^ rk[0]]; t[1] = sm4_sbox[x[1] ^ rk[1]]; t[2] = sm4_sbox[x[2] ^ rk[2]]; t[3] = sm4_sbox[x[3] ^ rk[3]]; /* L:线性变换,含循环左移 */ b = ((unsigned int)t[0] << 24) | ((unsigned int)t[1] << 16) | ((unsigned int)t[2] << 8) | (unsigned int)t[3]; b ^= ROL32(b, 2) ^ ROL32(b, 10) ^ ROL32(b, 18) ^ ROL32(b, 24); x[0] = (unsigned char)(b >> 24); x[1] = (unsigned char)(b >> 16); x[2] = (unsigned char)(b >> 8); x[3] = (unsigned char)b; }这里有个 8051 特有的坑:unsigned int在 Keil C51 里是 16 位,不是 32 位。上面代码为了可读性用了 32 位拼接,实际源码里是用 4 个字节分别做移位异或,避免依赖 32 位整型——因为 C51 的 32 位运算要靠库函数模拟,一次ROL32可能编译出上百字节的调用。参数说明:x指向当前 4 字节状态,rk指向本轮 4 字节轮密钥,函数原地修改x。如果你把ROL32换成手写的字节级移位,代码体积能再省一截,但可读性会下降,建议先跑通再优化。
2.3 密钥扩展:一次算好还是每轮现算
密钥扩展用同样的轮函数结构,但线性变换换成 L'(移位参数不同)。源码在sm4_setkey里一次性把 32 组轮密钥算进sm4_rk,之后加解密只读不算。这个选择的理由是:加解密往往要处理多个分组(比如一帧数据 64 字节就是 4 个分组),密钥只扩展一次,摊到每个分组上的成本就低了。如果你的场景是「一次只加解密 16 字节就完事」,那流式扩展反而省 RAM,但绝大多数通信场景都是连续分组,所以默认一次性扩展是对的。
3. ECB与CBC两种模式的C51实现:接口怎么接、数据怎么喂
3.1 ECB模式的接口与调用顺序
ECB 是最简单的模式:每个分组独立加解密,相同明文块得到相同密文块。源码里 ECB 接口就两个函数,加解密各一个,输入输出都是 16 字节对齐的缓冲区。
/* ECB 加密:in/out 均指向 16 字节分组,可原地操作 */ void sm4_ecb_encrypt(unsigned char *in, unsigned char *out) { unsigned char i; unsigned char x[16]; for (i = 0; i < 16; i++) x[i] = in[i]; /* 拷进本地分组 */ for (i = 0; i < 32; i++) /* 32 轮 */ sm4_round(&x[(i & 3) * 4], sm4_rk[i]); /* 注意:SM4 最后一轮后要反序输出 */ for (i = 0; i < 16; i++) out[i] = x[15 - i]; }逻辑说明:先把输入拷进本地x,避免原地操作时读写冲突;32 轮里用(i & 3) * 4轮转四个 4 字节字;最后反序是 SM4 规范要求(加密最后一轮不做线性变换且字序反转)。参数上,in和out可以指向同一块内存,源码做了拷贝所以安全。调用顺序是:先sm4_setkey(key)扩展密钥,再对每个 16 字节分组调sm4_ecb_encrypt。如果你的数据长度不是 16 的整数倍,得自己做填充,源码不管填充——这是很多新手翻车的地方,后面避坑章细说。
3.2 CBC模式的链式异或与IV管理
CBC 比 ECB 多一个初始化向量 IV 和分组间的链式异或:每个明文块先和上一个密文块异或再加密,第一个块和 IV 异或。这样相同明文块在不同位置得到不同密文,安全性比 ECB 好,代价是不能并行、必须顺序处理。
/* CBC 加密:iv 为 16 字节初始向量,函数内会被更新为末块密文 */ void sm4_cbc_encrypt(unsigned char *in, unsigned char *out, unsigned int len, unsigned char *iv) { unsigned int off; unsigned char i; for (off = 0; off < len; off += 16) { for (i = 0; i < 16; i++) in[off + i] ^= iv[i]; /* 与上一密文块/IV 异或 */ sm4_ecb_encrypt(&in[off], &out[off]); for (i = 0; i < 16; i++) iv[i] = out[off + i]; /* 更新 IV 为当前密文块 */ } }逻辑说明:len必须是 16 的整数倍,函数按块推进;每块先异或iv再走 ECB 单块加密;加密完把iv更新成当前密文块,供下一块用。参数上,iv是输入输出参数,调用前填初始向量,调用后变成末块密文——如果你要连续加密多段数据且希望它们独立,每段前必须重置 IV,否则第二段会接着第一段的链,解密端对不上。这是 CBC 最容易被忽略的细节,源码把 IV 做成外部传入而不是内部静态,就是为了让调用者能控制这个重置时机。
3.3 解密路径与轮密钥反序
解密不是把加密反过来跑那么简单:SM4 解密用同一套轮函数,但轮密钥要反序使用(第 32 组先用于第 1 轮)。源码里解密函数在循环里用sm4_rk[31 - i]取反序密钥,其余结构和加密一致。CBC 解密则是先解密再异或,且异或用的是「上一块密文」,所以解密时要先把当前密文块存下来,解密后再用它更新 IV。
void sm4_cbc_decrypt(unsigned char *in, unsigned char *out, unsigned int len, unsigned char *iv) { unsigned int off; unsigned char i, next_iv[16]; for (off = 0; off < len; off += 16) { for (i = 0; i < 16; i++) next_iv[i] = in[off + i]; /* 暂存本块密文 */ sm4_ecb_decrypt(&in[off], &out[off]); for (i = 0; i < 16; i++) out[off + i] ^= iv[i]; for (i = 0; i < 16; i++) iv[i] = next_iv[i]; /* 用本块密文更新 */ } }这段的关键是next_iv这个临时缓冲:解密前先把密文块存下来,因为解密会覆盖in(如果原地操作),而更新 IV 又需要原始密文。参数含义和加密一致,len必须 16 对齐。到这里两种模式的四个函数就齐了,接进工程时按「setkey → 逐块调用」的顺序走即可。
4. 接进Keil C51工程:编译选项、内存模型与实测数据
4.1 内存模型选择与编译选项
Keil C51 有三种内存模型:SMALL(默认变量放data)、COMPACT(放pdata)、LARGE(放xdata)。这份源码的轮密钥表显式声明了xdata,所以用 SMALL 模型编译最合适——热点变量在data,大表在xdata,各得其所。如果你图省事用 LARGE 模型,所有变量都跑xdata,速度会明显下降。
编译选项上,优化等级建议-O2或-O3,但要注意-O3有时会把查表优化成奇怪的间接寻址,反而变慢,得实测。另外务必打开「Generate Assembler SRC File」看一眼生成的汇编,确认 S 盒确实落在code区而不是被拷到 RAM——这是判断实现是否合格的最快方法。
# Keil C51 命令行编译示例(路径按实际改) C51 SM4.C OPTIMIZE(2) MODEL(SMALL) DEBUG BL51 SM4.OBJ, STARTUP.OBJ TO SM4命令含义:C51编译源文件,OPTIMIZE(2)设优化等级,MODEL(SMALL)指定内存模型;BL51链接。参数上,如果你的工程已有启动文件,链接时把自己的STARTUP.OBJ换进去即可。编译完看.M51映射文件,确认sm4_rk在XDATA段、sm4_sbox在CODE段,就说明存储布局符合预期。
4.2 资源占用与吞吐实测
在一颗 12MHz 晶振的经典 8051 上实测(源码默认配置,-O2),大致数据如下表。这些数字会随芯片、晶振、优化等级浮动,但量级可以参考。
| 项目 | 数值 | 说明 |
|---|---|---|
| 代码空间占用 | 约 1.6KB | 含 S 盒 256 字节 |
| 轮密钥表 RAM | 128 字节 | 在xdata |
| 分组缓冲 RAM | 16 字节 | 在data |
| 单分组加密耗时 | 约 3.5ms | 12MHz,32 轮 |
| 单分组解密耗时 | 约 3.6ms | 反序取密钥略慢 |
3.5ms 一个分组意味着每秒约 280 个分组、约 4.5KB/s 的吞吐。对低速串口通信、传感器数据上报这类场景够用;如果你要跑高速链路,这颗芯片本身就不是合适的选择,别指望软件优化能救回来。参数上,晶振翻倍耗时大致减半,但xdata访问的MOVX周期是硬开销,优化空间有限。
提示:实测前先把
sm4_rk的存储区宏改成idata再测一遍,对比两者耗时差。如果差距很小,说明瓶颈在轮函数计算而非存储访问,优化方向就该转向轮函数而不是换存储区。
5. 避坑与排查:五个让SM4在8051上跑不对的典型问题
5.1 现象:解密出来全是乱码,加密结果和在线工具对不上
原因:最常见的是字节序搞错。SM4 规范里 128 位状态按大端解释,而 8051 是小端机,如果你直接把 4 字节unsigned long强转过去,字序就反了。另一个可能是最后一轮的反序输出漏了。
解决:先用一组标准测试向量验证——密钥和明文都取全0x01到0x10之类的固定值,加密结果和公开测试向量逐字节比对。对不上就检查sm4_ecb_encrypt末尾那个out[i] = x[15 - i]有没有写,以及轮函数里 32 位拼接的移位方向。
5.2 现象:编译报DATA SPACE MEMORY OVERFLOW或运行随机死机
原因:data区被塞爆了。8051 的data只有 128 字节(含寄存器组和位寻址区),轮密钥表如果误声明成data,128 字节直接占满,栈都没地方放,运行时栈溢出就表现为随机死机。
解决:确认sm4_rk声明在xdata或idata,看.M51映射文件的段分配。如果xdata也不够,改用流式密钥扩展,牺牲速度换空间。
5.3 现象:CBC 解密第一段对、第二段开始错位
原因:IV 没有在每段数据前重置。CBC 的 IV 是链式的,上一段的末块密文会变成下一段的 IV,如果两段数据本应独立加密却共用了一个 IV 变量,第二段解密必然错。
解决:每段数据加解密前显式重置 IV。源码把 IV 做成外部传入就是为此,调用方要自己管好这个状态,别图省事用全局静态 IV。
5.4 现象:数据长度不是 16 的倍数时,末尾几个字节解密出错
原因:SM4 是分组密码,只处理 16 字节整数倍。源码不做填充,长度不齐时最后不满一块的数据要么被丢弃要么越界读。
解决:在调用层做填充,常见做法是 PKCS#7——缺 n 字节就补 n 个值为 n 的字节,解密后按末字节值去掉。填充逻辑别塞进 SM4 函数里,保持密码原语和填充分离,换模式时不用改底层。
5.5 现象:加密速度比预期慢好几倍
原因:多半是unsigned long或 32 位运算用多了。C51 的 32 位乘除和移位都是库函数调用,一次可能几百个周期。另一个可能是 S 盒被拷到了 RAM 而不是留在code区。
解决:把轮函数里的 32 位操作改成字节级移位异或;检查.M51确认 S 盒在CODE段;优化等级调到-O2后实测对比,别盲目上-O3。
6. 进阶:用测试向量自检与轮函数字节级优化
拿到任何一份密码实现,我的习惯是先跑测试向量再谈集成,这一步能省掉后面大量「到底是算法错还是我的数据错」的扯皮。SM4 的标准测试向量是公开的,密钥0123456789abcdeffedcba9876543210,明文同值,加密结果应为681edf34d206965e86b3e94f536e4246。把它写成一个自检函数,上电时跑一遍,对不上就点灯报警,比在通信链路上抓瞎强得多。
/* 上电自检:返回 0 表示通过 */ unsigned char sm4_selftest(void) { unsigned char key[16] = {0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef, 0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10}; unsigned char pt[16] = {0x01,0x23,0x45,0x67,0x89,0xab,0xcd,0xef, 0xfe,0xdc,0xba,0x98,0x76,0x54,0x32,0x10}; unsigned char ct[16], expect[16] = {0x68,0x1e,0xdf,0x34,0xd2,0x06,0x96,0x5e, 0x86,0xb3,0xe9,0x4f,0x53,0x6e,0x42,0x46}; unsigned char i; sm4_setkey(key); sm4_ecb_encrypt(pt, ct); for (i = 0; i < 16; i++) if (ct[i] != expect[i]) return 1; /* 不匹配 */ return 0; }这段自检的价值在于把「算法正确性」和「业务数据正确性」解耦。参数上,测试向量是固定的,别改;返回值 0/1 直接驱动一个 LED 或写进状态寄存器,产线老化测试时能一眼看出问题。
再往深一层是轮函数的字节级优化。前面提过 C51 的 32 位运算慢,把ROL32展开成字节移位能省不少周期。以循环左移 2 位为例,可以写成先取高 2 位再整体左移 2 位再拼回去,全程用unsigned char操作,避免任何 32 位中间变量。这类优化一次只改一个函数,改完立刻跑自检向量,对得上再继续——我见过有人一口气把轮函数重写完,结果向量对不上,回头逐行查花了大半天,血泪经验就是小步改、每步验。
还有一个容易被忽略的点:如果你要在中断里调用 SM4(比如串口收到数据即时解密),务必确认轮密钥表不会被主循环的sm4_setkey改写。常见做法是加一个「密钥已就绪」标志,或者干脆在初始化阶段把密钥扩展好、运行期只读。中断和主循环共享sm4_rk却不做保护,是嵌入式里很隐蔽的一类翻车,现象是偶发解密错误,复现极难。
从那以后我每次把这类密码实现接进工程,都强制先跑一遍自检向量、再看一眼.M51的段分配、最后才碰业务数据,这三步走完基本不会在算法层面翻车。希望帮到你。
本文还有配套的精品资源,点击获取