AES文件加解密实战:C语言实现CBC/GCM模式与填充方案
2026/9/12 17:19:05 网站建设 项目流程

简介:这是一份使用C++编写、实现AES(高级加密标准)文件加解密功能的源码,面向信息安全初学者、密码学课程学生以及需要在项目中快速集成对称加密算法的开发人员。压缩包内共有1个文件,为cpp源文件,整体大小约4KB,代码结构紧凑,适合逐行阅读、调试与二次修改。已有243人学习,可放在课程设计或算法实验中使用。源码覆盖AES的完整流程,包含密钥扩展、初始轮、字节代换、行移位、列混淆、轮密钥加,以及最后一轮和逆向解密操作,同时给出了二进制文件读写、内存管理和错误处理的具体写法。学习者可以借此理解AES在真实文件加密场景中的落地步骤,也能为后续完善加密工具或完成密码学实验报告提供有价值的起点。

1. 从 aes.zip 到一套能落到文件上的 AES 加解密方案

项目里捡到一个 aes.zip,解开只有 aes.c、aes.h 和一段语焉不详的 README,任务是把几十 MB 的配置文件加密后再分发——这时候最不该做的是立刻把它嵌进工程里。AES 算法本身只解决“16 字节进、16 字节出”的块变换,文件加解密里真正的复杂度全在模式、填充、IV、密钥派生和文件格式上。你会遇到一连串绕不开的问题:CBC 还是 CTR,PKCS7 填充在满块时为什么还要补 16 字节,salt 和 IV 要不要存进文件头,以及为什么两次加密同一份文件结果不一样。这些都不是 AES 的数学问题,而是工程决策。这篇的思路是先把 AES 的边界条件说清楚,再给一套能在本地直接跑通的 C 语言实现,最后落到交叉验证和排错上。适合要接手文件加密模块的 C 开发者,也适合在嵌入式环境里实现离线加密的人。

2. AES 模式与填充选型:C 语言实现前先定死三个设计点

2.1 为什么 ECB 模式在文件加解密里要避开

ECB 是最直观的模式:每个 16 字节明文块独立加密,互不影响。但它有一个致命特征,相同的明文块产生完全相同的密文块。文件场景里这个特征会被放大:一个日志文件可能连续出现重复行,一张 BMP 位图可能有大量同色像素,ECB 会把这种重复结构直接映射到密文里,攻击者不需要解密就能看出文件内容的大致轮廓。

这不是理论风险,是实际可复现的问题。把一张内容分块的图片用 ECB 加密后,肉眼能从密文还原出原图的形状,因为每个区块的重复模式被保留了下来。对于要长期存放、可能被多次拷贝的文件,这不是一个“概率很低”的隐患,而是结构性的信息泄露。文件加解密不是网络包那种短时传输场景,密文会一直躺在磁盘上,可被反复分析。

所以我一般会把 ECB 直接排除,不让它进入候选清单。它在某些单块加密场景(比如加密一个密钥、加密一个 16 字节令牌)里完全够用,但文件不是单块,甚至不是小数据量。选型第一步就是从“能用的模式”里去掉一个。

2.2 CBC 与 CTR:先只保留两个候选

CBC 把每个明文块与前一个密文块异或后再加密,解决了重复块泄露问题;第一个块则与随机 IV 异或。CTR 是把计数器加密后与明文异或,属于流式结构,加解密完全对称。两者在文件加密里都是成熟方案,但工程特性差别明显。

维度AES-CBCAES-CTR
填充需要,可用 PKCS7不需要
IV / Nonce16 字节随机 IV,每次加密都要变12 或 16 字节 Nonce,保证不重复
加密并行性不可并行,后块依赖前块密文可并行,各块独立
错误传播某字节损坏会影响后续两个块某字节损坏只影响对应明文字节
认证能力无,需另加 HMAC 或改用 GCM无,同样需要额外认证

注意表里的关键词:CBC 的“错误传播”是双向的,密文某个块损坏,会波及当前块和下一个块,但后续块不受影响;CTR 则完全不传播。这看起来是 CTR 更优,但 CTR 的 Nonce 管理更敏感:Nonce 重复意味着两个文件流共享同一个密钥流,会直接把异或结果暴露出来。所以选 CTR 时要有一个可靠的随机数源和文件级唯一性保证。

这也是“aes 加密模式”里最容易困惑的点:模式不是“哪个更强”,而是“哪个约束你能满足”。CBC 要求随机 IV,CTR 要求唯一 Nonce,两者都不是拿来就能用。对绝大多数文件加密模块,CBC 是最保守的起点,因为它对随机数质量的要求相对宽容,实现资料多,出问题也好排查。第 3 章就先把它做完整,GCM 的认证优势留到第 4 章专门展开。

2.3 PKCS7 填充:满块也要补 16 字节

CBC 要求输入长度是 16 的倍数,文件大小通常不满足,所以必须填充。PKCS7 的规则是:缺几个字节就填几,填的每个字节值等于缺的字节数。如果明文本身正好是 16 的倍数,按规则补 16 个 0x10;如果差 1 个字节,就补 1 个 0x01。

size_t pkcs7_pad_len(size_t data_len, size_t block_size) { size_t rem = data_len % block_size; return rem ? (block_size - rem) : block_size; }

这是最容易写错的地方。新手经常在rem == 0时返回 0,导致文件尾部少写 16 字节。解密侧必须能区分“文件本来没有填充”和“填充了 16 个 0x10”,靠的就是满块也填充这一约定。解密返回后要取出最后一个字节pad = last_byte,先验证它落在 1 到 16 区间,再检查末尾连续pad个字节值都等于pad,任何一个条件失败都不能当成正常解密结果。

这个校验放在EVP_DecryptFinal_ex之外单独做会比较稳。OpenSSL 对填充错误返回 0,但错误信息并不总是精确到“哪个字节的填充坏了”,自己写一遍校验能更早定位问题。填充校验还牵扯到文件截断:如果文件在传输中被截断,末尾 16 字节大概率对不上,此时解密函数会直接失败,这也是 CBC 比 CTR 多出来的一层“意外完整性检查”,虽然不能替代认证,但至少能挡住一部分偶然损坏。

2.4 独立 AES 实现还是 OpenSSL EVP:按运行环境选

拿到一个 aes.zip 时,先别急着把里面的 aes.c 编进工程。要区分两种常见做法。桌面端 Linux、Windows 环境,直接使用 OpenSSL 的 EVP 接口;嵌入式、RTOS 或无标准库环境下,才用独立 AES 实现。

独立实现要重点检查三件事:支持哪些密钥长度,AES-128/192/256 是否都齐;加解密表是否占内存,Te/Td 表大约需要 4KB 左右;有没有配套的已知测试向量。没有测试向量的 AES 源码等于一堆未验证的常量,风险极高。OpenSSL EVP 则把这些都封装好了,模式、填充、GCM 标签都能在一个上下文里处理,而且 OpenSSL 1.1.1 之后的实现普遍带 AES-NI 硬件加速路径,性能和代码量都优于手写。

也常有人拿 Twofish、ChaCha20 来和 AES 比较。Twofish 在今天的使用面非常有限,ChaCha20 在没有 AES 指令的 MCU 上表现不错,但桌面 CPU 几乎都带 AES 指令集,AES-GCM 在多数场景仍然是最省事的默认项。选型不是找“数学上最强的算法”,而是找“当前硬件和工具链维护成本最低的算法”,这条规则对文件加密同样成立。

3. 用 C 语言跑通 AES-256-CBC 文件加密:从命令到 EVP 代码

3.1 先用 openssl 命令行验证整个流程

动手写 C 之前,我一般先用 openssl 命令行把加解密链路完整跑一遍。这样后面写代码时,每一步输出都能和命令行结果对拍,排错范围会小很多。

printf 'hello aes from c\n' > plain.txt openssl enc -aes-256-cbc -pbkdf2 -pass pass:blog2024 \ -out cipher.bin plain.txt openssl enc -aes-256-cbc -d -pbkdf2 -pass pass:blog2024 \ -in cipher.bin -out check.txt cmp plain.txt check.txt && echo verified

这里的-pbkdf2是从口令派生密钥的标准做法,迭代次数按 OpenSSL 默认执行;-pass pass:blog2024直接给口令,另一种方式是用-K传 32 字节十六进制密钥、用-iv传 16 字节 IV,后面交叉验证 C 程序时会用到。命令行默认会生成随机 salt,所以同一口令两次加密出的文件完全不同,这也是标题里“每次加密结果都不一样”的直接答案:加密结果的不同来自随机 salt 和随机 IV,而不是 AES 本身。

后面写 C 程序时,我会把 salt 和 IV 都写进文件头,让程序在任何平台上都能脱离命令行独立完成解密。命令行这一层只是验证概念和作为参考实现,不参与最终产品的文件格式。

3.2 文件头设计:magic、salt、iv 与版本号

文件头和密文要存在同一个文件里,否则解密端拿不到 salt 和 IV。我常用的文件头结构是这样:

typedef struct { uint32_t magic; /* 固定魔数,例如 0xAE531001 */ uint32_t version; /* 格式版本,第 1 版 */ unsigned char salt[16]; /* PBKDF2 使用的随机盐 */ unsigned char iv[16]; /* AES-CBC 使用的初始向量 */ } aes_file_header_t; /* 共 40 字节 */

加 magic 是为了让解密程序能快速判断“这文件是不是我们能认的格式”,version 是为了将来从 CBC 升级到 GCM 或改填充方式时还能识别旧文件。salt 是给口令派生用的,要与 IV 分开保存,两者用途不同。写入时直接用fwrite把结构体按二进制写进文件,然后用RAND_bytes分别生成 salt 和 iv;RAND_bytes返回 1 才算成功,失败时加密应立即终止。

文件头这一段必须用固定大小、固定字段顺序。跨架构时要注意 uint32_t 的字节序,x86 和 ARM 基本都是小端,但显式按字节写入更稳妥:

static void write_u32(FILE *fp, uint32_t v) { unsigned char b[4]; b[0] = (unsigned char)(v >> 24); b[1] = (unsigned char)(v >> 16); b[2] = (unsigned char)(v >> 8); b[3] = (unsigned char)v; fwrite(b, 1, 4, fp); }

明文文件本身不要保存任何长度信息,密文长度可以由文件大小减文件头后反推,填充校验会负责处理尾部对齐问题。有了文件头,解密流程就是“读文件头、用 salt 派生 key、用 iv 初始化上下文、读密文体、解密、校验填充”。

3.3 最小化的 EVP 加密函数

下面是文件加密的核心函数,密钥 32 字节对应 AES-256,IV 16 字节由上层从文件头读出:

#include <openssl/evp.h> #include <stdio.h> int aes_cbc_encrypt_file(FILE *in, FILE *out, const unsigned char key[32], const unsigned char iv[16]) { EVP_CIPHER_CTX *ctx = NULL; unsigned char inbuf[4096]; unsigned char outbuf[4096 + EVP_MAX_BLOCK_LENGTH]; size_t rn; int elen = 0, tlen = 0; ctx = EVP_CIPHER_CTX_new(); if (ctx == NULL) return -1; if (1 != EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv)) goto err; while ((rn = fread(inbuf, 1, sizeof(inbuf), in)) > 0) { if (1 != EVP_EncryptUpdate(ctx, outbuf, &elen, inbuf, (int)rn)) goto err; fwrite(outbuf, 1, (size_t)elen, out); } if (1 != EVP_EncryptFinal_ex(ctx, outbuf, &tlen)) goto err; fwrite(outbuf, 1, (size_t)tlen, out); EVP_CIPHER_CTX_free(ctx); return 0; err: EVP_CIPHER_CTX_free(ctx); return -1; }

EVP_EncryptUpdate可以分批接收明文,每批次输出长度不会超过输入长度加一个块的大小,所以outbuf要比inbuf多留EVP_MAX_BLOCK_LENGTH字节。fread返回的rn是 size_t,传给 EVP 时强转 int 并保证缓冲上限为 4096,不会溢出。最后一次EVP_EncryptFinal_ex负责输出最后的 PKCS7 填充块,这步不能省,否则解密端会报填充错误。

解密函数与之对称,唯一的差异是EVP_DecryptInit_exEVP_DecryptFinal_ex,循环体的EVP_DecryptUpdate用法完全一样。所有 EVP 函数的返回值都要检查,返回 1 才表示成功,0 表示参数错误或状态不对,这是 C 语言调用 OpenSSL 最常见的坑。

3.4 从口令生成 key 和 iv:PBKDF2 的接入方式

直接使用 32 字节 key 调试没问题,产品里一般由用户口令派生。OpenSSL 提供PKCS5_PBKDF2_HMAC,调用一次就能同时生成 key 和 iv:

#include <openssl/evp.h> unsigned char key[32]; unsigned char iv[16]; if (1 != PKCS5_PBKDF2_HMAC(pass, (int)pass_len, salt, 16, 120000, /* 迭代次数 */ EVP_sha256(), 48, key)) { /* 32 字节 key + 16 字节 iv */ /* 派生失败,终止流程 */ } memcpy(iv, key + 32, 16);

两个参数要说明。迭代次数 120000 是 2024 年左右的常见下界,能在普通 CPU 上跑到 0.1 秒级别;调试时可以临时降到 1000 加速,但发布前必须调回。EVP_sha256()是伪随机函数,不要换成EVP_md5()。派生结果的 48 字节里,前 32 字节给 AES-256 当 key,后 16 字节给 CBC 当 iv。这个派生关系要严格固定,解密端用同一套盐和口令才能得到同一把 key。

salt 必须从RAND_bytes生成并写入文件头,不能在这段代码里写死;同一口令加密不同文件时要保证 salt 不同,否则攻击者可以用预计算表加速猜测。文件头里的 salt 不是秘密,它只负责防止多文件之间共享同一派生结果。

4. 从 CBC 到 GCM:文件加解密的分块、认证与性能取舍

4.1 4KB 缓冲与 EVP 分批输出的配合

第 3 章的示例用 4KB 缓冲循环读文件,这个尺寸不是随便选的。4KB 和内存页大小对齐,文件系统一次读取能命中页缓存;开到 64KB 能减少系统调用次数,但对加解密速率影响有限,在 NVMe 和高性能 CPU 上瓶颈通常在 OpenSSL 内部的内存拷贝。缓冲区调大时,outbuf始终要加EVP_MAX_BLOCK_LENGTH,这个上界在 EVP 接口里是硬保证。

每次EVP_EncryptUpdate的输出长度等于(输入长度 / 16) * 16,不足 16 字节的余量会留在 OpenSSL 内部上下文中,等待下一批输入或 Final 时合并。因此只要不关闭上下文,边界任意切分都不会丢数据。这也是 EVP 比直接调底层AES_encrypt更适合文件处理的原因:底层函数要求整块输入,而文件读取不可能保证每次都落在 16 字节边界上。

在嵌入式环境里,缓冲区大小要结合内存预算。一个 4KB 输入缓冲加 4KB 输出缓冲加上下文结构,大约是 9KB 内存,对 Flash 只有几十 KB 的 MCU 是可接受的。如果内存更紧张,可以降到 512 字节,加密速度会慢一些,但正确性不受影响,EVP 分批处理接口对上界的要求不随缓冲尺寸改变。

4.2 GCM 模式:为文件末尾加 16 字节 tag

CBC 只提供机密性,不提供完整性。密文把第 3 个块的第 5 字节翻转一下,解密后只有对应块损坏,程序未必能发现。文件加密如果没有防篡改需求可以接受,但如果配置文件被恶意替换会导致业务逻辑出错。GCM 在 CTR 基础上加了 GHASH 认证,一次加密同时输出密文和认证标签:

EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); unsigned char iv12[12]; unsigned char tag[16]; unsigned char outbuf[4096 + EVP_MAX_BLOCK_LENGTH]; int elen = 0, tlen = 0; EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, 12, NULL); RAND_bytes(iv12, sizeof(iv12)); EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv12); /* 循环读文件,调 EVP_EncryptUpdate,写 outbuf,和 CBC 一致 */ EVP_EncryptFinal_ex(ctx, outbuf, &tlen); fwrite(outbuf, 1, (size_t)tlen, out); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, 16, tag); fwrite(tag, 1, sizeof(tag), out); /* tag 追加到文件末尾 */

GCM 的 IV 固定 12 字节是推荐值,不要再用 16 字节。标签长度用 16 字节,这是安全性与文件大小开销的常见折中。解密时先读文件末尾 16 字节拿到 tag,同样用EVP_CTRL_AEAD_SET_TAG设置进去,正常解完文件后调EVP_DecryptFinal_ex,返回值是 1 表示认证通过,0 表示密文被篡改或标签不匹配。这一步带出来的错误信息比 CBC 的填充错误更清楚,能直接回答“文件是不是被动过”。

GCM 和 CBC 的工程对比可以这样收拢:

项目AES-256-CBCAES-256-GCM
IV 长度16 字节12 字节
填充必须 PKCS7不需要
认证需要额外 HMAC自带 16 字节 tag
并行加密不支持支持
文件额外开销16 字节 tag

推荐的迁移路径是:旧格式 CBC + header 结构不动,新格式用 GCM + 文件头里加一个 mode 字段。这样两种格式能共存,解密代码按 version 分流,不用一次性推倒重来。

4.3 CTR 分块并行与多线程加解密的前置条件

如果文件到了 GB 级,单线程 AES-GCM 在支持 AES-NI 的 CPU 上已经能跑几百 MB/s,通常问题不大。但有些场景要求“边读边算”,或者用多线程榨干多核性能,CTR 和 GCM 的分块独立性就有了用武之地。

分块并行加密需要把文件切成 N 个独立段,每段用同一个 key、不同的 nonce/counter 区间,最后按偏移写回。块边界必须落在 16 字节整数倍上,每段起始位置重新初始化一个 EVP 上下文。这里有个容易出错的地方:OpenSSL 的 EVP 上下文一旦开始处理数据,就不能再改 IV 或计数器,所以每个分片必须EVP_CIPHER_CTX_new()独立创建。

CBC 加密不具备这个条件,因为后块依赖前块的密文。如果只能在 CBC 和并行之间选,一个退化方案是:先用 CTR 并行加密全文件,再对结果做一次 HMAC。这就是把机密性和完整性拆开处理,代码量比直接上 GCM 多,但多线程改造灵活得多。大多数文件加密需求用不到这个复杂度,先单线程跑通、用 profile 数据说话才是合理的节奏。

4.4 嵌入式场景的内存预算与指针安全性

在 MCU 上没有 OpenSSL 可用时,只能回到第 2.4 节说的独立 AES 实现。独立的 CBC 代码要把三块东西分配清楚:AES 密钥扩展后的轮密钥,我一般放在上下文中而不是每次加密都重算;Te/Td 查找表,总共约 4KB;加解密缓冲,可以是文件系统页大小的整数倍。三者加起来会超过 8KB,对内存紧张的设备要提前规划,不能在运行时才malloc

指针安全的核心不是 AES 本身,而是长度检查。解密入口处必须先验证文件头 magic、文件总长度是否大于 40 字节文件头加一个块,再调用解密循环;否则恶意构造的 20 字节文件会让EVP_DecryptUpdate读到越界数据或产生截断错误。常见的做法是把文件头解析函数和长度校验函数独立出来,加解密入口统一调用,不要把长度检查散落在业务代码里。这类细节在嵌入式 C 语言项目里比算法选型更容易引起线上事故。

5. 验证加解密结果与定位三个典型失败

5.1 用 openssl 命令交叉验证 C 程序的输出

C 程序写完后,最可靠的自检是让 OpenSSL 命令行来解密自己产出的文件。为了能对接,测试模式下建议让程序打印 key 和 iv 的十六进制,或者直接从文件头里读出来再转成 hex:

dd if=cipher.bin of=body.bin bs=1 skip=40 status=none openssl enc -aes-256-cbc -d \ -K 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff \ -iv deadbeefdeadbeefdeadbeefdeadbeef \ -in body.bin -out restore.bin cmp plain.txt restore.bin && echo pass

skip=40跳过的正是文件头字段:4 字节 magic、4 字节 version、16 字节 salt、16 字节 iv。-K参数去掉0x前缀,必须是 64 个十六进制字符;-iv是 32 个十六进制字符。由于固定了 key 和 iv,openssl 解密时不再走 PBKDF2,直接进入 AES-CBC 解密流程。如果这里cmp通过,说明 C 程序的文件头长度、字节序、加密模式、填充机制全部正确。

如果程序是从口令派生 key 的,还要加测一组:把文件头里的 salt 打印出来,用openssl enc -d -pbkdf2 -pass pass:<pwd> -S <salthex>命令行解密,验证 PBKDF2 派生结果和 C 程序一致。这个测试能排除 key 派生环节的偏差。

5.2 三个典型失败现象的定位顺序

现象最可能原因验证方法
解密前几块乱码,文件尾部正常IV 或 key 不匹配打印文件头的 iv,对比 OpenSSL 用的值
EVP_DecryptFinal_ex返回 0密文被截断、填充被改动或文件格式损坏检查文件总长度减文件头后是否为 16 的倍数
C 程序每次结果不同,但都不能被外部解密salt/iv 未写入文件头,或写入位置错位xxd cipher.bin查看前 40 字节,核对字段顺序

第一个现象往往是调试模式里 key 写错了。IV 错误只会让第一块完全错、第二块错一半,后面从第三个块开始恢复正常,这个特征可以拿来快速判断 IV 没对齐。第二个现象要先看文件尺寸,不看报错日志——不是 16 的倍数时填充必然失败,此时要回到写入端检查EVP_EncryptFinal_ex是否有输出。第三个现象几乎都是自定义文件头解析问题,建议先用固定 key/iv 模式调试,让每次密文完全一致,再引入随机 salt。

在 CI 里把这组交叉验证固化成脚本,加解密函数任何一次改动都重新跑一遍,比人工对比省事得多。把EVP_DecryptFinal_ex和填充校验的返回值当成测试断言,再配合密文长度校验放在调用之前,绝大多数文件加解密故障都能在十分钟内定位到具体环节。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询