低内存MCU上的SM2国密算法实现:STM32优化实践
发布时间:2026/9/8 21:56:25 作者:尧图编辑部 阅读量:1,286

简介这份源码将sm2国密算法完整移植到低内存stm32单片机环境面向嵌入式安全开发与国密改造场景适合需要在资源受限设备上集成国产密码算法、实现通信加密与数据签名的软硬件工程师。压缩包共421个文件、约6.88MB以c源码、h头文件为主体同时包含启动文件、MDK工程配置、编译中间产物与清理脚本目录结构完整可直接导入Keil工程运行调试。核心代码涵盖椭圆曲线运算、大数运算与定时器驱动等关键模块针对低内存环境做了细致适配描述中注明经过实际测试、保证可运行并支持在此基础上扩展Linux版本。对关注国密算法单片机落地的开发者而言这套代码在内存占用、运算效率与曲线参数组织上的处理方式均具有直接参考价值。已有1962人学习下载。 做嵌入式开发这件事早晚会撞上“国密”两个字。前两年我给一个工业设备做身份认证功能客户提了个硬性要求设备之间的身份认证和数据加密必须用SM2算法。当时我第一个反应是加一颗安全芯片一看BOM成本、供货周期再一看PCB上剩下的那几平方毫米面积还是决定自己在单片机上把SM2跑起来。项目主控用的是STM32F103C8T6RAM只有20KBFlash 64KB属于典型的低内存环境。折腾了两周把SM2的签名、验签、加解密以及配套的SM3哈希全部调通单独算SM2核心部分动态RAM占用约1.5KBFlash增加约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_t8个元素就是一个大数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 0003SM2椭圆曲线公钥密码算法标准附录以及常见的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[ij]的更新做展开优化但逻辑骨架就是这样。每次乘法指令用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开销性能是否适合低内存MCUMontgomery约减中需要额外的Montgomery域转换中较快一般适合有大片ROM的芯片p结构快速约减低直接对乘积折叠小中等偏快非常适合代码固定可预测最终选快速约减主要原因是省掉了进入和离开Montgomery域时的两次额外变换代码也更直观。实现细节其实就是按256位为一组做移位相加建议先把公式在纸上推一遍再写代码别直接抄网上的尤其注意借位处理。3.3 点加、倍点与标量乘标量乘是所有流程的核心有了模加减和模乘就可以实现投影坐标下的点加和倍点。具体公式不在这里展开标准文档和很多代码库里都有核心是控制每个点的Z坐标不能为零。我的实现里用Z是否等于0表示无穷远点点加和倍点在运算前先做这一步判断避免除零错误。标量乘直接用从左到右的二进制方法/* R [k]Pk是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或rk等于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重用对很多设备认证场景很实用。缺点是它只解决了签名所需的kSM2加密时计算C1 [k]G的那个k仍然需要随机数这个绕不开。我的建议是如果芯片有硬件RNGF2/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字节的数组、几百次循环乘法和一堆坐标变换。真正难的是踩过坑之后还能保持耐心把每一步都验证扎实。本文还有配套的精品资源点击获取