AES128 ECB、CBC模式加密解密函数(C语言实现 - 单片机/嵌入式)
在物联网设备日益普及的今天,从智能门锁到工业传感器,大量终端每天都在处理敏感数据。这些设备往往运行在资源极其有限的单片机上——Flash 只有几十KB,RAM 不过几KB,连标准库都难以完整支持。然而,安全需求却一点没打折扣:用户密码不能明文存储,通信报文不能被嗅探,固件更新必须防篡改。
于是问题来了:我们能否在一个没有操作系统的裸机环境中,用纯 C 实现一套真正可用的 AES 加解密模块?不仅要小,还得安全、可移植、易于审计。
答案是肯定的。本文将带你一步步构建一个适用于 STM32、ESP32、nRF 等主流 MCU 的轻量级 AES-128 实现,重点聚焦 ECB 与 CBC 两种工作模式,并深入剖析它们在实际应用中的取舍。
AES(Advanced Encryption Standard)自 2001 年成为 NIST 标准以来,早已取代 DES 成为对称加密的事实标杆。它采用 128 位分组长度,支持 128、192、256 三种密钥长度。其中 AES-128 因其在安全性与性能之间的优秀平衡,成为嵌入式领域的首选。
它的核心是一个基于“代换-置换网络”(SPN)的迭代结构,每轮包含四个关键步骤:
- SubBytes :通过 S-Box 对每个字节进行非线性替换,引入混淆;
- ShiftRows :行内循环移位,增强扩散;
- MixColumns :在伽罗瓦域 GF(2⁸) 上对列做线性变换,进一步打乱数据关系;
- AddRoundKey :将当前状态与轮密钥异或。
整个加密过程共 10 轮,初始先执行一次
AddRoundKey
,随后进行 9 轮完整操作,最后一轮省略
MixColumns
。这种设计确保了即使明文仅有一位差异,密文也会呈现完全随机的分布。
为了在单片机上高效运行,我们可以选择不使用查表法(T-table),虽然会牺牲一些速度,但换来的是极低的内存占用和清晰的逻辑结构,更适合调试和安全审查。
下面是一个简化版的单块 AES-128 加密函数实现:
const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
/* ... 完整 S-Box 表略,共 256 字节 */
};
uint8_t galois_mul2(uint8_t x) {
return (x << 1) ^ ((x & 0x80) ? 0x1b : 0x00);
}
void aes128_encrypt(const uint8_t* input, uint8_t* output, const uint8_t* w) {
uint8_t state[16];
memcpy(state, input, 16);
// 初始轮密钥加
for (int i = 0; i < 16; i++) {
state[i] ^= w[i];
}
// 主循环:9轮完整操作
for (int round = 1; round < 10; round++) {
// SubBytes
for (int i = 0; i < 16; i++) {
state[i] = sbox[state[i]];
}
// ShiftRows
uint8_t t;
t = state[1]; state[1]=state[5]; state[5]=state[9]; state[9]=state[13]; state[13]=t;
t = state[2]; state[2]=state[10]; state[10]=t; t=state[6]; state[6]=state[14]; state[14]=t;
t = state[3]; state[3]=state[15]; state[15]=state[11]; state[11]=state[7]; state[7]=t;
// MixColumns
uint8_t col[4];
for (int c = 0; c < 4; c++) {
col[0] = state[c + 0];
col[1] = state[c + 4];
col[2] = state[c + 8];
col[3] = state[c + 12];
state[c + 0] = galois_mul2(col[0]) ^ galois_mul2(col[1]) ^ col[1] ^ col[2] ^ col[3];
state[c + 4] = galois_mul2(col[1]) ^ galois_mul2(col[2]) ^ col[2] ^ col[3] ^ col[0];
state[c + 8] = galois_mul2(col[2]) ^ galois_mul2(col[3]) ^ col[3] ^ col[0] ^ col[1];
state[c + 12] = galois_mul2(col[3]) ^ galois_mul2(col[0]) ^ col[0] ^ col[1] ^ col[2];
}
// AddRoundKey
for (int i = 0; i < 16; i++) {
state[i] ^= w[16 * round + i];
}
}
// 第10轮:无 MixColumns
for (int i = 0; i < 16; i++) {
state[i] = sbox[state[i]];
}
// ShiftRows 同上
uint8_t t = state[1]; state[1]=state[5]; state[5]=state[9]; state[9]=state[13]; state[13]=t;
t = state[2]; state[2]=state[10]; state[10]=t; t=state[6]; state[6]=state[14]; state[14]=t;
t = state[3]; state[3]=state[15]; state[15]=state[11]; state[11]=state[7]; state[7]=t;
for (int i = 0; i < 16; i++) {
state[i] ^= w[160 + i]; // 最后一轮密钥
}
memcpy(output, state, 16);
}
这个版本虽然未使用 T-table 优化,但在 Cortex-M0 等低端芯片上仍可接受。更重要的是,它的每一步都是透明的,便于验证正确性和排查侧信道风险。
接下来,真正的挑战来了:如何把这块“砖”砌进墙里?也就是选择合适的工作模式。
ECB 模式:简单但危险
ECB(Electronic Codebook)可能是最直观的模式:每个 16 字节的明文块独立加密,互不影响。
void aes_ecb_crypt(const uint8_t* input, uint8_t* output, size_t length,
const uint8_t* key, int encrypt) {
uint8_t expanded_key[176];
if (encrypt) {
aes128_expand_key(key, expanded_key);
for (size_t i = 0; i < length; i += 16) {
aes128_encrypt(input + i, output + i, expanded_key);
}
} else {
aes128_expand_key_decrypt(key, expanded_key);
for (size_t i = 0; i < length; i += 16) {
aes128_decrypt(input + i, output + i, expanded_key);
}
}
}
看起来干净利落,甚至可以并行处理。但问题也正出在这里—— 相同的明文块总是生成相同的密文块 。
想象一下你正在加密一张黑白图像。如果某个区域全是白色像素,那么对应的多个明文块完全相同,加密后也会得到一串重复的密文块。攻击者即使不知道具体内容,也能看出原始数据的结构。这就是所谓的“大象图”效应——加密后的图像轮廓依然可见。
因此,除非你在加密的内容本身就是高熵的(比如另一个加密算法的输出、随机密钥或 nonce),否则绝不该使用 ECB。
那它还有用武之地吗?有。例如,在安全启动流程中,你可以用 ECB 加密一个由硬件 RNG 生成的临时密钥,然后用它来解密后续的固件镜像。此时输入是真正随机的,重复概率几乎为零,ECB 的确定性反而成了优势。
CBC 模式:实用主义的选择
CBC(Cipher Block Chaining)才是大多数场景下的合理选择。它通过引入“链式依赖”打破了 ECB 的重复性缺陷。
其核心思想很简单: 每一个明文块在加密前,先与前一个密文块异或 。第一个块则与一个称为 IV(初始化向量)的随机值异或。
加密过程如下:
C₀ = IV
Cᵢ = AES_Encrypt(Key, Pᵢ ⊕ Cᵢ₋₁)
解密时反过来:
Pᵢ = AES_Decrypt(Key, Cᵢ) ⊕ Cᵢ₋₁
注意,IV 不需要保密,但必须满足两个条件: 唯一性 和 不可预测性 。换句话说,同一把密钥下,永远不要重复使用同一个 IV。
下面是 CBC 模式的实现:
void aes_cbc_crypt(const uint8_t* input, uint8_t* output, size_t length,
const uint8_t* key, uint8_t* iv, int encrypt) {
uint8_t expanded_key[176];
uint8_t prev_block[16];
aes128_expand_key(key, expanded_key);
if (encrypt) {
if (iv == NULL) {
// 生产环境应使用真随机源,如 HAL_RNG_GenerateRandomNumber()
memset(prev_block, 0x55, 16); // 示例填充
} else {
memcpy(prev_block, iv, 16);
}
memcpy(output, prev_block, 16); // 将IV写入输出头部
for (size_t i = 0; i < length; i += 16) {
for (int j = 0; j < 16; j++) {
prev_block[j] ^= input[i + j];
}
aes128_encrypt(prev_block, output + 16 + i, expanded_key);
memcpy(prev_block, output + 16 + i, 16);
}
} else {
memcpy(prev_block, input, 16); // 第一块是IV
for (size_t i = 16; i < length + 16; i += 16) {
uint8_t temp[16];
memcpy(temp, input + i, 16);
aes128_decrypt(input + i, output + i - 16, expanded_key);
for (int j = 0; j < 16; j++) {
output[i - 16 + j] ^= prev_block[j];
}
memcpy(prev_block, temp, 16);
}
}
}
可以看到,加密输出包含了前置的 IV(16 字节),接收方只需读取前 16 字节即可还原上下文。这也意味着你每次传输的数据会多出 16 字节开销,但对于大多数应用场景来说完全可以接受。
实际系统中的集成建议
在一个典型的嵌入式通信链路中,AES-CBC 通常位于协议栈的应用层之下:
[传感器数据打包]
↓
[PKCS#7 填充 + AES-CBC 加密]
↓
[加入CRC校验 → 发送至LoRa/BLE/Wi-Fi]
几个关键设计点值得强调:
1. 填充策略
由于 AES 是分组密码,输入长度必须是 16 字节的倍数。推荐使用 PKCS#7 填充:若缺少 N 字节,则补上 N 个值为 N 的字节。
例如,明文缺 3 字节,则末尾添加
0x03 0x03 0x03
。解密后检查最后几个字节是否构成合法填充,再予以移除。
2. IV 的生成与管理
强烈建议使用硬件随机数发生器(RNG)生成 IV。STM32、ESP32 等平台均提供此类外设。切勿用时间戳或计数器这类可预测的方式生成 IV。
此外,IV 应随每次通信重新生成,哪怕是在同一个会话中。
3. 密钥保护
最忌讳的就是把密钥写死在代码里。理想做法是在生产阶段通过安全烧录工具注入密钥,或者配合 ECDH 等密钥协商协议动态生成会话密钥。
如果你的芯片支持 TrustZone 或 Secure Element,优先利用这些硬件机制来保护密钥。
4. 性能优化方向
-
在性能敏感场景,可启用 T-table 查表法,将
SubBytes + ShiftRows + MixColumns合并为 4 个 256 项查找表,速度提升可达 3~5 倍。 - 若目标 MCU 支持硬件 AES 引擎(如 STM32F4/F7/L4+),直接调用底层驱动,不仅能提速,还能有效抵御时序攻击。
- 配合 DMA 使用,实现数据流的零拷贝处理。
回过头看,AES 本身并不复杂,真正决定安全水位的是 你怎么用它 。ECB 的陷阱提醒我们:密码学不是“用了就安全”,而是“正确使用才安全”。
对于绝大多数嵌入式开发者而言, AES-128 + CBC + 随机 IV + PKCS#7 填充 是一个足够稳健的基础组合。它既不会过度消耗资源,又能抵御常见的被动攻击。
未来若需更高安全性,可逐步引入 CTR 模式实现流式加密,或采用 GCM 模式获得认证加密能力(AEAD),从而同时保障机密性与完整性。
但无论如何演进,理解这些基础模式的原理与边界,始终是构建可信系统的起点。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
2万+

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



