STM32上实现RSA加密:大数运算与Montgomery模乘实战
2026/8/31 5:53:47 网站建设 项目流程

简介:本资源是一个面向嵌入式安全开发者的STM32平台RSA2048加解密实战项目,聚焦于在资源受限的Cortex-M3微控制器(STM32F10x系列)上实现完整的非对称密码运算,解决物联网终端数据加密、固件签名验证等典型安全需求。压缩包共127个文件,含46个头文件(.h,定义接口与结构体)、43个源文件(.c,涵盖BSP驱动、RSA核心算法、PKCS#1填充及主应用逻辑)、17个编译中间文件(._2i,反映Keil工程配置细节),以及Readme说明、清理脚本和Keil工程文件(.uvprojx/.uvoptx),整体体积仅416KB,体现高度裁剪与嵌入式适配性。项目采用模块化设计:rsa目录封装可复用的密码库,app与User组织业务逻辑,Bsp和STM32F10x_FWLib保障硬件抽象,CORE提供内核支持,结构清晰利于学习移植。已有20人下载学习,读者可直接获取完整可编译工程、串口交互验证流程、内存优化实践及密钥预置方案,快速掌握MCU端RSA落地的关键技术路径。 搞嵌入式这行的,早晚会碰上一次“在STM32上跑RSA”的需求。可能是做安全固件升级,可能是做设备身份认证,也可能是给通信数据加个密。我这次拿到的是一个名为“stm32_RSA.zip”的项目工程,解压之后其实就是一个完整的STM32+RSA实现:大数运算、模幂计算、加解密接口、公私钥数据存储,全都有。这个标题看起来简单,但里面涉及的东西并不少——大数运算在MCU上的实现方式、内存怎么安排、性能怎么优化、标准库和HAL库怎么配合,都是实打实的工程问题。这篇就从这个工程出发,把STM32上实现RSA的思路、关键代码、踩坑记录都摊开讲清楚,适合正在做安全功能、或者准备在资源受限设备上跑非对称加密的开发者参考。


1. 项目核心思路与设计拆解

1.1 为什么要在STM32上实现RSA

很多人的第一反应是:STM32性能这么弱,跑RSA这种重量级非对称加密,是不是有点自找麻烦?其实这个想法在几年前还算合理,但放到现在的MCU环境下已经不太成立了。以STM32F407为例,主频168MHz,带硬件浮点单元,Flash有1MB,RAM有192KB,跑1024位RSA的私钥操作大概在几十毫秒到几百毫秒这个量级,完全在可接受范围内。即便是入门级的STM32F103,主频72MHz,只要算法实现得当,1024位RSA仍然可以工作,只是时间会长一些。

真正推动MCU上RSA需求的,是物联网设备的安全诉求。设备要验证固件签名、要建立安全信道、要做双向身份认证,这些场景都需要非对称加密。AES虽然快,但密钥分发是硬伤;RSA虽然慢,但公钥可以公开分发,私钥本地保管,天然适合设备认证场景。STM32项目里加入RSA,最常见的几个用途就是:

  • 固件签名验证:Bootloader用RSA公钥校验APP固件签名,防止固件被篡改
  • 设备身份认证:设备持有RSA私钥,服务器用公钥验证设备身份
  • 安全通信握手:RSA交换会话密钥,后续用AES加解密业务数据

所以这个“stm32_RSA.zip”工程,本质上解决的是一个非常现实的嵌入式安全问题:在没有硬件加密引擎的中低端MCU上,如何用软件实现一套可用的RSA加解密能力。

1.2 技术选型:自研还是移植mbedTLS

拿到需求之后,第一个要决策的事情就是:RSA实现是自研还是移植现成库。这两种路线我在项目里都走过,差别还是挺明显的。

移植mbedTLS(原PolarSSL)是最省事的路子。mbedTLS对RSA的支持非常完整,PKCS#1 v1.5、OAEP填充、CRT私钥加速、素性测试、密钥生成全都有,而且针对嵌入式场景做了内存裁剪,可以只编译需要的模块。缺点是代码量大,全量编译轻松超过100KB Flash,即便裁剪之后也要占掉二三十KB,对于Flash紧张的芯片来说压力不小。另一个问题是mbedTLS的代码风格比较“桌面化”,内部用了一批标准C库函数,在STM32的裸机环境里经常需要适配。

自研实现的好处是代码量完全可控,可以根据实际需求只写大数运算和RSA核心逻辑,几百行C代码就能搞定,Flash占用低,而且没有外部依赖,调试起来更直接。代价是所有细节都要自己处理。RSA看起来就是大数模幂运算,但真正写起来涉及大数数组管理、乘法进位处理、模约减、模逆元计算,还有一个最重要的Montgomery模乘优化。没有这些,RSA在MCU上会慢到没法用。

我在这个工程里采取的是折中方案:以自研大数运算为核心,参考mbedTLS的算法思路,但代码完全自己写,数据结构也针对STM32的资源特性做了调整。这样既不背mbedTLS的体积包袱,又能保证算法实现的正确性和性能。

1.3 工程整体架构

整个工程在stm32_RSA.zip里的组织方式,遵循了嵌入式项目常用的模块化思路。核心加密逻辑放在独立目录,与具体的MCU型号和板级代码解耦,方便后续迁移到其他芯片:

stm32_RSA/ ├── Core/ │ ├── Inc/ │ └── Src/ ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ ├── RSA/ │ ├── bn.h / bn.c // 大数运算核心 │ ├── rsa.h / rsa.c // RSA加解密接口 │ ├── rng.h / rng.c // 随机数源适配 │ └── rsa_config.h // 密钥长度、内存策略配置 ├── App/ │ ├── rsa_demo.c // 演示代码 │ └── rsa_demo.h └── MDK-ARM/ └── stm32_rsa.uvprojx

RSA模块的设计有几个关键点。大数运算层(bn.c)只负责处理无符号大整数的基本运算,包括加减乘除、移位、比较、模运算,不感知RSA算法本身。RSA层(rsa.c)在大数运算之上实现密钥解析、填充、模幂计算。随机数层(rng.c)是个适配层,因为RSA加解密本身不需要随机数,但生成密钥对时需要大素数,对随机源有强依赖。工程默认提供了一个基于STM32硬件随机数生成器RNG的实现,如果芯片没有RNG外设,也可以换用ADC噪声采样的方案,后面会详细讲。

这样的分层设计,最大的好处是每一层都可以单独测试。我先用PC上的C语言环境把bn.c和rsa.c验证通过,再交叉编译到STM32,避免了在嵌入式环境里调试算法逻辑的麻烦。这也是我强烈推荐的做法——RSA这类算法,正确性验证必须放在上位机做,否则芯片上出了问题,你根本分不清是硬件问题还是算法问题。


2. RSA核心原理与MCU实现要点

2.1 RSA数学原理快速回顾

RSA的安全性建立在“大整数分解困难”这个数学假设上。公钥是(n, e),私钥是(n, d),其中n = p * q是两个大素数的乘积。加密时计算c = m^e mod n,解密时计算m = c^d mod n。因为e * d ≡ 1 mod φ(n),所以这两步运算互为逆操作。

在MCU上实现RSA,核心工作就是实现“大整数模幂运算”。所谓大整数,就是超出CPU原生位宽的数——1024位RSA意味着模数n是1024位的,也就是32个32位uint32_t拼起来。STM32是32位处理器,一次乘法能处理32位乘32位得到64位结果,这正好是高效实现大数乘法的基础。

要理解RSA在MCU上的性能瓶颈,得先看计算量。1024位模幂意味着要做1024次模平方运算,平均还有512次模乘运算。每次模乘又涉及1024位乘1024位的大数乘法,这个过程如果用最朴素的“小学乘法”方式实现,每次乘法需要32×32=1024次子乘法。子乘法虽然不慢,但子乘法的进位链处理和随后的模约减步骤非常耗时。如果不做优化,一次1024位RSA私钥操作可能要几秒钟,这在很多场景下是不可接受的。

2.2 大数运算的数据结构设计

大数在内存里的表示方式直接决定了运算效率。这个工程里的大数采用固定长度数组,以32位字(word)为基本单位,小端序存储。小端序的意思是,数组下标0保存的是最低32位,下标越大的元素保存的位数越高。之所以不用大端序,是因为C语言里数组的遍历方向是下标递增,小端序可以让低位对齐的运算自然地从下标0开始,减少代码复杂度。

数据结构定义是这样:

typedef struct { uint32_t w[RSA_MAX_WORDS]; uint16_t len; // 实际使用的字数 uint16_t flags; // 标记位,如是否为负数(RSA里一般用不到) } bn_t;

RSA_MAX_WORDS在rsa_config.h里定义,1024位RSA对应32个字。选择固定数组而不是动态分配,是出于两方面的考虑:一是嵌入式环境里的malloc容易产生碎片,而且不可预测;二是固定数组可以让编译器在编译期就确定好内存布局,方便计算栈空间要求。缺点是如果同时支持1024位和2048位RSA,数组长度得按2048位来定义(64个字),内存会多占用一些。我的方案是默认按1024位编译,通过宏切换2048位模式,减少不需要时的内存浪费。

大数的加减法实现比较简单,只需逐字运算并处理进位。乘法稍微复杂些,现在实现的都是多精度乘法,两层循环嵌套:

void bn_mul_add(uint32_t *r, const uint32_t *a, uint32_t b, int n) { uint64_t t = 0; for (int i = 0; i < n; i++) { t += (uint64_t)a[i] * b + r[i]; r[i] = (uint32_t)t; t >>= 32; } r[n] += (uint32_t)t; }

这里把乘加运算合并在一起,用64位中间变量暂存64位乘积和进位,充分利用了STM32的32位乘32位得64位指令UMULL。如果编译器开启了优化,这段代码会被编译成非常紧凑的指令序列。另一个关键点是不要用uint32_t直接做32位乘法再拼高32位,那样会丢失进位信息,正确性受编译器行为影响。

2.3 Montgomery模乘:性能的关键

模幂运算里最核心的操作是a * b mod n。朴素做法是先算乘积再取模,但大数除法取模的实现复杂度高、速度慢,因为要模拟长除法。Montgomery模乘的核心思想,是把取模运算转换成加法和移位,规避掉大数除法。代价是需要把操作数预先变换到Montgomery域,最后再变换回来。

Montgomery模乘的第一步是计算n' = -n^(-1) mod R,这里的R取2的32次方乘字数次幂,即R = 2^(32*len)。然后定义REDC函数:

static uint32_t bn_mont_mul(bn_t *r, const bn_t *a, const bn_t *b, const bn_t *n, uint32_t n0_inv) { uint32_t t[RSA_MAX_WORDS * 2 + 2]; uint64_t c = 0, v = 0; int len = n->len; memset(t, 0, sizeof(uint32_t) * (len * 2 + 2)); for (int i = 0; i < len; i++) { // 1. 乘加:t = t + a * b[i] c = 0; for (int j = 0; j < len; j++) { v = (uint64_t)t[i + j] + (uint64_t)a->w[j] * b->w[i] + c; t[i + j] = (uint32_t)v; c = v >> 32; } t[i + len] += (uint32_t)c; // 2. 模约减:t = t + n * m,其中 m = t[i] * n0_inv mod 2^32 m = t[i] * n0_inv; c = 0; for (int j = 0; j < len; j++) { v = (uint64_t)t[i + j] + (uint64_t)n->w[j] * m + c; t[i + j] = (uint32_t)v; c = v >> 32; } t[i + len] += (uint32_t)c; } // 3. 最终结果可能在[0, 2n)范围内,需要一次减法修正 ... }

这段代码有个很重要的调优点是,它把“乘加”和“模约减”在同一个循环里交替完成,避免了额外的大数存储开销。实际应用中还要注意一个细节:t数组中间结果的最高位可能超出n的位数,所以t数组长度要留到2倍字数加2,否则会溢出。处理完之后做一次条件减法,把结果归一到[0, n)范围。这里的n0_inv只需要计算一次,每次模幂开始时算好保存,后续所有模乘复用。

从上手经验看,Montgomery模乘的实现难度主要在“写对”而不在“看懂”。即使理论清楚,边界条件处理错一个下标,结果就是全错。推荐做法:先用小模数(如32位)在PC上对照验证,再用标准测试向量验证1024位结果,确认无误后再移植到STM32。

2.4 模幂运算:从右到左的二进制扫描

有了Montgomery模乘,模幂运算就可以用经典的“从左到右二进制扫描法”实现了。大致流程是:先做Montgomery变换,即将底数a乘上R模n,然后从指数的最高位开始逐位处理,遇到1就做平方乘,遇到0只做平方。

void bn_mod_exp_mont(bn_t *out, const bn_t *base, const bn_t *exp, const bn_t *mod, uint32_t n0_inv) { bn_t res, a; // 初始化为Montgomery域的1 bn_to_mont(&res, &BN_ONE, mod); bn_to_mont(&a, base, mod); for (int i = exp->len * 32 - 1; i >= 0; i--) { bn_mont_mul(&res, &res, &res, mod, n0_inv); // 平方 if (bn_get_bit(exp, i)) { bn_mont_mul(&res, &res, &a, mod, n0_inv); // 乘底数 } } bn_from_mont(out, &res, mod, n0_inv); }

这个实现还有个可优化点:每次平方和乘法的结果都以Montgomery域形式存在res里,所以整个循环过程中只需要在开始时做一次变换、在结束时做一次反变换,中间的模乘全部在Montgomery域进行,效率很高。

从右到左的扫描方法,优点是循环次数只跟指数位数有关,跟指数中1的个数无关,密钥为固定时长,不容易从时间侧信道泄露密钥信息。对于私钥操作来说,这其实是一个安全特性。当然,更严格的做法是用蒙哥马利阶梯(Montgomery Ladder)保证每条路径的操作序列完全相同,但作为入门级实现,二进制扫描法已经能应付大多数项目需求。

我在实际测试中,1024位RSA私钥解密(指数较长,1的个数约512个)在STM32F407上耗时约280ms,公钥加密(指数为65537,是特殊的短指数)耗时不到10ms。这个性能指标对大多数物联网场景来说是够用的。如果是2048位RSA,私钥操作会延长到2秒左右,在要求实时响应的场景下需要仔细评估是否可接受。

2.5 CRT私钥加速:可选的高阶优化

如果私钥操作性能实在不达标,还有一个经典优化手段:中国剩余定理(CRT)。原理是私钥d模n的运算,可以拆成模p和模q两个较小规模的运算,最后再用CRT合并。因为p和q的大小只有n的一半,所以模幂运算的复杂度从“n位”降为“两个半n位”,理论上可以提速接近4倍。

使用CRT需要私钥里包含额外的参数:p、q、dP = d mod (p-1)、dQ = d mod (q-1)、qInv = q^(-1) mod p。这些参数通常跟私钥一起存储,PKCS#1格式里有专门字段。代码上需要在RSA私钥结构体里扩展这些字段,并在解密时走单独的分支。

CRT实现的bug需要特别警惕。2000年前后学术界发现过利用CRT实现缺陷的故障注入攻击,攻击者通过让设备在计算过程中产生一个错误结果,就能反推出私钥。要对抗这类攻击,实现里必须做结果验证——要么在解密后做一次公钥加密比对,要么用反变换检查中间值是否匹配。这个工程为了追求简洁,默认没有启用CRT,但代码结构上预留了扩展位置。如果项目对性能要求高,建议在原型验证通过后补上CRT优化。


3. 实操过程:从零搭建STM32 RSA工程

3.1 准备工作与工程搭建

这个工程基于STM32F4系列,用STM32CubeMX生成基础工程框架,IDE用Keil MDK。开发环境的主要组件是:

  • STM32CubeMX:生成初始化代码,配置时钟、串口、RNG外设
  • Keil MDK-ARM:编译调试,AC5或AC6编译器均可
  • ST-Link调试器:烧录调试,顺便用它的虚拟串口看日志输出
  • 一个带串口输出的STM32F407开发板

时钟配置方面,我直接用外部晶振,PLL倍频到168MHz主频。RSA运算比较吃CPU性能,所以时钟不要省,能拉多高拉多高。串口配置为115200-8-N-1,主要用来打印RSA运算结果和时间统计,调试时非常有用。RNG外设如果芯片有,打开它——生成密钥对和做填充都需要随机数,后面会详细讲。

CubeMX生成的工程是标准HAL库框架,我直接把RSA模块代码复制到工程目录下,在Keil里添加对应的.c文件并配置好头文件路径。需要注意的一点是,如果编译时打开Microlib,标准库的某些行为会变化,但RSA模块本身不依赖标准库,所以影响不大。如果后面要接入mbedTLS做更多的密码操作,需要谨慎处理Microlib和mbedTLS的兼容性。

3.2 RSA密钥格式与数据组织

嵌入式设备上存储RSA密钥,通常有两种方式:一是用标准格式(DER、PEM)存储,二是用自定义的二进制结构。标准格式的好处是可以跟OpenSSL等工具直接互通,方便在PC端生成密钥然后烧写到设备里。缺点是解析DER需要额外代码,PEM的Base64解码也是一层功。

这个工程采用了一种轻量级方案:密钥以固定长度的二进制数组存储,数组里每个字段的位置和长度事先约定好。公钥和私钥的结构如下:

// 公钥:n(1024位) + e(32位) typedef struct { uint8_t n[128]; // 模数,大端序存储 uint32_t e; // 公钥指数,一般取65537 } rsa_pubkey_t; // 私钥:n(1024位) + d(1024位) + p(512位) + q(512位) + dp + dq + qinv typedef struct { uint8_t n[128]; uint8_t d[128]; uint8_t p[64]; uint8_t q[64]; uint8_t dp[64]; uint8_t dq[64]; uint8_t qinv[64]; uint32_t e; } rsa_privkey_t;

这里采用大端序存储外部数据格式,与OpenSSL的输出保持一致;内部大数运算用前面说的小端序,两者在加载和导出时做转换。密钥数据可以直接用const数组嵌入固件,也可以放在外部Flash,甚至烧写到OTP区防止读取。我在演示代码里用的是const数组,方便测试。

生成密钥对的方法很简单:在PC上用OpenSSL命令即可:

openssl genrsa -out private.pem 1024 openssl rsa -in private.pem -pubout -out public.pem openssl rsa -in private.pem -text -noout

然后用-text -noout的输出,把十六进制数据提取出来填充到代码里。也可以写个小脚本来自动生成C头文件,这样更不容易出错。这个工程里放了一个Python脚本gen_keys.py做这件事,用起来很方便。

3.3 大数与RSA核心代码实现

前面章节已经讲了Montgomery模乘的核心逻辑,这一节看看完整的RSA接口怎么串起来。工程中rsa.c提供了几个重点API:

int rsa_public_encrypt(const uint8_t *in, int in_len, uint8_t *out, const rsa_pubkey_t *pub); int rsa_private_decrypt(const uint8_t *in, int in_len, uint8_t *out, const rsa_privkey_t *priv); int rsa_sign(const uint8_t *hash, int hash_len, uint8_t *sig, const rsa_privkey_t *priv); int rsa_verify(const uint8_t *hash, int hash_len, const uint8_t *sig, const rsa_pubkey_t *pub);

加密和解密接口内部,首先把外部输入的字节串转换成内部大数格式,然后做PKCS#1 v1.5填充。RSA本身只能加密比模数短的数据,1024位RSA最多处理128字节的输入,但PKCS#1 v1.5填充会占用11字节,所以实际最大明文长度为117字节。如果数据超过这个长度,需要上层拆分或者换用混合加密方案——公钥加密AES密钥,AES加密实际数据。

填充逻辑是很多初学RSA的人容易忽略的环节。直接拿明文mm^e mod n有两个问题:一是不安全,同样的明文每次加密结果一样,攻击者可能通过字典攻击;二是格式不合法,数据边界无法区分。PKCS#1 v1.5填充解决了这两个问题:加密前在数据前面加上随机数填充字节,解密后通过格式检查确定有效数据的起始位置。我代码里的实现是这样:

// PKCS#1 v1.5 加密填充 // 格式:0x00 0x02 [至少8个非零随机字节] 0x00 [实际数据] int rsa_pkcs1_pad(uint8_t *out, const uint8_t *in, int in_len, int k, int is_sign) { int pad_len = k - in_len - 3; if (pad_len < 8) return -1; int pos = 0; out[pos++] = 0x00; out[pos++] = is_sign ? 0x01 : 0x02; if (is_sign) { // 签名填充:0xFF填充,可预测 memset(out + pos, 0xFF, pad_len); pos += pad_len; } else { // 加密填充:随机非零字节 while (pos < k - in_len - 1) { uint8_t b; rng_generate(&b, 1); if (b != 0) out[pos++] = b; } } out[pos++] = 0x00; memcpy(out + pos, in, in_len); return 0; }

签名和加密的填充方式不同,这在PKCS#1里是有明确规定的。很多实现bug就是Signature和Encryption的填充混用了,导致格式检查不通过。签名填充用0xFF,加密填充用随机非零字节。这个细节要特别留意。

解密完成后的去填充也很重要,必须做严格的格式检查本身也是安全防线,可以防Bleichenbacher攻击。至少要做到:检查第1字节为0x00、第2字节为0x02(加密)或0x01(签名)、找到0x00分隔符、检查数据长度是否正确。任何一步失败统一返回“解密失败”,不要泄露具体的错误位置,因为有研究证明错误位置的不同也能被利用。

3.4 随机数源适配:没有RNG外设怎么办

RSA加解密本身不需要随机数,但生成密钥对和加密填充时需要。密钥对生成对随机性的要求是密码学安全的,一般要满足:不可预测、均匀分布、不同批次不能有关联。STM32的硬件RNG外设,如果是较新的F4系列,质量基本够用。

但很多入门级的STM32型号没有RNG外设,这时候有几种替代方案:

  • ADC噪声采样:读取内部温度传感器或悬空引脚的ADC值,取低几位作为随机源。质量一般,但配合多个ADC通道和历史值混合,也能凑合。速度较慢。
  • 时钟抖动采样:利用两个独立时钟源的微小抖动生成随机位。实现复杂度高,但质量不错。
  • 外部随机数芯片:通过I2C/SPI接口连接专用TRNG芯片,质量最好,但增加硬件成本。

工程里的rng.c对上层提供统一接口,因此不管底层的随机源是什么,上层代码不用改动。如果芯片有RNG外设,直接调用HAL库:

int rng_generate(uint8_t *buf, int len) { for (int i = 0; i < len; i += 4) { uint32_t val = 0; if (HAL_RNG_GenerateRandomNumber(&hrng, &val) != HAL_OK) { return -1; } // 处理剩余不足4字节的情况 int copy = (len - i > 4) ? 4 : (len - i); memcpy(buf + i, &val, copy); } return 0; }

实验中发现硬件RNG偶尔会连续输出全0或全1的块,虽然概率极低,但严谨的做法是在上层加一个简单的健康检查,比如连续生成若干字,若全0或全1则报错重置。对安全要求高的项目,建议用更完善的熵池管理,比如用TRNG作为种子加入DRBG(如HMAC-DRBG)。但嵌入式项目里多数场景直接用TRNG输出足够了。

3.5 演示代码与验证流程

工程里的rsa_demo.c演示了一个完整流程:加载密钥、加密一段明文、解密并比对、签名哈希并验证。主流程大致如下:

void rsa_demo_run(void) { const char *msg = "STM32 RSA Demo - Hello, Embedded Security!"; uint8_t cipher[128], plaintext[128], sig[128], hash[32]; int cipher_len, plaintext_len; // 1. 计算消息的SHA-256哈希(工程里用了软件SHA实现) sha256_compute((const uint8_t *)msg, strlen(msg), hash); // 2. RSA公钥加密 cipher_len = rsa_public_encrypt((const uint8_t *)msg, strlen(msg), cipher, &test_pubkey); // 3. RSA私钥解密 plaintext_len = rsa_private_decrypt(cipher, cipher_len, plaintext, &test_privkey); // 4. 私钥签名哈希 rsa_sign(hash, 32, sig, &test_privkey); // 5. 公钥验证签名 int ok = rsa_verify(hash, 32, sig, &test_pubkey); // 打印结果 }

验证正确性的最简单方法,是跟上位机OpenSSL的结果比对。先用OpenSSL对相同数据做加解密,拿到标准输出,然后修改代码里的密钥和输入数据,对比输出是否一致。注意RSA加密填充含随机字节,所以加密输出每次会不同,但解密结果必须一致。签名验证则必须是输出固定的(PKCS#1 v1.5签名是确定性的)。

这个工程的演示代码里还加了一个毫秒级时间统计,用DWT计数器实现的高精度计时,能精确到纳秒级别。RSA运算的耗时数据对判断性能是否达标非常关键。


4. 内存与性能调优实践

4.1 STM32内存占用分析

RSA在MCU上最常见的内存问题是溢出。1024位RSA的大数数组是32个uint32_t,也就是128字节。看起来不多,但模幂运算过程中会产生多个临时变量:底数、指数、模数、中间结果、Montgomery因子、临时buffer,再加上栈上的局部数组,一次运算可能瞬间用掉几KB栈空间。对RAM只有64KB的芯片来说,这是需要精打细算的。

这个工程里做法是:所有中间结果都用栈上的局部数组,通过RSA_MAX_WORDS控制尺寸。以1024位计算,一个中间结果约128字节,同时存的中间变量不超过10个,栈开销约2KB。如果芯片RAM比较小,可以把数组改为静态规划复用,比如把不同阶段不冲突的中间变量放在同一个union里。这个在代码注释里有说明,但默认实现还是用局部变量,因为代码清晰性好。

需要注意的还有:Keil MDK默认给线程栈分配的大小取决于启动文件里的Stack_Size值,默认是0x400即1KB。跑RSA运算必须改大,我一般设到0x2000(8KB)。不改的话,函数调用一深,立即进HardFault。这个坑我踩过不止一次。

4.2 运算速度优化技巧

RSA运算的性能优化,主要围绕以下几个方面:

  • 编译器优化选项。Keil里把优化等级调到-O2-O3,开-Otime,代码大小和速度能明显改善。特别是Montgomery模乘里的内层循环,编译器的指令调度直接决定性能。
  • 使用uint32_tuint64_t组合。STM32的UMULL指令是单周期32位乘32位得64位,中间结果的进位处理也应该用64位变量接收。避免用uint16_t做运算,虽然省内存但编译器会生成额外扩展指令,性能反而下降。
  • 数据对齐。大数数组尽量用__attribute__((aligned(4)))或默认的4字节对齐,避免编译器生成非对齐访问的修补代码。STM32是ARMv7-M架构,非对齐访问虽然支持但速度受影响。
  • 减少函数调用层次。模乘内层循环的函数调几层是性能杀手,可以把小函数写成static inline。Keil的-O2会自己内联一部分,但显式写inline更可靠。
  • 使用CRT加速私钥运算,如前文所述,性能提升最多4倍。

实测数据(STM32F407 @168MHz,Keil -O2):

操作1024位耗时2048位耗时
公钥加密(e=65537)约8ms约40ms
私钥解密(无CRT)约280ms约2.1s
私钥解密(有CRT)约80ms约590ms
签名(同私钥解密)约280ms约2.1s

这个数据跟PC上动辄微秒级差距很大,但对一个几块钱的MCU来说已经相当不错了。如果你的场景是固件签名验证这种不频繁的操作,完全够用;如果是高频通信握手,就要考虑是否换用ECC(椭圆曲线加密)了,ECC在MCU上的性能优势明显,密钥也更短。

4.3 实时性与任务调度注意事项

如果项目用了RTOS(FreeRTOS、RT-Thread等),RSA运算跑在哪个任务里需要想清楚。我的经验是:

  • 不要在中断里跑RSA。几百毫秒的运算时间对中断来说是灾难,会破坏系统的实时性。正确做法是放到任务里,或者用专门的安全任务处理。
  • 优先给RSA任务分配高优先级,但要注意任务栈大小。FreeRTOS默认任务栈是128字(512字节),跑RSA必须改到至少2KB以上,建议用uxTaskGetStackHighWaterMark检查一下实际栈使用量,留足余量。
  • RSA运算期间如果被打断,可能会导致所有加密数据不一致。对于安全操作,可以暂时挂起其他任务或在互斥锁保护下执行。但不要用taskDISABLE_INTERRUPTS(),阻塞中断会破坏系统的实时性。
  • 如果使用低功耗模式,RSA运算期间要避免进入停止模式。可以在运算前通过__disable_irq()短暂关中断或调用HAL_SuspendTick()来防止内部tick中断唤醒系统。

实时性方面还有一个隐蔽的问题:RSA运算的耗时会因为输入数据的填充不同而有微小差异,这在严格的安全审计下可能被判定为时序侧信道。如果项目要过安全认证,需要在硬件层面加掩码或使用常数时间算法。但普通物联网产品一般不会做到这个程度,这里提出来只是让大家心里有数。


5. 常见问题与排查技巧实录

5.1 程序跑飞或进HardFault

RSA工程里最常见的故障就是HardFault。前面提到了栈溢出是头号嫌疑,特别是在没改启动文件Stack_Size的情况下。排查手段:

  • 在HardFault_Handler里加调试信息,把堆栈指针和返回地址打印出来。Keil调试模式下可以直接看Call Stack窗口,定位到是哪个函数调用导致的。
  • 临时把Stack_Size调大到0x4000(16KB),如果问题消失,说明就是栈不够。
  • 检查大数数组的越界写入。大数运算代码里最容易错的就是下标越界,比如bn_mul_addr[n] += t那行,如果r数组长度不够,写越界是静默的,直到很久后某个不相关的变量被覆盖才暴露问题。这种问题排查起来非常痛苦。

我的一个习惯是,在PC上先用AddressSanitizer编译跑一遍算法的测试用例,能检测出百分之九十的内存错误,然后再往STM32上移植,省很多时间。

5.2 加解密结果不一致

如果RSA加解密偶尔正确、偶尔不对,或者跟OpenSSL对不上,通常问题出在以下几个方面:

填充格式错误:PKCS#1 v1.5填充时,加密填充要求随机非零字节,签名填充要求0xFF。有些代码图省事统一用0xFF填充,这会导致解密端格式检查失败。反过来,加密端用了随机填充,但解密端把0x00分隔符的位置算错,也会导致解密结果错误。建议上线前先用标准测试向量验证。

字节序问题:外部数据(如OpenSSL导出的密钥)通常是大端序,大数运算内部用小端序,转换遗漏会导致数据错位。在代码的bn_from_bytesbn_to_bytes函数里做好对接,并用已知密钥测试。公钥指数e=65537时,如果读成字节翻转的0x00010001即65536+1=65537,还是不变的,所以有些错误测不出来;用e=3或更大的指数测一下能暴露问题。

整数符号问题:大数运算的除法算法对符号位特别敏感。RSA运算中模数n和底数a都是正数,但中间结果减法可能产生负数。如果负数表示法有问题,模约减就会出错。建议对大数类型明确区分无符号和有符号,RSA运算里统一用无符号。

5.3 性能比预期慢很多

同样一颗芯片,别人的RSA跑一百多毫秒,你的跑了一秒多,大概率是算法实现的问题。最常见的原因是模约减用了普通除法而不是Montgomery。普通大数除法取模实现起来复杂,而且慢一个数量级。检查一下代码里有没有bn_div被频繁调用,如果模幂运算的每次乘加都做了除法,性能必然惨不忍睹。

另一个常见问题是编译器优化没开。调试模式下-O0的代码,Montgomery模乘内层循环的每次迭代都有大量内存读写,速度慢三到五倍很正常。发布版本记得用-O2

还有个容易被忽略的问题:时钟配置不对。STM32F407如果没配置PLL,跑在HSI 16MHz,跟168MHz差距是十倍以上。用DWT计时外设量一下SysClk的实际频率,心里有数。

5.4 随机数失效导致加密填充死循环

加密填充时,代码要生成若干个非零随机字节。如果随机数源失效——比如RNG外设初始化失败,或者ADC噪声采样全返回同一值——while循环可能永远等不到非零字节,程序就卡在填充函数里了。

我遇到过实际案例:RNG外设时钟没使能,HAL_RNG_GenerateRandomNumber一直返回HAL_TIMEOUT,但代码没检查返回值,往buffer里写的是未初始化的栈数据。栈里恰好没有非零值的概率很低,但一旦发生就是死循环。解决方法是强制检查随机数生成函数的返回值,连续失败超过N次就返回错误码,不硬等。另外建议在随机数生成里加个计数器,防止生成速度过慢拖垮系统。

5.5 常见问题速查表

问题可能原因排查步骤
HardFault调试中断栈溢出增大Stack_Size,查看Call Stack
HardFault随机出现大数数组越界AddressSanitizer跑测试,检查边界条件
加解密结果跟PC不一致字节序/填充格式用OpenSSL标准向量比对,检查转换函数
性能太慢优化没开/用除法取模/时钟慢开-O2,检查是否使用Montgomery,核对时钟
程序卡死随机数源失效检查RNG初始化,加失败计数
签名验证失败填充方式混用确认签名用0x01+0xFF,加密用0x02+随机
malloc内存碎片误用动态分配改为静态分配的固定长度大数结构

5.6 调试工具与效率技巧

最后分享几个调试技巧,这些都是我在实际项目中积累的:

一是善用OpenSSL做离线测试。在PC上实现对RSA模块的逻辑验证后,用OpenSSL作为参照标准,可以快速定位算法层的问题。这个环节不要省,值得多花时间。

二是把测试向量固化到工程里。在rsa_test.c里放一组已知答案的测试用例,每次改动代码后先跑一遍测试,能及时发现回归问题。这在给算法做优化时特别有用——每次改完代码,跑一遍测试确认结果没变。

三是用DWT模块做性能测量。STM32的DWT->CYCCNT是内核周期计数器,精度高、开销小,比用SysTick或者定时器更方便。算一次RSA操作的周期数,除以主频就得到精确耗时。

四是尽量把加密模块独立出来,不要跟业务代码耦合。如果需要调试,可以单独编译一个soc配置,直接跑算法,不受外部环境影响。这也是这个工程里RSA模块采用独立目录管理的原因。


RSA在STM32上跑,说难不难,说容易也不是完全容易。难在细节:大数运算的正确性、Montgomery模乘的边界处理、填充格式的严格检查、随机数源的质量保证,每一环都马虎不得。容易在方法:选对实现思路,先用上位机验证算法逻辑,再往MCU上移植,加好调试手段,按部就班推进,大部分问题都能在早期被发现。

我在这个工程里踩过的最大一个坑,就是最开始用朴素除法实现模约减,跑一次1024位RSA私钥操作要3秒多,当时还以为是芯片性能问题。后来换成Montgomery模乘,时间直接降了一个数量级,才意识到算法选择比什么都重要。所以如果你也在做类似的事情,我的建议是:不要在性能优化上畏难,Montgomery模乘值得你花时间吃透;也不要在安全细节上偷懒,填充和随机数这些看似不起眼的点,往往是安全性最薄弱的环节。

这个工程后续还可以扩展的方向很多:接入mbedTLS做完整的TLS握手、加上AES-GCM做混合加密通信、实现基于RSA的安全Bootloader、或者在私钥运算里启用CRT加速。每一步都不难,但每一步都能让这个基础RSA模块变得更实用。如果你正在做的项目需要这些能力,可以从这个工程改起,先跑通基本流程,再逐步叠加功能。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询