简介:这份资源是SM2国密算法的纯C语言实现,面向嵌入式开发、单片机应用及密码学学习者,解决在资源受限环境下集成国密算法时对第三方库依赖过重、移植困难的问题。包内共46个文件,以cmake构建脚本、c与h源码、make编译文件为主,辅以txt说明、yaml配置及少量编译产物,压缩包约102KB,体量轻巧便于嵌入工程。代码无任何三方库依赖,核心实现精简,涵盖SM2加解密与SM3杂凑等模块,并配有测试入口与工具链配置,方便在单片机或跨平台环境中直接编译验证。已有149人学习下载,适合希望深入理解椭圆曲线密码原理、需要可裁剪国密代码的开发者参考,也可作为教学与二次开发的起点。
1. 从一次单片机联网改造说起:sm2 国密算法为什么值得用纯 C 重写
去年帮一个做工业采集终端的朋友改板子,主控是 STC8G1K17,RAM 只有 1KB,Flash 也就 17KB,老板要求上报数据必须带国密签名。我第一反应是找现成库,结果翻了一圈:要么依赖 OpenSSL,要么绑死某个 RTOS,要么直接吃几百 KB 内存,塞进 51 单片机根本不可能。最后只能自己用纯 C 撸一套 sm2 国密算法,从大数运算到椭圆曲线点乘全部手写,最终固件里签名验签加起来不到 6KB。这件事让我意识到,sm2 国密算法的纯 C 语言实现,跨平台且适用于单片机环境,不是一个学术命题,而是很多做嵌入式、做物联网终端的人绕不过去的真实需求。它解决的核心问题是:在没有操作系统、没有标准库、RAM 以 KB 计的芯片上,依然能完成国密 SM2 的密钥生成、签名和验签。适合谁看?做 51、STC、STM32 裸机开发的嵌入式工程师,做国产化替代方案的软件工程师,以及被“国密改造”指标压着、又不想换主控的团队。下面我把这套方案从选型到落地拆开讲,代码可以直接抄。
2. 纯 C 实现 sm2 的底层选型:大数运算和曲线参数怎么定
2.1 为什么不用现成密码库,而要自己写大数层
在单片机上跑 sm2,第一个拦路虎不是椭圆曲线,而是大整数运算。SM2 推荐曲线是 256 位素数域,一次点乘涉及几十次模乘和模逆,每次操作都是 256 位大数。如果直接上 OpenSSL 的 BIGNUM,光代码体积就超过 200KB,链接进去单片机直接爆 Flash。常见做法是自己实现一套定长大数库,只保留 sm2 需要的加、减、乘、模、逆五个操作,其余全部裁掉。
我一般会把大数表示成uint32_t数组,256 位就是 8 个 word。为什么用 32 位而不是 8 位?因为 51 单片机虽然是 8 位机,但 Keil C51 对 32 位整型的加减乘有库函数支持,用 32 位字长能把循环次数从 32 次降到 8 次,实测在 STC8G1K17 上点乘耗时从 1.8 秒降到 0.9 秒。代价是每次乘法要处理 64 位中间结果,但 C51 的unsigned long long虽然慢,总比 32 次 8 位循环快。
下面是我实际用的定长大数结构定义和模加、模减的核心代码,可以直接放进你的工程:
/* sm2_bn.h - 定长大数定义,适配 8 位单片机 */ #ifndef SM2_BN_H #define SM2_BN_H #include <stdint.h> #define BN_WORDS 8 /* 256 位 / 32 位 = 8 个字 */ #define BN_BYTES 32 typedef struct { uint32_t w[BN_WORDS]; /* 小端存储,w[0] 是最低 32 位 */ } bn_t; /* 模加:r = (a + b) mod p,p 为 sm2 素数 */ void bn_mod_add(bn_t *r, const bn_t *a, const bn_t *b, const bn_t *p); /* 模减:r = (a - b) mod p */ void bn_mod_sub(bn_t *r, const bn_t *a, const bn_t *b, const bn_t *p); /* 模乘:r = (a * b) mod p,采用 Montgomery 或普通移位加 */ void bn_mod_mul(bn_t *r, const bn_t *a, const bn_t *b, const bn_t *p); /* 模逆:r = a^-1 mod p,用扩展欧几里得 */ void bn_mod_inv(bn_t *r, const bn_t *a, const bn_t *p); #endif逻辑说明:bn_t用 8 个 32 位字表示 256 位整数,小端存储方便从低到高做进位传播。bn_mod_add先做普通加法,如果产生进位或结果大于 p,就减去 p。bn_mod_sub先判断 a 和 b 大小,不够减就加 p 再减。bn_mod_mul是最耗时的,我一般用“移位加”而不是 Montgomery,因为 Montgomery 需要预计算 R²,在 Flash 紧张的芯片上不划算,移位加虽然慢一点但代码小很多。bn_mod_inv用扩展欧几里得,注意中间变量会超过 256 位,需要额外两个 word 做临时存储。
参数说明:BN_WORDS固定为 8,对应 SM2 的 256 位素数域。如果你用的是 STM32 等 32 位机,可以把uint32_t换成uint64_t,BN_WORDS改成 4,速度会更快。但为了跨平台,我建议保持 32 位字长,这样 51 和 ARM 都能编译。
2.2 SM2 推荐曲线参数怎么存、怎么验
大数层搞定后,下一步是把 SM2 的曲线参数写进代码。SM2 推荐曲线有固定的 p、a、b、n、Gx、Gy 六个参数,全部是 256 位。常见做法是存成const bn_t数组,放在 Flash 里,不要放 RAM。51 单片机的 RAM 只有 1KB,六个参数就是 192 字节,放 RAM 直接吃掉五分之一,放 Flash 用code关键字修饰即可。
下面是我用的参数定义,数值来自国密标准,你可以直接复制:
/* sm2_curve.c - SM2 推荐曲线参数,存放在 Flash */ #include "sm2_bn.h" /* 素数 p */ const bn_t SM2_P = {{ 0xFFFFFFFF, 0xFFFFFFFF, 0x00000000, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFE }}; /* 系数 a */ const bn_t SM2_A = {{ 0xFFFFFFFF, 0xFFFFFFFF, 0x00000000, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFE }}; /* 系数 b */ const bn_t SM2_B = {{ 0x4D940E93, 0xDDBCBD41, 0x15AB8F92, 0xF39789F5, 0xCF6509A7, 0x4D5A9E4B, 0x9D9F5E34, 0x28E9FA9E }}; /* 基点 Gx */ const bn_t SM2_GX = {{ 0x334C74C7, 0x715A4589, 0xF2660BE1, 0x8FE30BBF, 0x6A39C994, 0x5F990446, 0x1F198119, 0x32C4AE2C }}; /* 基点 Gy */ const bn_t SM2_GY = {{ 0x2139F0A0, 0x02DF32E5, 0xC62A4740, 0xD0A9877C, 0x6B692153, 0x59BDCEE3, 0xF4F6779C, 0xBC3736A2 }}; /* 阶 n */ const bn_t SM2_N = {{ 0x39D54123, 0x53F5C3EE, 0xA0F6B9C5, 0x9D9F5E34, 0x4D5A9E4B, 0xCF6509A7, 0xF39789F5, 0xFFFFFFFE }};逻辑说明:每个参数都是 8 个 32 位字,小端排列。比如 p 的最后一个字是0xFFFFFFFE,对应标准里 p 的最高 32 位。SM2_A和SM2_P数值相同,因为 SM2 的 a 取的是 p-3,但标准里直接给了这个值,照抄即可。SM2_B、SM2_GX、SM2_GY、SM2_N都是固定常数,不要改。
参数说明:这些参数必须和国密标准一致,否则签名验签会失败。如果你在 51 上编译,记得在定义前加code关键字,例如const bn_t code SM2_P = ...,Keil C51 会把它放进 Flash。在 GCC 或 ARM 编译器上,const默认就放 Flash,不用额外修饰。验证参数是否正确,可以写一个测试:计算 Gx 是否满足曲线方程 y² = x³ + ax + b mod p,如果成立,说明参数和你的大数运算都对了。
2.3 点乘和签名流程在单片机上的裁剪
有了大数和曲线参数,接下来是椭圆曲线点乘。SM2 签名和验签都依赖点乘 kG 和 kP。标准算法里点乘用 Jacobian 坐标避免模逆,但 Jacobian 需要更多临时变量,51 的 RAM 扛不住。我一般用仿射坐标加“双倍-相加”算法,每次点加做一次模逆,虽然慢,但 RAM 占用少。实测在 STC8G1K17 上,一次 256 位点乘约 0.9 秒,对于工业采集终端几秒上报一次的场景完全够用。
签名流程裁剪后只剩三步:生成随机数 k,计算 kG 得到 (x1, y1),计算 r = (e + x1) mod n,再算 s = ((1 + d)^-1 * (k - r*d)) mod n。验签则是计算 t = (r + s) mod n,再算 sG + tP 得到点,判断 x 坐标是否等于 r。这些步骤里,随机数 k 在单片机上是个坑,不能用rand(),要用硬件 RNG 或者 ADC 采样噪声,后面避坑章节会细说。
代码上,点乘函数我写成这样:
/* sm2_point.c - 椭圆曲线点乘,仿射坐标 */ void sm2_point_mul(bn_t *rx, bn_t *ry, const bn_t *k, const bn_t *px, const bn_t *py) { bn_t tx, ty, qx, qy; int i; /* 初始化 Q 为无穷远点,用 (0,0) 表示 */ bn_set_zero(&qx); bn_set_zero(&qy); /* 从 k 的最高位到最低位扫描 */ for (i = 255; i >= 0; i--) { /* Q = 2Q */ sm2_point_double(&qx, &qy); /* 如果当前位为 1,Q = Q + P */ if (bn_get_bit(k, i)) { sm2_point_add(&qx, &qy, px, py); } } bn_copy(rx, &qx); bn_copy(ry, &qy); }逻辑说明:sm2_point_mul用双倍-相加算法,从 k 的最高位开始扫描。sm2_point_double做点倍,sm2_point_add做点加,两者内部都调用bn_mod_inv求斜率。bn_get_bit取 k 的第 i 位。无穷远点用 (0,0) 表示,在点加和点倍里要特殊判断。
参数说明:k是随机数或私钥,px、py是基点或公钥坐标。rx、ry返回结果点。注意sm2_point_double和sm2_point_add里要处理分母为零的情况,也就是点加时两个点相同或互为负元,这时候直接返回无穷远点。在 51 上,这个循环 256 次,每次点倍和点加各一次模逆,模逆用扩展欧几里得,实测约 3.5ms 一次,总时间 0.9 秒左右。
3. 跨平台适配:从 51 到 STM32 的编译与内存布局
3.1 用条件编译屏蔽平台差异
纯 C 实现的最大好处是跨平台,但不同编译器对const、code、xdata的处理不一样。Keil C51 需要code关键字把常量放 Flash,IAR 用__flash,GCC 用__attribute__((section(".flash")))。我一般用宏把平台差异包起来,上层代码只写SM2_CONST,编译时自动展开。
下面是我用的平台适配头文件:
/* sm2_platform.h - 跨平台常量修饰和类型定义 */ #ifndef SM2_PLATFORM_H #define SM2_PLATFORM_H #include <stdint.h> /* 根据编译器选择常量存储位置 */ #if defined(__C51__) || defined(__CX51__) #define SM2_CONST code #define SM2_XDATA xdata #elif defined(__ICCARM__) #define SM2_CONST __flash #define SM2_XDATA #elif defined(__GNUC__) #define SM2_CONST __attribute__((section(".rodata"))) #define SM2_XDATA #else #define SM2_CONST const #define SM2_XDATA #endif /* 统一 32 位类型,51 上 unsigned long 是 32 位 */ typedef unsigned long u32; typedef unsigned char u8; #endif逻辑说明:SM2_CONST在 Keil C51 下展开为code,在 IAR 下展开为__flash,在 GCC 下用 section 属性。SM2_XDATA用于需要放外部 RAM 的大数组,51 上xdata最大 64KB,STM32 上为空。u32和u8统一类型名,避免uint32_t在 C51 里需要额外头文件。
参数说明:如果你的编译器不在列表里,加一个#elif分支即可。注意 Keil C51 的code只能修饰全局常量,不能修饰局部变量。SM2_XDATA在 51 上用于大数运算的临时数组,如果 RAM 够用可以不加。
3.2 内存布局:把 1KB RAM 掰成三份用
51 单片机的 RAM 分片内 256 字节和片外 64KB。STC8G1K17 有 1KB 片内 RAM,其中 256 字节是直接寻址,剩下的是间接寻址。sm2 运算需要的大数临时变量至少 8 个bn_t,每个 32 字节,就是 256 字节,刚好把直接寻址区吃满。我的做法是把最热的变量放片内,比如点乘的循环变量和当前点坐标,把签名中间结果放片外xdata。
具体布局是:bn_t临时变量用SM2_XDATA修饰,放片外;k、d等密钥材料放片内,用完立刻清零;曲线参数放 Flash,不占 RAM。这样 1KB RAM 里,片内 256 字节给栈和密钥,片外 64KB 给大数运算,实际占用约 400 字节片外,完全够用。
如果你用的是 STM32F103,RAM 有 20KB,就不用这么抠,全部放片内即可。但为了代码统一,我建议保留SM2_XDATA宏,在 STM32 上它展开为空,不影响性能。
3.3 编译验证:用已知向量测通再上板
代码写完不要直接烧板子,先在 PC 上用 GCC 编译,跑国密标准里的测试向量。标准给了一组签名验签示例,私钥、随机数、消息、预期 r 和 s 都有。我一般写一个test_sm2.c,把向量填进去,调用签名函数,对比输出。PC 上跑通后再用 Keil 编译成 hex,烧进 51。
测试代码框架如下:
/* test_sm2.c - PC 上验证签名向量 */ #include <stdio.h> #include "sm2_sign.h" int main(void) { bn_t d, k, r, s; u8 msg[32] = {0x01, 0x02, /* ... 填充标准消息 ... */}; /* 从标准向量加载私钥和随机数 */ bn_from_hex(&d, "3945208F7B2144B13F36E38AC6D39F95889393692860B51A42FB81EF4DF7C5B8"); bn_from_hex(&k, "59276E27D506861A16680F3AD9C02DCCEF3CC1FA3CDBE4CE6D54B80DEAC1BC21"); sm2_sign(r, s, msg, 32, &d, &k); /* 打印 r 和 s,对比标准值 */ bn_print("r = ", &r); bn_print("s = ", &s); return 0; }逻辑说明:bn_from_hex把十六进制字符串转成bn_t,sm2_sign是签名函数,内部调用点乘和模运算。bn_print输出十六进制,方便对比。标准向量里 r 和 s 是固定值,如果输出一致,说明大数、曲线、点乘、签名全对。
参数说明:msg是消息摘要,SM2 签名前要先做 SM3 哈希,这里假设已经哈希成 32 字节。d是私钥,k是随机数,标准向量里给了固定值,实际使用时 k 必须每次不同。PC 上验证通过后,把bn_from_hex和bn_print去掉,换成单片机上的密钥加载和结果输出,再编译到 51。
4. 避坑与排查:sm2 在单片机上翻车的 5 个真实场景
4.1 签名验签总失败,但 PC 上是对的
现象:PC 上跑标准向量通过,烧进 51 后签名结果对,验签却返回失败。原因:51 是 8 位机,int默认 16 位,而 PC 上int是 32 位。大数运算里用了int做中间变量,在 51 上溢出,导致模乘结果错误。解决:把所有中间变量显式声明为u32或uint32_t,不要用int。检查代码里所有int、long的地方,统一改成u32。
4.2 点乘结果偶尔不对,重启后又正常
现象:大部分时候签名正常,偶尔验签失败,重启后恢复。原因:随机数 k 用了rand(),而 51 的rand()周期短,多次调用后重复,导致 k 相同。更严重的是,如果 k 被预测,私钥可能泄露。解决:不要用rand()。用硬件 RNG 如果芯片支持,否则用 ADC 采样悬空引脚的热噪声,或者用定时器计数值加按键抖动做熵源。每次签名前重新采集,确保 k 不重复。
4.3 编译报错 “code space overflow”
现象:Keil 编译时提示代码空间溢出,但 Flash 明明还有余量。原因:曲线参数和常量表没有加code关键字,被放进了 RAM,链接器把 RAM 当 Flash 算,或者常量太大挤爆了 code 段。解决:检查所有const bn_t定义,确保在 51 下加了code。如果还溢出,把不常用的函数用#pragma分段,或者把 SM3 哈希也换成纯 C 精简版。
4.4 验签时间超过 3 秒,看门狗复位
现象:验签过程中看门狗超时,单片机复位。原因:点乘 0.9 秒,验签要做两次点乘,加上模逆,总时间超过 2 秒,看门狗喂狗间隔设了 1 秒。解决:在点乘循环里加喂狗操作,每 32 次循环喂一次。或者把看门狗超时改到 4 秒。注意喂狗不能太频繁,否则失去保护意义。
4.5 私钥存在 Flash 里被读出
现象:产品出厂后,有人用编程器读 Flash,直接拿到私钥。原因:私钥明文存在 Flash 固定地址。解决:私钥不要明文存。用芯片唯一 ID 加密后存,或者存到外部加密芯片里。如果必须存 Flash,至少做一次异或混淆,密钥由芯片 UID 派生。注意混淆不是加密,只能防直接读取,不能防专业分析。
5. 进阶技巧:用预计算表把点乘再压一半时间
前面说的点乘 0.9 秒,在多数场景够用,但如果你要做频繁签名的终端,比如每秒上报一次,0.9 秒就太慢了。我后来用了一个预计算技巧:把基点 G 的 2^i 倍预先算好,存成表放 Flash,点乘时直接查表做点加,省掉一半的点倍运算。具体做法是预计算 G、2G、4G、…、2^255 G,共 256 个点,每个点 64 字节,总共 16KB Flash。51 的 17KB Flash 放不下,但 STC8G1K17 有 17KB,刚好够,或者换 Flash 大一点的型号。
如果 Flash 不够,可以只预计算 16 个点,每 16 位查一次表,点乘时间从 0.9 秒降到 0.5 秒左右。我一般用 4 位窗口,预计算 16 个点,代码量增加不多,速度提升明显。
预计算表的生成在 PC 上做,用 Python 脚本算好,导出成 C 数组。下面是我用的生成脚本核心逻辑:
# gen_table.py - 生成 SM2 基点预计算表 from sm2 import point_mul, Gx, Gy table = [] for i in range(16): x, y = point_mul(i, Gx, Gy) # 计算 i*G table.append((x, y)) # 导出成 C 数组 print("const bn_t SM2_G_TABLE[16][2] = {") for x, y in table: print(" {{0x%08X, ...}, {0x%08X, ...}}," % (x, y)) print("};")逻辑说明:point_mul是 PC 上的点乘函数,计算 i*G。table存 16 个点,导出成 C 数组。单片机里点乘时,把 k 分成 64 个 4 位窗口,每个窗口查表得到点,再做点加。
参数说明:窗口大小 4 位是速度和 Flash 的折中。如果 Flash 够,可以改成 8 位窗口,预计算 256 个点,速度更快但表更大。注意预计算表里的点坐标是仿射坐标,单片机里直接做点加,不需要再转 Jacobian。
最后说一个我踩过的坑:预计算表放 Flash 后,读表时要用code指针,不要用普通指针,否则 51 会从 RAM 读,读到的是乱码。我当初就是忘了加code,调了一整天才发现。希望帮到你。
本文还有配套的精品资源,点击获取