资讯动态

纯C++手写AES-128加密算法:从原理到嵌入式实现全解析

发布时间:2026/10/16 5:49:07 来源:尧图企业网站定制
前阵子做某个工业设备的通信协议对接对方给出的技术规范里赫然写着“AES-128-ECB加密”而我手里的开发环境是个资源极其受限的嵌入式平台——内存不到几百KB没有OpenSSL更没有Crypto唯一的指望就是自己用C把AES的代码实现写出来。折腾了两天踩了不少坑最后把整套代码跑通并拿标准测试向量验证通过。这篇就把完整的实现思路、关键代码和调试经验整理出来给打算手写AES或者正在啃FIPS-197文档的朋友做个参考。先说清楚这篇内容的边界我实现的是AES-128的单分组加解密重点讲清楚每个环节的原理、C代码怎么写、为什么这么写以及怎么用标准测试向量验证正确性。掌握了AES-128这一套骨架之后想改成AES-192或AES-256并不难轮数、密钥扩展逻辑稍作调整即可。适合正在学密码学原理、做嵌入式开发、或者因为跨平台需要而被迫自研加密模块的人。1. 先想清楚一个问题现有加密库不香吗为什么还要手写AES很多人第一反应是AES不是有OpenSSL、Crypto、mbedTLS这些现成的库吗直接调用不就行了这话没错如果平台允许、依赖方便、性能要求又不苛刻直接用成熟库是最稳的选择。但现实里总有几个场景让人不得不亲手写一份嵌入式平台资源受限交叉编译OpenSSL费劲裁剪完体积还是大而且API版本之间的兼容性问题能让人折腾一整天。有些行业项目里客户明确要求加密模块必须完全自主可控不能依赖第三方闭源或半开源组件。做协议调试时需要对照中间状态排查问题自研实现可以把每一轮的结果都打印出来直观对照标准文档。纯粹想搞懂AES到底是怎么工作的。查表替换、行列变换、密钥扩展这些概念看十遍文档都不如跑通一遍代码记得牢。方案优势劣势OpenSSL功能全、性能强、认证充分体积大、交叉编译复杂、API版本碎片化Crypto纯C、文档丰富模板层次深、编译速度慢、对老编译器不友好mbedTLS模块化裁剪、适合嵌入式配置项多错误处理需要额外封装自研AES-128零依赖、代码全在掌控中需要自行验证正确性性能要靠优化在决定自研之前先明确AES的基本参数分组长度固定16字节密钥长度可选16/24/32字节对应AES-128/192/256轮数分别是10/12/14。轮密钥总量是16乘以轮数加一比如AES-128就是176字节。下面所有代码都围绕AES-128展开但结构上会把轮数等参数常量定义出来方便后续扩展。2. GF(2^8)上的乘法AES唯一需要补的数学课真正动手写代码之前如果你之前没接触过有限域第一个拦路虎大概率是列混合MixColumns里的乘法。AES里的字节不是普通的整数而是被看成GF(2^8)有限域上的元素。简单说一个字节对应一个8次多项式加法就是按位异或乘法则是多项式相乘之后对一个固定不可约多项式取余。AES选用的不可约多项式是 x^8 x^4 x^3 x 1。在计算机里怎么算逐位移位加异或。以乘2为例把字节左移一位如果最高位是1说明乘出来的结果超出了8位需要异或0x1b来“取余”。0x1b就是上面那个不可约多项式去掉x^8项之后剩下的低8位。这个操作很多人第一次看会懵其实可以类比成普通乘法溢出时丢掉进位位而伽罗华域乘法溢出时要做一次特殊的“回卷”靠的就是0x1b。下面这个函数就是GF(2^8)乘法的逐位实现它是后面所有复杂变换的地基uint8_t gmul(uint8_t a, uint8_t b) { uint8_t p 0; for (int i 0; i 8; i) { if (b 1) p ^ a; // b 当前位为1把 a 累加进结果 uint8_t hi a 0x80; // 保存 a 的最高位判断是否溢出 a 1; // a 乘以 x if (hi) a ^ 0x1b; // 溢出后对不可约多项式取模 b 1; // 继续处理 b 的下一位 } return p; }这个函数虽然性能不算高但逻辑非常直白最适合教学和排错。实际工程里通常会提前把乘2、乘3的结果做成查表因为列混合里只会用到这几个固定系数。这里我建议先用逐位版本跑通全流程确认无误后再想优化的事——调错时越简单越容易定位问题。3. 四大变换的C实现S盒、行移位、列混合、轮密钥加AES的一轮加密由四个变换组成字节代换SubBytes、行移位ShiftRows、列混合MixColumns、轮密钥加AddRoundKey。把这四个函数写清楚加密主干就完成了一大半。3.1 S盒与SubBytes查表比现场算快得多字节代换的作用是把状态矩阵里的每个字节通过S盒映射成另一个字节。S盒本身由两个数学步骤生成先在GF(2^8)上求乘法逆元再做一次仿射变换。如果每次加密都现场计算乘法逆元速度和实现复杂度都会很糟糕所以工程上几乎全部采用预计算的查表法。S盒是一张256字节的表我直接把它贴出来这份数据你要么从FIPS-197附录A抄要么从可靠开源工程里复制强烈不建议手敲手敲错一个字节后面全盘皆输static const uint8_t sbox[256] { 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 };SubBytes实现就一行循环void SubBytes(uint8_t state[16]) { for (int i 0; i 16; i) { state[i] sbox[state[i]]; } }解密时需要的逆S盒不需要手动抄表用下面的循环从S盒反推出来即可uint8_t inv_sbox[256]; for (int i 0; i 256; i) { inv_sbox[sbox[i]] i; }这个技巧能省不少事也避免了抄错逆表的风险。只要S盒正确逆S盒一定正确。3.2 ShiftRows搞清方向别让密文悄悄长歪行移位是个纯粹的索引重排操作状态矩阵的第0行不动第1行循环左移1字节第2行循环左移2字节第3行循环左移3字节。听起来非常简单但实现里最大的坑是状态矩阵在内存里的排列方式。AES的16字节状态在内存中按列存储state[0..3]是第一列state[4..7]是第二列依此类推。也就是说矩阵元素 state[r][c] 对应内存下标 c*4 r。如果你按行主序的直觉去写索引第一轮可能还看不出问题第二轮起就会得到完全错误的中间状态。我推荐的实现是先把结果写进临时数组再拷贝回state避免原地覆盖导致读到已经被改过的值void ShiftRows(uint8_t state[16]) { uint8_t tmp[16]; for (int c 0; c 4; c) { tmp[c*4 0] state[c*4 0]; // 第0行不动 tmp[c*4 1] state[((c 1) % 4)*4 1]; // 第1行循环左移1字节 tmp[c*4 2] state[((c 2) % 4)*4 2]; // 第2行循环左移2字节 tmp[c*4 3] state[((c 3) % 4)*4 3]; // 第3行循环左移3字节 } memcpy(state, tmp, 16); }这段代码的精髓在于用(ck)%4去取原矩阵里对应位置的元素。加密过程要左移解密时的逆操作就是右移相应字节。方向写反是最经典的错误之一测试向量在第二轮就会出现不一致。3.3 MixColumnsGF乘法第一次派上用场列混合把状态矩阵的每一列看成一个四字节向量左乘一个固定矩阵。对于每一列的四个字节s0、s1、s2、s3得到的新字节为s0 2s0 ^ 3s1 ^ s2 ^ s3 s1 s0 ^ 2s1 ^ 3s2 ^ s3 s2 s0 ^ s1 ^ 2s2 ^ 3s3 s3 3s0 ^ s1 ^ s2 ^ 2s3上面的乘法全部是GF(2^8)乘法用前面写好的gmul实现。最容易被忽视的问题是新值计算依赖旧列的所有字节所以必须先保存原始s0到s3再逐个赋值。如果边算边覆盖后面的计算会用到已经被改掉的值结果必然错乱void MixColumns(uint8_t state[16]) { for (int c 0; c 4; c) { uint8_t* col state c*4; uint8_t s0 col[0], s1 col[1], s2 col[2], s3 col[3]; col[0] gmul(2, s0) ^ gmul(3, s1) ^ s2 ^ s3; col[1] s0 ^ gmul(2, s1) ^ gmul(3, s2) ^ s3; col[2] s0 ^ s1 ^ gmul(2, s2) ^ gmul(3, s3); col[3] gmul(3, s0) ^ s1 ^ s2 ^ gmul(2, s3); } }逆列混合用的矩阵系数变成9、11、13、14逻辑完全一样写成InvMixColumns备用void InvMixColumns(uint8_t state[16]) { for (int c 0; c 4; c) { uint8_t* col state c*4; uint8_t s0 col[0], s1 col[1], s2 col[2], s3 col[3]; col[0] gmul(14, s0) ^ gmul(11, s1) ^ gmul(13, s2) ^ gmul(9, s3); col[1] gmul(9, s0) ^ gmul(14, s1) ^ gmul(11, s2) ^ gmul(13, s3); col[2] gmul(13, s0) ^ gmul(9, s1) ^ gmul(14, s2) ^ gmul(11, s3); col[3] gmul(11, s0) ^ gmul(13, s1) ^ gmul(9, s2) ^ gmul(14, s3); } }3.4 AddRoundKey轮密钥加最简单也最关键轮密钥加只有一个操作把16字节状态和16字节轮密钥逐字节异或。它简单到不需要任何解释但却是整个AES里最关键的一步因为密钥真正进入数据流的通路就在这里。void AddRoundKey(uint8_t state[16], const uint8_t roundKey[16]) { for (int i 0; i 16; i) { state[i] ^ roundKey[i]; } }我见过有人为了“性能”用uint64_t拆两个8字节异或这在部分平台上确实快但必须注意字节顺序。这里先用最直白的逐字节版本保证和标准文档的中间值能逐字节对上后续优化再考虑SIMD或宽整数。4. 密钥扩展16字节密钥如何变成176字节轮密钥第一轮使用的轮密钥就是原始密钥本身但后续每一轮都需要独立的16字节轮密钥。AES-128总共有11组轮密钥一共176字节。密钥扩展算法以4字节为一个“字”从原始密钥的4个字一步步生成后面40个字。4.1 轮密钥的组织方式初始w[0]到w[3]直接来自原始密钥的16字节。之后每个新字w[i]由w[i-4]和w[i-1]组合得到。绝大多数情况下w[i] w[i-4] ^ w[i-1]。当i是4的倍数时也就是每个新字组的开头要先对w[i-1]做一次特殊变换再异或。这套规则保证了每16字节4个字一轮密钥总共生成44个字。4.2 字循环RotWord、SubWord、Rcon特殊变换分三步先对w[i-1]做循环左移一位即RotWord再把四个字节分别过S盒即SubWord最后把结果第一个字节异或轮常量Rcon。轮常量是一组固定值它们本质上是2在GF(2^8)中的幂用来打破不同轮之间的对称性。static const uint8_t rcon[11] { 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36 };注意rcon[0]那个0x00只是占位实际使用从索引1开始。很多第一次写的人容易把索引对齐搞错导致第二轮密钥就开始歪掉。4.3 完整KeyExpansion代码void KeyExpansion(const uint8_t* key, uint8_t roundKeys[11][16]) { uint8_t w[176]; for (int i 0; i 16; i) w[i] key[i]; for (int i 4; i 44; i) { uint8_t temp[4] { w[(i-1)*4 0], w[(i-1)*4 1], w[(i-1)*4 2], w[(i-1)*4 3] }; if (i % 4 0) { // RotWord循环左移一字节 uint8_t t temp[0]; temp[0] temp[1]; temp[1] temp[2]; temp[2] temp[3]; temp[3] t; // SubWord逐字节过S盒 for (int j 0; j 4; j) { temp[j] sbox[temp[j]]; } // 异或轮常量 temp[0] ^ rcon[i / 4]; } for (int j 0; j 4; j) { w[i*4 j] w[(i-4)*4 j] ^ temp[j]; } } for (int r 0; r 11; r) { for (int j 0; j 16; j) { roundKeys[r][j] w[r*16 j]; } } }这里我用了一个固定176字节的w数组对AES-128来说正好。如果以后想扩展到AES-192或AES-256需要把w数组加大到240循环边界和roundKeys数量都要相应调整另外AES-256在每8个字时还有额外的SubWord步骤这点在FIPS-197里有明确规定别想当然。5. 加解密主流程组装代码只有20行坑有20个四个基础变换和密钥扩展都齐了接下来就是按标准顺序把它们串起来。5.1 加密主流程与最后一轮的特殊处理AES-128加密流程是先AddRoundKey然后执行9轮完整变换SubBytes、ShiftRows、MixColumns、AddRoundKey最后一轮做SubBytes、ShiftRows、AddRoundKey但没有MixColumns。为什么最后一轮要省掉列混合这是AES设计者定义的规范结构目的之一是让加解密结构尽量对称解密时两步逆变换可以互相抵消降低实现复杂度。如果你在最后一轮错误地补了MixColumns密文必然和标准测试向量对不上。void AES_EncryptBlock(const uint8_t in[16], uint8_t out[16], const uint8_t roundKeys[11][16]) { uint8_t state[16]; memcpy(state, in, 16); AddRoundKey(state, roundKeys[0]); for (int r 1; r 9; r) { SubBytes(state); ShiftRows(state); MixColumns(state); AddRoundKey(state, roundKeys[r]); } SubBytes(state); ShiftRows(state); AddRoundKey(state, roundKeys[10]); memcpy(out, state, 16); }5.2 解密主流程逆变换的顺序不能错解密的思路是把加密流程整体取逆。但顺序不是简单地把加密函数倒过来写核心注意点是解密时先AddRoundKey再InvMixColumns。原因是加密的最后一轮没有MixColumns所以解密的第一步不需要InvMixColumns而加密轮内顺序是MixColumns然后AddRoundKey对应解密的逆序就是先AddRoundKey异或是自逆的再InvMixColumns。void AES_DecryptBlock(const uint8_t in[16], uint8_t out[16], const uint8_t roundKeys[11][16]) { uint8_t state[16]; memcpy(state, in, 16); AddRoundKey(state, roundKeys[10]); for (int r 9; r 1; r--) { InvShiftRows(state); InvSubBytes(state); AddRoundKey(state, roundKeys[r]); InvMixColumns(state); } InvShiftRows(state); InvSubBytes(state); AddRoundKey(state, roundKeys[0]); memcpy(out, state, 16); }注意这里InvShiftRows和InvSubBytes的顺序与加密轮的SubBytes、ShiftRows正好相反。如果你看到密码学资料里写“先InvSubBytes再InvShiftRows”那也没毛病因为这两个变换作用在独立的字节/行上顺序互换不影响结果。但一旦涉及AddRoundKey和InvMixColumns顺序就必须严格遵守上面这种结构。5.3 分组模式与PKCS#7填充上面的实现只是AES的“电子密码本”单分组加解密。实际使用中数据往往不止16字节需要选择分组模式。最简单的ECB模式把数据切成一堆16字节分块每块独立加密但它不能隐藏相同明文块的规律安全性堪忧生产环境我更推荐CBC或CTR。用CBC时要注意第一块需要初始化向量IV每个明文块在加密前先和上一个密文块异或。数据长度不是16的整数倍时还要填充通用做法是PKCS#7缺几个字节就补几个字节补的数值等于缺少的字节数。如果正好是16字节对齐也要额外填充一个完整的16字节填充块否则解密端无法区分“刚好满一整个分组”和“没有填充”这两种情况。void AES_CBC_Encrypt(const uint8_t* input, size_t len, const uint8_t key[16], const uint8_t iv[16], uint8_t* output) { uint8_t roundKeys[11][16]; KeyExpansion(key, roundKeys); uint8_t prev[16]; memcpy(prev, iv, 16); size_t blocks len / 16; for (size_t i 0; i blocks; i) { uint8_t block[16]; for (int j 0; j 16; j) { block[j] input[i*16 j] ^ prev[j]; } AES_EncryptBlock(block, block, roundKeys); memcpy(output i*16, block, 16); memcpy(prev, block, 16); } }上面的代码假设已经按PKCS#7填充好了输入长度是16的整数倍。同理可以推出CBC解密先分组解密再把每个明文块和上一个密文块异或即可。5.4 可运行的main函数示例把上面所有函数拼一起写一个最小测试入口用FIPS-197的官方向量做验证#include cstdio #include cstring int main() { uint8_t key[16] { 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c }; uint8_t pt[16] { 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a }; uint8_t ct[16] {0}; uint8_t rt[16] {0}; uint8_t roundKeys[11][16] {0}; KeyExpansion(key, roundKeys); AES_EncryptBlock(pt, ct, roundKeys); AES_DecryptBlock(ct, rt, roundKeys); printf(Ciphertext: ); for (int i 0; i 16; i) printf(%02x , ct[i]); printf(\n); printf(Decrypted: ); for (int i 0; i 16; i) printf(%02x , rt[i]); printf(\n); return 0; }编译运行后密文应当输出3a d7 7b b4 0d 7a 36 60 a8 9e ca f3 24 66 ef 97解密结果应当和原始明文一致。6. 用FIPS-197标准向量验证99%的Bug出现在这些位置手写密码算法最大的问题不是写不出来而是写错之后完全不自知。AES的每一轮变换都会把前一轮的错误扩散得面目全非所以必须用已知标准测试向量进行校验。6.1 FIPS-197 C.1标准测试向量FIPS-197附录C提供了官方测试向量我上面用的这一组就是AES-128的标准样例项目数据十六进制密钥2b7e151628aed2a6abf7158809cf4f3c明文6bc1bee22e409f96e93d7e117393172a密文3ad77bb40d7a3660a89ecaf32466ef97不要自己随便拿“1234567890abcdef”当测试数据除非你已经有其他可信库验证过。我个人的习惯是拿至少两组向量一组来自FIPS-197一组来自NIST的SP 800-38A示例两组都过了才敢往项目里放。6.2 高频Bug清单和定位方法我这次写代码遇到的Bug以及常年帮朋友排查时看到的高频问题基本集中在下面几个地方行移位方向搞反或者索引公式写错。加密时左移写成右移是最容易犯的低级错误。MixColumns没有保存原始列值边算边覆盖导致同一个列内部互相污染。密钥扩展的rcon索引从0开始取正确做法是i/4映射到rcon[1]到rcon[10]。密钥扩展里改动了temp却没影响后续字循环条件或数组下标错位。加密最后一轮多加MixColumns或者忘了AddRoundKey。解密时把InvShiftRows和InvSubBytes顺序放错或者AddRoundKey和InvMixColumns顺序颠倒。状态矩阵索引按行主序写结果和文档里的中间状态完全对不上。定位Bug最有效的办法不是看密文而是打印中间状态。FIPS-197附录B把AES-128第一轮加密每一步的中间值都列出来了包括AddRoundKey后、SubBytes后、ShiftRows后、MixColumns后的完整16字节。我在调试时会在每个函数末尾加一行临时printf和文档逐轮逐字节对比十分钟就能锁定是哪一步出错。调试完再把打印去掉。6.3 后续扩展与优化建议验证通过之后如果你准备用于实际项目还有几件事值得继续做性能优化通常会预计算乘2、乘3的查表或者把S盒、行移位、列混合合并成四个T表。查T表方案在普通PC上可以跑得很快但要注意缓存侧信道问题严格的场景需要用常量时间实现。扩展密钥长度把代码改成支持AES-192和AES-256轮数分别改成12和14roundKeys改成13组和15组密钥扩展增加AES-256特有的处理分支。分组模式ECB只适合极少数场景CBC要处理IV和填充CTR适合流式数据GCM则同时提供认证。你甚至可以基于现有单分组代码把这些模式逐一写出来。我自己的体会是亲手实现一遍AES之后再看那些加密库的文档和源码理解深度完全不一样。以前觉得S盒就是一张神秘的表现在知道它是有限域求逆加仿射变换的产物以前觉得密钥扩展的代码只是条件分支现在明白Rcon背后是在对抗轮与轮之间的对称性。如果你正在学密码学或者被自研加密模块折磨照着这篇文章把这套代码跑通收获会比想象中大得多。

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

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

免费获取报价 →
↑