简介:这份资源面向嵌入式开发初学者与需要落地数据安全方案的工程师,提供一套在STM32F4平台上验证AES加解密流程的完整测试工程。程序采用AES对称加密配合PKCS7填充与解填充,保证任意长度数据在加密前与解密后的完整性,并通过串口1(PB6、PB7)接收不定长数据,模拟外部设备异步通信下的真实加密场景,可用于评估加密方案的正确性与稳定性。压缩包共775个文件、约21.3MB,以368个c源文件与142个h头文件为主体,另含70个s汇编文件、46个icf链接脚本及工程配置与调试文件,覆盖HAL库加密外设、定时器、I2C等模块,目录结构完整,便于直接编译与二次开发。目前已有258人学习下载。读者可据此掌握AES在Cortex-M4上的调用方式、PKCS7填充边界处理与串口不定长收包思路,并作为产品数据加密功能的参考实现。
1. 从一块板子上的加密黑匣子说起
手上有一块 STM32F4 的板子,串口助手连上去,发一串明文进去,回一串看不懂的十六进制,再发回来又能还原成原文——这就是这个 AES 加密解密测试程序最直观的样子。它不做什么花哨的界面,也不依赖上位机软件,核心就两件事:把 AES 算法在 STM32F4 上跑通,以及把串口接收不定长数据这套机制做扎实。前者是安全能力的底座,后者是实际项目里绕不开的通信基本功。适合谁?正在做物联网终端、需要给本地数据或通信报文加一层对称加密的开发者,以及想找一个能在裸机环境里直接编译、直接验证的 AES 参考实现的人。整个工程是标准 C 语言加 STM32 标准外设库或 HAL 库的写法,编译工具链用 Keil 或 STM32CubeIDE 都能接。
2. 工程结构与技术选型:为什么是软件 AES 而不是硬件加密
2.1 目录骨架与文件职责
拿到源码包,先别急着点编译,把目录翻一遍心里才有底。这类 STM32F4 的测试工程,常见结构是围绕一个主工程文件展开,外设驱动、算法实现、应用逻辑分层放置。典型布局如下:
AES_Test_STM32F4/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── aes.h // AES 算法对外接口 │ │ └── usart.h // 串口驱动接口 │ └── Src/ │ ├── main.c // 初始化 + 主循环 │ ├── aes.c // AES 加解密实现 │ ├── usart.c // 串口收发与中断处理 │ └── stm32f4xx_it.c // 中断服务函数 ├── Drivers/ │ └── STM32F4xx_HAL_Driver/ ├── MDK-ARM/ // Keil 工程目录 └── README.mdaes.c和aes.h是算法核心,和硬件无关,理论上可以整段搬到别的 MCU 上;usart.c负责把串口收到的字节流攒成完整帧;main.c只做初始化和调度。这种分层的好处是,你换芯片时只动驱动层,算法层几乎不用碰。我一般会先确认aes.h里暴露了哪些函数,再看main.c怎么调用,最后才去啃aes.c的轮函数实现,顺序反了容易陷进细节里出不来。
2.2 软件 AES 与硬件加密外设的取舍
STM32F4 的部分型号带 CRYP 硬件加密外设,理论上比软件实现快得多。但这个测试程序走的是软件 AES 路线,原因很实际:软件实现不挑型号,F407、F429、F411 都能跑,移植成本低;代码全在手里,调试时能单步跟进每一轮的状态矩阵,出问题好定位。硬件外设虽然快,但配置寄存器、处理 DMA 搬运、对齐要求这些细节,对只想验证算法正确性的场景来说是额外负担。
软件 AES 的代价是速度和 Flash 占用。AES-128 一次加密在 168MHz 的 F4 上大概几十微秒量级,对于串口这种波特率级别的数据流完全够用;如果是要加密大块 Flash 数据或者跑高速通信,那就得考虑硬件外设或者查表优化了。选型上没有绝对对错,看你的数据量和实时性要求。这个工程的价值在于给你一个能跑、能改、能对照标准测试向量验证的基线。
2.3 编译环境与工程配置要点
工程一般同时提供 Keil 的.uvprojx和 CubeIDE 的.cproject,两边都能打开。用 Keil 的话,注意在 Options for Target 里确认芯片型号选对(比如 STM32F407VG),否则下载会报错;用 CubeIDE 的话,导入时选 "Existing Projects into Workspace",让它自己识别编译配置。时钟配置通常在SystemClock_Config()里,F4 跑 168MHz 需要正确设置 PLL 的 M、N、P 分频,这部分如果是从别的工程抄来的,务必核对晶振频率——8MHz 和 25MHz 晶振的配置完全不同,配错了串口波特率会整体偏移,收到的全是乱码。这是新手最容易翻车的地方之一。
3. 核心模块拆解:AES 算法与串口不定长接收怎么落地
3.1 AES 加解密的接口设计与调用方式
先看算法层对外长什么样。一个干净的 AES 接口通常长这样:
// aes.h #ifndef __AES_H #define __AES_H #include <stdint.h> #define AES_BLOCK_SIZE 16 // AES 分组固定 16 字节 #define AES_KEY_SIZE_128 16 // AES-128 密钥长度 // 单分组加密:输入 16 字节明文,输出 16 字节密文 void AES128_Encrypt(const uint8_t *key, const uint8_t *plain, uint8_t *cipher); // 单分组解密:输入 16 字节密文,输出 16 字节明文 void AES128_Decrypt(const uint8_t *key, const uint8_t *cipher, uint8_t *plain); #endif接口只暴露单分组操作,密钥、输入、输出都是指针,长度固定 16 字节。这种设计的好处是职责单一,分组模式(ECB、CBC)留给上层去拼。参数说明:key指向 16 字节密钥,plain和cipher分别指向输入输出缓冲区,调用方要保证缓冲区至少 16 字节且不重叠。注意这里没有做长度校验,因为 AES 分组长度是编译期常量,传错长度是调用方的责任——在资源紧张的 MCU 上,省掉运行时检查是常见做法,但你自己封装业务层时最好加一层断言。
3.2 轮函数与状态矩阵的实现细节
AES-128 的核心是 10 轮变换,每轮包含字节代换(SubBytes)、行移位(ShiftRows)、列混合(MixColumns)和轮密钥加(AddRoundKey),最后一轮省掉列混合。软件实现里,S 盒通常做成 256 字节的常量表放在 Flash 里:
static const uint8_t sbox[256] = { 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, // ... 完整 256 项省略 }; static void SubBytes(uint8_t state[16]) { for (int i = 0; i < 16; i++) { state[i] = sbox[state[i]]; // 查表代换,非线性核心 } } static void ShiftRows(uint8_t state[16]) { uint8_t tmp; // 第 1 行左移 1 字节 tmp = state[1]; state[1] = state[5]; state[5] = state[9]; state[9] = state[13]; state[13] = tmp; // 第 2 行左移 2 字节、第 3 行左移 3 字节同理 // ... }state数组按列优先存储 4x4 状态矩阵,SubBytes逐字节查 S 盒完成非线性变换,ShiftRows对每一行做循环左移。列混合涉及 GF(2^8) 上的乘法和异或,实现时常用 xtime 函数配合移位处理,避免直接建乘法表占空间。轮密钥扩展(KeyExpansion)把 16 字节原始密钥扩展成 11 组轮密钥,共 176 字节,存在 RAM 里供每轮使用。这些函数都是static的,不对外暴露,外部只看到AES128_Encrypt和AES128_Decrypt两个入口,封装边界清晰。
3.3 串口接收不定长数据的空闲中断方案
串口收不定长数据,最稳的方案是「接收中断 + 空闲中断」组合。思路是:每收到一个字节进一次接收中断,把数据塞进缓冲区并重置计数器;当一帧发完、总线空闲一个字节时间后触发空闲中断,此时缓冲区里的就是完整一帧。核心代码大致如下:
#define RX_BUF_SIZE 256 static uint8_t rx_buf[RX_BUF_SIZE]; static uint16_t rx_len = 0; static uint8_t frame_ready = 0; // 接收中断:逐字节存入 void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART2); if (rx_len < RX_BUF_SIZE) { rx_buf[rx_len++] = ch; } USART_ClearITPendingBit(USART2, USART_IT_RXNE); } // 空闲中断:一帧结束 if (USART_GetITStatus(USART2, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART2); // 读 DR 清 IDLE 标志 frame_ready = 1; // 通知主循环处理 } }参数说明:RX_BUF_SIZE决定单帧最大长度,超过就截断,实际项目里要按协议最大帧长来定;rx_len是当前累计字节数,空闲中断触发后主循环读取并清零。这里有个细节:清 IDLE 标志必须先读一次 SR 再读 DR,顺序错了标志清不掉,会反复进中断——这是 STM32 串口的经典坑。主循环里检测到frame_ready后,把rx_buf前rx_len字节交给 AES 处理,处理完再复位rx_len和frame_ready,形成闭环。
3.4 加解密与串口的数据流串联
把两块拼起来,主循环的逻辑就很直白了:收到一帧数据,判断是加密指令还是解密指令,调用对应函数,把结果从串口发回去。测试时通常约定一个简单协议,比如首字节 0x01 表示加密、0x02 表示解密,后面跟 16 字节数据。不足 16 字节的用 PKCS#7 填充补齐,解密后再去掉填充。这样在串口助手里发01加 16 字节明文,就能收到 16 字节密文,发02加密文又能还原,肉眼就能验证算法正确性。整个链路没有文件系统、没有网络,纯粹是算法加通信的最小闭环,特别适合拿来当二次开发的起点。
4. 跑起来与验证:从编译到串口回环的完整步骤
4.1 编译下载与串口参数确认
第一步,用 Keil 或 CubeIDE 打开工程,直接 Build,正常情况下零错误零警告(有警告也先看是不是未使用变量这类无害的)。编译通过后连接调试器下载,ST-Link 或 J-Link 都行。下载完复位,板子开始跑。第二步,串口助手连上对应的 USART(看代码里初始化的是 USART1 还是 USART2),波特率按usart.c里的配置来,常见是 115200、8 数据位、1 停止位、无校验。参数对不上就是满屏乱码,别怀疑算法,先怀疑波特率和晶振配置。
4.2 用标准测试向量验证算法正确性
在信任这个实现之前,先用 NIST 的标准测试向量对一遍。AES-128 的经典向量:密钥000102030405060708090a0b0c0d0e0f,明文00112233445566778899aabbccddeeff,期望密文69c4e0d86a7b0430d8cdb78070b4c55a。可以在main.c里临时加一段自测代码,上电时跑一次比对:
const uint8_t test_key[16] = {0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}; const uint8_t test_plain[16] = {0x00,0x11,0x22,0x33,0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; const uint8_t expect[16] = {0x69,0xc4,0xe0,0xd8,0x6a,0x7b,0x04,0x30, 0xd8,0xcd,0xb7,0x80,0x70,0xb4,0xc5,0x5a}; uint8_t out[16]; AES128_Encrypt(test_key, test_plain, out); // 逐字节比对 out 与 expect,不一致就点亮 LED 或打印错误这段自测跑通,说明算法层没问题,后面串口出问题就只往通信层找,排查范围直接砍一半。我习惯把这种自测保留在工程里,用宏开关控制,改算法时随时能回归。
4.3 串口回环测试与结果判读
算法验证完,再测串口链路。串口助手里发一帧加密指令,观察返回。如果返回长度对但内容不对,先确认填充规则和字节序;如果返回长度都不对,回去看空闲中断有没有正确触发、rx_len有没有被及时清零。一个实用技巧是先把 AES 调用注释掉,让程序原样回显收到的数据,确认串口收发本身没问题,再把加密逻辑加回来。分层验证比一上来就端到端调要快得多,这是踩过坑之后养成的习惯。
5. 避坑与排查:五个真实会卡住你的问题
5.1 串口收到乱码
现象:串口助手满屏乱码,或者偶尔对几个字符。原因:波特率不匹配,根源多半是系统时钟配置和晶振频率对不上,比如板子是 8MHz 晶振却用了 25MHz 的 PLL 参数。解决:核对SystemClock_Config()里的 HSE 频率宏,用示波器或调试器读时钟寄存器确认实际主频,再反推波特率分频值。
5.2 空闲中断反复触发
现象:程序卡在中断里出不来,或者一帧数据被拆成好几段处理。原因:IDLE 标志清除顺序不对,只读了 DR 没读 SR,或者反过来。解决:严格按「先读 SR 再读 DR」的顺序清标志,并在中断里加一个标志位判断,避免重复处理同一帧。
5.3 解密结果尾部多出乱码字节
现象:加密再解密后,原文后面跟着几个奇怪的字节。原因:PKCS#7 填充没去掉,或者填充值本身被当成数据。解决:解密后读最后一个字节作为填充长度 n,校验 n 在 1 到 16 之间,然后截掉末尾 n 字节。填充校验不做,遇到篡改数据会越界。
5.4 大数据量时栈溢出或 HardFault
现象:处理较长帧时程序跑飞,进 HardFault。原因:把大缓冲区定义成了局部变量,F4 默认栈空间有限,几百字节的数组就压爆了。解决:大缓冲区一律用static或全局定义,放静态区;同时检查startup文件里的栈大小配置,按需调大。
5.5 换芯片后编译过但运行异常
现象:代码在 F407 上好好的,换到 F411 上跑不起来。原因:外设基地址、中断向量号、时钟树配置不同,直接搬工程没改启动文件和器件宏。解决:换型号时同步改 Keil 的 Device 选项、启动文件(startup_stm32f4xx.s对应型号)和全局宏定义,别只改一行代码就以为完事。
6. 二次开发:把测试程序改成能用的加密通道
这个工程本质是个验证 Demo,要变成产品里的加密通道,得补几块东西。第一块是分组模式,单分组 ECB 模式相同明文出相同密文,安全性弱,实际用 CBC 或 CTR 更合适。CBC 需要一个 16 字节初始向量 IV,每次加密随机生成并随密文一起传输,解密方用同一个 IV 还原。改造时在aes.c外面包一层:
// CBC 加密:iv 传入 16 字节初始向量,len 必须是 16 的倍数 void AES128_CBC_Encrypt(const uint8_t *key, const uint8_t *iv, const uint8_t *in, uint8_t *out, uint32_t len) { uint8_t prev[16]; memcpy(prev, iv, 16); for (uint32_t i = 0; i < len; i += 16) { for (int j = 0; j < 16; j++) { out[i + j] = in[i + j] ^ prev[j]; // 先与前一密文块异或 } AES128_Encrypt(key, &out[i], &out[i]); memcpy(prev, &out[i], 16); // 更新链值 } }参数说明:iv每次会话随机生成,绝不能固定;len必须是 16 的整数倍,不足的先填充。第二块是密钥管理,硬编码在 Flash 里的密钥等于没有密钥,实际项目里密钥要么从安全存储区读,要么走一次密钥协商流程下发,至少要做个简单的混淆存储。第三块是帧协议,把指令、长度、密文、校验码组织成固定格式,接收端按长度字段解析,比现在这种「靠空闲中断切帧」更健壮。
验证改造效果时,我一般会做两件事:一是用 PC 端的 OpenSSL 或 Python 的cryptography库对同一组密钥和 IV 加密,和板子输出逐字节比对,确保跨平台一致;二是构造边界数据,比如全零明文、全 FF 明文、刚好 16 字节和 17 字节的输入,专门测填充和分块逻辑。这两步走完,基本能排除大部分实现层面的问题。
改造过程中最容易忽略的是字节序和内存对齐。STM32 是小端,PC 端处理时如果按大端解析就会对不上;DMA 搬运时缓冲区地址没对齐也可能出问题。从那以后我每次做跨平台加密联调,都强制先用一组固定向量在两端各跑一遍、打印十六进制逐字节比对,确认底层一致了再往上叠业务逻辑。希望这个思路帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取