资讯详情

SM4国密算法在8051单片机上的移植实践:ECB/CBC模式实现与优化

📅 2026/9/9 21:06:53 | 华诺云谱 👁 阅读
SM4国密算法在8051单片机上的移植实践:ECB/CBC模式实现与优化
简介面向8051等8位单片机开发者这份源码提供国密SM4算法的完整C51实现支持ECB与CBC两种常用分组模式适用于物联网终端、嵌入式控制器的数据加密传输与存储场景也能帮助初学者理解国密算法在资源受限平台上的落地方式。整个资源包共2个文件包含1个c源文件和1个h头文件c文件封装密钥扩展与加解密核心逻辑h文件提供接口声明和必要的数据类型定义压缩包仅3KB可直接加入Keil或SDCC工程使用。目前已有1364人学习/下载足见其在51单片机安全开发中的参考价值。借助这份代码读者既能直接调用接口完成SM4加解密又能对照源码梳理轮函数、密钥调度及ECB/CBC模式异或细节作为国产加密算法在低资源MCU上移植与优化的样板。 最近在一个安全认证的小项目里我把国密SM4算法完整移植到了8051上用Keil C51编写支持ECB和CBC两种分组模式配套源码已经跑通。折腾完最大的感受是网上讲SM4原理的文章不少但能直接在8051上编译运行、代码风格又贴合C51习惯的完整实现真不多。这篇文章把我踩过的坑、做过的取舍、实测的数据一次说清楚给需要在8位单片机上落地国密算法的朋友做个参考。1. 先把账算明白8051跑SM4到底卡不卡很多人一听8051就摇头觉得这是上个世纪的老古董跑不了现代密码算法。但SM4这个算法恰恰是为低端环境准备的。它的分组长度128位、密钥长度128位一共32轮迭代核心操作就三件事S盒查表、32位异或、32位循环左移。S盒是8位输入8位输出天生适配8位CPU32位运算在8051上无非是拆成多个字节操作指令多几条而已但绝对没有到跑不动的程度。真正需要担心的是内存不是算力。一个SM4上下文最少要保存32个32位轮密钥也就是128字节。加解密过程中16字节的分组状态数组加几个临时变量又是二三十字节。而8051经典的内部data区只有128字节增强型虽然有256字节但高128字节往往要用idata访问操作起来也没data区那么顺手。所以移植SM4到8051第一原则是能放code区的都放code区能放xdata的放xdata千万别把大表塞进data。还有个容易被忽略的问题轮密钥是现场现算还是提前算好如果每加密一个分组都重新执行一次密钥扩展那相当于每个分组多算32轮T变换性能接近翻倍。我的做法是在setkey阶段一次性算出32个轮密钥存进ctx结构体加解密时只查数组。对于8051这种主频不到几十MHz的芯片这个设计决策比很多所谓“算法优化技巧”都重要。我把资源账列成了一张表资源项SM4实际需求典型8051配置内部data RAM状态16B 临时变量若干128B省着点用轮密钥存储128B建议放xdata或idataS盒 / 常量表256B 128B 16B放code区ROM计算能力32轮 × 字节级操作12MHz下约1MIPS结论很明确算力不是瓶颈内存规划才是。理解了这一点后面的代码设计就有了总纲。2. SM4算法骨架移植前必须决定的三件事2.1 算法结构一句话拆解SM4加密就是把16字节明文分成4个32位字X0、X1、X2、X3然后做32轮迭代每一轮的公式是X[i4] X[i] ^ T(X[i1] ^ X[i2] ^ X[i3] ^ rk[i])这里的T变换分两步先对32位中间结果按字节查S盒得到另一个32位数再做线性变换L也就是把上一步结果分别循环左移2、10、18、24位后和自己异或。32轮跑完后把X3、X2、X1、X0反序拼接输出就是密文。解密不需要单独写一套轮函数只要把加密轮密钥逆序使用同一个加密逻辑就是解密逻辑。这是SM4设计上很舒服的一点。2.2 字节序不要依赖任何CPUC51里的unsigned long在内存中到底怎么排列不同编译器、不同配置可能都不一样标准里也没规定。但SM4标准向量里的密钥、明文、密文都是按字节给的所以我统一用大端方式把4个字节拼成u32static u32 load_be(const u8 *p) { return ((u32)p[0] 24) | ((u32)p[1] 16) | ((u32)p[2] 8) | p[3]; } static void store_be(u8 *p, u32 v) { p[0] (u8)(v 24); p[1] (u8)(v 16); p[2] (u8)(v 8); p[3] (u8)v; }这样写出来的代码拿到PC的GCC或者Keil C51上结果完全一致不会出现“在电脑上跑得好好的烧到单片机上密文就不对”这种玄学问题。2.3 表放code、轮密钥放xdataS盒有256字节CK常量表32×4128字节FK表4×416字节这些全部声明成code数组让编译器把它们放到ROM里。Keil C51的code关键字写在类型后面比如u8 code SBOX[256]别写反了。轮密钥的存储要稍微讲究一点。如果在small内存模型下把sm4_ctx_t定义成局部变量128字节的数组会直接压爆data区。我把ctx定义在xdata这是8051外部RAM或扩展RAM区域空间大无非是访问速度比data慢一些。SM4不是每微秒都在跑的算法这点速度损失完全可以接受。2.4 32位循环左移的写法C51没有32位循环左移指令我用宏#define ROTL(x, n) (((x) (n)) | ((x) (32 - (n))))注意n必须是编译期常量。SM4用到的循环左移位数为2、10、18、24、13、23全是固定值编译器能把这种宏展开成字节移位指令效率尚可。如果n是变量C51会生成一长串动态移位代码性能明显下降热循环里要避免。3. ECB模式源码拆解轮函数、密钥扩展与标准向量验证3.1 接口与数据结构头文件我定义成最简形式不依赖任何库和平台类型/* sm4.h */ #ifndef SM4_H #define SM4_H typedef unsigned char u8; typedef unsigned long u32; typedef struct { u32 rk[32]; } sm4_ctx_t; void sm4_setkey_enc(sm4_ctx_t *ctx, const u8 key[16]); void sm4_setkey_dec(sm4_ctx_t *ctx, const u8 key[16]); void sm4_encrypt_block(sm4_ctx_t *ctx, const u8 in[16], u8 out[16]); #endif解密入口我直接复用加密函数所以接口里只留了encrypt_block。3.2 轮函数和密钥扩展关键代码S盒是国标SM4的标准内容256字节我这里就不全部贴出来了源码包里是完整的。CK表也可以用标准公式生成每个字节满足CK[i][j] (4*i j) * 7 % 256既可以直接查表也可以现场算。核心代码是这样static const u32 code FK[4] { 0xa3b1bac6UL, 0x56aa3350UL, 0x677d9197UL, 0xb27022dcUL }; static u32 tau(u32 a) { u8 b0 SBOX[(a 24) 0xFF]; u8 b1 SBOX[(a 16) 0xFF]; u8 b2 SBOX[(a 8) 0xFF]; u8 b3 SBOX[a 0xFF]; return ((u32)b0 24) | ((u32)b1 16) | ((u32)b2 8) | b3; } static u32 L(u32 b) { return b ^ ROTL(b, 2) ^ ROTL(b, 10) ^ ROTL(b, 18) ^ ROTL(b, 24); } static u32 Lp(u32 b) { return b ^ ROTL(b, 13) ^ ROTL(b, 23); } void sm4_setkey_enc(sm4_ctx_t *ctx, const u8 key[16]) { u32 k[4], t; int i; k[0] load_be(key) ^ FK[0]; k[1] load_be(key 4) ^ FK[1]; k[2] load_be(key 8) ^ FK[2]; k[3] load_be(key 12) ^ FK[3]; for (i 0; i 32; i) { t k[1] ^ k[2] ^ k[3] ^ ((u32)CK[i][0] 24) ^ ((u32)CK[i][1] 16) ^ ((u32)CK[i][2] 8) ^ CK[i][3]; ctx-rk[i] k[0] ^ Lp(tau(t)); k[0] k[1]; k[1] k[2]; k[2] k[3]; k[3] ctx-rk[i]; } } void sm4_setkey_dec(sm4_ctx_t *ctx, const u8 key[16]) { sm4_ctx_t enc; int i; sm4_setkey_enc(enc, key); for (i 0; i 32; i) { ctx-rk[i] enc.rk[31 - i]; } } void sm4_encrypt_block(sm4_ctx_t *ctx, const u8 in[16], u8 out[16]) { u32 x[4], t, nx; int i; x[0] load_be(in); x[1] load_be(in 4); x[2] load_be(in 8); x[3] load_be(in 12); for (i 0; i 32; i) { t x[1] ^ x[2] ^ x[3] ^ ctx-rk[i]; nx x[0] ^ L(tau(t)); x[0] x[1]; x[1] x[2]; x[2] x[3]; x[3] nx; } store_be(out, x[3]); store_be(out 4, x[2]); store_be(out 8, x[1]); store_be(out 12, x[0]); }这里解释一下窗口移位的逻辑x[0]到x[3]始终保存最近的4个状态值。每一轮用x[1]、x[2]、x[3]算中间量和轮密钥异或后进T变换最后和x[0]异或产生新的状态值然后整体平移。这个写法比用X[36]大数组节省了大量内存在8051上非常有意义。3.3 验证方法与调用示例移植完第一件事用国标标准向量验证密钥01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10明文01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10ECB加密结果68 1E DF 34 D2 06 96 5E 86 B3 E9 4F 53 6E 42 46这几个十六进制数必须完全对上对不上就先查S盒表再查字节序最后查轮密钥顺序。调用方式很直接sm4_ctx_t xdata ctx; u8 xdata key[16] {0x01,0x23,0x45,0x67,0x89,0xAB,0xCD,0xEF, 0xFE,0xDC,0xBA,0x98,0x76,0x54,0x32,0x10}; u8 xdata in[16] {0x01,0x23,0x45,0x67,0x89,0xAB,0xCD,0xEF, 0xFE,0xDC,0xBA,0x98,0x76,0x54,0x32,0x10}; u8 xdata out[16] {0}; sm4_setkey_enc(ctx, key); sm4_encrypt_block(ctx, in, out);注意ctx放到了xdata。如果Keil工程是small模型而函数参数写的是sm4_ctx_t *ctx默认会被当成data指针访问xdata就会错乱。这时候要么把存储模型改成Large要么把参数明确写成sm4_ctx_t xdata *ctx。这个坑我后面还会重点讲。4. CBC模式源码IV链、PKCS7填充与C51指针陷阱4.1 CBC加解密流程CBC的逻辑大家都熟。加密时当前明文分组先和上一个密文分组异或再进SM4加密解密时当前密文分组先解密再和上一个密文分组异或得到明文。第一个分组用的是外部传入的IV。直接上代码void sm4_cbc_encrypt(sm4_ctx_t *ctx, const u8 iv[16], const u8 *in, u8 *out, u16 len) { u8 chain[16]; u16 i; u8 j; for (j 0; j 16; j) chain[j] iv[j]; for (i 0; i len; i 16) { for (j 0; j 16; j) { chain[j] ^ in[i j]; } sm4_encrypt_block(ctx, chain, out i); for (j 0; j 16; j) { chain[j] out[i j]; } } } void sm4_cbc_decrypt(sm4_ctx_t *ctx, const u8 iv[16], const u8 *in, u8 *out, u16 len) { u8 chain[16], next[16]; u16 i; u8 j; for (j 0; j 16; j) chain[j] iv[j]; for (i 0; i len; i 16) { for (j 0; j 16; j) next[j] in[i j]; sm4_decrypt_block(ctx, in i, out i); for (j 0; j 16; j) { out[i j] ^ chain[j]; chain[j] next[j]; } } }解密函数里那个next数组不少人会省掉觉得先复制in[i]到next再解到out多此一举。其实这是必须的解密调用结束后in[i]对应的密文分组已经被覆盖成了解密后的明文中间结果如果这时候再用in[i]去更新chain下一个分组的异或链就彻底错了。我第一版就吃过这个亏解密出来从第二段开始全是乱的。4.2 PKCS7填充与长度边界SM4分组是16字节实际业务数据基本不太可能刚好是16的倍数所以要做填充。最常用的是PKCS7差n个字节满16就补n个0x0n。代码很简单u8 pad 16 - (len % 16); for (j 0; j pad; j) { buf[len j] pad; }这里有个隐藏细节如果len恰好是16的倍数pad会算出16也就是说要多补一个完整分组。这不是浪费而是标准要求。否则接收方遇到明文本来就以0x01结尾的情况会分不清这个0x01是数据还是填充。解密后的去填充操作要加边界判断防止越界读取u8 pad out[len - 1]; if (pad 1 pad 16 len pad) { len - pad; }4.3 C51指针存储类型一个特别容易翻车的点CBC函数参数里的const u8 *in在很多C51工程里会出问题。原因是Keil C51的普通指针默认指向哪个存储空间取决于编译器的内存模型。small模型下普通指针是data指针只能访问内部data/idata区而你实际传进来的很可能是一个xdata大数组。结果就是指针乱指取出来的字节完全不是预期值还特别难排查。解决办法有三条工程存储模型直接选Large普通指针默认就是xdata指针省心。函数参数显式写成const u8 xdata *in不管存储模型是什么都不会错。严格统一数据所在区域。如果明文、密文、IV全在xdata那指针全部显式加xdata如果有些在idata、有些在code还得分开处理。我的建议是第二种显式声明。C51这种玄学问题越是靠“默认”越容易埋雷不如把存储区域写到面子上后面接手的同事也不会骂人。5. Keil C51编译实测优化选项、内存占用与性能5.1 工程配置与优化等级Keil C51的优化等级默认是Level 3跑SM4有点浪费我打开到Level 8Favor speed并勾选Global Register Coloring编译后速度明显提升。内存模型我直接选Large因为工程里大数据本来就要放xdata省得每个指针都写存储类型。芯片型号我用的STC8系列Keil里选Generic 8051也能编。需要注意老版本C51可能不支持stdint.h所以我在这篇文章的代码里用的是自定义的typedef就是为了兼容性。编译后的资源占用大致如下项目占用ROMS盒256B CK表128B FK表16B 代码约2~3KB合计约3.5KBxdatactx结构体128B 加解密缓冲区data约20~40B临时变量和返回地址这个体量对8051来说非常友好普通几十KB Flash的型号随便装。5.2 实测性能与瓶颈在12MHz标准805112时钟周期下ECB加密一个16字节分组大约5~10ms。这个数据受编译器优化等级、S盒访问方式、L变换是否展开影响很大。如果你用的是STC8这种1T内核同样代码能快8~12倍一个分组能压到1ms以内。如果对性能还不满意可以按字节展开L变换。SM4的线性变换L本质上是多个循环左移的异或在8位机上完全能写成直接查表移位拼字节的操作比32位宏生成的代码更快代价是源码可读性变差。我测下来展开后大概能再快30%~50%。不过对大多数应用场景比如终端认证、密钥协商、小包数据加密5~10ms完全够用。真要拿8051去持续加密大流量数据那是选型问题不是算法问题。5.3 重入问题与调试经验默认情况下C51的函数是不可重入的。如果主程序里正在跑SM4加密中断突然来一下也要调用SM4函数会直接把正在计算的轮密钥状态改掉回来就全错了。最简单的应对是中断里不碰SM4只置标志位真正计算放主循环。如果实在要在中断里用要么给函数加reentrant关键字配置模拟栈要么每个调用方各维护一份自己的ctx互不干扰。最后分享一个调试技巧先把sm4.c拿到PC上用GCC编译跑通标准向量再移植到Keil C51。这样可以把“算法写错”和“C51编译/内存地址错”两类问题彻底分开。真要在C51里定位时Keil调试器的Memory窗口比printf好用得多把rk数组和密文地址填进去一眼就能看出字节序和值对不对。我自己做完这个项目的体会是SM4在8051上跑通不难难的是内存布局和C51指针存储类型的细节。网上很多现成实现是照着PC内存模型写的拿到C51里编不过或者编过了跑飞。这篇文章里的源码全部用静态数组和显式拷贝牺牲了一点点性能换来的是稳定和容易移植。卡在C51存储模型上的朋友按第4节说的把指针类型显式写清楚大概率就能救回来。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。