简介面向8051等8位单片机开发者的国密SM4算法C语言实现源码聚焦资源受限嵌入式环境下的对称加密落地需求。包内共2个文件以c源文件与h头文件为主分别承载SM4核心加解密逻辑与类型定义、接口声明压缩包整体约3KB体量轻巧便于直接嵌入现有工程。源码同时支持ECB与CBC两种工作模式适合需要在单片机侧完成数据加密、身份认证或安全通信的开发者参考移植。已有1370人学习下载具备一定的实践参考基础。读者可从中获得可编译的SM4基础实现、分组加解密流程与模式调用方式并据此理解国密算法在8位平台上的资源占用特点与移植要点为后续裁剪优化与工程集成提供起点。1. 从一块只有 256 字节 RAM 的单片机说起去年帮一个做工业采集终端的朋友收拾烂摊子他们的板子上挂着一颗 8051 内核的 MCURAM 只有 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 容易把栈挤爆。轮密钥数组也是xdata128 字节。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 默认支持。如果用的是 SDCCcode要换成__codexdata换成__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 链式两个分组相同明文密文不同边界长度len16/32/48无越界、结果正确内存占用看 map 文件SBOX 在 CODErk 在 XDATA还有一个容易忽略的点密钥管理。源码只负责算法密钥怎么存、怎么下发是另一回事。8051 项目里常见做法是把密钥放 Flash 的受保护区域或者每次上电由上位机下发不要硬编码在源码里否则固件一泄露密钥就没了。如果板子上有唯一 ID可以用 ID 派生密钥增加一点提取难度。最后说个我自己的教训。早些年做第一个 8051 加密项目时测试向量过了就直接量产结果现场反馈偶发解密失败。查了两天才发现是 CBC 的chain缓冲放在了 idata中断里调用了另一个函数把栈压深把chain覆盖了。从那以后我每次在 8 位机上做加密都强制把链式缓冲和轮密钥放xdata并且在 map 文件里逐个确认段归属再也不敢凭感觉。希望这份拆解能帮你在自己的板子上少走点弯路源码拿过去先跑标准向量再按上面的清单过一遍基本就能稳稳接进项目里。本文还有配套的精品资源点击获取