简介:一套完整的AES(ECB、CBC、CFB、CTR)加密/解密C语言实现,覆盖AES-128、192、256三种密钥长度,适用于金融POS安全认证、嵌入式安全模块、网络通信加密等对数据保密性有严格要求的场景。压缩包共6个文件,以3个C源码文件为核心,配套2个头文件与1个Makefile,整体仅16KB,工程结构非常精简;源码按算法模块划分,包含密钥扩展、分组加解密以及四种工作模式的完整逻辑,头文件对外暴露简洁的调用接口,Makefile则免去手动配置编译参数的麻烦。资源自带测试程序,在Linux环境下进入目录执行make即可编译,已在Ubuntu 16.04上验证通过,可快速验证各模式加解密结果与标准测试向量是否一致。目前已有5316人学习下载,适合信息安全、嵌入式及底层开发人员直接参考或二次移植,可显著降低从零实现AES的难度与时间成本。 搞嵌入式安全通信的人,迟早会碰到AES。我最近在一个MCU项目里做固件升级包的对称加密,需要在资源受限的环境下用C语言实现AES算法,支持ECB、CBC、CFB、CTR四种工作模式,密钥长度覆盖128、192、256三种。整个模块从设计、编码到调通踩了不少坑,今天把思路和代码骨架整理出来,给同样需要在C项目里落地AES的朋友做个参考。这篇内容适合两类人:一类是刚接触AES、只听说过名字但不知道分组模式和密钥扩展怎么玩的初学者;另一类是已经有了一个可用的AES核心,但不确定该怎么封装成多模式接口的工程师。无论你是纯软件实现还是打算移植到STM32这类单片机上,思路和注意事项都是通用的。
1. AES算法拆解:从字节操作到轮函数
1.1 状态矩阵与四个基本操作
AES不是面向比特的算法,它操作的是一个4乘4的字节矩阵,叫做状态。输入数据按列优先填入这个矩阵,然后每一轮都在这个状态上做四件事:字节代换、行位移、列混合、轮密钥加。这四件事理解起来都有点绕,但落到C语言里其实非常机械。
字节代换就是用S盒查表替换每个字节,S盒是一个256字节的静态数组,属于AES标准定义好的常量。行位移是把矩阵的第1行循环左移1字节,第2行左移2字节,第3行左移3字节,第0行不动。列混合是每一列做一个有固定矩阵的有限域乘法,这个乘法是在GF(2^8)上进行的,不是普通的整数乘法。轮密钥加就是把状态和本轮的子密钥逐字节异或,实现起来就是一个循环异或。
解密过程就是这四个操作的逆过程,注意顺序也要反过来,并且列混合逆变化和行位移逆位移的参数要和加密对应好。很多人写AES容易在解密的轮变换顺序上翻车,我的经验是不要试图优化顺序,严格按照FIPS-197的伪代码来,先把功能跑通再考虑性能。
1.2 四种模式:加密粒度与错误传播
AES核心算法一次只能处理16字节,也就是一个128位块。实际通信中的数据往往不止16字节,所以需要定义“怎么把数据分成一块一块,块与块之间怎么关联”,这就是工作模式。我实现的是最常见的四种:
| 模式 | 是否填充 | 是否可以并行 | 错误传播范围 | 典型用途 |
|---|---|---|---|---|
| ECB | 需要 | 可以 | 单块独立,错误不传播 | 不适合协议数据,仅适合单块加解密或测试 |
| CBC | 需要 | 加密不可并行,解密可并行 | 当前块错误会影响下一块 | 文件加密、固件加密、磁盘加密 |
| CFB | 不需要 | 加密不可并行,解密可并行 | 错误传播一个块加一个字节 | 流式通信、低延时光盘数据 |
| CTR | 不需要 | 可以并行 | 错误只影响当前块 | 高速通信、磁盘扇区加密、GCM的基础 |
ECB模式最简单,每个明文块独立加密,同样内容的明文块加密后结果相同,缺乏语义安全性,正规协议里基本不建议使用,但很多老项目还在用,所以我也保留了。CBC模式是ECB的增强,加密前先跟上一块密文异或,第一个块跟IV异或。CFB和CTR模式本质上是把AES当成密钥流生成器,加密和解密都用同一个AES加密函数,省去了解密函数的调用,这在内存紧张的嵌入式平台上很实用。
关键点是:CFB和CTR不需要填充,因为它们处理的是任意长度的字节流,最后一轮只取需要的密钥流长度即可。而ECB和CBC因为是对块加密,最后一块不足16字节时必须先填充。这个差异是后面代码设计的分水岭。
2. C语言实现前的准备:轮密钥、填充、数据接口
2.1 密钥长度与轮数:Nk、Nr、Nb的关系
AES支持128、192、256位三种密钥,对应16、24、32字节。代码里通常用四个符号描述参数:Nb=4固定表示状态矩阵列数,Nk=密钥长度除以32,Nr=轮数。它们的关系是:
- AES-128:Nk=4,Nr=10
- AES-192:Nk=6,Nr=12
- AES-256:Nk=8,Nr=14
轮密钥扩展就是从原始密钥生成Nk字节到(Nr+1)*16字节的扩展密钥。每一轮的轮密钥加都要用这16字节。我最初的实现一上来就把三种密钥长度全部用条件分支塞进同一个函数,结果代码臃肿且容易出错。后来改成用一个上下文结构体保存Nk、Nr、扩展密钥数组,统一起来就清晰多了。
密钥扩展的核心思路是:扩展密钥数组看成一个个4字节字,初始的字直接来自密钥,后续每个字是前一个字和Nk位置之前字的异或,每Nk字触发一次S盒变换并异或轮常数。轮常数是一个固定字节数组,用来破坏对称性。这部分我在3.1节给出可运行代码。
2.2 数据填充与模式选择:PKCS#7 vs 零填充
ECB和CBC的填充方式也必须提前定好。最通用的是PKCS#7填充:如果最后一组还差n字节,就补n个值为n的字节。比如还差5字节就补5个0x05,完整多出16字节的倍数时也必须额外填充一个完整的16字节块,否则接收端无法区分原始数据是否刚好对齐。类似地,解密后要检查最后一个字节的值是否合法,这个是接收端最容易忽略的地方。
CBC模式的解密还需要处理好IV。IV长度固定为16字节,解密方必须用和加密时相同的IV,否则第一个块的明文会整体出错。我在项目里遇到过好几次密钥和加密数据都对,但解密出来的前16字节乱码,最后发现是IV传错了。推荐的做法是:IV要么写入密文头部,要么通过密钥协商单独下发,千万不要硬编码在固件里。
CFB和CTR不需要填充,但它们也有一个隐含坑:加密端和解密端的计数器必须严格同步。CTR模式常用16字节计数块,前4字节可以是随机Nonce,后12字节为递增计数器,所有平台都必须用大端序编码计数器,否则不同架构的MCU对不上。
2.3 对外接口设计:统一加解密函数
我最终设计的对外接口很简单,一个上下文结构体 + 三个函数:
typedef struct { uint8_t key[32]; // 原始密钥 uint8_t round_keys[240]; // 扩展密钥(最多60字*4字节) uint8_t iv[16]; // 初始向量/计数器 uint8_t mode; // AES_MODE_ECB/CBC/CFB/CTR uint8_t key_len; // 16/24/32 } aes_ctx_t; void aes_init(aes_ctx_t *ctx, const uint8_t *key, int key_len, const uint8_t *iv, uint8_t mode); void aes_encrypt_block(aes_ctx_t *ctx, const uint8_t in[16], uint8_t out[16]); void aes_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt);aes_encrypt_block是核心单块加密,除了ECB解密需要对应aes_decrypt_block之外,CFB和CTR模式的解密也只需要调用aes_encrypt_block生成密钥流再异或。aes_crypt负责模式调度和填充逻辑,这样上层业务代码只需要关心数据长度,不需要知道分组模式的内部细节。
3. 核心代码实现与模式实现细节
3.1 AES-128/192/256核心加解密代码
先给出一个精简但可运行的AES核心实现。这个实现参考了FIPS-197标准伪代码和开源项目tiny-AES-c的结构,我用它做过LPC1768和STM32的移植,只要修改字节序相关代码即可。
S盒我直接用开源标准定义好的值,实际工程里建议放在静态只读区:
static const uint8_t sbox[256] = { 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, // ... 请从FIPS-197附录A抄完整表 }; static const uint8_t rcon[10] = { 0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36 };轮密钥扩展:
void key_expansion(uint8_t *round_keys, const uint8_t *key, int nk, int nr) { for (int i = 0; i < nk; i++) { ((uint32_t *)round_keys)[i] = ((uint32_t *)key)[i]; } for (int i = nk; i < 4 * (nr + 1); i++) { uint32_t temp = ((uint32_t *)round_keys)[i - 1]; if (i % nk == 0) { temp = sub_word(rot_word(temp)) ^ (rcon[i / nk - 1] << 24); } else if (nk > 6 && i % nk == 4) { temp = sub_word(temp); } ((uint32_t *)round_keys)[i] = ((uint32_t *)round_keys)[i - nk] ^ temp; } }单块加密:
void aes_encrypt_block(uint8_t *state, const uint8_t *round_keys, int nr) { add_round_key(state, round_keys, 0); for (int round = 1; round < nr; round++) { sub_bytes(state); shift_rows(state); mix_columns(state); add_round_key(state, round_keys, round * 16); } sub_bytes(state); shift_rows(state); add_round_key(state, round_keys, nr * 16); }解密时把里面的mix_columns换成inv_mix_columns,sub_bytes换成inv_sub_bytes,shift_rows对应的逆移位也要实现。完整的S盒逆表、列混合的GF(2^8)乘法函数这里不贴了,网上很容易找到,但注意一定要逐个函数对照测试用例验证。
3.2 四种模式的状态机实现
有了单块加密函数,四种模式的封装就简单了。下面是我最终采用的骨架:
ECB模式:
void aes_ecb_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt) { int blocks = len / 16; for (int i = 0; i < blocks; i++) { if (is_decrypt) aes_decrypt_block(in + i * 16, out + i * 16); else aes_encrypt_block(in + i * 16, out + i * 16); } // 填充校验或剥离由上层处理 }CBC模式:
void aes_cbc_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt) { if (is_decrypt) { uint8_t prev[16]; memcpy(prev, ctx->iv, 16); for (int i = 0; i < len / 16; i++) { uint8_t block[16]; aes_decrypt_block(in + i * 16, block); for (int j = 0; j < 16; j++) out[i * 16 + j] = block[j] ^ prev[j]; memcpy(prev, in + i * 16, 16); } } else { uint8_t prev[16]; memcpy(prev, ctx->iv, 16); for (int i = 0; i < len / 16; i++) { uint8_t block[16]; for (int j = 0; j < 16; j++) block[j] = in[i * 16 + j] ^ prev[j]; aes_encrypt_block(block, out + i * 16); memcpy(prev, out + i * 16, 16); } } }CFB模式:
void aes_cfb_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len, int is_decrypt) { uint8_t feedback[16]; memcpy(feedback, ctx->iv, 16); for (int i = 0; i < len / 16; i++) { uint8_t keystream[16]; aes_encrypt_block(feedback, keystream); for (int j = 0; j < 16; j++) { out[i * 16 + j] = in[i * 16 + j] ^ keystream[j]; } // 注意:加密和解密都是反馈密文,不是反馈明文 memcpy(feedback, out + i * 16, 16); } // 最后一组如果不足16字节,同样先用AES加密反馈得到密钥流,异或剩余字节即可 int remain = len % 16; if (remain) { // 处理剩余部分,与上面类似,只是循环次数少一次 } }CTR模式:
void aes_ctr_crypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, int len) { uint8_t counter[16]; memcpy(counter, ctx->iv, 16); for (int i = 0; i * 16 < len; i++) { uint8_t keystream[16]; aes_encrypt_block(counter, keystream); int chunk = (len - i * 16 > 16) ? 16 : (len - i * 16); for (int j = 0; j < chunk; j++) { out[i * 16 + j] = in[i * 16 + j] ^ keystream[j]; } // 计数器递增(大端序) for (int j = 15; j >= 0; j--) { if (++counter[j] != 0) break; } } }这个CFB版本我在最后一次生成密钥流时没有复用缓冲区,是为了确保最后一组长度的处理不会越界。CFB模式加密和解密都要把当前块密文存入反馈缓冲区,如果误用明文做反馈,解密结果会全错。
4. 踩坑记录与调试经验
4.1 密钥长度14字节的InvalidKeyException
看到热词里有人问java.security.InvalidKeyException: Invalid AES key length: 14 bytes,这几乎是跨语言对接时的高频问题。C语言里uint8_t key[32]只是开了一块内存,但AES标准要求密钥长度必须是16、24、32字节。我在一个项目里用Java生成14字节的"密钥",其实那是把密码字符串直接当密钥传了,正确做法是先用哈希或KDF把任意长度口令扩展到标准长度。C端也要校验接收到的密钥字节数,否则memcpy越界或者后续密钥扩展计算读到未初始化内存,一点都不安全。
建议在初始化函数里增加长度判断:
if (key_len != 16 && key_len != 24 && key_len != 32) { return AES_ERR_KEY_LEN; }这个检查一定要做,不要依赖外部保证。嵌入式里最烦的bug就是传入非法长度后,密钥扩展函数跑飞。
4.2 解密后前16字节乱码或尾部报错
CBC解密后前面16字节乱码,九成是IV不对。我在自己代码里调试过一个问题:加密端和解密端用的IV都是同一个静态数组,但加密过程中我记得修改了IV数组内容,导致解密初始化时读到了被改过的IV,结果第一个块全乱。后来统一设计成aes_init内部深拷贝IV到上下文,外部接口只负责传入原始IV,不在模式函数里改动上下文里的IV值,问题才彻底消失。
另一个困扰是PKCS#7填充剥离。接收端拿到解密后的明文,要先检查最后一个字节的值是否在1到16之间,然后确认最后那n个字节是否真的都是n,否则就是数据被篡改了。如果只检查尾部是否越界,很可能被构造出错误填充导致解密异常。这一部分建议单独写个函数,别和业务逻辑混在一起。
4.3 嵌入式平台上用CFB/CTR省去填充的陷阱
CFB和CTR确实不需要填充,但有一个隐含问题:它们一次只处理16字节的倍数,最后一组不足16字节时,处理代码不能简单跳过。我在CFB实现里,如果剩余字节不为0,需要先用上一块的密文和AES加密生成16字节密钥流,再异或剩余数据。这上面特别容易踩的坑是缓冲区越界:剩余字节可能少于16,但aes_encrypt_block仍然需要16字节输入反馈,所以额外开辟一个16字节临时数组是值得的,不要图省事直接用栈上指针。
CTR模式还要注意计数器溢出和回绕的问题。大文件超过2^32个16字节块后,计数器低位会回绕,必须由上层协议约定好如何处理,否则密钥流会重复。另外,如果所有分区都用同一个Nonce,从头计数,那么不同分区的密文流可能被攻击者做重放攻击。安全要求高的场景,Nonce最好每次随机。
4.4 性能优化:查表法还是位运算
AES的列混合部分用GF(2^8)乘法,这个运算如果每次都算移位和异或,在MCU上会很慢。更常见的做法是用预计算的乘法表(如gmul2、gmul3查表),或者直接使用T-Tables优化(把SubBytes和MixColumns合并成四个大表)。我实测过在72MHz的ARM Cortex-M3上,纯字节操作的AES-128加密速度大约只有几十KB/s,换用查表法后能提升一个数量级。
但查表法有两个代价:一是ROM占用,T-Tables大约8KB,很多小型MCU的Flash紧张;二是查表时如果访问索引是外部可控的,理论上存在缓存时序攻击的风险。对于固件升级、系统密文这种不确定实时攻击面的场景,一般问题不大;如果做对外的加密协议通信,建议直接用硬件AES或mbedTLS的常量时间实现。
更进一步,我用单片机硬件AES外设的体会是:别小看硬件加速。STM32的AES硬件模块虽然只支持ECB/CBC,但速度非常可观,而且不占CPU。CFB/CTR这种流模式可以自己用硬件AES加密16字节计数器,再异或用户数据,也算曲线救国。
4.5 测试与验证:先用标准向量再过业务
最后一条,也是我每写一个加密模块都要强调的:不要拿业务数据直接当测试用例。先用NIST提供的AES测试向量做最基础的验证。比如AES-128,密钥000102...0f,明文00112233...ff,加密后的密文是69c4e0d86a7b0430d8cdb78070b4c55a。把这段测试向量跑到C代码里,如果结果不对,说明核心轮函数有问题。然后再分别用ECB和CBC的NIST示例数据验证每种模式。连我这种老手都犯过把S盒抄错导致中间轮结果偶尔错位的错,直接用业务数据排查会分不清是填充、IV还是模式问题。
等核心算法和模式都通过测试向量后,再对接协议数据,用双向加密解密互测收尾。这样做能节省几天的调试时间。
结尾的一点个人体会
这几年代码越写越多,我越来越觉得AES这种成熟算法,能用现成稳定库就用现成库,尤其是mbedTLS这种经过大量审计的,比自己手写重轮函数安全得多。手写实现的动力无非是嵌入式资源极紧、需要绕过大型依赖,或者想彻底搞懂算法原理。如果你也是后者,建议按“测试向量→单块接口→模式封装→协议对接”的顺序来,每一步都留好验证点,别急着把所有代码一次性写完。做纯软件实现时,记得把数组越界、长度检查和填充校验这些基础防护做扎实,密码学代码的严谨程度是要高于普通业务代码的。希望这篇分享能让你少走几个我走过的弯路。
本文还有配套的精品资源,点击获取