简介:本资源是面向嵌入式开发初学者与51单片机实践者的AES-128对称加密算法轻量级实现方案,聚焦资源受限场景下的密码学落地问题,适用于无线通信加密、设备身份认证及敏感数据存储等典型嵌入式安全需求。压缩包共3个文件(2个C源文件+1个头文件),总大小仅10KB,结构精简:其中AES_Lib.c与AES_Lib.h封装了核心加解密逻辑与状态矩阵操作,TestAES.c提供可直接运行的测试用例,涵盖128位密钥调度、10轮字节代换/行移位/列混淆/轮密钥加等完整流程,并针对51单片机特性进行了查表优化与位运算精简,避免浮点与大数组,兼顾可读性与执行效率。目前已有155人学习下载,代码注释清晰、模块划分明确,是理解AES算法在8051架构上从数学原理到C语言映射的关键范例,亦可作为课程设计、毕业设计或IoT终端安全模块的可移植基础组件。 最近整理旧项目时翻出一个压了好几年的工程包,文件名是AAES-128Bit-CE.rar,解压一看,内容是 51 单片机上的 AES-128 加解密代码。这项目当年折腾了我一阵子,核心就一句话:在资源抠到极致的 51 上,把 AES-128 加密算法跑起来。今天正好把整个思路、优化过程和踩过的坑都梳理一遍,给需要在老平台上做数据加密的朋友一个参考。
这个内容适合谁看?如果你正在做单片机通信加密、产品防止被抄板、或者在校生做课程设计/毕业设计时选了“基于51单片机的数据加密”这类题目,这篇东西可以帮你少走很多弯路。我会把 AES 算法本身、51 平台的内存限制、Keil C51 上的优化技巧、以及调试时最容易踩的坑全部摊开讲。
1. 先聊聊:为什么要在51这种老平台上跑AES
1.1 老平台遇到的新需求
很多人一听说 51 单片机,第一反应是“这玩意儿还能跑加密算法?”但现实是,目前市面上仍有大量在用设备用的是 51 内核:家电控制板、智能仪表、传感器节点、门禁系统、小型工控模块,存量巨大。这些设备早期设计时根本没考虑数据安全问题,但现在设备要联网、要透传数据、要远程升级,数据一上总线就裸奔,不得不补加密这一课。
典型场景有这么几个:第一,串口通信加密,两个设备之间通过 485 或 UART 传数据,明文容易被抓包分析;第二,固件防抄板,把一些关键参数加密存储到 EEPROM,别人读出来也是一堆乱码;第三,数据上行到网关时做认证,防止伪造指令。这些场景的共同特点是:主控已经是 51 了,产品已经量产了,不可能为加个密重新画板换主控,只能在现有平台上做文章。
这就面临一个现实约束:51 的资源太紧张了。以最经典的 STC89C52 为例,片内 RAM 只有 128 字节,外部扩展也不方便;ROM 最大 8KB;主频通常 12MHz 或 11.0592MHz;数据总线是 8 位。而 AES-128 算法,标准的实现方式需要 S 盒 256 字节、轮密钥 176 字节、状态矩阵 16 字节,还有一堆临时变量。随便一算,光核心数据就接近 500 字节,远超片内 RAM。所以这不是一个“把代码抄过来就能跑”的项目,而是要在算法实现和硬件资源之间做精确的平衡。
1.2 为什么非得是AES-128
做嵌入式加密,可选算法其实不少:DES、3DES、AES、RC4、TEA/XTEA,还有各种轻量级算法。但我最终选 AES-128,理由很实际。
DES 已经不安全了,3DES 速度太慢、模式陈旧,在 51 上跑一场加密要等老半天。RC4 虽然轻量,但它是流密码,密钥流重用后极其脆弱,而且 RC4 本身饱受诟病,用在产品里被审计时很难看。TEA 系列实现简单,但学术界对其安全性一直有争议,很多客户不认。相比之下,AES 是国际标准算法(FIPS 197),安全性和通用性都无需解释,上位机、服务器、手机端全都原生支持,不存在对接障碍。
为什么选 128 位而不是 192 或 256?嵌入式环境下,密钥长度每增加一档,轮密钥表就要增大 1/3,运算轮数也要增加,51 上每多跑一轮都是真金白银的时间开销。AES-128 的 10 轮运算已经能提供足够的安全余量,对绝大多数物联网设备的业务数据加密来说完全够用,而且密钥长度 16 字节在 51 的存储布局里也比较舒展。如果你的应用对保密等级有变态要求,或者平台换成了 32 位 MCU,再考虑 AES-256 也不迟。
项目包名里的 “CE”,我理解是 CBC 模式 + Encrypt 的缩写。CBC(密码分组链接)每个明文块先跟上一块的密文异或再加密,能有效掩盖明文统计特征,比 ECB 模式安全得多。关于模式选择的细节,后面我会专门展开。
2. AES-128算法核心拆解与手算验证
2.1 加密流程与状态矩阵
AES 是分组加密算法,不管平台是什么,算法逻辑是一样的。AES-128 的数据块固定 128 位,也就是 16 字节,密钥也是 128 位。这 16 字节在算法内部被组织成一个 4×4 的字节矩阵,称为状态矩阵(State),按照列优先的顺序填充:
输入:01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 状态矩阵: | 01 05 09 0D | | 02 06 0A 0E | | 03 07 0B 0F | | 04 08 0C 10 |这个矩阵是 AES 所有轮运算的基本单位。加密过程总共 10 轮,每轮内部依次做四件事:SubBytes(字节代换)、ShiftRows(行移位)、MixColumns(列混合)、AddRoundKey(轮密钥加)。但要注意,第 10 轮有一个特殊之处:它少做 MixColumns,只做 SubBytes、ShiftRows、AddRoundKey。初始阶段还要先做一次 AddRoundKey。整体的结构可以概括为“初始密钥加 → 前9轮完整变换 → 第10轮简化变换”,这样设计的目的和差分分析、线性分析等攻击方法有关,我们做工程实现只需要按这个流程走。
SubBytes 是查表操作:每个字节通过 S 盒替换成另一个字节。ShiftRows 是把矩阵的四行分别做 0、1、2、3 字节的左循环移位。MixColumns 是矩阵乘法,把每一列经过一个固定矩阵变换得到新列。AddRoundKey 则是最简单的异或操作。
2.2 S盒是怎么来的,为什么直接查表
S 盒是整个 AES 里唯一让人感觉“不明觉厉”的部分。它本质上包含两步数学运算:第一步,在有限域 GF(2^8) 上求乘法逆元;第二步,做一次仿射变换。这两步组合起来,就是为了让每个字节的输出与输入之间呈现高度非线性,从而抵抗代数攻击。
但从工程角度来看,你根本不需要关心 S 盒怎么算出来的,直接把这个 256 字节的表格放进代码里查就行了。原因很简单:每当加密一个字节,都要进行一次 GF(2^8) 求逆,而求逆本身需要做扩展欧几里得或查对数表,运算量巨大。用查表法,一次索引就完成了替换,代价只是 256 字节的 ROM 空间。这在 PC 上没什么感觉,但在 51 上,ROM 换时间是非常划算的买卖。
S 盒的 C 语言描述很容易写:
unsigned char sbox[256] = { 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, // ... 共256个字节 };2.3 轮密钥扩展与标准测试向量
加密的 10 轮,每轮都要用一组 16 字节的轮密钥。这些轮密钥不是随便生成的,而是由最初的 16 字节种子密钥通过密钥扩展算法生成。扩展规则是:前 16 字节直接取种子密钥;之后的每个 4 字节字,根据它与前一个字以及前一组字的位置关系,通过异或、循环左移、S 盒替换以及 Rcon 轮常量生成。整个扩展过程最终得到 11 组轮密钥,也就是 176 字节。
这里有一个初学者常犯的错误:只给加密过程实现密钥扩展,而解密过程没有配套的轮密钥逆序使用。AES 解密时,轮密钥的使用顺序恰好是加密的逆序,虽然解密可以即时生成所需轮密钥(密钥扩展本身可以任意顺序生成每个轮密钥),但在 51 上我们还是建议一次性把 176 字节全部计算出来存好,后面加解密直接查表,速度快,逻辑也简单。
如何确认自己实现的算法没问题?NIST 在 FIPS 197 标准文档里提供了官方测试向量,这是我最常用来验证算法正确性的办法。下面这个组合是我反复用的:
密钥 2b7e151628aed2a6abf7158809cf4f3c 明文 6bc1bee22e409f96e93d7e117393172a 加密结果 3ad77bb40d7a3660a89ecaf32466ef97如果你实现的 AES-128 ECB 加密能对上述输入输出一模一样的结果,说明加解密核心逻辑正确。注意,这是 ECB 模式下的测试向量,CBC 模式还要额外叠加 IV 异或逻辑。
3. 51单片机上的移植与优化实战
3.1 内存规划:把S盒和轮常量交给ROM
前面提到标准 AES 实现在 51 上最大的障碍是 RAM。以 STC89C52 为例,片内 RAM 只有 128+128 字节(后 128 字节还要被 SFR 占用一部分),而 S 盒就要 256 字节,这显然是放不下 的数据。要么把 S 盒放 RAM、其他变量挤一挤,要么换一种思路。
我采用的方案很传统:S 盒用code关键字直接定义到 ROM 里。Keil C51 中,code段位于程序存储器,单片机运行时按字节读取,这在 89C52 这类哈佛架构的芯片上是可以直接寻址的。这样一来,256 字节的 S 盒不再占用宝贵的 RAM,只占 ROM 的 256 字节。对 51 来说,ROM 动辄几 KB,完全能用这个空间来换算法的可运行性。
轮密钥和状态矩阵则要放在 RAM 里。这里还需要考虑 Keil 的内存模型设置。C51 编译器有SMALL、COMPACT、LARGE三种内存模型,SMALL模式把所有变量默认放在data区,访问速度最快但空间有限;LARGE模式变量默认放xdata区(外部 RAM,需要MOVX指令访问),空间大但速度慢。AES 的轮密钥表有 176 字节,肯定不能全部放到data区,所以我把它显式声明到xdata,再配合Small: variables in DATA的编译器选项,让其他临时变量保持最快访问。
unsigned char xdata round_keys[176]; // 轮密钥表 unsigned char xdata state[16]; // 状态矩阵 unsigned char code sbox[256] = { /* ... */ };这样分配合法的原因是:轮密钥和状态矩阵在加密循环中的访问频率极高,但同时它们又是整块数据,放在xdata并不算慢太多;而高频的临时变量保留在data区,两者各得其所。
3.2 列混合的实现:从笨办法到查表优化
MixColumns 是 AES 里运算量最大的部分。它的本质是对每个状态列做矩阵乘法,但矩阵元素是 GF(2^8) 上的多项式系数。具体来说,每一列四个字节乘以一个固定矩阵,等效于:
new[0] = (2 * old[0]) ^ (3 * old[1]) ^ old[2] ^ old[3] new[1] = old[0] ^ (2 * old[1]) ^ (3 * old[2]) ^ old[3] new[2] = old[0] ^ old[1] ^ (2 * old[2]) ^ (3 * old[3]) new[3] = (3 * old[0]) ^ old[1] ^ old[2] ^ (2 * old[3])这里的乘号不是普通整数乘法,而是 GF(2^8) 上的多项式乘法,溢出时要对不可约多项式 0x1B 做异或归约。如果直接实现这个乘法,每个字节都要做循环移位和异或判断,代码又慢又绕。最常用的优化是用xtime函数代替乘 2:
unsigned char xtime(unsigned char x) { return (x << 1) ^ ((x & 0x80) ? 0x1B : 0x00); }乘 3 就是xtime(x) ^ x。于是每一列的变换可以写成:
unsigned char xt = xtime(old[0]) ^ xtime(old[1]); new[0] = xtime(old[0]) ^ (xtime(old[1]) ^ old[1]) ^ old[2] ^ old[3];而更进一步的技巧是使用 256 字节的 T 表。将(2 * b)和(3 * b)的运算结果各做一张 256 字节表,查找即得,这样 MixColumns 中的乘法全部被查表替代。不过对于 51 来说,T 表虽然快,但会再吃 512 字节 ROM。如果 ROM 空间充足,我推荐用 T 表;如果空间不够,xtime已经足够。我自己的工程里最终选择了xtime方案,因为当时 ROM 里还塞了协议栈和字库,空间比时间更紧张。
3.3 一套好用的接口与代码组织
移植 AES 到 51 时,代码组织直接影响后续维护。我项目的核心文件结构是:
aes.h —— 对外接口与数据结构声明 aes.c —— 核心加解密实现 main.c —— 测试入口与调用示例接口设计要尽量精简。我在aes.c里提供四个核心函数:
void aes_key_expansion(unsigned char *key); void aes_encrypt_block(unsigned char *plaintext, unsigned char *ciphertext); void aes_decrypt_block(unsigned char *ciphertext, unsigned char *plaintext);调用范例非常直观:
unsigned char key[16] = {0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c}; unsigned char plain[16] = {0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17, 0x2a}; unsigned char cipher[16]; aes_key_expansion(key); aes_encrypt_block(plain, cipher); // 此时 cipher 应为 3ad77bb40d7a3660a89ecaf32466ef97我再强调一遍,先用标准测试向量验证你的核心算法,再谈优化和实际业务接入。这一步能卡掉 90% 的后续问题。
3.4 实测性能与资源占用
我当时的测试平台是普中科技的一款开发板,用的是 STC89C52RC,晶振 12MHz,编译环境 Keil C51。这个平台非常典型,任何 89C52 用户都能直接对号入座。
整包代码编译后,ROM 占用约 1.6KB(其中 S 盒占了 256 字节),RAM 占用约 240 字节(含堆栈段)。这个占用对 51 来说已经很克制了。加密一个 16 字节数据块,用逻辑分析仪抓引脚电平变化计时,实测大约耗时 6ms~8ms,具体取决于编译器优化等级。如果换用 1T 的 STC15 系列单片机,主频跑到 24MHz,同一份代码可以把加密时间压到 1ms 以内。
这样的性能对于低频的数据加密(比如每秒加密一帧控制指令、每分钟上传一次状态数据)完全够用。但要意识到,它不适合做高吞吐量的音视频流加密,那是 32 位 MCU 或专用加密芯片的领域。
4. 调试记录与常见问题速查
4.1 加密结果不对,首先怀疑数据类型和下标
我第一次移植这个算法时,加密结果跟标准测试向量对不上。排查了很久,最后发现问题出在数据类型上。Keil C51 里char默认是signed char,而 AES 的 S 盒索引在 0x80~0xFF 范围内时,如果字节被当成有符号数,索引就变成了负数,直接查到 S 盒的负偏移,结果全乱。
解决方法是显式把 S 盒的索引、状态矩阵、轮密钥等都声明为unsigned char,或者在 Keil 编译器选项里把char设为unsigned。很多网上的代码写得不严谨,一下插到 51 上就翻车,十有八九是这个原因。
第二个高频问题是数组越界。轮密钥表是 176 字节,索引在 0 到 175 之间,但如果你在实现密钥扩展时循环边界算错了一位,很容易访问到自己,导致后面的加密结果全飞。别看一时之间结果不对,先检查所有循环边界。
4.2 大数组一定义就编译失败或者上电死机
如果你按照标准 PC 端 AES 代码直接在函数内定义unsigned char sbox[256],那么 C51 会告诉你DATA segment too large。因为在默认SMALL模式下,局部变量都试图塞进data段,加上轮密钥表 176 字节,瞬间爆掉 128 字节的物理上限。
即使编译过了,上电也可能直接死机。我自己踩过一个大坑:某一版代码用了约 380 字节的 RAM 变量,编译过但运行时频繁跑飞。后来检查内存映射才发现,data区被占满,堆栈被挤到了idata区末端,一旦函数嵌套深一点或者中断频繁一点,栈溢出覆盖了关键数据,程序就随机死机。
解决方法是:底层查重表放code,大块缓冲区放xdata,堆栈要留足空间。同时可以在 Keil 的Memory Model里把Variables in XDATA选中,然后显式把需要快速访问的少量变量用data关键字标注,这种“默认放外部、关键放内部”的模式更灵活。另外,51 的中断函数要尽量短,不要在中断里调用 AES 加解密函数,否则栈压力难以控制。加解密逻辑放在主循环里,配合标志位处理。
4.3 性能慢得不可接受,优先检查这三处
如果加密一个块花了几十毫秒甚至上百毫秒,先别急着换主控,按顺序排查这几个地方:
第一,关闭 Keil 的调试信息,把编译器优化等级从Level 0调到Level 8 (Speed)。我之前在默认优化下加密 16 字节用了 13ms,换到最高优化后降到 7ms,差距非常明显。但要注意,优化等级过高可能会把某些变量放寄存器导致中断冲突,所以调完要跑长时间稳定性测试。
第二,检查自己有没有在循环里做重复的密钥扩展。有些初版代码会在每个数据块加密前都调用一次aes_key_expansion,白白多做 176 字节的扩展计算。正确做法是:密钥不变时只扩展一次,后面直接使用。
第三,看清零操作有没有拖后腿。如果每加密一块都对状态矩阵做一次memset,对小容量 RAM 来说开销占比很高。直接复用寄存器与局部变量,不必每次清零,因为 AES 每一块数据都会完全覆盖中间状态,不需要清零。
4.4 常见问题速查表
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 加密结果与测试向量不一致 | 数据类型符号问题 / S盒索引错误 | 统一使用unsigned char;核对索引边界 |
编译报DATA segment too large | RAM 超限 | S盒放code,轮密钥放xdata |
| 上电跑飞、频繁复位 | 堆栈溢出 / 内存冲突 | 减少栈深度,中断里不做重型运算,检查内存模型 |
| 加密速度过慢 | 优化等级低 / 重复扩展密钥 | 开最高优化,密钥扩展只做一次 |
| CBC模式加密结果与参考不一致 | IV 未正确初始化或未逐块异或 | 确认 IV 正确,每块密文参与下一块异或 |
| 解密出现乱码但加密正常 | 轮密钥顺序反了 | 解密应逆序使用轮密钥,或调用逆密钥扩展流程 |
5. 应用落地:工作模式与总线场景的联动
5.1 工作模式选型:ECB是个陷阱
加密算法外层的工作模式选择,比算法本身更容易出错。AES 核心一次加密 16 字节,实际业务数据往往不止 16 字节,需要把长数据切分成多个块,而块与块之间采用什么关联方式,就是模式的事。
ECB 模式最简单,每块明文独立加密,相同的明文块永远得到相同的密文块。这在数据模式比较规律时非常危险,比如你的数据里有很多0x00填充字节,用 ECB 加密后密文也会出现大量重复块,攻击者很容易从统计规律里反推出很多信息。所以我不建议在产品里直接用 ECB,做演示和测试可以,上线就算了。
CBC 模式是目前用的最多的:每个明文块先与上一块的密文异或,再进行加密。第一块用 IV 初始化。这样同样的明文在不同位置、不同上下文中会得到不同的密文,安全性大幅提升。但 CBC 的缺点是加密过程是串行的,不能随机访问某个数据块,因为解密时依赖前一个块的密文。
基于这个项目包名里的 “CE”,我判断当时选用的是 CBC 模式。
实现 CBC 模式的代码也不复杂,以加密为例:
unsigned char iv[16] = { ... }; // 初始向量,双方约定好 unsigned char previous[16]; // 拷贝iv到previous memcpy(previous, iv, 16); for (int i = 0; i < block_count; i++) { for (int j = 0; j < 16; j++) { plain[i * 16 + j] ^= previous[j]; } aes_encrypt_block(&plain[i * 16], &cipher[i * 16]); memcpy(previous, &cipher[i * 16], 16); }要注意,这个简单示例里将 IV 和密钥都硬编码在代码里了。实际产品中,IV 必须是随机数,每次通信会话都不同,否则 CBC 模式也会退化出安全问题。随机数来源可以直接利用定时器采样的噪声:初始化时连续读定时器低字节,或多读几次引脚电平拼凑一个 16 字节的随机种子。虽然不是密码学意义上的强随机数,但对大部分通信防重放场景已经够用了。
5.2 串口与485场景的联动设计
在这类项目里,AES 加密通常跑在透明传输层,也就是说,上位机发来请求,单片机解密后才是真正的控制指令。以 RS485 总线为例,一个常见的数据帧格式是:
帧头(2字节) + 长度(1字节) + 密文(16字节整数倍) + CRC校验(2字节) + 帧尾(2字节)为什么要在密文外面再加 CRC?因为 AES 解密之后的明文如果被篡改,通常会产生随机乱码,靠业务逻辑很难判断数据是否合法,而物理层的 CRC 可以提前把大部分错误帧过滤掉,避免解密模块被脏数据干扰。
解密时先收完整帧,校验 CRC,然后再做 AES 解密。不要收到一字节解一字节,因为 CBC 模式要求按块处理,拆碎了容易把状态搞乱。
另外,密钥管理也是经常被忽略的部分。单片机端密钥通常写死在 Flash 里,这本身就存在被读取的风险。如果你的产品有固件加密或代码保护功能,一定要打开;如果用的是 STC 系列,还可以用其内置的程序加密后传输功能,防止被直接读出固件。密钥千万不能明文打印到日志里,这是最蠢的做法。
还有一个实际工程里要处理的问题是数据填充。AES 是分组加密,如果最后一组不足 16 字节,就要做填充(Padding)。最常用的是 PKCS7:缺几个字节就补几个字节,比如缺 5 个字节就补0x05 0x05 0x05 0x05 0x05;如果正好 16 字节整倍数,也要额外补一整块0x10。这样解密端就可以根据最后一个字节的值安全地去掉填充。
5.3 一点避坑心得收尾
回头再看这个AAES-128Bit-CE工程,其实最有价值的并不是那几百行 AES 代码,而是整个“在资源受限平台上实现标准算法”的思考过程。我在实际投入中最大的体会是:不要只盯着“算法原理”这种听起来高端但其实很容易的事,端口落地时真正的难点是内存分配、数据类型的严谨性、模式选择的合理性,以及调试时有没有一套可以验证的测试基准。
所以最后再分享一个小技巧:不管你的最终业务是用 CBC 还是 CTR 模式,开发阶段一定要先把核心 AES 模块用 ECB 模式跑通标准测试向量。因为 ECB 没有 IV 和链接逻辑,出错时变量最少,最容易定位问题在哪一层。等测试向量完全匹配后,再在外层套上 CBC 或 CTR 的业务逻辑,两层分开调试,绝对比直接一次性写完所有代码然后瞪眼找 bug 要高效得多。
本文还有配套的精品资源,点击获取