资讯动态

8位MCU上跑SM2国密算法:内存优化与C语言实现指南

发布时间:2026/9/13 21:05:08 来源:尧图企业网站定制
简介面向8位微控制器环境的SM2算法实现采用纯C语言编写不依赖OpenSSL等第三方库适合嵌入式开发、物联网安全相关开发者参考学习。代码基于nano-ecc改造针对SM2推荐曲线参数完成了大数模运算优化完整实现了256位SM2签名与验签流程有助于理解在资源受限设备上部署国密算法的工程思路。压缩包总计8个文件包含3个C源码、1个头文件、2个Makefile以及License和README说明文档整体仅13KB结构轻量可快速移植到MCS-51、AVR等8位MCU平台。目前已有488人学习下载README中提供了详细使用方法和改动说明适合对照源码逐段分析大数模运算、签名/验签实现细节的开发者作为入门参考资料。1. 8 位 MCU 上做 SM2瓶颈不是算法而是 RAMSM2 是国密体系里的公钥密码算法负责数字签名和密钥交换它和 SM4 这类分组密码不在同一层拿它和 AES256 比没有可比性。真正让人头疼的是椭圆曲线点乘要算 256 位大数而 8 位微控制器往往只有 1~2KB RAM。把 SM2 跑在 8051、AVR 这类芯片上常规思路是直接移植 OpenSSL 或 GmSSL 的大数库结果代码体积和内存占用立刻失控。这个问题适合两类人一类是做电力、物联网终端国密改造的嵌入式工程师一类是评估“最小可用实现”边界的安全从业者。本文顺着一个可复现的最小 C 语言实现讲清楚内存布局、模运算、点乘和签名验签流程里那些容易被忽略的细节。2. 用 C 语言把 256 位整数拆成字节数组内存布局与模运算的取舍整个实现的地基是 256 位大数。8 位 MCU 上没有原生的 32 位乘法指令也没有硬件除法器所以大数的内存布局直接决定后续每一行代码的效率。下载包里通常只需要三个文件sm2.h、sm2_math.c、sm2_sign.c其中sm2_math.c是全部性能瓶颈所在。/* sm2.h */ #ifndef SM2_H #define SM2_H #include stdint.h typedef struct { uint8_t d[32]; /* 256 位大数大端字节序 */ } bint; void bint_add(bint *r, const bint *a, const bint *b); void bint_sub(bint *r, const bint *a, const bint *b); int bint_cmp(const bint *a, const bint *b); void bint_inv(bint *r, const bint *x, const bint *mod); void bint_mul_mod(bint *r, const bint *a, const bint *b, const bint *mod); #endifbint用 32 字节数组表示一个 256 位数索引 0 是最高字节。大端序的好处是调试时直接对照 SM2 标准文档里的十六进制参数不用做字节翻转坏处是移位和进位传播要顺着数组从尾往头走。对于 8 位机这个选择是值得的。2.1 为什么不用 uint32_t8 位乘法器的进位成本在 Cortex-M 上理所当然用uint32_t数组但 8 位 MCU 上这么写是陷阱。AVR 和 8051 的 8 位乘法指令一次只能乘出 16 位结果uint32_t的乘法和进位传播会被编译器展开成一长串 8 位操作RAM 里还要多存 4 字节对齐的临时变量。改用uint8_t[32]后编译器能直接把数组下标映射成寄存器间接寻址代码体积更小可预测性也更强。另一个附带好处是绕开了指针别名问题——全程用数组下标操作编译器可以做更多优化。这种“用下标不用指针”的写法在 8 位 C 编译器上生成的代码通常更紧凑排查内存覆盖时也更容易在调试器里按字节检查。缺点是人写起来啰嗦但换来的是整个大数层只需要 3 个函数。2.2 大数加减法uint16_t 进位链的正确写法加减法是模运算的基础正确性比性能重要。下面的加法函数展示了 8 位机上最稳妥的进位传播方式。/* sm2_math.c */ void bint_add(bint *r, const bint *a, const bint *b) { uint16_t carry 0; int i; for (i 31; i 0; i--) { uint16_t t (uint16_t)a-d[i] b-d[i] carry; r-d[i] (uint8_t)t; carry t 8; } } void bint_sub(bint *r, const bint *a, const bint *b) { uint16_t borrow 0; int i; for (i 31; i 0; i--) { uint16_t t (uint16_t)a-d[i] - b-d[i] - borrow; r-d[i] (uint8_t)t; borrow (t 8) 1; } }carry和borrow用uint16_t而不是int关键在t a b carry这行不会溢出 16 位编译器能直接识别成带进位加法指令AVR 的ADC、8051 的ADDC。循环从 31 递减到 0因为索引 0 是最高字节进位要从低位向高位传递。减法同理borrow借位信号通过高 8 位是否为 1 判断。这两个函数不处理结果溢出或负数调用方必须在模运算语境下自行保证结果落在合法区间。2.3 模逆与模乘没有硬件除法器时的两个选择SM2 点运算在仿射坐标下每做一次点加就需要一次模逆所以模逆的效率直接影响整体性能。8 位 MCU 上最常见的两套方案是二进制扩展欧几里得算法和费马小定理。费马小定理要做约 256 次模乘在 8 位机上代价偏高二进制扩展欧几里得只用移位和加减更适合没有除法器的环境。2.3.1 二进制扩展欧几里得求模逆void bint_inv(bint *r, const bint *x, const bint *mod) { bint u, v, x1, x2; bint_copy(u, x); bint_copy(v, mod); bint_set_uint8(x1, 1); bint_set_zero(x2); while (!bint_is_zero(u) !bint_is_zero(v)) { if (!(u.d[31] 1)) { /* u 为偶数 */ bint_shr1(u); if (!(x1.d[31] 1)) bint_shr1(x1); else { bint_add(x1, x1, mod); bint_shr1(x1); } } else if (!(v.d[31] 1)) { /* v 为偶数 */ bint_shr1(v); if (!(x2.d[31] 1)) bint_shr2(x2); else { bint_add(x2, x2, mod); bint_shr1(x2); } } else if (bint_cmp(u, v) 0) { bint_sub(u, u, v); if (bint_cmp(x1, x2) 0) bint_add(x1, x1, mod); bint_sub(x1, x1, x2); } else { bint_sub(v, v, u); if (bint_cmp(x2, x1) 0) bint_add(x2, x2, mod); bint_sub(x2, x2, x1); } } if (bint_is_zero(u)) bint_copy(r, x2); else bint_copy(r, x1); }这个算法的物理含义是把求x在模mod下的逆元转化成对两个数的辗转相除同时维护两个系数x1、x2跟踪u和v与初始值的关系。每次u或v除以 2 时对应的系数也必须除以 2如果除不尽就先加一个mod再除。两个系数相减时如果出现负值同样加mod拉回正数。循环结束时u或v为 0另一个就是最大公约数而它对应的系数就是逆元。SM2 的曲线参数p和阶n都是素数最大公约数恒为 1所以结果必然存在。2.3.2 长除法取模可复现但慢优化往 Montgomery 走模乘的实现是全文最容易写错的地方。一个 256 位乘 256 位的积是 512 位直接对p做长除法最稳妥。下面的mod_512把 512 位积逐位移入余数每移入一位做一次比较和条件减法。/* t 为 64 字节大端积p 为 32 字节模数结果写入 r */ static void mod_512(const uint8_t t[64], const uint8_t p[32], uint8_t r[32]) { uint8_t rem[32] {0}; uint16_t i, j; for (i 0; i 512; i) { uint8_t bit (t[i 3] (7 - (i 7))) 1; uint8_t carry bit; for (j 31; j ! 0xFF; j--) { /* rem 左移 1 位大端 */ uint8_t nc rem[j] 7; rem[j] (rem[j] 1) | carry; carry nc; } if (memcmp(rem, p, 32) 0) { /* rem p 就减一次 */ uint8_t borrow 0; for (j 31; j ! 0xFF; j--) { uint16_t d rem[j] - p[j] - borrow; rem[j] d 0xFF; borrow (d 8) 1; } } } memcpy(r, rem, 32); }这个函数每次循环最多减一次p原因是上一轮结束时rem p左移一位后最多接近2p减一次就回到[0, p)。正确性容易验证但速度一般一次模约减要跑 512 轮移位和比较。在 16MHz 的 8051 上做一个 256 位模乘大约要几毫秒整条标量乘下来要数十秒。生产环境里我一般会换成 Montgomery 模乘利用R 2^256的预计算把除法转换成乘法和移位代价是多两个常量R mod p和R^2 mod p。如果只想先跑通流程这个长除法版本完全够用。3. 点运算只留三件套点加、点倍、Montgomery Ladder 标量乘椭圆曲线点运算有仿射坐标、射影坐标、雅可比坐标等多种表示。8 位 MCU 上选择坐标系统的标准不是数学上的优雅而是 RAM 峰值。仿射坐标下点就是(x, y)两个 32 字节数结构体只有 65 字节但每次点加都要一次模逆。雅可比坐标免去模逆让点加变成纯乘加运算但每个点要维护X, Y, Z三个数临时变量轻松超过 200 字节。对于 1KB RAM 的芯片我一般选仿射坐标把模逆写得足够快整体占用反而更可控。typedef struct { bint x; bint y; uint8_t inf; /* 1 表示无穷远点 */ } ec_point;inf字段处理无穷远点也就是单位元。点加公式里如果忽略无穷远点会出现除零或者错误分支。3.1 仿射坐标点加倍点、互反点、常规点三路分支仿射坐标下P Q的斜率 λ 有两种情况P ! Q时λ (y2 - y1) / (x2 - x1)P Q时λ (3x1^2 a) / (2y1)。SM2 曲线的参数a p - 3所以3x^2 a可以写成3x^2 - 3省一次取模。/* 仿射坐标点加R P Q结果写回 R */ void ec_add(ec_point *R, const ec_point *P, const ec_point *Q) { bint lam, t1, t2; if (P-inf) { *R *Q; return; } if (Q-inf) { *R *P; return; } if (bint_cmp(P-x, Q-x) 0) { if (bint_cmp(P-y, Q-y) 0) { /* 倍点λ (3x^2 a) / (2y)SM2 中 a p - 3 */ bint_mul_mod(t1, P-x, P-x); /* x^2 */ bint_add(t1, t1, t1); bint_add(t1, t1, t1); /* 3x^2 */ bint_sub_p(t1, t1); /* 3x^2 - 3即 3x^2 a */ bint_add(t2, P-y, P-y); /* 2y */ bint_inv(t2, t2, sm2_p); bint_mul_mod(lam, t1, t2); } else { R-inf 1; /* P (-P) 无穷远点 */ return; } } else { /* 常规点加λ (y2 - y1) / (x2 - x1) */ bint_sub_mod(t1, Q-y, P-y); bint_sub_mod(t2, Q-x, P-x); bint_inv(t2, t2, sm2_p); bint_mul_mod(lam, t1, t2); } /* x3 λ^2 - x1 - x2 */ bint_mul_mod(t1, lam, lam); bint_sub_mod(t1, t1, P-x); bint_sub_mod(t1, t1, Q-x); R-x t1; /* y3 λ(x1 - x3) - y1 */ bint_sub_mod(t2, P-x, R-x); bint_mul_mod(t2, lam, t2); bint_sub_mod(t2, t2, P-y); R-y t2; R-inf 0; }bint_sub_mod和bint_add不同它在减法后立刻判断结果是否为负是负数就加一次模数拉回[0, p)。这个步骤必须有否则后续模乘的输入会带着符号位参与运算结果一致但代码调试起来特别痛苦。倍点分支里3x^2 - 3的模减同样由bint_sub_p完成。注意这里所有模运算都用sm2_p而不是签名时用的sm2_n。3.2 标量乘从左到右的 Montgomery Ladder点乘[k]P是整个 SM2 里最耗时的操作。朴素的双倍加算法按k的每一位做双倍和条件加执行时间随k的汉明重量变化侧信道攻击代码很容易通过时间差还原私钥。Montgomery Ladder 的写法是每一位都做一次双倍和一次点加两个操作交替进行执行路径与位值无关。void ec_mul(ec_point *R, const bint *k, const ec_point *P) { ec_point R0, R1; int i; R0.inf 1; /* R0 无穷远点 */ R1 *P; /* R1 P */ for (i 255; i 0; i--) { uint8_t bit (k-d[i 3] (7 - (i 7))) 1; if (bit) { ec_add(R0, R0, R1); /* R0 R0 R1 */ ec_add(R1, R1, R1); /* R1 2 * R1 */ } else { ec_add(R1, R0, R1); /* R1 R0 R1 */ ec_add(R0, R0, R0); /* R0 2 * R0 */ } } *R R0; }循环从最高位扫到最低位确保对同一个k的任意两个值循环体执行的加法次数完全一样。R0和R1两个点各占 65 字节加上临时变量这个函数峰值 RAM 在 260 字节左右对 8 位机是可以接受的。性能方面一次标量乘要做 256 轮、每轮两次点加也就是约 512 次仿射点加每次点加含一次模逆和两次模乘。这就是前面模运算性能如此重要的原因。4. SM2 签名与验签把国密流程压进 300 字节 RAMSM2 数字签名的数学流程不长但每一步都在消耗 RAM。签名需要k、私钥dA、公钥PA、摘要e、临时点(x1, y1)、结果r, s这些数据不能同时存在否则 1KB RAM 直接爆掉。解决办法是精心安排生命周期让不同阶段的变量复用同一块内存。4.1 签名流程的 RAM 预算表与复用方案下表是签名过程中所有大数变量的布局。单位是字节括号里表示与上一阶段复用的变量。阶段变量字节数说明预处理Z, e64SM3 输出先算 Z 再算 e生成 kk32随机数需真随机源点乘R0, R1 (复用 Z/e 区)130临时点点乘结束后释放计算 rr, x164x1 是点乘结果的 x 坐标计算 sdA, k, r, s128私钥在最后阶段才参与按这个表布局峰值出现在点乘阶段R0, R1两个点 130 字节加上k和当前正在用的曲线常量整体控制在 260 字节内。剩下的 RAM 留给 SM3 的上下文88 字节和调用栈1KB 的芯片能跑。4.2 SM3 摘要怎么进签名Z 值与 e 的计算顺序SM2 签名不是直接对消息M做 SM3而是先算一个Z值再把Z || M一起做摘要。Z的计算公式是Z SM3(ENTLA || IDA || a || b || xG || yG || xA || yA)其中ENTLA是 ID 长度IDA的 2 字节大端表示。国密标准里默认 ID 是字符串1234567812345678十六进制是31323334353637383132333435363738长度 16所以ENTLA 0x0080。a, b, xG, yG是曲线参数xA, yA是签名者公钥坐标。注意Z值计算一次即可同一对密钥验多次签名时可以把Z缓存起来。/* 默认 ID1234567812345678 */ static const uint8_t default_id[] { 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38 }; void sm2_compute_z(uint8_t z[32], const ec_point *pub) { sm3_ctx ctx; uint16_t id_len sizeof(default_id); sm3_init(ctx); sm3_update(ctx, (uint8_t[]){ id_len 8, id_len 0xFF }, 2); sm3_update(ctx, default_id, sizeof(default_id)); sm3_update(ctx, sm2_a.d, 32); sm3_update(ctx, sm2_b.d, 32); sm3_update(ctx, sm2_gx.d, 32); sm3_update(ctx, sm2_gy.d, 32); sm3_update(ctx, pub-x.d, 32); sm3_update(ctx, pub-y.d, 32); sm3_final(ctx, z); }ENTLA高字节在 8 位机上直接写成id_len 8不要用htons之类的宏避免引入字节序依赖。sm3_update和sm3_final用流式接口这样曲线参数可以从 Flash 里一段段喂进去不必在 RAM 里拼出完整消息。4.3 生产环境的随机数不能直接用 rand()签名里的k必须是均匀分布的随机数且每次签名都要重新生成。标准库的rand()是线性同余发生器序列可预测私钥会被侧信道手段还原。8 位 MCU 上常见的做法是采集 ADC 噪声、时钟抖动或者片上温度传感器的低有效位喂给一个基于 SM3 的 PRNG 做后处理。没有硬件随机源的芯片至少也要用k参与生成下一个k的方式做链式更新。/* 签名主流程骨架 */ int sm2_sign(const bint *dA, const ec_point *pub, const uint8_t *msg, uint32_t len, bint *r, bint *s) { uint8_t e[32], z[32]; bint k, x1, t; ec_point pt; sm2_compute_z(z, pub); sm2_hash(z, msg, len, e); /* e SM3(Z || M) */ do { random_bytes(k, 32); /* 真随机源或 PRNG */ ec_mul(pt, k, sm2_G); /* (x1, y1) k * G */ bint_add(r, e, pt.x); bint_mod_n(r); /* r (e x1) mod n */ bint_add(t, k, r); if (bint_is_zero(r) || bint_cmp(t, sm2_n) 0) continue; /* 条件不满足重新生成 k */ bint_mul_mod(t, r, dA); /* r * dA */ bint_sub(t, k, t); bint_mod_n(t); /* k - r*dA mod n */ bint_add(x1, dA, sm2_one); bint_inv(x1, x1, sm2_n); /* (1 dA)^-1 mod n */ bint_mul_mod(s, x1, t); /* s ... mod n */ } while (bint_is_zero(s)); return 0; }这段代码里有两个必须检查的失败条件r 0和r k n。前者会导致签名退化后者会让s的计算失效。随机数k的生成质量直接决定这两个条件出现的概率好的随机源几乎不会重试差的随机源这里会变成性能瓶颈。bint_mod_n是对n取模和之前的bint_mul_mod不同这里的模数是曲线的阶不是素数p。4.4 验签先检 r/s 区间再算 t验签的数学过程是t (r s) mod n然后算(x1, y1) [s]G [t]PA最后检查(e x1) mod n r。这里最常见的安全漏洞是不检查r和s是否落在[1, n-1]。边界值r 0或s 0可以通过构造特殊签名绕过验签所以第一步必须是区间检查。int sm2_verify(const ec_point *pub, const uint8_t *msg, uint32_t len, const bint *r, const bint *s) { uint8_t e[32], z[32]; bint t, x1; ec_point p1, p2, sum; if (bint_is_zero(r) || bint_is_zero(s)) return 0; if (bint_cmp(r, sm2_n) 0 || bint_cmp(s, sm2_n) 0) return 0; sm2_compute_z(z, pub); sm2_hash(z, msg, len, e); /* e SM3(Z || M) */ bint_add(t, r, s); bint_mod_n(t); /* t (r s) mod n */ if (bint_is_zero(t)) return 0; ec_mul(p1, s, sm2_G); /* [s]G */ ec_mul(p2, t, pub); /* [t]PA */ ec_add(sum, p1, p2); /* 两个点做一次点加 */ if (sum.inf) return 0; bint_add(x1, e, sum.x); bint_mod_n(x1); /* (e x1) mod n */ return bint_cmp(x1, r) 0; }验签比签名多做一次标量乘RAM 占用也更高因为p1、p2两个点同时存在。如果芯片 RAM 实在吃紧可以改成先算[s]G释放内存、再算[t]PA最后加一个点加但那样要多算一次模逆时间上不划算。区间检查和t 0的检查顺序不能颠倒t 0意味着r s是n的倍数标准里规定直接失败。5. 编译选项、Flash 常量与自检三板斧代码写完之后真正决定能不能跑到目标芯片上的是编译选项和存储布局。8 位 MCU 的 C 编译器差异很大但有几个通用做法能立刻把 RAM 占用降下来。5.1 用 -Os 和 code 段把常量赶出 RAM首先是编译优化级别统一用-Os。-O2虽然快但循环展开和函数内联会吃掉大量 Flash-Os在代码体积和性能之间最平衡。其次是常量存储。SM2 的曲线参数p, n, a, b, Gx, Gy一共 6 个 32 字节数组如果直接写static const bint sm2_p很多 8 位编译器会把它复制到 RAM 再访问白白吃掉 192 字节。必须显式放到 Flash 段Keil C51 用code关键字AVR-GCC 用PROGMEMIAR 用__flash。/* 以 IAR 的 __flash 为例 */ __flash const uint8_t sm2_p[32] { 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };注意这里sm2_p是裸数组类型而不是bint结构体。访问时需要强制转换或直接传入const uint8_t*给底层函数。我一般会在sm2_math.c内部用宏做一层封装避免上层代码到处写类型转换。5.2 曲线参数自检用最少的测试向量抓内存覆盖项目里没有现成参考实现时先用最少的测试向量验证基本运算而不是直接跑完整签名。自检顺序从底层往上层加减法用p 1是否等于2^256的大端表示模逆用x * inv(x) mod p 1曲线参数用yG^2 xG^3 a*xG b mod p点乘用[1]G G和[2]G的坐标与文档对照。这三层都过了签名验签流程才值得调试。int self_test(void) { bint inv, prod, y2, x3; ec_point pt, kpt; /* 模逆自检x * inv(x) mod p 1 */ bint_inv(inv, sm2_Gx, sm2_p); bint_mul_mod(prod, sm2_Gx, inv, sm2_p); if (memcmp(prod.d, sm2_one.d, 32) ! 0) return 1; /* 曲线方程自检y^2 x^3 a*x b */ bint_mul_mod(y2, sm2_Gy, sm2_Gy, sm2_p); bint_mul_mod(x3, sm2_Gx, sm2_Gx, sm2_p); bint_mul_mod(x3, x3, sm2_Gx, sm2_p); if (memcmp(y2.d, x3.d, 32) ! 0) return 2; /* 点乘自检[1]G G */ ec_mul(kpt, sm2_one, sm2_G); if (memcmp(kpt.x.d, sm2_Gx.d, 32) ! 0) return 3; return 0; }这类自检代码最值钱的地方不在“测试通过”而在“失败时能定位到层”。自检失败返回码 1、2、3 分别对应模运算、曲线参数、点乘三个不同层面配合调试器断点可以快速确认是底层大数写错还是曲线常量抄错。通常曲线常量抄错是最常见的问题尤其是从 PDF 文档复制十六进制字符串时多一个或少一个字节。5.3 中断、看门狗与标量乘的时序陷阱最后提醒一个 8 位机上特有的坑标量乘是长任务一次ec_mul可能要占据 CPU 几百毫秒到几秒。如果期间有中断频繁触发尤其是 ADC 或定时器中断里调用库函数可能破坏大数运算的临时变量。调试这类问题的方法是先把所有中断关掉跑通自检后再逐个开启。另外看门狗喂狗要放在标量乘循环内部不能在ec_mul的前后各喂一次否则运行途中看门狗复位现象表现为随机性签名失败。在ec_mul的主循环里每 32 轮喂一次狗既不影响时序又能保证长任务不被复位。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价