news 2026/10/26 19:46:36

8051单片机国密SM4 C51源码包:ECB/CBC模式实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
8051单片机国密SM4 C51源码包:ECB/CBC模式实现与避坑指南

简介:面向8051等8位单片机开发者的国密SM4算法C语言实现源码,聚焦在资源受限的嵌入式平台上落地国密加密能力,适合已具备C51基础、需要在单片机项目中集成SM4加解密的中级开发者参考。压缩包共2个文件,以1个c源文件与1个h头文件为主,前者承载SM4核心算法与ECB、CBC两种工作模式的实现,后者提供类型定义与接口声明,整体仅约3KB,体量轻巧便于直接嵌入工程。资源同时覆盖ECB与CBC两种分组模式,读者可据此理解分组密码在8位平台上的轮函数、密钥扩展与数据填充处理思路,并对照CBC的初始向量管理方式完成模式切换与移植适配。目前已有1370人学习下载,可作为单片机国密改造、嵌入式安全通信与算法移植练习的参考素材。

1. 一块 8051 上跑国密:这个 SM4 C51 源码包到底能干什么

去年帮一个做工业采集器的团队收拾烂摊子,他们的板子上挂着一颗 8051 内核的单片机,外接一颗 LoRa 模组往网关回传数据。协议里要求对载荷做国密 SM4 加密,结果原厂给的示例是 PC 端的 C 代码,直接丢进 Keil C51 编译,一跑就复位,串口吐出来的密文和 PC 端对不上。后来翻出这份 SM4 的 C51 源码包,把 ECB 和 CBC 两种模式都跑通,才算把这条链路救回来。这份资源就是干这个的:一套面向 8051 平台、用 C51 写的 SM4 分组密码实现,覆盖 ECB 与 CBC 两种工作模式,适合做嵌入式加密通信、终端数据保护、协议对接这类活。它不依赖操作系统,也不吃大内存,是那种能直接塞进小容量 Flash 里跑的实现。如果你手上正好有 8051 的板子要做国密对接,或者想搞清楚分组密码在资源受限环境里怎么落地,这份源码值得拆开看。

2. 技术选型与目录结构:为什么是 C51 而不是移植 OpenSSL

2.1 8051 的资源账本决定了实现方式

8051 这类内核的约束很硬:内部 RAM 通常只有 128 到 256 字节,程序空间从几 KB 到 64 KB 不等,主频常见 11.0592 MHz 或 12 MHz,没有硬件乘法器(部分增强型有),更没有 32 位寄存器。SM4 的轮函数里要做 32 位循环左移、S 盒查表、线性变换 L,如果按 PC 端写法用uint32_t数组和指针随意操作,编译器生成的代码会疯狂占用 data 区,栈一深就溢出。

所以这份源码的选型逻辑很清晰:不用标准库的大整数,不动态分配内存,密钥扩展和加解密全部用固定大小的unsigned char数组承载 32 位字,按大端拆成 4 字节存放。常见做法是把一个 32 位字拆成unsigned char x[4],移位和异或都在这 4 个字节上手工做,虽然代码啰嗦,但每一步的内存占用都是可预测的。这也是 C51 环境下写密码算法的血泪经验——你没法信任编译器的寄存器分配,只能自己把数据摆平。

2.2 目录结构长什么样

这类源码包一般不会太复杂,典型结构如下(不同版本命名略有差异,但职责划分基本一致):

sm4_c51/ ├── sm4.c # SM4 核心:S盒、轮函数、密钥扩展、加解密 ├── sm4.h # 对外接口声明、上下文结构体、模式枚举 ├── sm4_ecb.c # ECB 模式封装(按 16 字节分组循环调用) ├── sm4_cbc.c # CBC 模式封装(含 IV 管理与链式异或) ├── main.c # 示例:一组标准测试向量验证 └── readme.txt # 编译说明与测试向量

核心文件是sm4.c,它不关心你用什么模式,只提供“单分组加密/解密”和“密钥扩展”两类能力。ECB 和 CBC 是薄薄一层封装,把分组循环、IV 异或、填充策略这些外围逻辑隔离开。这种分层的好处是:你要换模式、加 CTR,只动封装层,核心算法一行不用碰。我一般会建议把sm4.c当成黑匣子,先跑通测试向量,再去改上层。

2.3 接口设计:上下文结构体是关键

一个合格的嵌入式密码实现,接口必须让调用方自己管内存。源码里通常定义这样一个上下文:

/* sm4.h —— 上下文与模式定义 */ typedef struct { unsigned char rk[32][4]; /* 轮密钥:32 轮,每轮 4 字节 */ unsigned char iv[16]; /* CBC 模式下的初始向量 */ } sm4_ctx; #define SM4_MODE_ECB 0 #define SM4_MODE_CBC 1 void sm4_setkey(sm4_ctx *ctx, const unsigned char key[16]); void sm4_encrypt_block(sm4_ctx *ctx, const unsigned char in[16], unsigned char out[16]); void sm4_decrypt_block(sm4_ctx *ctx, const unsigned char in[16], unsigned char out[16]);

rk占 128 字节,iv占 16 字节,一个上下文 144 字节。这个数字要记牢:8051 的 data 区放不下,得用xdata或者idata声明,具体看芯片型号。密钥扩展只做一次,之后加解密复用rk,这是省算力的关键——SM4 的密钥扩展本身就要做 32 轮,如果每次加密都重算,在 12 MHz 的板子上会明显拖慢吞吐。

3. 核心算法拆解:S 盒、轮函数与密钥扩展怎么在 C51 里落地

3.1 S 盒用常量表,别用算法生成

SM4 的 S 盒是一个 256 字节的固定置换表。在 PC 上你可以用算法动态生成,但在 8051 上,动态生成既费代码空间又费时间,正确做法是直接code常量表:

/* sm4.c —— S盒,放在 code 区,不占 RAM */ static const unsigned char SBOX[256] = { 0xd6,0x90,0xe9,0xfe,0xcc,0xe1,0x3d,0xb7,0x16,0xb6,0x14,0xc2,0x28,0xfb,0x2c,0x05, 0x2b,0x67,0x9a,0x76,0x2a,0xbe,0x04,0xc3,0xaa,0x44,0x13,0x26,0x49,0x86,0x06,0x99, /* ... 其余 224 字节省略,完整表见源码 ... */ };

code是 Keil C51 的关键字,表示把数据放到程序存储器(Flash)而不是 RAM。256 字节如果放 RAM,对很多 8051 型号是灾难性的。查表时用SBOX[x]即可,编译器会生成MOVC指令从 Flash 读,速度可以接受。注意:有些增强型 8051 支持xdata外扩 RAM,但查表走外部总线更慢,还是code区最稳。

3.2 32 位字的字节序处理

SM4 标准里,明文、密钥、轮密钥都按大端解释。C51 没有 32 位类型,所以源码里用 4 字节数组表示一个字,并且约定x[0]是最高字节。轮函数里的循环左移、异或都在这 4 个字节上手工展开。下面是一个典型的 32 位异或辅助函数:

/* sm4.c —— 4 字节异或:a = a ^ b */ static void xor32(unsigned char a[4], const unsigned char b[4]) { a[0] ^= b[0]; a[1] ^= b[1]; a[2] ^= b[2]; a[3] ^= b[3]; } /* 32 位循环左移 n 位(n 为 2/10/18/24,SM4 线性变换用) */ static void rotl32(unsigned char x[4], unsigned char n) { unsigned char t[4]; unsigned char i; for (i = 0; i < 4; i++) t[i] = x[(i + (n >> 3)) & 3]; /* 字节级移位,n 为 8 的倍数时简化 */ /* 非 8 倍数需再做位内移位,源码里按 n 的具体值展开 */ for (i = 0; i < 4; i++) x[i] = t[i]; }

这里要说明:SM4 线性变换 L 里的循环左移是 2、10、18、24 位,其中 24 位是 8 的倍数,可以直接字节搬移;2、10、18 不是,需要位内移位配合。源码通常针对这几个固定值写展开版本,避免通用移位函数的循环开销。参数n在调用点都是常量,编译器能进一步优化。这种“为常量写死”的做法在嵌入式密码实现里很常见,牺牲一点代码可读性换执行效率。

3.3 密钥扩展:32 轮,一次算好存起来

密钥扩展把 16 字节主密钥扩展成 32 个轮密钥。核心是:前 4 个轮密钥直接取主密钥的 4 个字,之后每轮用rk[i] = rk[i-4] ^ T'(rk[i-1] ^ rk[i-2] ^ rk[i-3] ^ CK[i]),其中T'是带线性变换的 S 盒变换,CK是固定参数。

/* sm4.c —— 密钥扩展 */ void sm4_setkey(sm4_ctx *ctx, const unsigned char key[16]) { unsigned char k[4][4]; unsigned char i, j; /* 主密钥按大端拆成 4 个字 */ for (i = 0; i < 4; i++) for (j = 0; j < 4; j++) k[i][j] = key[i * 4 + j]; for (i = 0; i < 32; i++) { unsigned char t[4]; /* t = k1 ^ k2 ^ k3 ^ CK[i] */ for (j = 0; j < 4; j++) t[j] = k[1][j] ^ k[2][j] ^ k[3][j] ^ CK[i][j]; /* T' 变换:S盒 + 线性变换 L' */ t[0] = SBOX[t[0]]; t[1] = SBOX[t[1]]; t[2] = SBOX[t[2]]; t[3] = SBOX[t[3]]; t[0] ^= t[1] ^ t[2] ^ t[3]; /* L' 的简化形式,具体按标准展开 */ /* rk[i] = k0 ^ t,并滚动 k */ for (j = 0; j < 4; j++) { ctx->rk[i][j] = k[0][j] ^ t[j]; k[0][j] = k[1][j]; k[1][j] = k[2][j]; k[2][j] = k[3][j]; k[3][j] = ctx->rk[i][j]; } } }

CK是 32 个 32 位固定常量,同样放code区。这段代码里k是 4×4 的滚动窗口,每轮把最老的k[0]挤出去。参数说明:key是 16 字节主密钥,ctx->rk输出 32×4 字节轮密钥。注意T'的线性变换和加密轮函数的L不同,前者是L'(B) = B ^ (B<<13) ^ (B<<23),后者是L(B) = B ^ (B<<2) ^ (B<<10) ^ (B<<18) ^ (B<<24),写代码时别抄错,这是最常见的翻车点。

3.4 单分组加解密:32 轮迭代

加密时把 16 字节明文拆成 4 个字X0..X3,做 32 轮:X4 = X0 ^ T(X1 ^ X2 ^ X3 ^ rk[i]),然后滚动。最后输出反序X35, X34, X33, X32。解密用同一套轮函数,只是轮密钥倒序使用。

/* sm4.c —— 单分组加密 */ void sm4_encrypt_block(sm4_ctx *ctx, const unsigned char in[16], unsigned char out[16]) { unsigned char x[4][4]; unsigned char i, j, t[4]; for (i = 0; i < 4; i++) for (j = 0; j < 4; j++) x[i][j] = in[i * 4 + j]; for (i = 0; i < 32; i++) { for (j = 0; j < 4; j++) t[j] = x[1][j] ^ x[2][j] ^ x[3][j] ^ ctx->rk[i][j]; t[0] = SBOX[t[0]]; t[1] = SBOX[t[1]]; t[2] = SBOX[t[2]]; t[3] = SBOX[t[3]]; /* 线性变换 L:t ^= rotl(t,2) ^ rotl(t,10) ^ rotl(t,18) ^ rotl(t,24) */ /* 源码里用展开的字节操作实现,此处略去细节 */ for (j = 0; j < 4; j++) { unsigned char tmp = x[0][j] ^ t[j]; x[0][j] = x[1][j]; x[1][j] = x[2][j]; x[2][j] = x[3][j]; x[3][j] = tmp; } } /* 反序输出 */ for (i = 0; i < 4; i++) for (j = 0; j < 4; j++) out[i * 4 + j] = x[3 - i][j]; }

解密函数结构完全一样,唯一区别是ctx->rk[31 - i]。这种“加解密共用轮函数”的设计是分组密码的通用套路,省一半代码空间。参数in和out都是 16 字节,允许原地操作(in == out)吗?源码里因为先拷到x再写out,所以原地是安全的,但调用方最好还是分开传,避免自己踩坑。

4. ECB 与 CBC 模式封装:分组循环、IV 管理和填充策略

4.1 ECB:最简单,但别用在多分组数据上

ECB 就是把数据按 16 字节切块,每块独立加密。封装层就是一个循环:

/* sm4_ecb.c —— ECB 加密,len 必须是 16 的倍数 */ void sm4_ecb_encrypt(sm4_ctx *ctx, const unsigned char *in, unsigned char *out, unsigned int len) { unsigned int i; for (i = 0; i < len; i += 16) sm4_encrypt_block(ctx, in + i, out + i); }

参数len是字节数,必须是 16 的整数倍,源码不做填充——填充策略交给调用方,因为嵌入式场景里数据长度往往是协议定死的。ECB 的问题是相同明文块产生相同密文块,如果数据有重复结构(比如固定格式的报文头),密文会泄露模式信息。所以它适合加密密钥、短随机数这类单块数据,不适合加密长报文。这一点在对接协议时要和对方确认清楚,别想当然。

4.2 CBC:链式异或,IV 必须随机且不可预测

CBC 的核心是每块明文先和上一块密文异或,再加密。第一块和 IV 异或。加密:

/* sm4_cbc.c —— CBC 加密 */ void sm4_cbc_encrypt(sm4_ctx *ctx, const unsigned char *in, unsigned char *out, unsigned int len) { unsigned char i, j; unsigned char blk[16]; for (i = 0; i < len; i += 16) { for (j = 0; j < 16; j++) blk[j] = in[i + j] ^ ctx->iv[j]; /* 与 IV 或上一块密文异或 */ sm4_encrypt_block(ctx, blk, out + i); for (j = 0; j < 16; j++) ctx->iv[j] = out[i + j]; /* 更新 IV 为当前密文块 */ } }

解密时顺序反过来:先解密,再和上一块密文异或,IV 更新用当前密文块(注意是密文,不是明文):

/* sm4_cbc.c —— CBC 解密 */ void sm4_cbc_decrypt(sm4_ctx *ctx, const unsigned char *in, unsigned char *out, unsigned int len) { unsigned char i, j; unsigned char blk[16], cipher[16]; for (i = 0; i < len; i += 16) { for (j = 0; j < 16; j++) cipher[j] = in[i + j]; sm4_decrypt_block(ctx, cipher, blk); for (j = 0; j < 16; j++) out[i + j] = blk[j] ^ ctx->iv[j]; for (j = 0; j < 16; j++) ctx->iv[j] = cipher[j]; /* 用密文更新 IV */ } }

这里有个容易翻车的细节:解密时更新 IV 必须用密文块,而且要在覆盖out之前保存密文。如果in和out是同一块缓冲区,顺序错了就会把密文冲掉。源码里用cipher临时数组规避了这个问题,代价是多 16 字节栈空间。8051 的栈很浅,如果调用层级深,这 16 字节可能成为压垮骆驼的最后一根稻草,必要时把cipher改成静态变量(但那样就不可重入了,单线程场景可以接受)。

4.3 填充与 IV 传递:协议层的事,别塞进算法层

SM4 是分组密码,明文长度不是 16 的倍数时要填充。常见做法是 PKCS#7:缺 n 字节就补 n 个值为 n 的字节。但这份源码把填充留给调用方,原因很实际——嵌入式协议里经常用固定长度报文,或者用长度字段而非填充来界定边界,强行内置填充反而碍事。IV 的传递也一样:CBC 的 IV 要随密文一起发给对方,通常放在报文头,接收方先取 IV 再解密。源码里ctx->iv是调用方初始化的,加密前memcpy进去,解密前用对方传来的 IV 覆盖。这两件事都属于协议设计,算法层不该越俎代庖。

5. 避坑与排查:8051 上跑 SM4 最容易翻车的五个地方

5.1 编译通过但一加密就复位

现象:Keil C51 编译无警告,下载后调用加密函数,单片机直接复位或跑飞。原因多半是栈溢出或data区耗尽。sm4_ctx有 144 字节,如果声明成局部变量放在data区,再加上函数内的临时数组,很容易超过 128 字节的默认data上限。解决:把sm4_ctx声明为全局或xdata,临时数组尽量复用,编译时打开LST文件看DATA和XDATA占用,留出至少 20% 余量。

5.2 密文和 PC 端对不上

现象:同样的密钥和明文,8051 算出的密文和 PC 端 OpenSSL 或在线工具不一致。原因通常是字节序或T'线性变换写错。先查密钥扩展:CK常量是不是按大端存放,T'的移位量是不是 13 和 23。再查轮函数:L的移位量是 2、10、18、24,别和T'混了。排查方法:用标准测试向量(密钥0123456789abcdeffedcba9876543210,明文同)单步跑,对比每一轮的中间值,第一轮就对不上说明密钥扩展错了,最后一轮才对不上说明输出反序错了。

5.3 CBC 解密结果只有第一块对

现象:CBC 解密后第一块明文正确,后面全乱。原因几乎都是 IV 更新用错了数据——解密时应该用当前密文块更新 IV,如果误用了明文块,第二块开始就全错。另一个可能是in和out共用缓冲区且更新顺序不对,密文被提前覆盖。解决:按 4.2 节的代码,先保存cipher,解密,异或,最后用cipher更新 IV。

5.4 加密速度慢到影响通信

现象:12 MHz 的板子上,加密 64 字节要几十毫秒,拖慢了整个采集周期。原因通常是轮函数里的移位用了通用循环,或者SBOX没放code区导致从 RAM 查表(有些型号 RAM 查表反而慢),再或者每次加密都重新sm4_setkey。解决:确认SBOX和CK都在code区;密钥扩展只做一次,ctx复用;移位按固定值展开,别写通用for循环。实测优化后 64 字节加密能压到几毫秒量级,具体看主频和编译器优化等级。

5.5 换芯片后代码行为异常

现象:在 A 型号 8051 上跑得好好的,换到 B 型号后密文错乱或死机。原因是不同厂商的 8051 在xdata访问时序、code区大小、中断向量布局上有差异,尤其是sm4_ctx如果放在xdata,某些型号的外部总线等待周期配置不对会读到错数据。解决:换芯片先跑测试向量,确认code区常量读取正常;检查xdata初始化;如果用了中断,确认加密函数不被中断打断(或者做好临界区保护),因为ctx是共享状态,中断里再调加密会踩内存。

6. 二次开发:把 SM4 接进你的协议栈并验证正确性

拿到这份源码,第一件事不是改代码,是跑通测试向量。main.c里通常带一组标准向量,编译下载后通过串口打印结果,和标准值逐字节比对。这一步过了,才说明工具链、字节序、内存布局都没问题。我一般会再加一组自己构造的向量:全零密钥、全零明文,以及长度刚好 16、32、48 字节的 CBC 数据,覆盖单块和多块场景。

接进协议栈时,建议把sm4.c和模式封装编译成静态库或独立目标文件,对外只暴露sm4_setkey、sm4_ecb_encrypt、sm4_cbc_encrypt这几个函数,SBOX和CK用static藏起来。这样别人改上层业务时不会误碰算法层。如果协议要求 CTR 或 CFB 模式,在封装层加一个文件即可,核心的sm4_encrypt_block直接复用——CTR 就是把计数器加密后和明文异或,CFB 是密文反馈,都不需要动轮函数。

验证方法上,除了标准向量,我习惯做一个“回环测试”:随机生成 256 字节数据,加密再解密,比对原文。这个测试能抓出填充、IV 管理、缓冲区重叠这类外围 bug,比单看密文值更全面。在 8051 上跑回环要注意 RAM 够不够,可以分块做,每块 32 字节。

改造时有个技巧:把sm4_ctx里的iv和rk分开管理。rk在密钥生命周期内不变,iv每次会话都要重置。如果协议支持多会话,可以只存一份rk,每个会话一个iv,省内存。另外,C51 的const和code要配合用,只写const不写code,Keil 可能仍把表放 RAM,编译后一定看M51文件确认。

最后说个我自己的教训:早期图省事,把sm4_ctx定义成函数内的static变量,想着省栈空间,结果两个任务交替调用时 IV 互相污染,密文时对时错,查了两天才定位到。从那以后我每次做嵌入式密码移植,都强制把上下文的所有权交给调用方,谁用谁声明,绝不在算法层藏全局状态。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 19:45:02

SpaTime:面向动作因果推理的时空联合建模方法

1. 这不是又一个“多模态大模型”&#xff0c;而是一次对“时间”本身的建模重构你有没有试过让当前主流的视觉语言模型&#xff08;VLM&#xff09;回答这样一个问题&#xff1a;“视频里穿红衣服的人&#xff0c;在踢球之前&#xff0c;是否先弯腰系了鞋带&#xff1f;”——…

作者头像 李华
网站建设 2026/10/26 19:44:21

nanoMuse:轻量级本地化个人代理框架,实现跨设备无感协同

1. 项目概述&#xff1a;这不是又一个“AI助手”&#xff0c;而是一套可嵌入任何设备的轻量级智能代理框架你有没有遇到过这样的场景&#xff1a;早上出门前想让客厅音箱播报天气&#xff0c;顺带把咖啡机启动&#xff1b;到公司后用笔记本调取昨晚手机拍下的会议白板照片&…

作者头像 李华
网站建设 2026/10/26 19:43:23

Python+Keras+TensorFlow环境搭建与手写数字识别实战

这几年大大小小带过不少人入门神经网络&#xff0c;问得最多的往往不是「反向传播到底怎么推」&#xff0c;而是「我这环境到底能不能跑起来」。今天这篇系列开篇&#xff0c;就把 Python、Keras、TensorFlow 这条最经典的技术栈从头捋一遍&#xff0c;从环境搭建到第一个能跑通…

作者头像 李华
网站建设 2026/10/26 19:39:45

TensorFlow深度学习全流程:环境、数据管道、训练调参与部署排障

深度学习这件事&#xff0c;说穿了就是把数据喂进一张计算图里&#xff0c;让它在误差反馈中一点点把参数调对。TensorFlow 是这套流程里最主流的一套工具箱之一&#xff0c;它把张量运算、自动求导、模型封装、导出部署串成了一条完整的链路。但真正动手的人知道&#xff0c;这…

作者头像 李华
网站建设 2026/10/26 19:39:44

从 EGL 到 VR 双目:OpenGL ES 最小渲染工程实战

简介&#xff1a;OpenGLESDemo.zip 是一份面向 Android 平台的 OpenGL ES 图形开发示例合集&#xff0c;适合具备一定 Java 与 Android 基础、希望系统掌握移动端 2D/3D 渲染的开发者。内容围绕基本图元渲染、GLSL 顶点与片段着色器、纹理映射与 MIP 贴图、视口与正交/透视投影…

作者头像 李华
网站建设 2026/10/26 19:39:28

BlenderProc合成6D位姿数据集:坐标系、位姿校验与域随机化

1. 为什么我把位姿数据的主力放在渲染管线上 做 6D 位姿估计的朋友大概都经历过同一个阶段&#xff1a;花两周搭采集台&#xff0c;架双目或者深度相机&#xff0c;打光、贴标记、拍几百组&#xff0c;然后开始标。标到第三十个物体的时候人会崩溃——手工标一个姿态平均要十几…

作者头像 李华