简介:这份资源提供AES128对称加密算法在ECB与CBC两种工作模式下的C语言实现,面向单片机与嵌入式开发人员,解决低资源平台上数据加解密与PKCS7Padding补齐的实际需求。压缩包共2个文件,包含1个c源文件与1个h头文件,整体约5KB,代码结构精简,便于直接移植到资源受限的嵌入式工程中。内容涵盖密钥扩展、逐块加密解密流程、CBC模式初始向量异或处理,以及解密后按填充值移除PKCS7填充字节的完整逻辑,可帮助读者理解ECB模式在重复明文下的模式可预测性,以及CBC模式借助IV提升保密性的原理。已有7328人学习下载,适合需要快速验证AES128加解密流程、研究嵌入式安全实现或进行代码二次开发的开发者参考。
1. 从一次串口日志翻车说起:为什么嵌入式端要自己撸一份 AES
前阵子调一个低功耗采集终端,设备通过串口往网关吐数据,协议里有一段配置密文。抓日志的时候发现,同样的明文、同样的密钥,网关解出来的东西跟设备发出去的对不上,折腾半天才定位到:设备端用的是 AES128 ECB,网关那边默认按 CBC 解,模式没对齐,白白耗掉一个下午。这件事之后,我把手头几个单片机项目里的加解密逻辑统一收拢,整理成了一份纯 C 的 AES128 实现,覆盖 ECB 和 CBC 两种模式,补齐算法用的是 PKCS7Padding。它不依赖任何第三方库,不动态分配内存,编译出来体积可控,适合资源紧张的嵌入式/单片机场景。如果你也在 Cortex-M、51 或者 RISC-V 这类平台上做数据加密,又不想引入庞大的密码学库,这份代码值得拿来直接用或者二次裁剪。
2. 技术选型与整体架构:为什么是纯 C 加查表实现
2.1 嵌入式场景下的取舍逻辑
在单片机上做 AES,第一件要想清楚的事不是算法本身,而是资源边界。很多密码学库功能全,但带了一堆用不上的模式、哈希、大数运算,链接进来动辄几十 KB,对只有 64KB Flash 的片子来说太奢侈。这份实现走的是极简路线:只做 AES128,只做 ECB 和 CBC,只做 PKCS7 补齐,其余一概不要。
选 AES128 而不是 192/256,是因为在多数嵌入式通信场景里,128 位密钥已经够用,而且轮数少(10 轮),运算量和代码量都更友好。选查表实现而不是纯位运算,是因为单片机主频普遍不高,S 盒查表能把 SubBytes 这一步从几十条指令压到一次数组访问,速度提升明显。代价是占用 256 字节的 S 盒常量和一份逆 S 盒,对 Flash 来说完全可以接受。
内存策略上,整个实现不调用 malloc,所有中间状态都放在调用者传入的缓冲区或者栈上的固定数组里。这一点对裸机或者 RTOS 环境很关键,避免堆碎片,也避免在中断上下文里因为分配失败而翻车。
2.2 模块划分与目录结构
代码按职责拆成几个文件,方便单独替换和测试。常见做法是这样组织:
aes/ ├── aes.h // 对外接口声明:密钥扩展、ECB/CBC 加解密 ├── aes.c // AES 核心:S 盒、轮函数、密钥扩展 ├── aes_modes.c // ECB / CBC 模式封装 ├── aes_padding.c // PKCS7 补齐与去补齐 └── test_aes.c // 自测用例,验证标准向量aes.h只暴露必要的函数原型和上下文结构体,调用方不需要关心内部状态怎么存。aes.c负责最底层的轮运算,aes_modes.c把轮运算串成完整的模式流程,aes_padding.c单独处理数据长度对齐。这样拆的好处是,如果你只想用 ECB,可以把 CBC 相关代码裁掉,链接器不会把没用的函数带进来。
核心数据结构是一个保存轮密钥的结构体,密钥扩展只做一次,后续每次加解密复用:
typedef struct { uint8_t round_key[176]; // 11 组轮密钥,每组 16 字节 uint8_t iv[16]; // CBC 模式使用的初始向量 } aes_ctx_t;round_key固定 176 字节,对应 AES128 的 10 轮加初始轮,共 11 组 16 字节密钥。iv只在 CBC 模式下有意义,ECB 模式可以忽略。把 IV 放在上下文里,是为了让同一份密钥可以配合不同 IV 做多次会话,而不用反复扩展密钥。
2.3 接口设计:加解密函数长什么样
对外接口尽量保持简单,参数少、语义直白。密钥扩展和模式加解密分开:
// 初始化:扩展密钥,设置 IV(ECB 可传 NULL) void aes_init(aes_ctx_t *ctx, const uint8_t *key, const uint8_t *iv); // ECB 模式:输入输出长度必须是 16 的整数倍 void aes_ecb_encrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len); void aes_ecb_decrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len); // CBC 模式:内部处理 IV 链式异或 void aes_cbc_encrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len); void aes_cbc_decrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len);len参数单位是字节,必须是 16 的整数倍,这一点在函数内部会做断言或者返回错误码。ECB 模式下iv传 NULL 即可,CBC 模式下必须传一个 16 字节的初始向量。加解密都支持原地操作,也就是in和out指向同一块缓冲区,这在内存紧张时很有用,但要注意 CBC 解密时原地操作需要小心处理,后面会讲。
3. 核心模块拆解:从密钥扩展到 CBC 链式异或
3.1 密钥扩展:一次算好,反复使用
AES128 的密钥扩展把 16 字节原始密钥扩展成 176 字节轮密钥。核心逻辑是按 4 字节一组,逐组生成,每 4 组做一次特殊变换。代码大致如下:
static const uint8_t sbox[256] = { /* 标准 S 盒,略 */ }; // 轮常量,用于密钥扩展 static const uint8_t rcon[11] = { 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36 }; void aes_init(aes_ctx_t *ctx, const uint8_t *key, const uint8_t *iv) { uint8_t i; // 前 16 字节直接拷贝原始密钥 for (i = 0; i < 16; i++) { ctx->round_key[i] = key[i]; } // 逐组扩展,共 40 组(160 字节) for (i = 4; i < 44; i++) { uint8_t temp[4]; // 取上一组 4 字节 temp[0] = ctx->round_key[(i - 1) * 4 + 0]; temp[1] = ctx->round_key[(i - 1) * 4 + 1]; temp[2] = ctx->round_key[(i - 1) * 4 + 2]; temp[3] = ctx->round_key[(i - 1) * 4 + 3]; if (i % 4 == 0) { // 循环左移一字节 uint8_t t = temp[0]; temp[0] = temp[1]; temp[1] = temp[2]; temp[2] = temp[3]; temp[3] = t; // S 盒替换 temp[0] = sbox[temp[0]]; temp[1] = sbox[temp[1]]; temp[2] = sbox[temp[2]]; temp[3] = sbox[temp[3]]; // 异或轮常量 temp[0] ^= rcon[i / 4]; } // 与 i-4 组异或得到新组 ctx->round_key[i * 4 + 0] = ctx->round_key[(i - 4) * 4 + 0] ^ temp[0]; ctx->round_key[i * 4 + 1] = ctx->round_key[(i - 4) * 4 + 1] ^ temp[1]; ctx->round_key[i * 4 + 2] = ctx->round_key[(i - 4) * 4 + 2] ^ temp[2]; ctx->round_key[i * 4 + 3] = ctx->round_key[(i - 4) * 4 + 3] ^ temp[3]; } // 保存 IV if (iv != NULL) { for (i = 0; i < 16; i++) { ctx->iv[i] = iv[i]; } } }这段代码里,i从 4 开始是因为前 4 组(16 字节)就是原始密钥。每 4 组做一次特殊处理:先循环左移,再查 S 盒,最后异或轮常量rcon。rcon数组的第 0 位是占位,实际从rcon[1]开始用。轮常量在 GF(2^8) 上按 x 的幂次生成,超过 0x80 后要异或 0x1B,这是 AES 规定的不可约多项式。
密钥扩展只做一次,之后每次加解密都复用round_key。如果你需要频繁切换密钥,可以把aes_init的开销算进去,但通常一个会话内密钥是固定的,所以这部分开销可以忽略。
3.2 ECB 模式:最简单也最容易踩坑
ECB 是最基础的模式,每个 16 字节块独立加密,相同明文块产生相同密文块。实现上就是循环调用单块加解密:
void aes_ecb_encrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len) { uint32_t i; for (i = 0; i < len; i += 16) { aes_encrypt_block(ctx, in + i, out + i); } } void aes_ecb_decrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len) { uint32_t i; for (i = 0; i < len; i += 16) { aes_decrypt_block(ctx, in + i, out + i); } }aes_encrypt_block和aes_decrypt_block是单块加解密,内部走 10 轮轮函数。ECB 的坑在于:如果明文有重复模式,密文也会重复,攻击者能看出数据规律。所以 ECB 一般只用于加密随机数或者密钥本身,不适合直接加密业务数据。但它在嵌入式里仍有价值,比如加密一段固定长度的配置,或者配合上层协议做完整性保护。
单块加密的轮函数顺序是:初始轮密钥加、9 轮完整轮变换、最后 1 轮省略列混合。解密则是逆过程。这里不展开每一轮的代码,重点看模式封装。
3.3 CBC 模式:链式异或与 IV 管理
CBC 在 ECB 基础上引入 IV 和链式反馈,每个明文块先和上一个密文块异或再加密。加密逻辑:
void aes_cbc_encrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len) { uint32_t i, j; uint8_t prev[16]; // 第一块用 IV 作为前一个密文块 for (j = 0; j < 16; j++) { prev[j] = ctx->iv[j]; } for (i = 0; i < len; i += 16) { uint8_t block[16]; // 明文与 prev 异或 for (j = 0; j < 16; j++) { block[j] = in[i + j] ^ prev[j]; } // 加密后输出 aes_encrypt_block(ctx, block, out + i); // 当前密文块成为下一块的 prev for (j = 0; j < 16; j++) { prev[j] = out[i + j]; } } }解密时顺序反过来,先解密再异或:
void aes_cbc_decrypt(aes_ctx_t *ctx, const uint8_t *in, uint8_t *out, uint32_t len) { uint32_t i, j; uint8_t prev[16]; for (j = 0; j < 16; j++) { prev[j] = ctx->iv[j]; } for (i = 0; i < len; i += 16) { uint8_t block[16]; // 先解密 aes_decrypt_block(ctx, in + i, block); // 再与 prev 异或得到明文 for (j = 0; j < 16; j++) { out[i + j] = block[j] ^ prev[j]; } // 当前密文块成为下一块的 prev for (j = 0; j < 16; j++) { prev[j] = in[i + j]; } } }注意解密时prev取的是输入密文,而不是输出明文。如果原地操作(in和out同一缓冲区),解密时in[i+j]会在写out[i+j]之前被覆盖,所以需要先把当前密文块存到临时变量。上面代码里prev在写输出之后才更新,但in和out相同时,out[i+j]的写入会破坏in[i+j],导致prev更新错误。稳妥做法是在循环开头先把in[i..i+15]拷到临时数组,再解密。这个细节在内存紧张的裸机上很容易被忽略,后面避坑章节会再提。
IV 的管理上,CBC 要求每次会话使用不可预测的 IV,且同一密钥下 IV 不能重复。嵌入式里常见做法是用硬件随机数或者计数器生成 IV,随密文一起发送。接收方用同样的 IV 解密。如果 IV 固定,CBC 的安全性会打折扣,这一点在协议设计时要注意。
3.4 PKCS7 补齐:让任意长度数据对齐到 16 字节
AES 是块加密,输入必须是 16 字节整数倍。PKCS7 补齐的规则很简单:缺 n 个字节就补 n 个值为 n 的字节。如果刚好对齐,也要补一整块 16 个 0x10。补齐代码:
uint32_t aes_pkcs7_pad(const uint8_t *in, uint32_t in_len, uint8_t *out, uint32_t out_size) { uint32_t pad_len = 16 - (in_len % 16); uint32_t total = in_len + pad_len; uint32_t i; if (total > out_size) { return 0; // 缓冲区不够 } for (i = 0; i < in_len; i++) { out[i] = in[i]; } for (i = in_len; i < total; i++) { out[i] = (uint8_t)pad_len; } return total; }去补齐时读取最后一个字节作为补齐长度,校验范围在 1 到 16 之间,然后截断:
uint32_t aes_pkcs7_unpad(const uint8_t *in, uint32_t in_len, uint8_t *out, uint32_t out_size) { uint8_t pad_len; uint32_t real_len; uint32_t i; if (in_len == 0 || in_len % 16 != 0) { return 0; } pad_len = in[in_len - 1]; if (pad_len < 1 || pad_len > 16) { return 0; // 补齐长度非法 } real_len = in_len - pad_len; if (real_len > out_size) { return 0; } for (i = 0; i < real_len; i++) { out[i] = in[i]; } return real_len; }pad_len的计算是16 - (in_len % 16),当in_len是 16 的倍数时,in_len % 16为 0,pad_len为 16,正好补一整块。去补齐时一定要校验pad_len的合法性,否则攻击者可以构造恶意密文让程序越界读取。这个校验在嵌入式里同样重要,别因为资源少就省掉。
4. 编译、移植与实测:从 PC 验证到单片机落地
4.1 PC 端自测:先用标准向量跑通
拿到代码别急着往板子上烧,先在 PC 上用标准测试向量验证一遍。NIST 提供了一组 AES128 的已知答案测试(KAT),密钥、明文、密文都是公开的。写个简单的 main 函数:
#include <stdio.h> #include <string.h> #include "aes.h" int main(void) { // NIST 标准测试向量 uint8_t key[16] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F }; uint8_t plain[16] = { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF }; uint8_t expect[16] = { 0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4, 0xC5, 0x5A }; uint8_t out[16]; aes_ctx_t ctx; aes_init(&ctx, key, NULL); aes_ecb_encrypt(&ctx, plain, out, 16); if (memcmp(out, expect, 16) == 0) { printf("ECB encrypt PASS\n"); } else { printf("ECB encrypt FAIL\n"); } return 0; }编译命令用 gcc 就行:
gcc -Wall -O2 -o test_aes aes.c aes_modes.c aes_padding.c test_aes.c ./test_aes如果输出 PASS,说明核心轮函数和密钥扩展没问题。再补一组 CBC 的测试向量,验证链式异或和 IV 处理。PC 端跑通之后再往单片机移植,能省掉大量在线调试时间。
4.2 移植到单片机:内存与对齐注意事项
移植到 Cortex-M 或者 51 时,有几个点要留意。第一是字节序,AES 标准按字节操作,不涉及大小端转换,但如果你从 Flash 读密钥时用了 32 位读取,要注意字节顺序。第二是内存对齐,有些单片机对非对齐访问会触发硬件异常,round_key和iv建议按 4 字节对齐声明:
typedef struct { uint8_t round_key[176] __attribute__((aligned(4))); uint8_t iv[16] __attribute__((aligned(4))); } aes_ctx_t;__attribute__((aligned(4)))是 GCC 的写法,IAR 和 Keil 有对应的关键字。第三是栈空间,单块加解密里用了几个 16 字节的临时数组,加上函数调用开销,栈占用大概几十字节,一般单片机都能承受。如果栈特别紧张,可以把临时数组改成静态或者放到上下文里复用。
Flash 占用方面,S 盒和逆 S 盒各 256 字节,加上代码逻辑,整体在 2KB 到 4KB 之间,具体取决于编译器优化等级。开-Os能进一步压缩。RAM 占用主要是aes_ctx_t的 192 字节,加上调用时的临时缓冲。
4.3 性能实测:不同主频下的加解密速度
在 72MHz 的 Cortex-M3 上,ECB 单块加密大概几十个微秒,CBC 因为多了异或和链式依赖,速度略慢但差距不大。如果主频降到 8MHz,单块加密会到几百微秒,对于低速串口通信来说完全够用。实测时可以用 GPIO 翻转加示波器测,或者在代码里插时间戳。注意别在中断里做整包加解密,尤其是 CBC 解密有链式依赖,会拉长中断响应时间。常见做法是把加解密放到主循环或者单独的任务里,中断只负责收数据。
5. 避坑与排查:那些年我们在 AES 上踩过的雷
5.1 现象:解密出来全是乱码,长度还对不上
原因通常是补齐和去补齐没配对。加密端补了 PKCS7,解密端却按原始长度截断,或者反过来。解决方法是确保加解密两端用同一套补齐规则,并且去补齐时严格校验最后一个字节。如果长度不是 16 的倍数,先检查是不是漏了补齐步骤。
5.2 现象:CBC 解密第一块正确,后面全错
这是 IV 或者链式反馈用错了。CBC 解密时,第一块用 IV 异或,后续块用前一个密文块异或。如果代码里把prev更新成了明文而不是密文,第二块开始就全错。检查prev的赋值来源,加密时取输出密文,解密时取输入密文。
5.3 现象:原地加解密时数据被覆盖
前面提过,CBC 解密原地操作时,in和out同一缓冲区,写输出会破坏还没读的输入。解决方法是先把当前密文块拷到临时数组,再解密和异或。ECB 原地操作相对安全,因为每块独立,但也要注意读写顺序。
5.4 现象:在 PC 上跑通,烧到板子上就挂
多半是内存对齐或者栈溢出。检查aes_ctx_t是否按平台要求对齐,临时数组是否超出栈限制。还有一种可能是编译器优化把某些变量优化掉了,加volatile或者降低优化等级试试。另外,单片机上的int可能是 16 位,代码里用uint32_t明确长度,避免隐式截断。
5.5 现象:相同明文加密结果每次不一样,以为代码有 bug
如果用了随机 IV,CBC 每次加密结果不同是正常的,这正是 CBC 的设计目的。ECB 则每次结果相同。如果你需要结果可复现,比如做测试,就固定 IV;如果用于实际通信,每次用随机 IV 更安全。别把正常行为当成 bug 去改。
6. 二次开发与改造:裁剪、加速与协议集成
拿到这份代码之后,二次开发的空间其实不小。最直接的是裁剪:如果你只用 ECB,把aes_modes.c里的 CBC 函数和aes_padding.c整个删掉,链接器不会报错,Flash 还能再省一点。如果只用 CBC,ECB 那两个函数也可以去掉。裁剪的时候注意头文件里的声明同步删掉,避免编译告警。
加速方面,如果单片机有硬件 AES 外设,可以把aes_encrypt_block和aes_decrypt_block替换成硬件调用,上层模式封装完全不用动。这是分层设计的好处:模式逻辑和轮运算解耦,换底层实现不影响接口。没有硬件加速的话,可以尝试把 S 盒查表改成 32 位 T 盒,一次查表处理 4 字节,速度能提升不少,代价是多占 4KB 左右的 Flash。T 盒的生成可以用脚本在 PC 上算好,直接以常量数组形式放进代码。
协议集成是另一个改造点。实际项目里,AES 通常不单独使用,而是配合 HMAC 或者 CRC 做完整性校验,防止密文被篡改。常见做法是先加密再算 MAC,接收方先验 MAC 再解密。如果资源实在紧张,至少加一个 CRC32,虽然不能防恶意篡改,但能发现传输错误。IV 的传递也要在协议里约定好,可以明文附在密文前面,接收方先读 IV 再解密。
还有一个容易忽略的点是密钥管理。代码里密钥是明文传入的,实际产品中密钥不能硬编码在 Flash 里,至少要做个简单的混淆或者存在受保护的区域。如果单片机有读保护功能,记得打开,防止密钥被直接读出来。
我自己的习惯是,每次改完加解密相关代码,不管改动多小,都强制跑一遍 PC 端的标准向量测试,再烧到板子上做一次回环验证。有一次偷懒只测了 ECB,结果 CBC 的 IV 初始化漏了一行,现场调试又花掉半天。从那以后,测试用例和代码一起进版本管理,改哪跑哪。希望这份整理能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取