3. AES核心算法内存占用:S盒、轮常数、密钥扩展表的存储分析
做嵌入式 AES 实现,说白了就是在跟内存较劲。很多开发者一上来就调 OpenSSL 或者 mbedTLS,觉得 AES 就是个黑盒子。但到了资源受限的 MCU 上——比如只有 32KB Flash、4KB RAM 的 Cortex-M0——你才会发现,光一个 S 盒就能吃掉你 1/4 的代码空间。
我个人习惯把 AES 核心的内存占用拆成三块:S 盒、轮常数 Rcon、密钥扩展表。这三块各有各的脾气,咱们一个一个说。
3.1 S 盒:256 字节的“硬骨头”
AES 的 S 盒是一个 16×16 的查找表,共 256 个字节。每个字节对应一个 GF(2⁸) 上的乘法逆元,再经过仿射变换得到。标准实现里,S 盒就是一张写死的 const 数组。
典型声明:
static const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5,
0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
// ... 共 256 个字节
};
嗯,这里要注意:S 盒占 256 字节,逆 S 盒(用于解密)同样占 256 字节。如果你同时支持加密和解密,光 S 盒就要 512 字节。在 Flash 里这不算什么,但在某些超低成本的 MCU 上,512 字节可能就是 1/64 的总 Flash 了。
我踩过的坑: 有一次做一款智能门锁,主控是某国产 Cortex-M0,Flash 只有 16KB。我把 AES 加解密都放进去,结果编译出来 Flash 超了 2KB。查了半天,发现 S 盒和逆 S 盒占了 512 字节,加上密钥扩展表又占了 176 字节(后面会讲),再加上轮函数代码……嗯,最后我不得不把解密部分砍掉,只保留加密,逆 S 盒用运行时计算替代。
3.1.1 运行时计算 S 盒:省空间换时间
如果你实在挤不出这 256 字节,可以用算法现场算。S 盒的数学本质是:
- 在 GF(2⁸) 上求乘法逆元(0 的逆元定义为 0)
- 做仿射变换:
b' = b ⊕ (b << 1) ⊕ (b << 2) ⊕ (b << 3) ⊕ (b << 4) ⊕ 0x63
但说实话,运行时计算 S 盒在 8 位 MCU 上非常慢。我测过一次,每次查表只要 1 个时钟周期,但现场算一次要 200 多个周期。如果你做的是高频数据流加密(比如每秒处理几百个包),这个代价你扛不住。
我的建议: 如果 Flash 够用,老老实实用查表。如果 Flash 实在紧张,只保留加密 S 盒,解密时用 InvSbox[i] = Sbox[InvSubBytes(b)] 的方式间接计算——虽然还是慢,但至少省了 256 字节。
3.2 轮常数 Rcon:被严重高估的“内存大户”
很多人以为轮常数 Rcon 要存 10 个 4 字节的值(AES-128),总共 40 字节。其实你仔细看看 Rcon 的生成规律:
Rcon[1] = 0x01
Rcon[2] = 0x02
Rcon[3] = 0x04
Rcon[4] = 0x08
Rcon[5] = 0x10
Rcon[6] = 0x20
Rcon[7] = 0x40
Rcon[8] = 0x80
Rcon[9] = 0x1B
Rcon[10] = 0x36
看出来了吗?除了 Rcon[9] 和 Rcon[10] 因为溢出做了模多项式处理,前面的值就是 2 的幂次。所以实际上你只需要存一个 uint8_t rcon = 0x01,每次左移一位,溢出时异或 0x1B 就行。
运行时生成 Rcon 的代码:
uint8_t rcon = 0x01;
for (int i = 1; i <= 10; i++) {
// 使用 rcon 进行密钥扩展
// ...
rcon = (rcon << 1) ^ (rcon & 0x80 ? 0x1B : 0x00);
}
你看,这样 Rcon 的内存占用就从 40 字节降到了 1 字节。我个人习惯在代码里直接写死 Rcon 数组,因为 40 字节真的不算什么。但如果你在做 256 字节 Flash 级别的极端优化,这个技巧能帮你省一点是一点。
3.3 密钥扩展表:176 字节的“隐形杀手”
AES-128 的密钥扩展表是 44 个字(每个字 4 字节),共 176 字节。这个表在加密/解密过程中被反复使用,每次轮密钥加操作都要从表里取 4 个字(16 字节)。
很多人觉得 176 字节不多,但你要知道:在 TinyAES 这类经典实现里,密钥扩展表是放在 RAM 里的。为什么?因为密钥扩展表在加密过程中会被频繁访问,放在 Flash 里用 LDM/STM 指令读太慢了。但 RAM 在嵌入式系统里比 Flash 贵得多——4KB RAM 的 MCU 比比皆是,176 字节就是 4.3% 的 RAM 占用。
| 密钥长度 | 轮数 | 扩展表大小(字) | 扩展表大小(字节) |
|---|---|---|---|
| AES-128 | 10 | 44 | 176 |
| AES-192 | 12 | 52 | 208 |
| AES-256 | 14 | 60 | 240 |
3.3.1 按需扩展:省 RAM 的终极方案
你想想看,真的需要一次性把 176 字节全部展开吗?其实不需要。AES 加密是逐轮进行的,每一轮只需要当前轮的轮密钥。所以你可以只保留原始密钥(16 字节),每轮现场计算当前轮的轮密钥。
这样做的好处是:RAM 占用从 176 字节降到 16 字节,省了 160 字节。代价是每轮要多做一次密钥扩展计算,速度会慢一些。
注意: 按需扩展在解密时更复杂。因为解密需要逆序使用轮密钥,如果你按需扩展,得先算出最后一轮的密钥,然后反向推导。我建议只在加密场景用这个技巧,解密还是老老实实预计算。
3.4 三块内存的汇总对比
咱们把这三块放在一起看,心里就有数了:
| 数据块 | 标准大小(字节) | 优化后大小(字节) | 优化方法 |
|---|---|---|---|
| S 盒 | 256 | 0(运行时计算) | 现场计算 GF(2⁸) 逆元 |
| 逆 S 盒 | 256 | 0(间接查表) | 用 S 盒反查 |
| 轮常数 Rcon | 40 | 1 | 运行时移位生成 |
| 密钥扩展表 | 176 | 16 | 按需扩展 |
| 总计 | 728 | 17 | — |
你看,从 728 字节优化到 17 字节,差了 40 多倍。当然,代价是速度。我做过实测:在 72MHz 的 STM32F103 上,标准查表实现加密 1KB 数据大约 0.5ms;全运行时计算版本要 8ms。差了 16 倍。
所以我的经验是:先评估你的瓶颈是 Flash 还是 RAM,还是速度。如果 Flash 够用但 RAM 紧张,就把 S 盒和密钥扩展表放 Flash(用 const 修饰),只留少量 RAM 做中间变量。如果 RAM 够用但 Flash 紧张,就运行时计算 S 盒和 Rcon。如果两者都紧张……嗯,你可能选错 MCU 了。
一个小技巧: 很多 MCU 的 Flash 读取速度比 RAM 慢,但 Flash 有指令缓存(Prefetch Buffer)。如果你把 S 盒放在 Flash 里,并且代码是顺序执行的,缓存命中率很高,实际速度损失不到 10%。我建议你优先把大表放 Flash。
3.5 知识结构图
下面这张图把 AES 核心算法的三块内存占用、优化方向以及权衡关系串起来了。你可以把它当作本章的“思维导图”:
这张图把 AES 核心的三块内存——S 盒、Rcon、密钥扩展表——以及它们的优化选项和权衡关系都画出来了。你可以看到,从标准的 728 字节到极致优化的 17 字节,差了 42 倍。但代价是速度,这个取舍你得根据实际项目来定。
我个人建议:不要为了优化而优化。先跑起来,再用量产工具(比如 Keil 的 Code Coverage 或者 IAR 的 Stack Usage)看看实际内存占用。如果 Flash 用了 60%,RAM 用了 40%,那根本不需要动。如果 Flash 用了 95%,RAM 用了 80%,那才需要动这些“手术”。