资讯详情 C语言手写AES加密:从分组密码原理到嵌入式工程实现
📅 2026/10/14 7:03:00
1. 项目概述1.1 为什么在2025年还要自己动手写AES先说结论大多数业务场景下你不需要自己从零实现AES直接用OpenSSL、mbedTLS这些成熟库就好。但如果你接手的是一个跑在裸机上的嵌入式设备、一个没有操作系统依赖的物联网终端、或者一个需要把加解密逻辑写进加密固件里的安全模块你很难绕开“用C语言手工实现AES”这个现实问题。我做过一个模拟项目X需要在某嵌入式板上做传感器数据的端到端加密。那个环境没有OpenSSL可链接内存只有几十KB唯一的依赖是标准C库。当时我的第一反应也是“直接移植一个实现算了”但后来发现真正动手之前先想清楚几件事能省掉后面至少一周的调试时间第一件事是搞清楚你到底需要AES的哪一部分。AES本身只是分组加密算法处理的是固定16字节的数据块但实际业务里几乎没有数据恰好是16字节的倍数所以你必须处理填充、模式CBC、CTR等、密钥扩展、还有初始化向量IV。这些加在一起才是一个“能用的加密系统”单独写一个AES加密函数意义不大。第二件事是确认目标平台的字节序、内存对齐方式和编译器特性。AES的标准参考实现里有大量数组操作和字节交换如果在16位MCU上跑int的长度可能是16位和PC上不一样这些看似微小的问题会在联调时集中爆炸。第三件事是明确你要的是“教学练习”还是“工程可用”。如果是教学知道S盒怎么生成、轮数怎么算就够了如果是工程你还需要考虑侧信道攻击的防护、内存中的数据擦除、随机数的质量这些都是另一个层面的事情。1.2 这篇博文适合谁看如果你是刚接触密码学的学生这篇能帮你把教材上的流程图变成可以在本地跑起来的代码。如果你是要在资源受限设备上做加密功能的嵌入式开发工程师这篇能帮你少踩我踩过的那些坑。如果你只是想快速在项目里集成AES我会在文末给出现成的方案建议不鼓励你重复造轮子。整个实现我会采用OpenSSL兼容的数据格式这意味着你用这里写出的接口加密的数据在PC上用命令行工具也能解出来方便联调和验证。2. AES核心概念拆解2.1 从宏观结构说起AES不是一个“算法”而是一族算法根据密钥长度分为AES-128、AES-192、AES-256三种。密钥越长加密轮数越多但数据块长度固定为128比特也就是16字节。整个加密过程可以理解为对16字节数据块进行十轮左右的“混淆替换扩散”操作最终让密文和明文之间看起来毫无统计关联。和DES相比AES不是Feistel网络结构而是SPN结构替换-置换网络这意味着在软件实现上更规整在硬件上更容易流水化。我自己在实现前的思路是定义状态矩阵把16字节明文排成4x4字节矩阵。每轮进行SubBytes字节替换、ShiftRows行移位、MixColumns列混合、AddRoundKey轮密钥加四个操作。第一轮之前只做一次AddRoundKey最后一轮省略MixColumns。密钥扩展从初始密钥派生出所有轮密钥提前计算好避免加密过程中重复计算。这个流程对应FIPS-197文档里的标准描述唯一要提醒的是M列混合里的矩阵乘法是GF(2^8)域上的乘法不是普通整数乘法这是新手最容易懵的地方。2.2 S盒与逆S盒AES的非线性核心AES的安全性很大程度上依赖S盒的构造。S盒是一个256字节的查找表输入一个字节输出另一个字节完成非线性替换。它的构造方式是先求每个字节在GF(2^8)域上的乘法逆元0x00映射到自身然后做一次仿射变换。仿射变换是线性的但乘法逆元是非线性的两者结合保证了S盒的复杂程度。实际编码中你不会每次运行都计算S盒而是直接把256个值写成常量数组把逆S盒也写成常量数组查表即可。这里贴一行我当时写的关键代码片段展示了S盒在SubBytes中的应用方式// state[x][y] 是当前状态矩阵 uint8_t row state[x][y]; state[x][y] sbox[row 4][row 0x0F];如果要求代码紧凑也可以直接索引一维数组省掉外层内层两个维度用gcc编译时计算偏移效率更高。要注意逆解密时用的是逆S盒两个表不可混用否则前两轮就能看到乱码。2.3 轮密钥加、行移位、列混合的工程视角轮密钥加的本质是一个16字节的异或操作把当前状态和这一轮对应的轮密钥逐字节异或。这是AES里唯一可逆且直接的操作不需要任何表。行移位是把状态矩阵的4行分别左移0、1、2、3个字节。解密方向是右移对应的偏移量。软件实现上因为状态是16字节的一维数组行移位需要小心索引我一开始直接按二维数组操作后来优化成通过一个固定的位置变换表来搬运字节。列混合是最耗计算量的一步。它把一列的4个字节通过GF(2^8)域上的矩阵乘法重新组合矩阵固定为2 3 1 1 1 2 3 1 1 1 2 3 3 1 1 2解密时乘的是另一个矩阵14 11 13 9 9 14 11 13 13 9 14 11 11 13 9 14在PC上查表可提速但在MCU上查表反而可能因为缓存不足变慢所以我在嵌入式版本里用的是直接计算方式基于GF(2^8)的xtime函数uint8_t xtime(uint8_t x) { return (x 1) ^ ((x 0x80) ? 0x1B : 0x00); }这个函数模拟的是GF(2^8)中乘以“2”的运算当最高位为1时异或0x1B因为0x1B是AES有限域不可约多项式x^8x^4x^3x1的低8位。2.4 密钥扩展到底在做什么密钥扩展的任务是从初始密钥生成44*(轮数1)个字的轮密钥每个字4字节。以AES-128为例一共需要11组轮密钥每组4字总计44个字。扩展算法的核心思路是初始的4个字直接来自原始密钥。后续每个字是前一个字和前面第4个字的异或。每隔4个字即每组轮密钥的开头要先做一次RotWord字循环位移、SubWord对4字节逐字节查S盒和Rcon轮常量异或。这边关键点是Rcon它只在每组开头起作用不同轮次使用不同常量0x01、0x02、0x04、0x08、0x10、0x20、0x40、0x80、0x1B、0x36。这些值本质上是GF(2^8)中“2”的幂。我当时调试时遇到过一个问题AES-128扩展出来的轮密钥最后两轮看起来“很规律”一度怀疑自己写错了。后来比对了标准向量才发现规律是对的因为轮常量的加入只影响每组第一个字后面几个字确实只是纯异或视觉上就会显得规整。3. 模式选择与填充方案3.1 ECB、CBC、CTR怎么选AES算法本身是固定的业务区别主要来自分组模式。ECB模式把每个16字节块独立加密简单但有明显缺陷相同的明文块会得到相同的密文块在图片、固件这类有大量重复数据的场景下密文会泄露明文的统计特征。我在模拟项目X的最初版本里用了ECB因为实现最简单但做完安全性评审后被要求换成CBC。至于CTR它把AES当流密码用加密和解密可以是并行计算非常直观。CTR模式下对计数器块做AES加密再将结果与明文异或得到密文解密完全是同样的操作所以不需要实现AES解密算法——如果你只需要“单向加密”CTR反而省一半代码。这里有一个重点提醒CTR模式下绝对不能用重复的计数器值否则两次加密同一个明文异或之后密钥流就被抵消了密文直接暴露明文关系。我在文档里看到过多个真实事故所以强烈建议使用随机IV加上计数器的组合方式而不是固定计数器。3.2 填充方式的坑AES要求输入长度必须是16字节的倍数。PKCS7填充是最主流的方案缺几个字节就补几个字节补满一整块时额外加16个0x10。解密时读取最后一个字节的值按该值剥离填充字节。判断填充是否合法有讲究不能只看最后一位字节值是否在1到16之间还要验证所有填充字节是否相同。如果只检查长度攻击者可以构造恶意密文通过不同的报错信息判断“填充长度不对”和“MAC不对”这在CBC模式下会打开填充预言攻击的大门。3.3 初始向量IV的生成与传递CBC模式下IV不需要保密但必须不可预测且每次加密随机生成。千万不要用固定IV哪怕只改动一条记录固定IV也会让相同明文前缀在密文中暴露相同前缀这等价于ECB的部分泄漏。生成IV的方式优先使用操作系统提供的随机源比如Linux下的/dev/urandom。嵌入式环境下如果硬件提供了TRNG或真随机数种子拿来用如果只有伪随机数的库至少要混入时间戳、ADC噪声、传感器抖动等信息做种子。我见过有人拿rand()%65535直接生成IV配合CTR模式使用密钥流直接可预测这种项目上线就会被破严重点说这不是“实现细节问题”而是根本性的安全缺陷。4. 用C语言实现AES加密和解密4.1 接口设计先定义对外接口我采用的是类似OpenSSL的风格但针对嵌入式做了裁剪typedef enum { AES_128 16, AES_192 24, AES_256 32 } aes_key_len; typedef struct { uint8_t round_key[240]; // 最大AES-256需要60字即240字节 uint8_t nr; // 轮数 } aes_ctx; void aes_init(aes_ctx *ctx, const uint8_t *key, aes_key_len key_len); void aes_encrypt_block(const aes_ctx *ctx, const uint8_t in[16], uint8_t out[16]); void aes_decrypt_block(const aes_ctx *ctx, const uint8_t in[16], uint8_t out[16]);这里的round_key预留240字节足以容纳AES-256的60个四字节字。初始化时根据密钥长度设置轮数AES-128为10轮AES-192为12轮AES-256为14轮。4.2 加密接口加密块是核心逻辑按标准执行每一轮操作。每一轮内部顺序是固定的需要注意最后一轮时要跳过MixColumns。为了让代码更直观我建议把SubBytes、ShiftRows、MixColumns、AddRoundKey四个操作拆成独立函数哪怕这样会多几次函数调用开销。下面是一个符合FIPS-197要求的加密块函数实现骨架void aes_encrypt_block(const aes_ctx *ctx, const uint8_t in[16], uint8_t out[16]) { uint8_t state[16]; memcpy(state, in, 16); // 初始轮密钥加 add_round_key(state, ctx-round_key, 0); // 主循环9/11/13轮 for (uint8_t round 1; round ctx-nr; round) { sub_bytes(state); shift_rows(state); mix_columns(state); add_round_key(state, ctx-round_key, round * 16); } // 最后一轮无MixColumns sub_bytes(state); shift_rows(state); add_round_key(state, ctx-round_key, ctx-nr * 16); memcpy(out, state, 16); // 清空栈上的状态防止敏感数据残留 memset(state, 0, 16); }注意我特意在函数末尾把state清零。在嵌入式安全模块中这类中间缓冲区如果不清零调试时可能通过内存转储直接提取明文碎片。有人可能会说“这无所谓系统是自己的”但我自己的习惯是凡涉及密钥或明文的栈变量用完后统一清掉。4.3 解密接口解密过程是加密过程的逆序每一轮依次执行InvShiftRows、InvSubBytes、AddRoundKey、InvMixColumns。注意轮密钥的使用顺序和加密相反但轮密钥本身不需要重新计算直接从round_key数组尾部向前取即可。解密骨架void aes_decrypt_block(const aes_ctx *ctx, const uint8_t in[16], uint8_t out[16]) { uint8_t state[16]; memcpy(state, in, 16); // 初始轮密钥加使用最后一组轮密钥 add_round_key(state, ctx-round_key, ctx-nr * 16); for (uint8_t round ctx-nr - 1; round 0; round--) { inv_shift_rows(state); inv_sub_bytes(state); add_round_key(state, ctx-round_key, round * 16); inv_mix_columns(state); } inv_shift_rows(state); inv_sub_bytes(state); add_round_key(state, ctx-round_key, 0); memcpy(out, state, 16); memset(state, 0, 16); }这里最容易被搞混的是InvMixColumns的执行顺序。在加密流程中MixColumns在AddRoundKey之前解密时则是AddRoundKey之后再做逆列混合。你如果先做了逆列混合再加轮密钥结果会相差甚远。这个顺序问题我调试时排查了整整半天最后靠打印中间状态矩阵对比标准结果才定位到。4.4 加密和解密的完整流程封装在工程中很少直接调用aes_encrypt_block处理数据块通常还需要包括填充和模式逻辑。下面给出一段比较实用的CBC加密接口示例它接收任意长度输入自动进行PKCS7填充并执行CBC模式的逐块加密int aes_cbc_encrypt(const uint8_t *key, aes_key_len key_len, const uint8_t *iv, const uint8_t *in, size_t in_len, uint8_t *out, size_t *out_len) { if (in_len 0 || in_len MAX_IN_LEN) return -1; size_t padded_len ((in_len / 16) 1) * 16; if (out_len NULL || *out_len padded_len) return -1; aes_ctx ctx; aes_init(ctx, key, key_len); uint8_t block_in[16]; uint8_t block_out[16]; // 复制IV到链接变量 uint8_t link[16]; memcpy(link, iv, 16); for (size_t offset 0; offset padded_len; offset 16) { // 按需填充最后一组 if (offset 16 in_len) { memcpy(block_in, in offset, 16); } else { memset(block_in, 0, 16); memcpy(block_in, in offset, in_len - offset); uint8_t pad (uint8_t)(16 - (in_len - offset)); for (uint8_t i 0; i pad; i) { block_in[16 - i - 1] pad; } } // CBC模式先和链接块异或再加密 for (int i 0; i 16; i) block_in[i] ^ link[i]; aes_encrypt_block(ctx, block_in, block_out); memcpy(out offset, block_out, 16); memcpy(link, block_out, 16); } *out_len padded_len; return 0; }这段代码里最关键的细节是PKCS7填充当数据恰好是16字节的整数倍时仍然需要额外填充整整一个16字节的块填充值为0x10。因为解密时以最后一个字节作为剥离长度的依据如果没有这个“多余块”解密端就无法区分“原始数据本来就结束”和“上一次正好填满了16字节”。解密时先对块解密与链接块异或然后把末尾填充字节去掉。注意解密时的链接块是上一个密文块而不是上一个明文块。4.5 数据缓冲区管理与内存安全在这个接口设计里out缓冲区不能和in是同一个。因为填充后padded_len可能大于in_len而在填充拷贝过程中如果out和in重叠会发生未定义行为。同理aes_cbc_decrypt中也要保证输出缓冲区足够大且不能与原密文重叠。这方面的最佳实践是调用方本来就该分配好两个独立缓冲区。如果你的接口为了省内存设计成原地加解密你会陷入非常麻烦的边界处理而且不利于安全审计。不要为了省那几十个字节的RAM把逻辑搞混乱不值得。5. 工具链与调试方法5.1 编译选项与常见坑我测试用的环境是Linux下的GCC编译命令很简单gcc -O2 -Wall -Wextra -stdc99 aes.c test.c -o aes_test-Wall -Wextra打开所有常规警告-stdc99明确语言标准避免不同编译器对隐式函数声明或for循环内变量定义的处理不同。我在调试时还经常加-fsanitizeaddress,undefined能帮你捕获数组越界、未初始化内存读取、整数溢出等问题。如果你的目标平台是8位MCU建议关掉优化或只用-O0验证逻辑等全部跑通后再开优化。我之前在某个MSP430平台上遇到过-O2下加密结果正确、解密结果偶尔错误的情况最终定位是未定义行为——在编译优化后循环索引被重排导致的。先验证逻辑再调优化能少花很多时间。5.2 标准测试向量唯一的判断标准判断你写的AES是否正确唯一的依据是NIST FIPS-197附录里的标准测试向量。网上所谓的“和OpenSSL对比结果一致”有时候只是因为两个错误的实现产生了同一个错误结果这种巧合非常害人。FIPS-197文档里给出的每个测试向量都包含密钥、明文、密文三个值长度固定为128/192/256位。如果你用AES-128跑一次输出密文和文档完全一致基本可以断定核心实现正确。我在测试中自己写了一个简单的测试脚本把向量组织成如下格式typedef struct { const char *key; const char *plain; const char *cipher; } test_vector; test_vector vectors[] { { 000102030405060708090a0b0c0d0e0f, 00112233445566778899aabbccddeeff, 69c4e0d86a7b0430d8cdb78070b4c55a, }, // 更多向量... };然后把字符串解析成字节数组调用aes_encrypt_block和aes_decrypt_block分别比对密文和恢复后的明文。只要这些向量全过你的AES核心就稳了。5.3 中间状态打印法如果你实现有问题建议在每一轮末尾打印状态矩阵。FIPS-197文档里同样提供了中间轮状态示例你可以逐轮比对迅速定位是SubBytes算错了、行移位顺序反了还是轮密钥扩展有问题。切记不要在出了问题时直接怀疑S盒表抄错了。S盒表是最不容易抄错的优先排查自己的运算顺序尤其是解密方向的操作顺序。6. 加密正确性自测方案6.1 自测思路自测分三层第一层是单块加解密正确性用FIPS-197标准向量测试。第二层是分组模式正确性用NIST SP 800-38A中的CBC、CTR测试向量。第三层是随机往返测试生成随机密钥、随机明文、随机初始向量加密后再解密检查输出和输入是否一致。这一层能暴露缓冲区管理、填充处理、状态变量复用等问题。三层全过再和OpenSSL命令行为做交叉验证确认接口兼容性。注意OpenSSL命令行默认使用PKCS7填充如果你的实现也是PKCS7那么直接可以用# 加密 openssl enc -aes-128-cbc -K 00112233445566778899aabbccddeeff -iv 000102030405060708090a0b0c0d0e0f -in plain.bin -out cipher.bin # 解密 openssl enc -d -aes-128-cbc -K 00112233445566778899aabbccddeeff -iv 000102030405060708090a0b0c0d0e0f -in cipher.bin -out decrypted.bin6.2 我的测试代码结构这是我的测试目录结构供你参考aes/ ├── aes.c # AES核心实现 ├── aes.h # 对外接口 ├── test_vectors.c # 标准向量测试 ├── test_roundtrip.c # 随机往返测试 └── MakefileMakefile里默认构建两个可执行文件一个跑向量一个跑往返测试。我强烈建议在提交代码前把这两个都跑一遍而不是只跑其中一个。7. 常见问题与排查技巧实录7.1 解密出来的数据前面是好的最后一块乱码这通常是填充处理的问题。如果你是手动剥填充把最后一个字节当成填充长度之前要检查这个字节的值是否在1到16之间。如果不在说明密文在传输中被破坏或者模式逻辑不对。还有一种可能是你实现的是“零填充”但调用方用PKCS7去解两边口径不一致。排查技巧先把填充逻辑注释掉直接比较解密后的明文和原始明文的前in_len - 16字节。如果前面全对只有最后一个块不对那就是填充逻辑的问题如果从第一块就错那就是IV或者密钥的问题。7.2 加密结果和OpenSSL不一致首先检查密钥字节序。OpenSSL的-K参数是按16进制字符串直接解析的没有字符串转码。如果你那边是把ASCII字符串当密钥直接传入等于密钥完全变了结果自然不一致。其次检查填充方式。OpenSSL默认PKCS7如果你用Zero Padding长度恰好是16倍数时两者结果一样不是倍数时最后一块完全不同。最后检查IV是否一致不同的IV导致第一块密文不同进而影响后续所有块。7.3 在ARM板上运行速度特别慢AES在软件实现的性能瓶颈通常是MixColumns和S盒查表每个块至少要做16次S盒查表和大量的GF(2^8)乘法。如果你的板子没有AES硬件指令可以考虑用T-Tables实现用4个256x32位的查表合并SubBytes和MixColumns速度能提升3到5倍但消耗约4KB ROM。如果板子上有AES-NI或ARM的加密扩展优先用硬件加速接口不要自己算。确认编译器是否开启了优化-O0和-O2的性能差距可能在10倍以上。7.4 密钥或明文残留在内存里这是安全审计里常见的问题。malloc出来的缓冲区、栈上的局部变量在函数结束后并不会被清零。你在释放内存之前要手动memset并且用volatile修饰防止编译优化把这行清零指令优化掉。有意思的是有些编译器会在-O2下发现“memset后再free中间没有任何读取”于是把它优化掉安全专家管这叫“dead store elimination”。为了避免这个问题可以自己写一个不被优化的清零函数或者用编译器的内置安全函数接口。7.5 填充长度验证要完整我在前面已经提过解密时只检查最后一个字节的数值范围是不够的。严格的做法是static int pkcs7_unpad(const uint8_t *block, size_t *padded_len) { uint8_t pad block[15]; if (pad 1 || pad 16) return -1; for (uint8_t i 0; i pad; i) { if (block[15 - i] ! pad) return -1; } *padded_len - pad; return 0; }这段代码强制执行“所有填充字节必须相同”的校验能有效防止畸形填充被误当成合法数据。8. 用户体验细节加解密超时与进度反馈8.1 大文件加密时的超时问题AES本身极快大文件加密的瓶颈通常不在算法而在IO和内存拷贝。在PC上一块128KB的文件加密只需要几毫秒但如果你读到一块处理一块的模式没有设计好缓冲区频繁分配释放速度也会被拖慢。对于嵌入式UI触发的加密如果文件较大需要给出进度反馈否则用户会误以为程序死机。按块数计算进度即可size_t total_blocks (padded_len 15) / 16; // 每处理完10%就回调一次进度8.2 密钥输入体验让用户手动输入密钥是极度糟糕的设计因为十六进制字符串不仅难输还容易因为大小写不一致导致解不开。正确做法是让用户提供一个口令用KDF如PBKDF2或Argon2派生密钥。真正需要直接输入密钥的场景也应该提供复制粘贴和格式校验并在校验通过前给出明确的错误提示。9. 代码优化与安全性增强9.1 查表 vs 计算T-Tables把每一轮最昂贵的两个步骤合二为一一次查表同时完成SubBytes和MixColumns。下面这张表能说明两种方案的差异实现方式速度ROM占用适用场景纯计算慢极小存储极度受限的MCUS盒查表中256字节(S盒)256字节(逆S盒)大多数嵌入式设备T-Tables快约4KB需要按字节序调整性能敏感的PC或大内存MCU从安全角度讲查表实现容易受到缓存计时侧信道攻击。如果攻击者能够物理接触设备并对操作时间做统计分析T-Tables的缓存命中模式会泄露密钥信息。在安全模块设计中常采用恒定时间实现也就是让加密操作的时间不依赖密钥值。这个层面的防护比较复杂不是这篇博文能展开讲透的但你在方案选型时要有所意识。9.2 随机数质量CMS/业务接口里用到的IV和密钥种子都依赖随机数。在Linux上我直接用getrandom()或在/dev/urandom上读取。MCU上如果只有一个srand/rand式的伪随机数生成器直接用它生成IV非常危险。安全做法是把ADC读取的低位噪声、精密定时器计数器、多个独立时钟源做混合喂给一个密码学安全的伪随机数生成器如ChaCha20作为流密码来生成随机序列再用这个生成器的输出做IV和密钥。9.3 日志安全在调试过程中不要打印密钥和IV。如果你确实需要打印状态矩阵排查问题用脱敏标识比如只打印前4字节并在合入正式代码前把这些日志全部删掉。密钥一旦进入日志系统可能被日志采集工具同步到远端等于把保险箱钥匙贴在了保险箱外面。10. 再分享一个小技巧最后说一个我踩过多次坑后才养成的习惯在做完AES代码实现和自测之后拿一个超过100MB的测试文件做一次跨环境验证。用同一套密钥和IV在PC上加密把密文传输到另一台机器上用你自己的解密代码解密再比对哈希。这一步能一次性暴露字节序、填充、模式等几乎所有隐蔽问题比任何单元测试都管用。如果你要在嵌入式端使用这套代码建议在你的开发板上先跑通标准的NIST测试向量再跑随机往返测试最后再做跨平台验证。这个顺序可以根据你手头时间灵活调整但向量测试不要跳它是唯一的“锚点”。C语言实现AES不算太难难的是所有细节都正确、安全、可维护。希望这篇经验贴能帮你少走一些弯路。