简介:这份源码将sm2国密算法完整移植到低内存stm32单片机环境,面向嵌入式安全开发与国密改造场景,适合需要在资源受限设备上集成国产密码算法、实现通信加密与数据签名的软硬件工程师。压缩包共421个文件、约6.88MB,以c源码、h头文件为主体,同时包含启动文件、MDK工程配置、编译中间产物与清理脚本,目录结构完整,可直接导入Keil工程运行调试。核心代码涵盖椭圆曲线运算、大数运算与定时器驱动等关键模块,针对低内存环境做了细致适配;描述中注明经过实际测试、保证可运行,并支持在此基础上扩展Linux版本。对关注国密算法单片机落地的开发者而言,这套代码在内存占用、运算效率与曲线参数组织上的处理方式,均具有直接参考价值。已有1962人学习下载。 做嵌入式开发这件事,早晚会撞上“国密”两个字。前两年我给一个工业设备做身份认证功能,客户提了个硬性要求:设备之间的身份认证和数据加密,必须用SM2算法。当时我第一个反应是加一颗安全芯片,一看BOM成本、供货周期,再一看PCB上剩下的那几平方毫米面积,还是决定自己在单片机上把SM2跑起来。项目主控用的是STM32F103C8T6,RAM只有20KB,Flash 64KB,属于典型的低内存环境。折腾了两周,把SM2的签名、验签、加解密以及配套的SM3哈希全部调通,单独算SM2核心部分,动态RAM占用约1.5KB,Flash增加约10KB,一次签名大概几十毫秒的量级。这篇就把实现思路、内存优化取舍和调试中踩过的坑整理出来,给准备在MCU上做国密功能的工程师一个参考。
1. 先把账算清楚:SM2在低内存MCU上到底吃掉多少资源
1.1 为什么256位大数运算这么“占地方”
SM2是一种基于椭圆曲线的公钥密码算法,支持签名、验签、加密和解密。和AES这类对称算法完全不同,公钥算法天生就要处理上百位的整数。SM2选的是256位素域曲线,任何一个参与运算的数,在内存里至少要占32字节;两个256位数相乘,中间结果直接膨胀到512位,也就是64字节。
单看64字节不多,问题在于椭圆曲线标量乘[k]P没法一步算完。它需要反复执行“点加”和“倍点”,每一次点运算内部又会调用一批临时大数:点的坐标X、Y、Z,加上若干中间量,一次标量乘的完整过程,活跃的临时数据轻松上百字节。更麻烦的是这些临时数据层层嵌套——点运算里调大数运算,大数运算内部自己又需要乘法累加器,每一层都在消耗内存。PC上这根本不叫事,malloc一块几KB的缓冲区毫无压力,但MCU上的动态内存分配本身就是风险源,所以第一步必须把账算明白:我同时要保存几个大数?乘法的工作区要不要一次性分配?函数调用链的栈深是否可控?
1.2 内存分三块:常驻数据、临时工作区、栈
我习惯把SM2在MCU上的内存占用分成三个维度来看。
第一块是常驻数据:私钥、公钥、曲线参数,以及如果要做预计算表的话还有中间点数据。它们的生命周期是整个运算过程,全部放全局静态区。第二块是临时工作区:乘法结果、点坐标的中间值。这块有两种做法,可以放进一个大号的static缓冲区复用,也可以作为局部数组传入,取决于函数嵌套深度和栈的余量。第三块就是栈本身。Keil MDK的默认启动文件给栈分配的一般是0x400,也就是1KB,如果没做调整就跑大数运算,基本上一调底层函数就直接溢出。
我的做法是:所有大数和点都用静态或调用方传入的缓冲区,函数内部不定义大的局部数组,栈只给函数调用链条留百来字节余量。这个策略直接消掉了“RAM 20KB够不够”的焦虑。算下来整个SM2运算需要的RAM大约1.5KB,对于F103C8T6的20KB RAM来说非常宽裕,甚至如果是8KB RAM的型号也能跑。
2. 数据布局与坐标系选择:内存优化从设计层开始
2.1 大数怎么存:uint32_t数组是最佳形态
在C语言里表示256位大数,有两条路:用uint8_t数组按字节存,或者用uint32_t数组按字存。我强烈建议用uint32_t,8个元素就是一个大数:
typedef struct { uint32_t d[8]; /* d[0] 存放低32位 */ } sm2_bn;原因有两个。第一,STM32是32位处理器,底层一次乘法指令就是32位乘32位得到64位结果,用字节数组会让进位传递变得极其痛苦;第二,绝大多数椭圆曲线加密算法参考资料和标准文档里的伪代码,都是以字为单位描述的,用uint32_t实现时几乎可以直接照着翻译。内部统一用小端存储,也就是d[0]是低32位,这样做加法、乘法时进位方向最自然。不过协议层交换数据时SM2标准按大端字节序,所以我在API边界统一做字节序转换,内部运算绝不混着来。
点的坐标也用类似的思路:
typedef struct { sm2_bn X, Y, Z; } sm2_point;一个Jacobian投影坐标点占96字节,这个结构体会被大量使用,可以作为一个参数传入各个运算函数,避免函数内部再定义大数组。
2.2 投影坐标:把模逆运算的钱省下来
接触过ECC的人一定知道仿射坐标下的点加公式:每做一次点加或倍点,都要做一次模逆运算。模逆在数学上很简单,但在MCU上非常昂贵,通常用费马小定理实现,也就是要算a^(p-2) mod p,这本身就是一次完整的模幂运算,需要几百次模乘。如果每步都做一次,标量乘256轮下来,绝大部分时间都耗在求逆上。
解决办法是换成Jacobian投影坐标。投影坐标下点的表示从两个数变成三个数(X, Y, Z),实际对应的仿射坐标是(X/Z², Y/Z³)。好处是点加和倍点全程只需要模乘、模平方和模加减,完全不需要模逆,只在最终把结果转回仿射坐标时做一次模逆。正是靠这一步,标量乘的性能一下从“没法用”变成“可接受”。
代价是内存占用增加,一个点从64字节变成96字节,换来的是几百次模逆的消除,这笔买卖非常划算。可参考的标准公式在GM/T 0003(SM2椭圆曲线公钥密码算法)标准附录以及常见的ECC实现资料里都能找到,照着翻译成C函数就行。
2.3 工作区复用设计:一个全局缓冲打天下
既然MCU的内存紧张,最忌讳的就是每个函数都自己定义一堆数组。我是这样设计的:模块内维护一组static的工作变量,所有底层函数都基于这组变量运算,只有最外层的API才把结果拷到调用方的缓冲区。
static sm2_bn bn_tmp[8]; /* 底层通用临时大数 */ static sm2_point pt_tmp; /* 点运算临时区 */这里的关键是函数调用顺序必须严格串行,绝不允许多个调用交错使用同一个工作区。如果以后要跑RTOS,多个任务同时调SM2 API,就得加互斥锁,或者把工作区改成调用方传入的结构体,让每个任务自带一份。我的做法是先做成全局static,跑裸机验证没问题,上RTOS再加信号量保护,这样代码改动最小。
3. 从大数乘法到签名验签:核心代码的实现路径
3.1 多精度乘法:一切运算的地基
椭圆曲线运算的性能,90%取决于底层大数模乘的性能。大数乘法是典型的小学竖式乘法,只不过把十进制换成了32位进制:
void bn_mul(uint32_t r[16], const uint32_t a[8], const uint32_t b[8]) { uint64_t acc; uint32_t carry; int i, j; memset(r, 0, sizeof(uint32_t) * 16); for (i = 0; i < 8; i++) { carry = 0; for (j = 0; j < 8; j++) { acc = (uint64_t)a[i] * b[j] + r[i + j] + carry; r[i + j] = (uint32_t)acc; carry = (uint32_t)(acc >> 32); } r[i + j] += carry; } }实际实现时还会对r[i+j]的更新做展开优化,但逻辑骨架就是这样。每次乘法指令用uint64_t接收结果,保证了不会溢出。注意外层循环每轮结束要处理一次向上的进位,很多初学者在这里丢进位,导致结果不对,调一天都找不到原因。
3.2 模约减:利用SM2曲线参数的特殊结构
乘法得到的是512位结果,还要模掉素数p才能回到256位。模约减有两条路:通用Montgomery约减和针对曲线参数的结构化快速约减。SM2的素数p有一个非常特殊的形式:
p = 2^256 - 2^224 - 2^96 + 2^64 - 1
这个结构和NIST P-256的素数有些类似,都是为了让高位折叠变得容易。所以我没用Montgomery,而是直接实现基于参数结构的快速约减:把512位乘积拆成若干个256位块,根据p的系数关系,通过移位和加减把高位折叠回低位,最后做一两次条件减法搞定。
这两种方案我做过对比:
| 方案 | 实现复杂度 | ROM开销 | 性能 | 是否适合低内存MCU |
|---|---|---|---|---|
| Montgomery约减 | 中,需要额外的Montgomery域转换 | 中 | 较快 | 一般,适合有大片ROM的芯片 |
| p结构快速约减 | 低,直接对乘积折叠 | 小 | 中等偏快 | 非常适合,代码固定可预测 |
最终选快速约减,主要原因是省掉了进入和离开Montgomery域时的两次额外变换,代码也更直观。实现细节其实就是按256位为一组做移位相加,建议先把公式在纸上推一遍再写代码,别直接抄网上的,尤其注意借位处理。
3.3 点加、倍点与标量乘:标量乘是所有流程的核心
有了模加减和模乘,就可以实现投影坐标下的点加和倍点。具体公式不在这里展开,标准文档和很多代码库里都有,核心是控制每个点的Z坐标不能为零。我的实现里,用Z是否等于0表示无穷远点,点加和倍点在运算前先做这一步判断,避免除零错误。
标量乘直接用从左到右的二进制方法:
/* R = [k]P,k是256位大数 */ point_set_infinity(&R); for (i = 255; i >= 0; i--) { point_double(&R, &R); /* 每轮必做一次倍点 */ if (bit_of_k(k, i)) { point_add(&R, &R, &P); /* 当前bit为1时再做一次点加 */ } }这个循环每次迭代固定做一次倍点,平均一半的迭代需要额外做一次点加。所以一次标量乘大约需要256次倍点加128次点加,按Jacobian坐标的标准公式算,相当于约2500到3000次模乘当量。这就是性能估算的基础。
3.4 签名、验签和加解密:把模块拼起来
标量乘是所有流程的发动机。签名流程大致是:
- 计算ZA = SM3(ENTL || ID || a || b || Gx || Gy || Px || Py),其中ENTL是用户ID的比特长度,固定占2字节
- 计算e = SM3(ZA || M)
- 生成随机数k,计算(x1, y1) = [k]G
- 计算r = (e + x1) mod n,如果r为0或r+k等于n就重新取k
- 计算s = ((1 + dA)^-1 * (k - r * dA)) mod n,如果s为0也要重新取k
验签则反过来,先算t = (r + s) mod n,如果t为0直接失败,然后计算[s]G + [t]P,检查坐标推导出的R是否等于r。加密需要两次标量乘:一次算C1 = [k]G,一次算[k]PB得到推导密钥材料,所以加密时间大约是签名的一倍以上。
我一直到把签名验签跑通,再去做加密解密,因为签名验签只需要标量乘和模逆,流程更短,出了问题好定位。等工作流没问题,再接KDF和哈希拼接,会顺手很多。
4. 性能实测与取舍:我跑出来的数据
4.1 编译器配置对性能的影响非常大
同样的C代码,在Keil MDK里用AC5和AC6编译,性能能差出30%到50%。我最后还是固定在AC6,开的优化级别是-O2,实测比-O1有明显提升,而-O3和-O2的差距在MCU上反而不大,代码体积还会涨。对于时间关键的函数,我会用__attribute__((optimize("O3")))单独标注,而不是整个文件开高优化,这样能兼顾性能和Flash占用。
RAM方面还需要提醒:Keil的默认栈只有1KB,如果你在调试时发现程序跑着跑着进了HardFault,或者某个局部变量莫名被篡改,先检查栈是不是被吃穿了。我的做法是把启动文件里的栈改成0x800也就是2KB,同时确保SM2模块内部的大数组都是static,栈压力就小多了。
4.2 实测数字:F103和F407的差距
以STM32F103 @72MHz、无硬件浮点、AC6 -O2为例,纯软件实现的实测量级如下:
| 操作 | 耗时(F103 @72MHz) | 耗时(F407 @168MHz) |
|---|---|---|
| 一次点乘[k]G | 约40~60ms | 约15~25ms |
| 签名(含SM3) | 约50~80ms | 约20~35ms |
| 验签(两个点乘) | 约80~130ms | 约35~55ms |
| 加密(短消息≤128字节) | 约100~160ms | 约45~70ms |
| 解密 | 约60~100ms | 约25~45ms |
这个量级对大多数非实时认证场景完全够用。如果对速度有更高要求,比如每次通信都要做几百次验签,这个软实现就会成为瓶颈,那就得考虑固定窗口预计算表或者外接安全芯片了。预计算表本质是用ROM和RAM换速度,把G的若干倍点提前算好,最多能再快一倍左右,但会增加几KB到十几KB的存储。
4.3 随机数:签名安全的命门
SM2签名的安全性完全建立在随机数k的不可预测性上。如果两次签名的k相同,私钥可以直接解出来,这不是理论攻击,是已经证明的数学事实。F103没有硬件RNG,这块我试过几种方案。
一种是利用ADC噪声或多路时钟抖动采集熵,然后过健康测试再使用,但MCU环境的电磁干扰、温度变化都会影响熵源质量,维护成本不低。另一种是借鉴RFC 6979的思路,用HMAC-SM3从私钥和消息确定性派生k。这种确定性签名不需要真随机源,还能防k重用,对很多设备认证场景很实用。缺点是它只解决了签名所需的k,SM2加密时计算C1 = [k]G的那个k仍然需要随机数,这个绕不开。
我的建议是:如果芯片有硬件RNG(F2/F4/F7系列),直接用它,但一定要做简单的健康检查,比如连续两次读数相同就报错重启,防止RNG故障后输出固定值;如果没有硬件RNG,优先考虑RFC 6979式的确定性派生方案,别自己堆一个“看起来随机”的软件随机数。
5. 最容易翻车的四个环节:测试向量、栈、字节序和ZA拼接
5.1 先用标准测试向量,别急着接业务
调SM2最忌讳的就是业务代码和算法代码混在一起调试。我把算法部分单独做了一个无硬件的测试工程,在PC上先把所有流程跑通,再烧到板子上。测试数据直接用GM/T 0003标准附录里的样例:私钥、公钥、消息M、用户ID,还有对应的预期签名值和密文。先在测试框架里逐项比对,全部符合之后,才算算法移植完成。
实测下来最容易出错的是签名验签这一组。如果签名自测能过,但验签对不上外部平台的结果,先检查ZA的计算顺序;如果加解密自测能过,但和别的平台互操作失败,先检查密文格式是C1C3C2还是C1C2C3。这两个问题在国密落地时非常常见,很多时候不是算法错了,是双方格式约定不一致。
5.2 栈溢出:Keil默认栈坑了很多人
前面提过,Keil MDK的默认栈是0x400,也就是1KB。SM2标量乘的函数调用链很长:API -> 标量乘 -> 点加 -> 模乘 -> 模约减,即便所有大数组都是static,每层函数仍然有局部变量和返回地址压栈。如果启动文件里栈没调大,大概率会出现一种诡异现场:程序运行结果和时间无关地随机出错,调试时一设置断点就恢复正常。
我排查这类问题的方法很简单:在HardFault_Handler里加一个断点,进入异常后查看栈指针SP的值,对比启动文件里定义的栈顶,如果SP快顶到栈底了,就是溢出。当然最省心的还是事前预防,启动文件里把栈配成0x800甚至0x1000,同时模块内部避免定义大局部数组。
5.3 字节序:大端协议和小端MCU的摩擦
SM2标准在协议层是典型的大端,而STM32的Cortex-M内核是小端。这个差异坑过的工程师不在少数,尤其是在和第三方平台联调时。我的做法是内部运算统一用小端,API边界处提供字节序转换函数:
void bn_from_bytes(sm2_bn *r, const uint8_t b[32]); /* 大端输入,转内部小端 */ void bn_to_bytes(const sm2_bn *a, uint8_t b[32]); /* 内部小端,转大端输出 */函数内部用循环逐字节倒序即可,简单可靠。关键是整个代码库只允许在这两个函数里看到字节序相关的代码,其他任何地方都不要碰。
5.4 ZA前缀:哈希拼接顺序不能凭感觉
SM2签名验签里有个ZA的计算,它把用户ID、曲线参数、公钥等一大堆信息先做一次SM3哈希,然后再和消息一起哈希。这个ZA本质上是为了防止密钥被替换。错误集中在两个地方:一是ENTL用户ID长度必须用比特数表示,占2字节大端序,有人填成字节数;二是ZA里的曲线参数顺序不能乱,必须是a、b、Gx、Gy、Px、Py,少一个或者颠倒一个,算出来的e就完全不同,而且这种错误自测时不一定能发现,只有两个平台互签互验时才暴露。
6. 落地扩展:这套代码还能用在哪
6.1 固件验签:Bootloader场景很实用
MCU的Bootloader验签是SM2在嵌入式里非常典型的应用。设备上电后先引导加载固件镜像,用SM2验签确认固件没有被篡改,再跳转执行。这个场景只需要公钥验签,不涉及私钥,签名动作在PC或产线工具完成,安全性高且不会在设备上暴露私钥。对低内存MCU来说,只要验签功能就能满足需求,还能把加密、KDF相关代码裁剪掉,进一步压缩Flash占用。
我的模块在编译时做了条件开关控制:
- SM2_ENABLE_SIGN:签名相关
- SM2_ENABLE_VERIFY:验签相关
- SM2_ENABLE_CRYPT:加解密相关
- SM2_ENABLE_CORE_ONLY:只用点乘等底层能力,不开放签名验签
只启用验签时,动态内存可以再降500字节左右,Flash也能省两三KB。如果目标芯片Flash紧张,这个裁剪能力非常实用。
6.2 配合安全芯片:软硬结合的分工
如果产品对密钥存储有更高要求,比如私钥不能以明文形式放在Flash里,因为攻击者可以直接读Flash,那就不能只靠软实现。常见架构是:主控MCU跑SM2软实现负责业务逻辑和协议流程,安全芯片负责私钥的生成、存储和关键运算。主机把待签名的摘要发给安全芯片,芯片返回签名结果,私钥永不离开芯片。
这时候我在软实现里积累的大数运算、字节序转换、ASN.1编码等辅助逻辑仍然有用,而且有了软实现的基础,和芯片厂商的Demo代码对接会容易很多,也能独立验证芯片输出的正确性。
6.3 如果再选一次,我会怎么做
回头看这个项目,如果一开始就按“先算法验证后业务集成”的节奏走,能省掉至少一个礼拜的反复调试。我后来把这套代码整理成独立模块,加了自测入口和方便移植的接口,再接到新的STM32型号时只改随机数适配层,半小时就能跑通。
我的建议是:拿到需求先别急着往项目里塞代码,先建一个空工程把SM2和SM3的源码放进去,用标准测试向量把签名、验签、加密、解密四条链路全部验证通过,再做集成。这一步做好,后面的业务开发基本不会卡在算法上。你说SM2难吗?拆开看就是一批32字节的数组、几百次循环乘法和一堆坐标变换。真正难的是踩过坑之后还能保持耐心,把每一步都验证扎实。
本文还有配套的精品资源,点击获取