1. 项目概述:为什么我们需要极致轻量化的AES
在嵌入式开发领域,资源永远是第一位的考量。当你的MCU只有几十KB的Flash和几KB的RAM时,任何库的引入都必须慎之又慎。AES(高级加密标准)作为目前应用最广泛的对称加密算法,其官方参考实现往往动辄十几KB的代码量,这对于许多资源受限的嵌入式设备来说,几乎是不可承受之重。这就是
tiny-AES-c
这类项目存在的根本价值——它不是一个功能阉割版,而是一个为嵌入式环境量身定制的、经过极致优化的AES实现。
我最早接触
tiny-AES-c
是在一个基于STM32F030的项目上,那个芯片只有64KB Flash和8KB RAM,项目需要与云端进行加密通信。当时尝试了OpenSSL的移植,光是静态链接进去就几乎耗尽了所有空间。后来找到了
tiny-AES-c
,整个C文件编译后,代码体积仅增加了不到3KB,RAM占用几乎可以忽略不计,完美解决了问题。从那以后,它就成了我嵌入式加密方案的首选“瑞士军刀”。
简单来说,
tiny-AES-c
就是一个用纯C语言编写的、高度可移植的AES-128、AES-192和AES-256加密/解密库。它的核心目标不是追求极致的运行速度(虽然也不慢),而是在保证算法正确性和安全性的前提下,将代码体积和内存占用压缩到极致。它没有依赖任何外部库,甚至对标准库的依赖都降到了最低,一个
.c
文件和一个
.h
文件就是全部,你可以轻松地把它丢进任何嵌入式项目的源码树里直接编译。
2. 核心设计思路与架构拆解
2.1 轻量化的哲学:牺牲什么,保留什么
要实现极致的轻量化,必然有所取舍。
tiny-AES-c
的设计哲学非常明确:
优先保证正确性、安全性和可移植性,在此前提下,极力压缩存储空间(ROM)占用,并谨慎优化运行效率(CPU和RAM)
。
它主要从以下几个层面进行“瘦身”:
- 算法层面 :完整实现了AES的标准流程,包括密钥扩展、字节替换、行移位、列混合和轮密钥加等所有步骤。没有使用任何会引入额外存储开销的“捷径”或简化算法,确保加密强度与标准AES完全一致。这是它的底线。
-
代码层面
:
-
无动态内存分配
:所有操作都在栈或静态数组上完成,避免了
malloc/free的开销和碎片化风险,这对没有成熟内存管理单元的MCU至关重要。 -
精简的API
:只提供最核心的
AES_ECB_encrypt/decrypt,AES_CBC_encrypt/decrypt等几个函数接口。像CTR、GCM等更复杂的模式需要用户基于基础块操作自行实现,这减少了代码量。 -
编译时配置
:通过宏定义(如
AES128,AES192,AES256)在编译时决定支持哪种密钥长度,避免将三种实现的代码全部链接进去。 -
查表法的极致优化
:AES算法中的S盒(SubBytes)和逆S盒,以及列混合所需的预计算表,是代码体积的大头。
tiny-AES-c对此做了精心设计。
-
无动态内存分配
:所有操作都在栈或静态数组上完成,避免了
2.2 核心数据结构与查表策略解析
这是理解其轻量化的关键。AES算法中,
SubBytes
(字节替换)操作通常通过一个256字节的查找表(S盒)来完成。加解密各需要一个S盒和逆S盒,这就是1KB的只读数据。此外,为了优化
MixColumns
(列混合)操作,通常还会使用4个1KB的预计算表(T-table),这又会增加4KB的ROM占用。
tiny-AES-c
采用了更节省空间的策略:
-
默认使用计算型S盒
:它没有将S盒作为静态常量数组存储,而是在代码中通过一个
getSBoxValue函数动态计算。这个函数内部是一个紧凑的算法,用几十行代码替代了256字节的存储空间。这是其ROM占用极低的主要原因。 -
提供可选的静态S盒
:同时,它也定义了
STATIC_TABLES宏。如果用户定义此宏,则会使用静态的、预计算好的S盒数组。这会增加约0.5KB的ROM,但能显著提升运行速度(因为省去了每次查表时的计算开销)。这种设计给了开发者“空间换时间”的选择权。 -
省略T-table
:
tiny-AES-c的列混合操作是通过公式直接计算实现的,而不是查T-table。这避免了4KB的巨大开销,代价是加密速度会慢一些。但对于很多嵌入式场景,每秒加密几十个数据块的速度已经足够,节省下的4KB Flash空间却可能是无价的。
// tiny-AES-c 中计算型S盒的示例(简化逻辑)
static uint8_t getSBoxValue(uint8_t num) {
// 这里是一个复杂的仿射变换计算过程,而非直接查表
// ... 计算代码 ...
return rsbox[num]; // 或者 sbox[num], 取决于加密还是解密
}
// 如果定义了 STATIC_TABLES, 则直接使用预计算的数组
#if defined(STATIC_TABLES)
static const uint8_t sbox[256] = { 0x63, 0x7c, ... };
static const uint8_t rsbox[256] = { 0x52, 0x09, ... };
#endif
2.3 可移植性设计
tiny-AES-c
的整个代码库只使用了C99标准。它通过
typedef
自定义了
uint8_t
等类型,确保在任何平台上,只要包含
stdint.h
(或提供类似定义)就能编译。它不依赖任何操作系统特性(如线程、文件IO),甚至对标准库的依赖也仅限于最基本的类型定义。你可以把它移植到Arduino、ESP32、STM32、甚至是8051内核的老古董MCU上。
注意 :这种极致的可移植性也意味着它不提供“开箱即用”的高级功能,如自动填充(PKCS#7)、加密模式初始化向量(IV)的自动生成与管理等。这些都需要应用层开发者自己处理,这既是灵活性,也增加了使用的复杂度。
3. 源码深度解析与关键函数剖析
要真正用好一个库,不能只停留在调用API,必须深入其实现,明白它每一步在做什么。我们以最常用的AES-128 CBC模式加密为例,拆解
tiny-AES-c
的核心流程。
3.1 密钥扩展(Key Expansion)
这是AES的第一步,也是安全的基础。它将一个短的初始密钥(16字节 for AES-128)扩展成一系列用于各轮加密的轮密钥(Round Keys)。
void AES_init_ctx(struct AES_ctx* ctx, const uint8_t* key) {
KeyExpansion(ctx->RoundKey, key);
}
KeyExpansion
函数是核心。它遵循AES标准,使用Rijndael的密钥调度算法。对于AES-128,需要生成11个轮密钥(第0轮为初始密钥,1-10轮为扩展密钥)。
tiny-AES-c
的实现非常直接,逐字节计算,其中用到了
getSBoxValue
函数进行S盒替换,以及Rcon轮常量。这里的内存占用是固定的:
ctx->RoundKey
是一个
uint8_t
数组,大小为
(Nr+1)*Nb
* 4 字节。对于AES-128, Nr=10, Nb=4, 所以是
11*4*4 = 176
字节。这部分内存通常分配在栈上(如果ctx是局部变量)或静态区。
3.2 加密主循环:Cipher
这是加密的核心函数,执行一轮又一轮的变换。我们看
Cipher
函数(用于ECB模式的一个块加密)。
static void Cipher(state_t* state, const uint8_t* RoundKey) {
uint8_t round = 0;
// 第0轮:仅加轮密钥
AddRoundKey(0, state, RoundKey);
// 第1到Nr-1轮:四步变换
for (round = 1; round < Nr; ++round) {
SubBytes(state);
ShiftRows(state);
MixColumns(state);
AddRoundKey(round, state, RoundKey);
}
// 最后一轮:省略列混合
SubBytes(state);
ShiftRows(state);
AddRoundKey(Nr, state, RoundKey);
}
逻辑清晰明了:
-
AddRoundKey:状态矩阵与轮密钥进行异或。 -
SubBytes:通过getSBoxValue函数或静态S盒,对每个字节进行非线性替换。 -
ShiftRows:对状态矩阵的行进行循环移位。 -
MixColumns:对状态矩阵的列进行线性变换,这是通过公式计算实现的,而非查表。
实操心得 :在调试加密结果不对时,我常用的方法是 逐轮打印状态矩阵 。在
Cipher函数的每个AddRoundKey之后,将state的16个字节以十六进制打印出来。然后与官方测试向量(例如NIST发布的已知答案测试)的中间结果进行比对。这样可以快速定位问题出在哪一轮,甚至是哪一个变换步骤。
3.3 加密模式:以CBC为例
tiny-AES-c
直接提供了CBC模式的封装函数
AES_CBC_encrypt_buffer
。CBC(密码分组链接)模式是常用的模式,它能有效隐藏明文的模式。
void AES_CBC_encrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, uint32_t length) {
uintptr_t i;
uint8_t* Iv = ctx->Iv;
for (i = 0; i < length; i += AES_BLOCKLEN) {
XorWithIv(buf, Iv); // 第一步:与IV或前一个密文块异或
Cipher((state_t*)buf, ctx->RoundKey); // 第二步:进行ECB加密
Iv = buf; // 第三步:更新IV为当前密文块
buf += AES_BLOCKLEN;
}
// 加密完成后,需要将最终的IV存回ctx,以便解密时使用
memcpy(ctx->Iv, Iv, AES_BLOCKLEN);
}
这个实现非常经典:
- 将当前明文块与前一个密文块(或初始IV)进行异或。
- 对异或后的结果进行标准的AES块加密(ECB)。
- 输出的密文块作为下一个块的IV。
这里有一个至关重要的细节
:
tiny-AES-c
的
AES_CBC_encrypt_buffer
函数会
修改传入的
ctx->Iv
。加密完成后,
ctx->Iv
会被更新为最后一个密文块。这个设计意味着:
-
加密连续数据流时是方便的
:你可以分多次调用该函数加密一段很长的数据,每次传入的
ctx是同一个,其内部的Iv会自动衔接。 -
解密时必须注意
:解密函数
AES_CBC_decrypt_buffer期望的ctx->Iv是加密完成后最终的那个IV(即最后一个密文块)。如果你在加密后把ctx保存下来用于后续解密,那么它是正确的。但如果你是独立加密/解密,就必须手动保存并传递这个最终的IV。
4. 在嵌入式项目中的集成与实战
4.1 集成步骤
-
获取源码
:直接从GitHub仓库下载
aes.c和aes.h。 - 添加到项目 :将这两个文件放入你的项目源码目录。
-
配置宏
:在
aes.h或你的编译器全局宏定义中,设置你需要的选项。最关键的几个:-
AES128,AES192,AES256: 必须且只能定义一个 ,决定密钥长度。 -
ECB,CBC:定义你需要的加密模式,可以同时定义。 -
STATIC_TABLES:如果Flash空间充裕且追求速度,定义它。 -
AES_BLOCKLEN:通常为16,除非你修改了AES块大小(极不推荐)。
-
-
包含头文件
:在需要使用AES的文件中
#include “aes.h“。 -
编译链接
:确保
aes.c被加入编译列表。
4.2 实战示例:STM32 HAL库下的数据加密传输
假设我们有一个STM32F103,通过UART向上位机发送传感器数据,需要加密。
// aes_comm.c
#include “aes.h“
#include “string.h“
// 1. 定义密钥和初始化向量 (IV)
// 警告:实际项目中,密钥绝不能硬编码!应从安全存储区读取或通过安全协议协商。
static const uint8_t aes_key[16] = {0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c};
static uint8_t aes_iv[16] = {0}; // 初始化向量,通常需要是随机数
// 2. 封装一个加密函数
int32_t encrypt_sensor_data(uint8_t* plaintext, uint32_t pt_len, uint8_t* ciphertext, uint32_t* ct_len) {
struct AES_ctx ctx;
uint32_t padded_len;
uint8_t padding;
// 2.1 计算填充长度 (PKCS#7)
padding = AES_BLOCKLEN - (pt_len % AES_BLOCKLEN);
padded_len = pt_len + padding;
// 检查输出缓冲区是否足够大
if (*ct_len < padded_len) {
return -1; // 缓冲区不足
}
// 2.2 复制明文并添加填充
memcpy(ciphertext, plaintext, pt_len);
for (uint32_t i = 0; i < padding; i++) {
ciphertext[pt_len + i] = padding;
}
// 2.3 初始化AES上下文 (设置密钥和IV)
AES_init_ctx_iv(&ctx, aes_key, aes_iv);
// 2.4 执行CBC加密
AES_CBC_encrypt_buffer(&ctx, ciphertext, padded_len);
// 2.5 更新输出长度和IV (加密后ctx.Iv已更新)
*ct_len = padded_len;
memcpy(aes_iv, ctx.Iv, AES_BLOCKLEN); // 保存新的IV供下次加密使用
return 0; // 成功
}
// 在主循环中调用
void send_encrypted_data(float temperature, float humidity) {
uint8_t tx_buffer[64]; // 明文缓冲区
uint8_t enc_buffer[64]; // 密文缓冲区
uint32_t enc_len = sizeof(enc_buffer);
int32_t ret;
// 构造明文数据 (例如: “T=25.6,H=60.2“)
int len = snprintf((char*)tx_buffer, sizeof(tx_buffer), “T=%.1f,H=%.1f“, temperature, humidity);
// 加密
ret = encrypt_sensor_data(tx_buffer, len, enc_buffer, &enc_len);
if (ret == 0) {
// 通过UART发送密文
HAL_UART_Transmit(&huart1, enc_buffer, enc_len, HAL_MAX_DELAY);
}
}
4.3 内存与性能实测分析
为了量化其“轻量化”,我在STM32F103C8T6(64KB Flash, 20KB RAM)上做了一次实测,使用ARM GCC编译器,优化等级为
-Os
(优化尺寸)。
| 配置 | 代码大小 (Flash) 增加量 | RAM 占用 (全局变量) | 备注 |
|---|---|---|---|
| 基础 (AES128 + CBC) | ~2.8 KB | ~200 字节 |
包含
AES_init_ctx_iv
,
CBC_encrypt_buffer
等
|
| + STATIC_TABLES | ~3.3 KB | ~0.7 KB | 增加了静态S盒和逆S盒,速度提升约30% |
| 对比: mbedTLS AES | ~12 KB+ | ~1-2 KB | 功能更全,但体积大得多 |
从数据可以看出,
tiny-AES-c
在默认配置下,Flash占用控制在3KB以内,这对于很多只有32KB甚至16KB Flash的MCU来说是可行的。RAM占用主要是上下文结构体和一些栈上的临时变量,非常低。
性能方面,在72MHz的STM32F103上,加密一个16字节的数据块(AES-128 CBC)大约需要 70-100微秒 (取决于是否使用静态表)。这意味着每秒可以加密约1万到1.4万个数据块,对于大多数传感器数据上报、指令加密等场景,性能完全过剩。
5. 常见问题、安全陷阱与排查技巧
即使代码简单,在实际使用中也会遇到各种坑。以下是我和同事们踩过的一些典型问题。
5.1 数据对齐问题
AES操作通常假设数据是32位对齐的,这有助于提高访问速度。
tiny-AES-c
的内部
state_t
类型通常是
uint8_t
数组,本身没有对齐要求。但是,如果你传递给
AES_CBC_encrypt_buffer
的缓冲区指针
buf
指向一个非对齐地址,在某些架构(特别是ARM Cortex-M系列,当启用硬件对齐检查时)可能会导致硬件错误或性能下降。
排查技巧 :如果遇到神秘的硬件错误(HardFault),特别是在加密函数内部,首先检查传入缓冲区的地址。确保
buf是4字节对齐的。在分配缓冲区时,可以使用编译器属性(如__attribute__((aligned(4))))或动态内存分配时使用对齐的分配函数。
5.2 填充(Padding)方案不匹配
tiny-AES-c
不处理填充。你必须自己实现填充和去填充。最常用的是PKCS#7。问题往往出现在
加密端和解密端使用了不同的填充方案
,或者解密后去填充的逻辑有误。
症状 :解密出来的数据末尾有乱码,或者去填充时计算出错(例如,填充值大于16或等于0)。
解决方案 :
- 双方明确约定使用同一种填充标准(强烈推荐PKCS#7)。
-
在解密后,严格验证填充字节。PKCS#7的规则是:最后一个字节的值
pad表示填充的长度,且所有填充字节的值都必须等于pad。// 解密后,验证PKCS#7填充 uint8_t pad = decrypted_data[data_len - 1]; if (pad == 0 || pad > AES_BLOCKLEN) { // 无效填充 return ERROR_INVALID_PADDING; } for (uint8_t i = 0; i < pad; i++) { if (decrypted_data[data_len - 1 - i] != pad) { // 无效填充 return ERROR_INVALID_PADDING; } } // 去除填充 *output_len = data_len - pad;
5.3 初始化向量(IV)管理混乱
这是CBC模式最常见的问题。IV必须是随机的、不可预测的,并且每次加密会话都应不同。但更重要的是, 加密端和解密端必须使用相同的IV 。
常见错误 :
-
加密后IV未更新/保存
:如前所述,
tiny-AES-c的CBC加密函数会修改ctx.Iv。如果你加密一段数据后,用同一个ctx(其IV已变)去加密下一段数据,这是正确的。但如果你需要将密文和IV分开传输,就必须在加密 前 保存原始的IV,并将这个原始IV发送给解密方。解密方用这个原始IV初始化ctx。 - 固定IV :永远不要使用全零或固定的IV。这会使CBC模式的安全性大打折扣。对于嵌入式设备,可以从硬件随机数发生器(如果可用)、ADC采样噪声、RTC计时器等获取熵源来生成IV。
5.4 多任务/中断环境下的重入问题
tiny-AES-c
的函数本身是
可重入的
(因为它们不修改全局变量,所有状态都在传入的
ctx
结构体中)。但是,如果你在中断服务程序(ISR)和主循环中
共享同一个
struct AES_ctx
变量
,就会发生数据竞争,导致加密结果错误或系统崩溃。
解决方案 :
-
为每个执行上下文提供独立的
ctx实例 。例如,ISR用ctx_isr,主循环用ctx_main。 -
如果必须共享,则需要使用互斥锁(mutex)或关中断等同步机制来保护对
ctx的访问。但在资源极度受限的系统中,方案1更简单可靠。
5.5 编译警告与优化
tiny-AES-c
的代码质量很高,但在高警告级别的编译器设置下,可能会看到关于“未使用的函数参数”或“指针类型转换”的警告。通常这些警告可以安全忽略。如果你希望代码完全干净,可以修改源码或调整编译器警告选项。
关于优化,务必使用
-Os
(优化尺寸)选项进行编译。对于性能敏感的场景,可以尝试
-O2
,但体积会增加。
切勿使用
-O3
,它可能会进行过于激进的循环展开和内联,导致代码体积爆炸,违背了使用
tiny-AES-c
的初衷。
6. 进阶话题:与其他轻量化方案的对比与选型
tiny-AES-c
并非唯一选择。在嵌入式加密领域,还有其他一些优秀的轻量化实现。
| 方案 | 语言/依赖 | 代码体积 | 特点 | 适用场景 |
|---|---|---|---|---|
| tiny-AES-c | 纯C, 无依赖 | 极小 (~3KB) | 极致轻量,可移植性最强,速度适中 | 资源极度受限的8/16/32位MCU, 对体积敏感 |
| mbedTLS AES | C, 依赖mbedTLS库 | 较大 (~12KB+) | 功能完整,支持多种算法和模式,经过充分审计,生态好 | 资源相对宽松(>64KB Flash),需要TLS/DTLS等完整协议栈 |
| wolfSSL AES | C, 依赖wolfSSL库 | 中等 (~8KB+) | 强调速度和安全性,有商业支持,可高度裁剪 | 对性能有要求,且有商业支持需求的物联网设备 |
| 硬件加密引擎 | 芯片内置 | 接近0 (驱动代码) | 速度极快,功耗低,不占用CPU | 芯片自带AES加速器(如STM32L4, ESP32, nRF52系列) |
如何选择?
-
首先检查你的MCU是否有硬件AES加速器
。如果有,毫不犹豫地使用它。这是性能、功耗和代码体积的最优解。
tiny-AES-c可以作为备用方案,当硬件引擎不可用时进行软件回退。 -
如果只有软件实现可选
,评估你的Flash和RAM预算。如果空间捉襟见肘(比如总Flash<32KB),
tiny-AES-c几乎是唯一可行的选择。 - 如果你的项目未来可能扩展,需要TLS等更复杂的加密协议 ,那么从一开始就集成一个更完整的库如mbedTLS可能是更省事的,尽管初期体积大些。
-
考虑安全审计和长期维护
。
tiny-AES-c代码简单,易于审计。而mbedTLS、wolfSSL等有专业团队维护,响应安全漏洞更快。
我个人在资源受限的裸机或RTOS项目中,90%的情况会首选
tiny-AES-c
。它的简洁、透明和可控性让我非常放心。只有当项目明确需要更复杂的密码学套件,或者MCU资源足够丰富时,我才会考虑更重量级的库。


5135

被折叠的 条评论
为什么被折叠?



