简介:压缩包内含一份 C/C++ 实现的 AES 文件加解密源码 aes.cpp,面向正在学习密码学、信息安全或 C/C++ 程序设计的读者,也可作为相关课程设计与项目实践参考。代码基于 NIST 发布的 AES 高级加密标准,采用 128 位数据块和可变密钥长度,完整覆盖密钥扩展、初始轮、中间轮、最后轮以及逆变换过程,具体实现了字节代换、行移位、列混淆和轮密钥加等核心环节,便于对照算法原理逐步剖析。资源共 1 个 cpp 文件,压缩包约 4KB,体积小巧,适合快速阅读和二次开发。已有 243 人浏览学习,适合作为从原理到工程实践的过渡样例。通过这份代码,读者可以掌握文件加解密中的二进制读写、内存管理、错误处理等落地技巧,同时理解密钥安全存储与传输的必要性,为后续在 TLS/SSL、数据保护等场景中运用 AES 打下基础。
1. 用 C 语言做 AES 文件加密,难的不是算法而是文件边界处理
拿到“aes.zip”这个标题,我第一反应不是代码怎么写,而是大部分人在 AES 文件加密上栽的跟头根本不在 AES 本身——轮函数、S 盒、密钥扩展这些算法细节,OpenSSL 或 mbedTLS 早就封装好了,真正让项目卡壳的是文件怎么切块、填充怎么做、IV 怎么存、密文怎么和原文长度对齐。C 语言做 AES 文件加密的典型场景有两类:一类是嵌入式环境,跑 Linux 都要精打细算,只能自己调 AES 库;另一类是安全工具开发,比如给配置文件加密、给固件分包加密,需要把加密逻辑和文件格式一起设计。适合读这篇文章的人,是那种已经能写文件读写、懂一点指针和内存布局,但没系统摸过分组加密工程化的开发者——你缺的不是 AES 算法本身,而是把它塞进文件流里的那套完整方案。
C 语言在文件加解密上有天然优势:内存可控、零依赖、交叉编译方便,但也因为太接近底层,所有坑都藏不住。接下来我在标题“aes.zip_AES_AES文件加密_aes c语言_aes 文件_文件加解密”的语义范围内,从 AES 的工程选型讲起,落到可编译的代码和排错思路。
2. AES 文件加密的选型:模式、填充与密钥长度先定死
2.1 为什么 ECB 模式不能用于文件加密
很多初学者第一次写 AES 文件加密,用的是 ECB 模式,因为写起来最简单——每个 16 字节块独立加密,不需要 IV。但 ECB 的致命缺陷是:相同的明文块产生相同的密文块。对文件来说,如果文件里有大量重复片段(比如全零的空白区域、位图文件头部、结构化日志),加密后这些重复模式直接暴露在密文里,文件的信息熵没有真正被抹平,这等于告诉攻击者文件结构没有变化。
工程上文件加密至少要选 CBC 或 CTR。CBC 每个明文块先和上一个密文块异或再加密,解决了重复模式问题;CTR 则是把计数器加密后和明文异或,可以并行处理,也天然支持随机读。对于我自己的项目,如果加密后要支持按块解密(比如分片下载校验),我会选 CTR;如果只是整文件加解密,CBC 更常见,因为 OpenSSL 和 mbedTLS 对 CBC 的优化最完善。
2.2 填充模式:文件加密必须用 PKCS7
AES 是分组密码,块大小固定 16 字节,而文件长度几乎不可能正好是 16 的倍数。C 语言里处理文件填充,我一般用 PKCS7:剩余字节数 n(1 到 16),就往尾部补 n 个值为 n 的字节。比如明文最后一块还差 5 字节满 16,就补 5 个 0x05。解密后取最后一个字节的值,把它当成填充长度去掉即可。
这里有个容易错的点:如果文件长度恰好是 16 的倍数,PKCS7 也要额外补满一整块(16 个 0x10)。因为解密时需要靠最后一个字节判断填充长度,如果原始文件正好对齐而不补块,解密时无法区分“没有填充”和“最后一个字节本来就是填充长度”。这个细节在文件加密里必须处理,否则边界文件会在解密后多出或缺失 16 字节。
2.3 密钥长度与轮数:AES-128、AES-192、AES-256 怎么选
AES 支持三种密钥长度:128 位(10 轮)、192 位(12 轮)、256 位(14 轮)。做文件加密,我建议直接用 AES-256-CBC,除非你的目标 CPU 没有 AES 硬件指令且性能受限。注意了,这不是因为 AES-128 不安全——它在当前算力下仍然安全——而是因为文件加密往往是静态落盘数据加密,攻击者可以离线拿密文做暴力破解或侧信道分析,密钥长度多一些冗余是值得的。
/* 密钥长度宏定义:统一走 AES-256 */ #define AES_KEY_LEN 32 /* 256 bit = 32 bytes */ #define AES_BLOCK_LEN 16 /* AES 块大小固定 */ unsigned char key[AES_KEY_LEN]; unsigned char iv[AES_BLOCK_LEN]; /* key 和 iv 从哪里来?见 2.4 节 */逻辑说明:密钥是 32 字节的数组,IV 是 16 字节数组。这里只是定义内存布局,真正的密钥派生逻辑在后面。参数上需要留意 AES_KEY_LEN 不要随意改成 16 或 24,否则调用底层 AES_set_encrypt_key 时传的位数参数也要同步修改,否则越界读内存是必然的。
2.4 密钥和 IV 怎么来:从口令到密钥的派生
另一个常见误区是直接拿用户输入的字符串当密钥。用户口令长度往往不是 32 字节,且可预测性太强。正确做法是用 PBKDF2 或 scrypt 从口令派生密钥和 IV。C 语言里 OpenSSL 的 PKCS5_PBKDF2_HMAC 函数可以实现这一点。
#include <openssl/evp.h> const char *pass = "user-passphrase"; unsigned char salt[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; PKCS5_PBKDF2_HMAC(pass, strlen(pass), salt, sizeof(salt), 100000, /* 迭代次数,越大越慢但更抗暴力 */ EVP_sha256(), AES_KEY_LEN + AES_BLOCK_LEN, key_iv_buffer); /* 前 32 字节当 key,后 16 字节当 IV */ memcpy(key, key_iv_buffer, AES_KEY_LEN); memcpy(iv, key_iv_buffer + AES_KEY_LEN, AES_BLOCK_LEN);参数说明:迭代次数这里写了 100000,在嵌入式环境可以降到 10000,但这是安全性和性能的权衡——100000 次 SHA-256 在普通 PC 上约 0.1 秒,在 200MHz 的 MCU 上可能要吃 3 到 5 秒。盐值 salt 应该随机生成并随密文存储,绝不能硬编码成固定值,否则攻击者可以预计算彩虹表。函数输出写进 key_iv_buffer 时,注意缓冲区大小必须是 48 字节。
2.5 常见的文件加密库选择
C 语言里做 AES 文件加密,常见的选择有几个:
| 库 | 包/获取方式 | 特点 | 适合场景 |
|---|---|---|---|
| OpenSSL EVP | apt install libssl-dev | 功能全,支持 PBKDF2、所有模式 | Linux 桌面端/服务端工具 |
| mbedTLS | GitHub 源码编译 | 体积小,可裁剪 | 嵌入式、RTOS |
| 自己写 AES 轮函数 | 代码公开 | 学习用途,不推荐直接用于生产 | 理解算法细节,笔试/竞赛 |
自己手写 AES 算法做文件加密,我只在一种情况下推荐:你所在的环境不允许链接任何动态库,或者编译器太老旧。否则用 OpenSSL 的 EVP 接口是最省心的,它帮你封装了模式切换、填充处理和硬件 AES 加速指令(AES-NI)的调用。
3. C 语言实现 AES 文件加密:整文件读入与流式分块
3.1 两种文件处理模型对比
AES 文件加密的代码组织,取决于文件大小的假设。小文件(比如配置文件、密钥材料)可以一次性读入内存,加密后再写回。大文件(比如磁盘镜像、日志归档)不能整文件读入,必须流式分块处理。两个模型的最大差别在于内存峰值和错误恢复能力。
我一般先判断文件大小。如果小于 64MB,直接走内存模型;超过 64MB,走分块模型。阈值设 64MB 不是 AES 的限制,而是 32 位嵌入式系统上 malloc 容易失败的边界。
/* 判断使用哪种模型 */ FILE *fp = fopen(input_path, "rb"); fseek(fp, 0, SEEK_END); long fsize = ftell(fp); fseek(fp, 0, SEEK_SET); if (fsize <= 64 * 1024 * 1024) { encrypt_file_memory(fp, output_path); } else { encrypt_file_stream(fp, output_path); } fclose(fp);逻辑说明:先获取文件大小再决定处理路径。这里 fseek/ftell 是传统做法,注意大于 2GB 的文件 ftell 返回 long 在 Windows 上是 32 位,要换 _ftelli64 或 fseeko。Linux 上 long 是 64 位,没这个问题。参数上 64MB 的阈值不是硬性规定,如果你的系统内存富裕可以调到 256MB,但 malloc 失败时的错误处理必须写全。
3.2 内存模型的完整实现
内存模型适合小文件,代码短、边界逻辑简单。加密流程:读全部明文到缓冲区、PKCS7 填充、按块 AES-CBC 加密、写入密文和 IV 头。
#include <openssl/evp.h> #include <string.h> int encrypt_file_memory(FILE *in, const char *out_path) { unsigned char *plain_buf, *cipher_buf; long fsize, padded_len; int outlen, tmplen; fseek(in, 0, SEEK_END); fsize = ftell(in); fseek(in, 0, SEEK_SET); int pad_len = AES_BLOCK_LEN - (fsize % AES_BLOCK_LEN); padded_len = fsize + pad_len; plain_buf = malloc(padded_len); cipher_buf = malloc(padded_len + AES_BLOCK_LEN); if (!plain_buf || !cipher_buf) return -1; fread(plain_buf, 1, fsize, in); memset(plain_buf + fsize, pad_len, pad_len); /* PKCS7 填充 */ EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv); EVP_EncryptUpdate(ctx, cipher_buf, &outlen, plain_buf, padded_len); EVP_EncryptFinal_ex(ctx, cipher_buf + outlen, &tmplen); EVP_CIPHER_CTX_free(ctx); FILE *out = fopen(out_path, "wb"); /* 文件格式:IV(16) + 密文 */ fwrite(iv, 1, AES_BLOCK_LEN, out); fwrite(cipher_buf, 1, outlen + tmplen, out); fclose(out); free(plain_buf); free(cipher_buf); return 0; }代码逻辑拆解:第一步算填充长度并申请 padding 后的缓冲区,这里 memset 的第三个参数是 pad_len,值等于 pad_len,这正是 PKCS7 的语义。第二步 EVP_EncryptUpdate 一次传入 padded_len,因为 CBC 模式可以一次加密多块。第三步写文件时先写 IV 再写密文,这个顺序不是随便定的,后面解密要从同一个文件中恢复 IV 才能开始解密。
这里有几个参数值得注意。EVP_EncryptUpdate 的 outlen 是本次输出长度,EVP_EncryptFinal_ex 的 tmplen 在 CBC 模式下通常为 0。但养成从 final 拿返回值再相加的习惯,因为以后切到 GCM 模式时 final 会输出 TAG。cipher_buf 申请了 padded_len + 16,是因为 OpenSSL 的 EVP 接口文档要求输出缓冲区必须比输入多一个块长,否则可能缓冲区溢出。
3.3 流式分块的实现:不把大文件塞进内存
大文件加密要按固定大小读取、加密、写入。关键在于处理好最后一块的填充逻辑。我用的块大小是 1MB,这个值对磁盘 I/O 和 AES-NI 指令都算友好。
#define CHUNK_SIZE (1024 * 1024) int encrypt_file_stream(FILE *in, const char *out_path) { unsigned char *in_chunk = malloc(CHUNK_SIZE + AES_BLOCK_LEN); unsigned char *out_chunk = malloc(CHUNK_SIZE + AES_BLOCK_LEN); FILE *out = fopen(out_path, "wb"); /* 先写 IV 头 */ fwrite(iv, 1, AES_BLOCK_LEN, out); EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv); size_t bytes_read; int outlen; while ((bytes_read = fread(in_chunk, 1, CHUNK_SIZE, in)) == CHUNK_SIZE) { EVP_EncryptUpdate(ctx, out_chunk, &outlen, in_chunk, bytes_read); fwrite(out_chunk, 1, outlen, out); } /* 处理最后一块并做 PKCS7 填充 */ int pad_len = AES_BLOCK_LEN - (bytes_read % AES_BLOCK_LEN); memset(in_chunk + bytes_read, pad_len, pad_len); EVP_EncryptUpdate(ctx, out_chunk, &outlen, in_chunk, bytes_read + pad_len); fwrite(out_chunk, 1, outlen, out); EVP_EncryptFinal_ex(ctx, out_chunk, &outlen); fwrite(out_chunk, 1, outlen, out); EVP_CIPHER_CTX_free(ctx); fclose(in); fclose(out); free(in_chunk); free(out_chunk); return 0; }边界情况说明:while 循环读到正好 CHUNK_SIZE 时继续,最后一次 fread 的返回值小于 CHUNK_SIZE,这个残留数据就是最后一块。PKCS7 填充在最后一次调用 EVP_EncryptUpdate 时交给 OpenSSL 处理——不对,这里有个细节要注意,OpenSSL EVP_CBC 模式不会自动填充,需要自己填。上面的做法是自己计算 pad_len 然后 memset,这是对的。最后一轮 Update 传入的数据含填充块,加密后 outlen 也会相应增加 16 字节。
这段代码里最关键的思路是:OpenSSL 的 EVP_EncryptUpdate 可以反复调用,每次放入任意长度的数据(只要不超过块大小的整数倍约束实际并不存在,内部会缓存未满块),但明文最后一次必须由 EncryptFinal 收尾。在流式模型中,如果你能保证 Update 每次都是 16 字节倍数,Final 就不会再产生输出。上面的实现里循环中的 CHUNK_SIZE 是 1MB,天然是 16 的倍数,所以没问题。
3.4 解密函数的对称实现
解密和加密代码几乎对称,只改两个地方:EVP_DecryptInit_ex 替换 Encrypt 版本,文件头先读 IV 而不是写 IV。
int decrypt_file_stream(FILE *in, const char *out_path) { unsigned char file_iv[AES_BLOCK_LEN]; fread(file_iv, 1, AES_BLOCK_LEN, in); /* 读回 IV */ unsigned char *in_chunk = malloc(CHUNK_SIZE + AES_BLOCK_LEN); unsigned char *out_chunk = malloc(CHUNK_SIZE + AES_BLOCK_LEN); FILE *out = fopen(out_path, "wb"); EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, file_iv); size_t bytes_read; int outlen; while ((bytes_read = fread(in_chunk, 1, CHUNK_SIZE, in)) == CHUNK_SIZE) { EVP_DecryptUpdate(ctx, out_chunk, &outlen, in_chunk, bytes_read); fwrite(out_chunk, 1, outlen, out); } EVP_DecryptUpdate(ctx, out_chunk, &outlen, in_chunk, bytes_read); fwrite(out_chunk, 1, outlen, out); EVP_DecryptFinal_ex(ctx, out_chunk, &outlen); /* 这里会校验 PKCS7 填充 */ fwrite(out_chunk, 1, outlen, out); EVP_CIPHER_CTX_free(ctx); fclose(in); fclose(out); free(in_chunk); free(out_chunk); return 0; }解密时 PKCS7 校验由 EVP_DecryptFinal_ex 完成。如果密文被篡改或密钥不对,Final 返回值会是 0,填充校验失败。你必须在代码里检查这个返回值——常见的错误是忽略它,导致解密攻击者改过的密文时输出了带填充的垃圾数据却报成功。注意解密后文件大小会比加密前的原文多出 0 到 16 字节的填充,需要按 PKCS7 规则在原位删除。
4. 文件格式设计与安全增强:CBC 之外还要想的事
4.1 自描述文件头:盐、IV、版本号
用 2.4 节的方式派生密钥时,盐值是随机生成的,IV 也是随机生成的。但解密端需要拿到盐和 IV 才能重新派生密钥并开始解密。这意味着文件格式必须自带这些参数,我前文的实现里只把 IV 写在文件头,这还不够完整。
推荐的文件格式设计:
typedef struct { unsigned char magic[4]; /* 'A', 'E', 'S', 'F' */ uint32_t version; /* 格式版本,当前为 1 */ unsigned char salt[8]; /* PBKDF2 盐值 */ unsigned char iv[16]; /* AES IV */ uint64_t orig_size; /* 原始文件长度,解密后截断用 */ } aes_file_header_t;这个头一共 40 字节,通过 fwrite 一次性写入。orig_size 字段解决了我之前讲的填充问题——解密后只要按 orig_size 截断即可,不用依赖 PKCS7 结构去推断原始长度,等于双重保障。version 字段用来应对以后加密算法升级,比如从 CBC 切到 GCM,旧文件还能识别版本并走老解密路径。
4.2 完整性校验:从 Ciphertext 到 Authenticated Encryption
写到这里,我必须给标题里没写但工程上绕不开的话题一个明确立场:只用 CBC 模式的 AES 加密,无法保证密文完整性。攻击者翻转密文中的某个 bit,解密后对应明文位会翻转且不报错。对配置文件或固件包来说,这等于给了攻击者修补字节的空间。
替代方案有两个。第一个是 Crypto++ 的 GCM 模式,密文后追加 16 字节认证标签,解密时校验标签,OpenSSL EVP 接口直接支持。第二个是加密后单独算 HMAC-SHA256,把 HMAC 值放在文件尾部。我个人倾向 GCM,因为接口统一、不需要额外管理 HMAC 密钥。如果沿用 CBC,至少要加 HMAC:
unsigned char hmac[EVP_MAX_MD_SIZE]; unsigned int hmac_len; HMAC(EVP_sha256(), key, AES_KEY_LEN, cipher_buf, cipher_len, hmac, &hmac_len); /* 把 hmac 追加到密文末尾,解密前先校验 */参数说明:HMAC 的密钥应该和 AES 密钥分开派生,比如 PBKDF2 输出 32 字节后,扩到 64 字节,前 32 字节给 AES,后 32 字节给 HMAC。如果同一个密钥既做加密又做认证,在理论上存在相关攻击风险,工程实践中要避免。
4.3 C 语言内存安全:密钥销毁与缓冲区清零
C 语言做加密,最容易在内存上留下密钥痕迹。malloc 出来的 key 和 iv 用完如果不主动清零,程序退出时这些敏感数据留在堆内存里,core dump 或调试器即可读取。AES 文件加密工具应该养成一个习惯:不再使用的敏感缓冲区,用 secure_zero 函数覆盖。
void secure_zero(void *ptr, size_t len) { volatile unsigned char *p = (volatile unsigned char *)ptr; while (len--) *p++ = 0; }这里用 volatile 指针而不是 memset,原因在于:编译器可能优化掉对即将释放的内存的 memset 调用(认为它无效)。volatile 强制写操作真正落在内存上。在 Linux 上还可以用 explicit_bzero 或 OPENSSL_cleanse,后者能避免某些架构上的编译器重排风险。
5. 性能调优、常见返回码排查与最小可验证命令
5.1 启用 AES-NI 硬件加速
现代 x86 和 ARMv8 处理器都带 AES 硬件指令,OpenSSL 在编译时默认启用。你不需要改代码,加密速度就能提升好几倍。我见过有人因为在自己代码里手工展开 AES 轮函数,结果反而绕过了 AES-NI,性能差 5 倍以上。
验证硬件加速是否生效,检查你的编译环境和运行环境:
# 查看 openssl 是否支持 aes-ni openssl speed -evp aes-256-cbc输出里如果看到“aes-256-cbc”的吞吐量在几百 MB/s 到几 GB/s 之间,说明硬件加速在工作;如果只有几十 MB/s,可能是库的编译配置问题。这条命令同时能测出你目标机器上 AES 加密的实际吞吐量上限,对预估大文件加密耗时很有用。
5.2 必须处理的 OpenSSL 返回码
EVP 接口几乎每个函数都有返回值,0 表示失败,1 表示成功。文件加密代码里至少这几个位置要检查返回值,否则失败时静默出错很难定位。
if (EVP_EncryptUpdate(ctx, out_chunk, &outlen, in_chunk, bytes_read) != 1) { fprintf(stderr, "EVP_EncryptUpdate failed: %s\n", ERR_error_string(ERR_get_error(), NULL)); goto cleanup; }常见失败原因对照:
| 返回错误 | 可能原因 | 排查方向 |
|---|---|---|
| EVP_EncryptFinal 返回 0 | 前面 Update 有未提交的数据或参数长度非法 | 检查每个 Update 的输入长度是否为 16 的倍数,需在 Final 前补齐 |
| EVP_DecryptFinal 返回 0 | 密钥/IV 不对或密文被篡改导致的 PKCS7 校验失败 | 对比头部的盐值和 IV 是否一致 |
| fwrite 返回短写 | 磁盘满或权限不足 | 检查 ferror(out) 和磁盘剩余空间 |
| PKCS5_PBKDF2_HMAC 返回 0 | 迭代次数传入 0 或密钥长度非法 | 确认迭代次数为正数,输出缓冲区容量不小于 key+iv |
5.3 用命令行全链路验证加解密
写完全部代码,用一个最小的 shell 命令序列验证加密-解密的正确性:
# 生成 1MB 随机测试文件 dd if=/dev/urandom of=plain.bin bs=1M count=1 # 编译你的程序后执行加密 ./aes_tool -e -i plain.bin -o cipher.bin -p "test-pass" # 解密回明文 ./aes_tool -d -i cipher.bin -o decrypted.bin -p "test-pass" # 对比原始明文和解密结果 cmp plain.bin decrypted.bin && echo "PASS"cmp输出为空且返回码为 0 则说明文件完全一致。这一步验证的是整个加密-解密回路,包括 PKCS7 填充和截断逻辑。建议再补一个破坏测试:用 dd 随便改密文的中间一个字节,再解密密文,预期是 EVP_DecryptFinal 报错或输出乱码——这个测试验证的正是 4.2 节里提到的完整性校验,如果你还想测试 GCM 或 HMAC 的追加逻辑,可以把篡改点放在认证标签上,解出来的结果不会一样。
5.4 调试时打印关键中间值
文件加密 bug 里最高频的问题就是填充长度算错。调试时我习惯在代码里加打印,看加密前后的长度变化:
printf("orig_size=%ld, pad_len=%d, padded_len=%ld\n", fsize, pad_len, padded_len); printf("cipher_len=%d, last_byte=0x%02x\n", outlen + tmplen, cipher_buf[outlen + tmplen - 1]);对照关系:cipher_len 应该等于 padded_len;解密时 last_byte 对应填充长度值,解密截断后文件应该回到 orig_size。如果发现 cipher_len 比 padded_len 长了 16 字节,说明你的缓冲区申请或 Update 调用里多了一次块加密,回头查是不是把 Final 的输出和 Update 重叠计算了。
本文还有配套的精品资源,点击获取