简介:面向嵌入式与金融安全场景的AES加密算法C语言完整实现,涵盖ECB、CBC、CFB、CTR四种工作模式,支持128/192/256位密钥长度,适合需要底层对称加密代码的开发者、密码学学习者及金融POS安全认证相关项目参考。压缩包共6个文件,包含3个C源文件、2个头文件和1个Makefile,整体仅16KB;C文件负责AES核心算法与配套测试程序,头文件声明接口与辅助函数,Makefile便于在Linux环境下一键编译。资源已在Ubuntu 16.04上编译并测试通过,下载后可直接运行验证,也可将AES模块提取出来集成到自己的工程中,省去重复造轮子的时间。目前已有5316人学习下载,作为简洁紧凑的AES多模式实现,既有完整的加解密功能,又有清晰的代码结构,适合用作教学示例或项目基础代码。 去年给一个嵌入式网关做固件升级包加密,我把手边能找到的AES代码翻了个遍,要么是只支持ECB模式的教学Demo,要么是OpenSSL那种编译链能把MCU工程拖到崩溃的“重型武器”。最后干脆自己拉了一套纯C实现,支持AES-128/192/256三种密钥长度,以及ECB、CBC、CFB、CTR四种常见工作模式。这篇文章把整个实现过程、原理重点、接口设计和实操中踩过的坑都写出来,适合要在单片机或普通C工程里加加密、又不想引入大型加密库的开发者参考。
1. 为什么自己在C语言里实现AES:嵌入式场景下的真实选择
1.1 直接移植开源库的问题
很多人第一反应是用OpenSSL或者MbedTLS。OpenSSL功能全,但在嵌入式平台上体积确实感人,就算裁剪也要处理一堆编译选项。MbedTLS相对友好,体积可控,但它有自己的平台抽象层和内存管理接口,想“塞”进一个裸机工程,配置起来也需要耐心。
网上流传的那些“AES代码”也未必能直接用。我见过不少版本,算法流程本身没错,但只能加密一个16字节分组,根本没有模式层的概念;还有的把S盒和逆S盒都写死成静态数组,但解密函数实现有误;更常见的是密钥只支持128位,想换192位或256位,几乎没有扩展性。这些都逼着你最终还是要自己把代码过一遍。
1.2 自己实现的两个前提
自己写AES并不是为了证明比开源库厉害,而是有两类场景特别适合:一是纯学习,想彻底搞懂S盒怎么来的、密钥扩展怎么算、四种模式区别在哪;二是产品确实只需要一个小而稳的加密模块,不想为几个函数引入一整个第三方库。
如果你的产品要求高安全级别、要过认证,或者面对的是强对抗环境,那我不建议自己写,直接用经过验证的库更稳妥。但如果你的需求是“嵌入式设备上做数据加密、对性能没有极致要求、想完全掌控代码逻辑”,那自己拉一套反而更踏实。
1.3 这套代码的设计目标
我给自己定了几个硬性目标:不依赖任何平台库,纯标准C;不使用动态内存分配,所有缓冲区由调用方提供或通过上下文结构体管理;分组加密算法与工作模式彻底解耦,底层只做16字节块加密/解密,上层跑ECB、CBC、CFB、CTR;密钥长度通过参数传入,一套代码处理128/192/256三种长度。这样的结构,后面不管是换平台还是接新业务,成本都很低。
2. AES算法核心运转逻辑:从字节代换到密钥扩展的每一步
2.1 状态矩阵与列优先填充
AES的固定分组长度是128位,也就是16字节。这16字节输入会被摆成一个4x4的状态矩阵,注意是按列填充:输入的第0到第3字节填第0列,第4到第7字节填第1列,以此类推。很多实现出现互操作问题,根源就在于把列优先填成了行优先。
uint8_t state[4][4]; for (int col = 0; col < 4; col++) { for (int row = 0; row < 4; row++) { state[row][col] = input[col * 4 + row]; } }只要保证“外部字节序”和“内部状态矩阵”的映射一致,加解密自洽没问题;但如果你要和其他语言、其他库互通,就必须严格按标准来。
2.2 四个基本运算
AES的每一轮加密由四个运算组成。
- SubBytes(字节代换):每个字节通过S盒查表替换成另一个字节。S盒本身是GF(2^8)乘法逆元再做仿射变换得到的,工程上直接把生成的256字节表放在ROM里查,效率远高于实时计算。
- ShiftRows(行移位):状态矩阵第0行不动,第1行循环左移1字节,第2行左移2字节,第3行左移3字节。解密时反向右移。
- MixColumns(列混合):对每一列的4个字节做GF(2^8)多项式乘法,相当于一个固定的矩阵乘法。实现上通常用xtime宏来算“乘2”操作,再组合出“乘3”等多字节结果,避免浮点和除法。
- AddRoundKey(轮密钥加):把状态矩阵的16字节与当前轮的16字节轮密钥异或,这是唯一用到密钥的运算,也保证了每一轮都让数据和密钥充分混合。
2.3 轮数与密钥扩展
轮数取决于密钥长度:AES-128是10轮,AES-192是12轮,AES-256是14轮。公式是Nr = Nk + 6,其中Nk是密钥长度除以32,128位对应Nk=4,256位对应Nk=8。
密钥扩展负责把原始密钥扩展成(Nr+1)组轮密钥,每组16字节。初始Nk个32位字就是原来的密钥,之后的每个字等于前一个字和Nk位置之前的字的异或。每当索引碰到Nk的整数倍,先做RotWord循环移位、再对每个字节做S盒替换、最后和轮常数Rcon异或。AES-256还要多一条规则:如果当前索引除以8余4,也要先做一次SubWord。这个细节不处理,256位密钥的密文就会直接错。
3. 四种分组模式工作机制对比:ECB的坑与CTR的“流式加密”
3.1 ECB:看起来最简单,使用场景却最窄
ECB模式把每块明文独立用同一把密钥加密。结构简单、可以并行、单个密文块出错只影响对应的那一个明文块,但这些优点掩盖不了一个致命问题:相同明文块永远产生相同密文块。加密一张有明显色块的位图,或者一段结构规则的固件数据,密文里会残留明显的模式特征,攻击者不需要破解AES就能猜出很多信息。
我的建议是:多块数据的通信加密不要用ECB。它比较适合的场景是单块数据,比如封装一个随机会话密钥,或者加密一个固定长度的随机数。
3.2 CBC:用IV打破模式,代价是加密不能并行
CBC模式先把当前明文块和上一个密文块异或,再进入AES加密。第一块用来异或的“上一个密文块”就是IV(初始向量)。这样,即使明文相同,只要IV不同或前一块密文不同,最终密文也不同。
CBC的约束条件很明确:加密必须串行,因为当前块依赖前一块加密结果;解密却可以并行,因为解密每个块只需要D(K, C_i)和C_{i-1}两个数据。IV必须每次随机生成且不可预测,并且同一个密钥下绝对不能用同一个IV加密两条消息。IV不需要保密,但一般需要随密文一起传输。
3.3 CFB:把AES当流加密器用,加解密走同一个函数
CFB模式把AES当成一个伪随机流生成器,先用密钥加密上一个密文块,再把结果与当前明文异或得到密文。特别注意:CFB的解密也调用AES加密函数,不是AES解密函数。
因为这个特性,CFB可以处理任意长度的数据而不需要补齐到16字节整数倍,非常适合按字节处理的流式场景,比如终端设备之间的持续通信流。CFB根据反馈粒度分为CFB-1、CFB-8、CFB-128等,常用的是CFB-8和CFB-128。它的缺点是数据依赖链比较长,加解密都无法并行。
3.4 CTR:计数器模式,加密解密完全对称
CTR模式的做法是:用一个初始计数器值(由nonce和计数器字段组成),从0开始递增,每个计数器值做一次AES加密,得到的伪随机流和明文异或就是密文。
CTR最舒服的地方在于加密和解密是完全相同的函数,只是输入换成密文、输出得到明文。它天然支持随机访问,知道某个位置的计数器值就能单独解密那一段数据,磁盘加密、网络协议包都很喜欢CTR。前提是计数器值不能重复使用,否则两个数据流的异或结果会直接泄露明文信息。
3.5 选型参考表
| 模式 | 加解密是否需要不同函数 | 是否需要IV | 能否并行加密 | 能否随机访问 | 典型场景 |
|---|---|---|---|---|---|
| ECB | 需要 | 不需要 | 可以 | 按块可以 | 单块密钥封装 |
| CBC | 需要 | 需要 | 不可以 | 不可以 | 文件、固件批量加密 |
| CFB | 不需要,都用加密函数 | 需要 | 不可以 | 不可以 | 流式通信数据 |
| CTR | 不需要,都用加密函数 | 需要 | 可以 | 可以 | 磁盘加密、协议包加密 |
4. C语言接口与状态设计:把这个加密库“焊”进你的工程
4.1 核心上下文结构体
设计接口时要考虑一件事:CFB和CTR是流模式,一次调用可能只处理几个字节,如果每个字节都触发一次完整AES块加密,性能会很难看。所以我用一个上下文结构体保存中途状态。
typedef struct { int mode; /* AES_MODE_ECB / CBC / CFB / CTR */ int key_bits; /* 128 / 192 / 256 */ uint8_t round_key[240]; /* AES-256最多需要 15组 x 16字节 = 240字节 */ uint8_t iv[16]; /* 保存当前IV或计数器状态 */ uint8_t stream_block[16]; /* CFB/CTR流模式缓存当前块的伪随机输出 */ size_t block_offset; /* 已消费到当前缓存块的哪个字节 */ } aes_ctx;round_key数组按最大尺寸240字节分配,这样一套结构体就能覆盖三种密钥长度,省去动态分配。stream_block和block_offset专门为流模式准备,保证即使每次只喂一个字节,底层也不用每次都重新跑完整分组加密。
4.2 分组算法与模式层分离
接口分三层,职责清晰:
- 初始化层:
aes_init(&ctx, key, key_bits),内部完成密钥扩展;可选aes_set_iv(&ctx, iv)单独设置IV或计数器初始值。 - 块级函数:
aes_encrypt_block(&ctx, in, out)和aes_decrypt_block(&ctx, in, out),只处理16字节分组,实现AES的轮运算。 - 模式级函数:
aes_ecb_crypt、aes_cbc_encrypt、aes_cbc_decrypt、aes_cfb_crypt、aes_ctr_crypt,负责把数据切成16字节块,按模式规则链式处理。
这样设计的好处是,如果你以后想加GCM这类带认证的模式,只需要复用块级函数,不需要动底层AES。密钥扩展只在init时做一次,频繁加密短数据时性能损失很小。
4.3 IV与流模式的内部状态管理
CBC、CFB、CTR的IV都不是一次性参数,它会在处理过程中被持续更新。比如CBC加密完第一个块后,C_i会成为下一个块的“IV”;CFB加密完一个块后,移位寄存器要装填新的密文块。把这些状态放在ctx里,就能支持分片调用:
/* 第一次调用 */ aes_cbc_encrypt(&ctx, buf1, out1, len1); /* 第二次调用,ctx内部已经保存了上一次的最后一个密文块,继续从断点处理 */ aes_cbc_encrypt(&ctx, buf2, out2, len2);如果业务上要求每次调用都从相同的初始状态开始,就在分片处理前重新调用aes_set_iv重置。
5. 128/192/256三种密钥长度的统一实现技巧
5.1 用Nk统一密钥扩展逻辑
三种密钥长度本质上只差两个参数:Nk(密钥包含的32位字个数)和Nr(轮数)。代码里不要写死三个分支,而是用参数驱动:
int nk = key_bits / 32; int nr = nk + 6; int total_words = 4 * (nr + 1); /* 轮密钥总字数,44/52/60 */密钥扩展数组用uint8_t w[240]这样的扁平数组,每4个字节构成一个32位字,步长按Nk动态调整。这样一套逻辑处理三种密钥长度,未来添加新长度也只需要改参数。
5.2 Rcon轮常数边界
密钥扩展里必须的轮常数Rcon数组,很多实现只准备到Rcon[10](0x36)。AES-256的密钥扩展实际还会用到更后面的值,虽然用到第7个左右就够,但为了稳妥,我建议直接把Rcon按GF(2^8)的乘法规则准备到足够长,数组多占几字节容量不是什么问题。
static const uint8_t rcon[] = { 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a };遇到轮常数超出0x80时就要异或0x1B,这其实是GF(2^8)里不可约多项式x^8+x^4+x^3+x+1在起作用。理解这一点后,Rcon数组不管多长都能自己推算出来。
5.3 解密时轮密钥逆序使用
加密时AddRoundKey按第0组、第1组……第Nr组顺序用。解密时要逆过来,从最后一组轮密钥开始往回调。最简单可靠的实现是:密钥扩展只做一次,得到完整的(Nr+1)组轮密钥,然后解密时按组从后往前取。
const uint8_t *round_key_group = ctx->round_key + (nr - round) * 16;如果你看过那些追求极端性能的实现,它们会把解密轮密钥预先做InvMixColumns变换,这样解密内部就不需要额外处理,但代码复杂度会明显上升。我建议第一版先做“正向扩展、反向取用”的版本,正确性更容易保证。
6. 实测数据与排错清单:文档里不会写的坑
6.1 不同平台下的速度参考
我同一套纯C查表版本,在桌面Linux上用gcc -O2编译,AES-128-CBC加密能跑到几十MB/s以上,具体数值受CPU、编译器优化和是否碰巧向量化影响很大;换到72MHz的STM32F103单片机,速度就掉到每秒几百KB这个量级。如果对速度敏感,第一步是把S盒换成T-table(4张1KB的查找表),能把每轮的多步运算合并成少量查表和异或;再往下就是针对特定CPU做AES-NI或汇编优化。但对绝大多数物联网设备场景,纯C查表版已经完全够用。
6.2 高频错误Top 5
| 现象 | 根本原因 | 修复思路 |
|---|---|---|
| 和其他语言库加密结果不一致 | 状态矩阵行/列填充顺序错误 | 对比标准测试向量,检查列优先填充逻辑 |
| CTR/CFB解密出来是乱码 | 解密用了AES_Decrypt而不是AES_Encrypt | 改为统一走加密函数生成伪随机流再异或 |
| CBC多段加密后中间某块错乱 | IV状态没有随数据持续更新 | 把当前密文块写回ctx的iv字段 |
| 密钥改成256位后栈溢出 | round_key数组按176字节分配 | 统一按AES-256上限240字节分配 |
| 最后一块不足16字节被丢弃 | ECB/CBC没有处理Padding | 加PKCS#7填充/去填充函数,CTR/CFB不需要 |
6.3 一次CBC解密前16字节乱码的实际排查
有一次联调,对端平台用AES-128-CBC解密,其他块都正常,唯独第一个16字节块是乱码。第一反应是密钥不对,但密钥错的话不可能后面都解对。于是先按FIPS-197官方测试向量跑块级解密,确认AES底层函数正确;再检查数据流,发现发送端把IV放在帧头,接收端解析时按照自己的协议头定义偏移读错,IV实际错位了4字节。
排查思路给后来人一个参考:遇到解密局部出错,先证明块级函数是对的,再检查IV、再检查Padding、最后检查数据截断。顺序对了,问题很快就能定位。别上来就怀疑密钥,密钥错通常是全部乱码,而不是局部乱码。
这套C语言实现我在好几个工程里复用过,当时踩过的坑如今都变成了注释写进代码。如果让我重写一版,第一件事就是把S盒换成T-table提升性能;但回过头看,先把分组算法、模式层和接口边界理清楚,永远比闷头优化更重要。
本文还有配套的精品资源,点击获取