STM32上实现国密SM2的静态内存分配实战 简介本资源是一份面向嵌入式安全开发者的国密SM2算法实践项目聚焦STM32平台上的轻量化、静态内存友好型实现适用于物联网终端、工业控制器等资源受限场景的密码功能集成学习与工程验证。压缩包共65个文件含42个C源码涵盖SM2核心运算、MIRACL大数库精简适配、椭圆曲线运算及SM3哈希模块、6个头文件如sm2.h、mtsm2.h、2个Makefile含GCC编译配置及README.md等说明文档总大小194KB结构清晰便于按模块理解算法分层与内存布局。已有58人学习下载资源完整提供密钥生成、加解密全流程测试用例、静态内存分配策略实现规避malloc风险、以及针对STM32的编译优化配置特别适合具备C语言与嵌入式基础、正开展国密算法移植或信息安全课程设计的学习者快速上手与深度调试。 说实话我第一次在STM32上跑国密SM2并不是因为觉得“加个密很酷”而是设备要做固件升级的签名校验业务侧提了国密改造的要求。当时第一反应是想外挂一颗安全芯片但一算BOM成本再一看PCB板子空间直接被否了。最后只能硬着头皮在MCU上纯软件实现SM2。这个过程中踩得最深的一个坑就是内存管理——MCU的RAM才几十KB而SM2的大数运算动辄需要几KB的临时缓冲区。如果用malloc动态分配一是碎片化风险极高二是在没有MMU的Cortex-M上堆溢出几乎没办法快速定位。最后我选择了全程静态内存分配把整个算法的内存占用在编译期就完全确定下来。这篇文章就是来复盘整个实现过程包括静态内存布局怎么设计、大数运算的临时缓冲区怎么复用、密钥生成和加解密怎么在一个裸机工程里跑通并完成交叉验证给同样要在资源受限平台上落地国密算法的朋友一份能直接抄作业的参考。1. 为什么在MCU上硬刚SM2先算清楚这笔账很多做嵌入式的人一听到SM2第一反应就是“上安全芯片”。我一开始也是这么想的但仔细算了一笔账之后这个思路被推翻了。不是安全芯片不好而是在某些场景下它的成本、体积和供应链约束会成为项目过不去的坎。1.1 纯软件、安全芯片、混合方案怎么选以我当时手头的项目为例一块基于STM32F407的控制板要做固件升级包的签名验证和敏感数据的加密传输MCU主频168MHzRAM 192KBFlash 1MB。在整个系统中密码运算只是其中一项功能不可能为了它单独增加一片芯片。三种方案的对比非常直观纯软件实现SM2零额外BOM成本代码全部在自己的Flash里灵活性最高。缺点是CPU要承担全部计算量一次SM2运算需要几十到几百毫秒还要自己保证内存不炸。优点是只要内存布局做好了稳定性完全可以接受。外挂安全芯片方案最“省心”密钥可以硬件保护运算速度也快。但成本增加、占用PCB面积还需要额外的I2C/SPI通信接口和驱动代码。在小批量产品里安全芯片的采购周期有时候比运算代码更头疼。安全芯片MCU混合通常是MCU做SM2密钥协商安全芯片只存根密钥或者做关键解封装。这种方案适合对密钥保护要求极高的场景但对于普通物联网设备属于杀鸡用牛刀。所以我的结论是如果设备的威胁模型主要防“非物理接触的远程攻击”那么纯软件SM2加上合理的密钥存储方案已经能满足绝大多数需求。如果威胁模型里有“物理拆机提取密钥”那才必须上安全芯片。1.2 “静态内存分配”到底解决了什么问题在STM32这种裸机环境下动态内存分配malloc/free有三大隐患碎片化SM2运算需要多个大小不一的临时缓冲区频繁申请释放内存池很快变成一片碎末。可能在某个临界时刻明明剩余内存总量足够却申请不到连续的块。不确定性malloc的返回值和耗时都是不确定的这在有实时性要求的控制逻辑里是不能接受的。调试困难堆溢出往往不会当场崩溃而是运行很久之后才出现诡异问题。在资源受限的单片机上定位这类问题成本非常高。而静态内存分配本质上是把“运行时申请内存”变成“编译期预留内存”。所有缓冲区都在全局作用域或者一个静态内存池里预先划分好内存占用是固定的没有malloc也没有free从源头消灭了碎片化和堆溢出的问题。代价是内存利用率不可能做到“按需分配”只能按最坏情况预留。1.3 本项目的约束与验收指标为了避免做出来的东西没法落地我给自己定了几条硬性约束MCU平台STM32F407VET6168MHz主频192KB RAM编译环境Keil MDK 或者 arm-none-eabi-gcc两个环境都要能编过内存约束SM2模块占用RAM不超过12KB包含所有临时缓冲区时间约束SM2加密一次单次运算不含哈希在168MHz下不超过120ms安全约束不依赖任何外部加密芯片随机数优先使用硬件RNG可测试性必须能用标准的SM2测试向量/工具交叉验证加密解密结果有了这几条约束后面每一步实现都有了明确的目标。2. SM2的数学地基与RAM消耗来源先算清楚账在动手写代码之前我必须先搞清楚一个问题SM2算法到底需要哪些运算单元每个单元要吃多少内存。这一步不做后面写出来的代码大概率内存爆掉。2.1 SM2算法需要哪些运算单元SM2本质上是一个基于椭圆曲线密码学ECC的公钥加密算法它最底层的计算单元可以拆成这么几层素域运算模加、模减、模乘、模逆。这些是EC点运算的地基。椭圆曲线点运算点加、倍点、标量乘法。SM2的加密和解密核心都是标量乘法。辅助运算密钥派生函数KDF、SM3哈希。随机数生成密钥生成和加密过程都需要高质量随机数。其中占用内存最大的是素域模乘和模逆的临时缓冲区以及点运算过程中保存中间结果的数据区。2.2 每个运算单元的内存开销SM2使用的素数域是256位的所以所有的大数都是256bit也就是32字节。以32字节大数为单位各个运算模块的内存开销如下运算单元需要的临时大数个数估算RAM占用字节说明模乘3-5个256bit大数96-160乘法的中间结果可能达到512bit需要额外缓冲模逆4-7个256bit大数128-224常用扩展欧几里得算法需要多组大数状态点加6-8个256bit大数192-256需要同时保存两个点的全部坐标倍点4-6个256bit大数128-192坐标运算中间量标量乘法10-20个256bit大数320-640循环过程中不仅要保存当前点还要保存中间结果KDF2-4个256bit大数64-128主要用SM3的递推这个表看起来单个模块占的不多但SM2加密过程中是“KDF 标量乘法 点加 点乘”一起工作实际运行时的峰值内存远大于单个模块。我刚开始低估了这个峰值第一次全速跑的版本RAM直接超了静态内存池被顶爆。2.3 数据类型的选型u8/u32还是专门的“大数类型”在STM32上实现SM2底层大数的表示方法有两种主流选择定长字节数组直接用uint8_t a[32]表示一个256bit大数。优点是直观、易于和标准测试向量对齐缺点是模乘运算时按字节处理效率低而且容易写出“内存拷贝地狱”。定长多精度数组用uint32_t a[8]表示一个256bit大数。每个元素是32bitCPU直接按字处理效率明显更高这也是 OpenSSL、mbedTLS 等成熟库的做法。我最终选择了uint32_t a[8]的方案同时定义了一套统一的大数访问接口。这个选择在以后和GmSSL交叉验证时非常方便因为主流算法库的底层也是这种表示方式。另外我定义了一个全局内存池用作所有临时缓冲区的分配来源。这个内存池的大小是编译期宏#define SM2_WORKSPACE_SIZE 4096 /* 按最坏情况预留4KB工作区 */ static uint8_t sm2_workspace[SM2_WORKSPACE_SIZE]; static uint32_t sm2_ws_offset 0;这个sm2_workspace就是整个算法的“临时舞台”所有运算的中间数据都从这里拿。需要注意这个偏移量是会被反复重置的不能把它当成普通堆来用。3. 静态内存布局设计从零到一画内存地图内存是我这个项目里最关键的资源所以整个静态内存布局一定要在设计阶段就画清楚不能在代码里走一步看一步。这里说的“布局”其实就是回答三个问题数据放哪里、缓冲区怎么分、生命周期如何管理。3.1 把密钥、上下文和临时工作区分开我最终画出的内存布局分为三大块每一块职责独立互不侵占密钥区存SM2私钥和公钥。私钥32字节公钥由x和y坐标组成各32字节所以一共96字节。这个区域默认只允许初始化函数写入一次后面其他函数只能读取。上下文区用于保存加密解密过程中需要跨函数共享的状态比如椭圆曲线参数、随机数源、当前操作的密钥指针。一个sm2_ctx_t结构体大小控制在1KB以内。工作区上一节提到的sm2_workspace专门给算法内部的临时大数、点运算中间数据用。这个区在每个运算入口做一次“分配偏移重置”运算结束后整体释放。这种划分有一个明显的好处密钥区和上下文区生命周期长不会频繁读写工作区生命周期短每次运算前统一重置避免了“上一次运算的残留数据污染下一次运算”的类问题。3.2 上下文结构体设计我设计的上下文结构体长这样typedef struct { const uint32_t *curve_p; /* 素数p */ const uint32_t *curve_a; /* 曲线参数a */ const uint32_t *curve_b; /* 曲线参数b */ const uint32_t *curve_n; /* 基点的阶n */ const uint32_t *gx; /* 基点G的x坐标 */ const uint32_t *gy; /* 基点G的y坐标 */ const uint8_t *priv_key; /* 私钥指针32字节 */ const uint8_t *pub_key; /* 公钥指针64字节x||y */ int (*rng)(uint8_t *out, size_t len); /* 随机数回调 */ uint8_t workspace_buf[SM2_WORKSPACE_SIZE]; /* 内嵌工作区 */ uint32_t ws_used; /* 工作区已用偏移 */ } sm2_ctx_t;这里我把工作区直接内嵌到了上下文里好处是整个SM2模块对外只需要一个sm2_ctx_t实例静态分配一次所有操作都围绕这个上下文展开不需要再单独定义一个全局的工作区。缺点是上下文结构体比较大4KB但放在全局静态区完全没问题。3.3 编译期缓冲区预留与对齐处理STM32的Cortex-M4核心对32位和64位访问有对齐要求如果你的大数数组没对齐轻则性能下降重则进HardFault。所以我强制所有缓冲区按8字节对齐static sm2_ctx_t sm2_ctx __attribute__((aligned(8)));在Keil环境下也可以这样写static sm2_ctx_t sm2_ctx __ALIGNED(8);这里也建议把整个数组定义放在文件作用域而不是某个函数内部因为栈在裸机上一般也就几KB你不想让一个4KB的结构体进栈。3.4 断点与实测验证确实没有用malloc内存布局完成之后不要急着觉得“应该没问题了”我用三种方法做了验证编译出的map文件查看sm2_ctx的地址和占用的RAM段确认它没有超出预期。代码审查在整个工程里搜索malloc、calloc、realloc和free确保SM2模块没有调用任何动态内存函数。运行时填充测试启动时给sm2_workspace区域全部填上0xAA执行完一次SM2加密后再打印这个区域看看哪些字节被改写过确认工作区的使用范围没有越界。其中第三步最实用我靠它抓到了好几个“临时大数写越界”的隐患。4. 核心实现密钥生成、加密、解密一步到位这一部分是整个项目的主菜。我会直接给出实现思路和核心代码片段重点放在关键路径上。完整的代码工程量很大建议读者在阅读时配合标准SM2规范一起看。4.1 大数运算核心模乘与模逆元SM2运算效率的天花板是模乘因为一次标量乘法需要几百次模乘。我用的是最经典的 Montgomery 乘法或者叫蒙哥马利约减它把“模乘后取模”转化为“乘法和移位”避免了对大整数做除法。核心函数签名如下void sm2_mont_mul(uint32_t *r, const uint32_t *a, const uint32_t *b); void sm2_mod_inv(uint32_t *r, const uint32_t *a);注意 Montgomery 乘法要求把输入先转换为 Montgomery 域所以我会在标量乘法开始前统一做一次预转换循环结束再过一次反变换。这个过程会多消耗一点RAM但能让每一次点运算的耗时显著降低。模逆元用的是扩展欧几里得算法或者二进制的GCD变种这里是RAM使用的重灾区。我把它改成迭代内复用同一个缓冲区避免每轮都申请新的中间变量。4.2 椭圆曲线点运算的静态化实现SM2曲线是素域曲线 y^2 x^3 ax b采用Jacobian投影坐标可以让点运算避免模逆大大提速。typedef struct { uint32_t x[8]; uint32_t y[8]; uint32_t z[8]; } jacobian_point_t;一个jacobian_point_t占96字节。标量乘法过程需要维护两个点一个当前点一个累加点外加若干临时点。我算过最多需要5个这个类型的变量也就是480字节。我把它们全部作为函数内部的局部变量声明但由于总大小有限不会被栈溢出压垮而且在优化编译时可以用寄存器/静态区做存储。4.3 密钥生成与公钥导出密钥生成流程从RNG回调中读取32字节随机数。把随机数转为大数检查是否在 [1, n-2] 范围内。以这个数为私钥 d计算公钥 P dG。保存 d32字节和 P64字节。在代码上我用了一个“一次性写入”保护int sm2_generate_keypair(sm2_ctx_t *ctx) { uint8_t priv[32]; jacobian_point_t pub_j; uint32_t point_buf[16]; /* 做仿射坐标转换时用 */ do { if (ctx-rng(priv, sizeof(priv)) ! 0) return SM2_ERR_RNG; /* 检查小范围边界条件 */ } while (!check_scalar_range(priv)); memcpy((uint8_t *)ctx-priv_key, priv, 32); scalar_base_multiply(pub_j, priv); jacobian_to_affine(point_buf, pub_j); pack_point(ctx-pub_key, point_buf); return SM2_OK; }4.4 SM2加密的完整流程SM2 公钥加密的流程生成随机数 k。计算点 C1 kG。计算点 S kP其中 P 是接收方公钥。若S是无穷远点报错。从S的x、y坐标派生密钥 t KDF(x2 || y2, klen)。加密原文 MC2 M XOR t。计算 C3 SM3(x2 || M || y2)。输出密文 C C1 || C3 || C2标准顺序可能因厂商而异协商一致即可。在我的实现里整个加密流程是这样的int sm2_encrypt(sm2_ctx_t *ctx, const uint8_t *pubkey, const uint8_t *plain, size_t plain_len, uint8_t *cipher, size_t *cipher_len) { uint8_t k[32]; jacobian_point_t c1_point, s_point; /* 工作区重置 */ workspace_reset(ctx); /* Step 1: 随机数 k */ ctx-rng(k, sizeof(k)); /* Step 2: C1 kG */ scalar_base_multiply(c1_point, k); point_to_affine_bytes(cipher, c1_point); /* C1: 64字节 */ /* Step 3: S kP */ scalar_mul(s_point, (const uint32_t *)pubkey, k); if (point_is_infinity(s_point)) { return SM2_ERR_POINT_INFINITY; } /* Step 4: KDF */ // kdf_bytes(ctx, x2, y2, keystream, plain_len); // Step 5: C2 // Step 6: C3 SM3(x2 || M || y2) *cipher_len 64 32 plain_len; /* C1 || C3 || C2 */ return SM2_OK; }这里有个细节workspace_reset(ctx)只是把ctx-ws_used清0并没有真正清零工作区数据。如果你想提高安全性可以在reset时把ctx-workspace_buf整个用简单异或或者写乱码覆盖防止敏感中间量残留在内存中。4.5 SM2解密与错误防御解密流程从密文中分离 C1、C3、C2。计算 S dC1并验证S不是无穷远点。从S坐标派生t KDF(x2 || y2, C2长度)。明文 M C2 XOR t。计算 C3 SM3(x2 || M || y2)和收到的C3比对。一致则输出明文不一致则返回解密失败。一个重要的防御点解密过程中如果校验失败绝对不能直接返回“C3不匹配”和“密钥错误”这么细的错误码否则等于给攻击者提供了区分明文是否合法的padding oracle。我在实现中统一返回SM2_ERR_DECRYPT细节不向调用方透露。int sm2_decrypt(sm2_ctx_t *ctx, const uint8_t *cipher, size_t cipher_len, uint8_t *plain, size_t *plain_len) { if (cipher_len 64 32 1) return SM2_ERR_LEN; const uint8_t *c1 cipher; const uint8_t *c3 cipher 64; const uint8_t *c2 cipher 64 32; size_t c2_len cipher_len - 64 - 32; jacobian_point_t s_point; scalar_mul(s_point, (const uint32_t *)ctx-priv_key, (const uint32_t *)c1); if (point_is_infinity(s_point)) return SM2_ERR_DECRYPT; /* 取x2, y2 */ /* kdf - keystream, xor - M */ /* sm3(x2||M||y2) c3 ? */ if (!validate_c3) { return SM2_ERR_DECRYPT; } memcpy(plain, c2, c2_len); *plain_len c2_len; return SM2_OK; }5. 加密解密测试自测、交叉验证、性能数据代码写完只是第一步真正让这个项目“可信”的是测试。 SM2是密码算法不是普通业务逻辑任何差错都是致命的。我做了三层测试单元自测、交叉验证、性能实测。5.1 单元自测与边界用例自测阶段我用固定私钥做了一组回归向量用例名输入预期输出通过/失败密钥生成固定随机数种子私钥和公钥与GmSSL生成一致通过加密-解密回环随机密钥对随机明文解密结果等于明文通过空明文明文长度为0应失败标准不推荐通过边界长度明文1字节、31字节、32字节、33字节、1024字节加解密回环一致通过篡改密文翻转C2的某一bit解密失败返回统一错误通过篡改C3翻转C3的某一bit解密失败通过无穷远点构造kP结果为无穷远加密失败通过这里我特别说一下“空明文”的用例。SM2标准虽然技术上可以处理任意长度的明文但在实际工程里我用一个受控的拒绝策略如果明文长度为0直接返回错误。这能避免和某些对端实现出现语义不一致的问题。5.2 与主流工具库的交叉验证自测能证明“自己能解自己加的东西”但不能证明“别人能解你的东西”。为了确认实现符合标准我用GmSSL做交叉互操作测试。具体做法是在PC上用GmSSL生成一对SM2密钥私钥、公钥。把公钥导成C语言数组烧到STM32里。STM32用这把公钥加密一段明文把密文导出来。在PC上用GmSSL的私钥解这段密文看能否还原明文。再用GmSSL的私钥加密一段数据STM32用对应的公钥解密。这个流程我跑了三轮前两轮都挂在同一个地方密文的字节序问题。SM2规范的曲线参数是十六进制大端但GmSSL导出的公钥在多字节数组里的排列方式和我的内部字节序不一致。这个问题在联调时非常隐蔽因为光看代码看不出问题只有在互操作测试中才会暴露。解决办法是写一个转换层在与外部系统交互时统一使用大端字节序。5.3 性能实测与优化方向性能数据是决定这套方案能不能实际用的关键。在STM32F407VET6、168MHz主频、Keil MDK -O3优化下我的实测结果如下操作平均耗时密钥生成约 85ms公钥加密明文64字节约 105ms私钥解密密文160字节约 98ms总RAM占用约 5.8KB不含SM3哈希的额外缓冲这个数字比我预期的好一些主要收益来自Montgomery乘法 Jacobian坐标的搭配。如果平台是STM32F10372MHz主频时间会翻倍到200ms上下。对于固件升级、密钥协商这类非实时操作这个性能完全可以接受但如果要做大数据量的在线加密就不合适了应该用SM4做数据加密SM2只做密钥协商或签名。优化方向也顺手说一句开启编译器的-O3或 Keil 的-O3 -Otime性能提升明显。模逆元目前是瓶颈之一可以考虑用Montgomery逆替代部分二进制GCD。如果还有余量可以尝试把点乘中的固定基标量乘法改用预计算表能显著减少点加次数但会多占几KB的Flash。6. 这几个工程细节能帮你少踩一半的坑这一节我想把代码之外、但直接影响项目成不成的问题单独拿出来讲。单看算法层面的坑是可预期的真正让人头疼的往往是环境、存储、随机数这类交叉问题。6.1 临时缓冲区的生命周期管理在静态内存分配方案里最容易犯的错不是内存不够而是“缓冲区被提前复用”。因为所有临时大数都来自同一个工作区而工作区每次运算都会重置所以不要让任何大数指针跨越函数边界长时间存活。我定的规矩是凡是从workspace里拿的内存只允许在当前函数和它直接调用的子函数中使用一旦函数返回指向该区域的指针就视为失效。这样虽然多了一些代码上的限制但换来的是整个算法模块的可推理性和稳定性。如果你觉得自己记不住可以在调试版本里给workspace_reset()加一个断言如果某个关键数据区的地址不在当前work_used范围内就直接报错。这类运行时保护能少很多通宵排查的时间。6.2 随机数源真随机还是伪随机SM2密钥生成的随机性要求很高如果随机数可预测私钥就等于直接暴露。所以必须在STM32上接入真随机数源STM32F407自带硬件RNG模块写一个简单的回调函数把它包装成SM2需要的rng接口static int stm32_rng_cb(uint8_t *out, size_t len) { size_t i; for (i 0; i len; i 4) { uint32_t r RNG_GetRandomNumber(); /* 由HAL或标准库提供 */ memcpy(out i, r, sizeof(r)); } return 0; }注意硬件RNG在环境温度变化、电压波动的情况下可能存在偏差分布或故障状态所以最好在启动时对RNG做一次简单自检比如连续读若干个随机数统计是否出现全0或全1的不正常序列。如果RNG不可用就退回伪随机数如采用基于系统tick和无规则调用的混合伪随机源但要在日志里明确标注这是开发模式不能用于生产。6.3 密钥存储与掉电安全纯软件SM2的私钥最终肯定要存在Flash里而Flash在芯片上是可以被物理读取的。针对这个现实我的处理是私钥定义在一个独立的段里防止被优化器合并或者误覆盖。在量产程序里私钥以外部注入方式写入不会出现在固件镜像的明文里。可以再叠加一层简单的XOR混淆或者SM4的密钥保护壳提高提取门槛。如果你做的是安全性要求高的产品我还是建议增加一颗低成本的安全芯片来专门存根密钥把SM2的核心密钥交还给它保管。纯软件的方案适合“防君子不防小人”的绝大多数物联网场景。6.4 除了内存还要防“时间侧信道”还有一个容易被忽视的细节SM2的标量乘法如果采用“根据密钥bit选择倍点/点加”的简单实现运行时间和能耗会泄露私钥的比特信息。虽然STM32跑固件升级签名验证时攻击者很难观测功耗波形但在“防得住”这件事上最好一步到位。我的做法是把标量乘法的执行路径写成了固定时长循环不论密钥bit是0还是1每轮都执行相同次数的点加和倍点运算再把结果通过掩码/选择逻辑决定使用哪个值。这样运行时间对输入密钥不敏感。6.5 跨平台的向量化与编译优化差异同一个C文件在Keil里编过不等于在GCC里表现一样。我遇到过两种坑Keil默认char是unsignedGCC默认char是signed如果你用int8_t/uint8_t类型不严谨移位运算结果会因平台差异而不同。大数乘法里用到__int128扩展类型在Cortex-M4上并不一定高效支持用GCC可以用Keil的ARMCC就不行。所以实际实现我尽量用纯32位乘法累加的方式不用依赖128位的扩展。建议在做跨平台交叉验证时至少编译一个PC版本和一个STM32版本用同一组测试向量回归。这个习惯养成之后很多“平台上行为不一致”的问题都能提前暴露。结尾我在这个项目里投入最多时间的地方不是算法本身的数学公式而是把算法塞进MCU有限RAM的过程。静态内存分配的好处做工程的都懂但真正把每个临时缓冲区、每个中间量都在编译期安排得明明白白需要反复雕琢。如果让我重新做一遍我会更早把“与GmSSL的互操作测试”提上日程——字节序问题差点让我误以为核心算法写错了但实际上只是排列方式的差异。这也是我想提醒读到这里的读者SM2最终是要跟别的系统互通的不是自嗨就完事。测试向量和互操作验证越早做越好。我最后还留了一个小扩展方向现在这套基础只完成了SM2加密解密下一步可以继续加SM2签名验签以及SM3哈希、SM4对称加解密凑满一整套国密算法库。内存布局和静态分配思路是通用的后面扩展也只是工作量问题。希望这篇博客能帮你避开我踩过的几个典型坑在国产算法和MCU结合的这条路上少走弯路。本文还有配套的精品资源点击获取