简介:面向STM32F4嵌入式开发者的AES加密解密测试工程,聚焦数据安全场景下的加解密验证与串口通信调试。程序采用AES对称加密算法配合PKCS7填充与解填充机制,确保数据块长度满足128位要求并保证加解密前后数据完整;同时通过串口1(PB6、PB7引脚)接收不定长数据,演示异步通信下任意长度数据的处理方式,适合具备一定HAL库与C语言基础的开发者参考移植。资源包共775个文件,以368个c源文件、142个h头文件、70个s汇编文件及46个icf、44个d等工程配置与编译中间文件为主,另含hex烧录文件与map映射文件,压缩包约21.3MB,工程结构完整。已有258人学习下载。读者可从中获得AES加解密与PKCS7填充的完整实现代码、串口不定长接收的处理思路,以及可直接编译烧录的工程模板,便于快速验证与二次开发。
1. 串口收数 + AES 加解密:一份能直接跑在 STM32F4 上的测试工程
手上有个 STM32F4 的板子,串口不定长收数还没调利索,又要往上叠 AES 加解密,很多人第一反应是去翻标准库例程,结果发现例程只给你一个固定长度的明文数组,跑通了也不知道怎么接到真实业务里。这份测试程序解决的就是这个断层:它把「串口接收不定长数据」和「AES 加解密」两件事串成一条完整链路,收到的数据直接进加解密流程,输出可以回传串口验证。适合正在做通信加密、固件参数保护、上位机联调的嵌入式从业者,也适合刚上手 F4 系列、想把外设和算法连起来练一遍的新手。下面按「工程怎么搭、串口怎么收、AES 怎么接、坑在哪」的顺序拆开讲,参数和边界都会落到具体位置。
2. 工程骨架与串口不定长接收:从 IDLE 中断到环形缓冲
2.1 为什么不定长接收不能只靠 RXNE 中断
STM32F4 的 USART 在收到一个字节时置位 RXNE,进中断读 DR 就完事,这是最朴素的写法。问题在于「不定长」:你不知道这一帧到底几个字节,靠 RXNE 只能一个字节一个字节攒,攒到什么时候算一帧结束?常见做法有两种,一种是约定结束符(比如\r\n或 0x0A),另一种是用 IDLE 空闲中断判断总线静默。前者要求协议里必须有结束符,二进制数据里出现同值字节就误判;后者利用「一帧发完总线会空闲一个字节时间」的物理特性,天然适合变长帧。
我一般会选 IDLE 中断 + DMA 的组合:DMA 负责把数据从 DR 搬到缓冲区,CPU 不参与搬运;IDLE 中断在帧结束时触发一次,此时读 DMA 剩余计数就能算出这一帧长度。这套组合在 F4 上很成熟,缺点是 DMA 通道和串口要对应好,配错了就是收不到数据还查不出原因。
2.2 串口与 DMA 的初始化配置
以 USART1 + DMA2 Stream2 Channel4 为例,初始化顺序是 GPIO → USART → DMA → NVIC,顺序反了容易出现 DMA 使能了但串口还没准备好。关键参数如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| 波特率 | 115200 | 与上位机一致,联调先用这个 |
| 字长 | 8 位 | 二进制数据必须 8 位,7 位会截断 |
| 停止位 | 1 | 常规配置 |
| 校验 | 无 | 加密数据自带完整性校验,串口层不加 |
| DMA 模式 | 循环或普通 | 单帧测试用普通,连续流用循环 |
| DMA 缓冲长度 | 256 | 要大于最大帧长,留余量 |
初始化代码骨架(基于标准外设库风格,HAL 库思路一致):
/* 串口1 + DMA2 Stream2 初始化,接收方向 */ void uart1_dma_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; DMA_InitTypeDef dma; NVIC_InitTypeDef nvic; /* PA9 TX / PA10 RX,复用推挽 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_AF; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); usart.USART_BaudRate = 115200; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &usart); /* DMA2 Stream2 Channel4 对应 USART1_RX */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_DeInit(DMA2_Stream2); dma.DMA_Channel = DMA_Channel_4; dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma.DMA_Memory0BaseAddr = (uint32_t)rx_buf; /* 全局接收缓冲 */ dma.DMA_DIR = DMA_DIR_PeripheralToMemory; dma.DMA_BufferSize = RX_BUF_SIZE; /* 256 */ dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Normal; dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA2_Stream2, &dma); /* 开 IDLE 中断,DMA 使能 */ USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); DMA_Cmd(DMA2_Stream2, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_Cmd(USART1, ENABLE); nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); }这段代码里有两个容易忽略的点。一是DMA_Mode_Normal和DMA_Mode_Circular的选择:普通模式下 DMA 搬满 256 字节就停,IDLE 中断里必须重新配置计数才能继续收;循环模式自动回绕,但帧长计算要用「缓冲长度减剩余计数」再对缓冲长度取模,逻辑更绕。测试工程建议先用普通模式,逻辑直白,出错好定位。二是USART_DMACmd必须在USART_Cmd之前或同时,先开串口再开 DMA 请求,个别批次芯片会出现首个字节丢失。
2.3 IDLE 中断里算帧长并交给处理函数
IDLE 中断的处理核心是三步:清标志、算长度、重启 DMA。清 IDLE 标志有固定套路,必须先读 SR 再读 DR,否则标志清不掉,中断会反复进。
/* USART1 中断服务函数 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { /* 清 IDLE:先读 SR 再读 DR,顺序不能反 */ volatile uint32_t tmp; tmp = USART1->SR; tmp = USART1->DR; (void)tmp; /* 剩余计数 = 还没搬的字节数,帧长 = 总长 - 剩余 */ uint16_t remain = DMA_GetCurrDataCounter(DMA2_Stream2); uint16_t frame_len = RX_BUF_SIZE - remain; if (frame_len > 0 && frame_len <= RX_BUF_SIZE) { /* 交给业务层,注意此时 DMA 已停,缓冲可安全读 */ aes_frame_handler(rx_buf, frame_len); } /* 普通模式必须重启 DMA,否则下一帧收不到 */ DMA_Cmd(DMA2_Stream2, DISABLE); DMA_SetCurrDataCounter(DMA2_Stream2, RX_BUF_SIZE); DMA_Cmd(DMA2_Stream2, ENABLE); } }frame_len的计算依赖「DMA 从缓冲头部开始搬」这个前提,所以每次重启 DMA 都要把计数重置为RX_BUF_SIZE,让下一次搬运仍从rx_buf[0]开始。如果用了循环模式,这里就不能简单相减,得记录一个读指针做环形判断,测试工程没必要上这个复杂度。另外aes_frame_handler里做加解密会耗时,中断里不宜久留,稳妥做法是置一个标志、把长度存到全局变量,主循环里再处理;测试阶段数据量小,直接在中断里跑也能接受,但要知道这是权宜之计。
3. AES 加解密接入:模式选择、密钥装载与数据对齐
3.1 ECB、CBC、CTR 怎么选
AES 本身是分组密码,16 字节一组,工作模式决定多组之间怎么串。ECB 最简单,每组独立加密,相同明文出相同密文,适合做功能验证但不适合真实数据;CBC 需要初始向量 IV,前一组密文参与下一组运算,相同明文出不同密文,是嵌入式里最常见的选法;CTR 把分组密码变成流密码,不需要填充,适合变长数据。这份测试程序的重点是「验证加解密链路通不通」,ECB 最容易对照标准测试向量,CBC 更接近实际用法。
我的建议是:先用 ECB 跑通标准向量(比如 FIPS-197 里那组00112233445566778899aabbccddeeff用 128 位密钥加密得到69c4e0d86a7b0430d8cdb78070b4c55a),确认算法实现和字节序没问题,再切到 CBC 接业务。字节序是这里最大的玄学:AES 标准按字节数组处理,但很多实现内部按 32 位字操作,大小端一错,标准向量就对不上,查半天以为是算法错,其实是端序问题。
3.2 密钥与 IV 的装载方式
测试工程里密钥一般写死在代码里,方便对照,但真实产品密钥不能这么放。常见做法是从 Flash 的指定扇区读、或者由上位机下发后存 RAM,掉电即失。测试阶段用常量数组即可:
/* 128 位密钥与 IV,测试用,真实产品不要硬编码 */ static const uint8_t aes_key[16] = { 0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f }; static const uint8_t aes_iv[16] = { 0x10,0x11,0x12,0x13,0x14,0x15,0x16,0x17, 0x18,0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f }; /* 加解密入口:mode 0 加密 1 解密,len 必须是 16 的整数倍(ECB/CBC) */ int aes_frame_handler(const uint8_t *in, uint16_t len) { uint8_t out[RX_BUF_SIZE]; uint16_t i; if (len == 0 || (len % 16) != 0) { /* 非整组数据,ECB/CBC 无法直接处理,先回错误帧 */ return -1; } for (i = 0; i < len; i += 16) { /* 单组加解密,内部按 16 字节处理 */ aes_crypt_block(&in[i], &out[i], aes_key, aes_iv, 0); } /* 回传结果,供上位机比对 */ uart_send_bytes(out, len); return 0; }len % 16这个判断是硬约束:ECB 和 CBC 都要求整组,串口收到的帧如果不是 16 的整数倍,要么补填充(PKCS#7 最常见),要么改用 CTR。测试工程里我倾向直接返回错误,让上位机知道帧长不对,而不是偷偷补零——补零会让解密端分不清哪些是真实数据、哪些是填充,联调时非常折磨。aes_crypt_block是底层单组函数,密钥扩展可以每次调用都做,也可以初始化时做一次存起来,后者省时间但占 RAM,F4 的 RAM 够用,建议初始化时扩展一次。
3.3 用标准向量做第一轮验证
算法接进来第一件事不是接串口,是拿标准向量对一遍。把上面那组 FIPS-197 向量写进一个测试函数,上电跑一次,串口打印结果,和预期值逐字节比对。对不上就查三处:密钥扩展的轮常量、字节代换的 S 盒索引、列混合的矩阵方向。这三处是手写 AES 实现翻车率最高的地方,尤其列混合,正向和逆向的矩阵不一样,解密时用错就全乱。
/* 上电自检:标准向量比对,通过后再进主循环 */ void aes_selftest(void) { const uint8_t 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]; aes_crypt_block(plain, out, aes_key, aes_iv, 0); if (memcmp(out, expect, 16) != 0) { /* 自检失败,点灯或打印,别进业务 */ while (1) { led_toggle(); delay_ms(200); } } }自检通过再往下走,能省掉后面「串口数据对不上到底是收错了还是算错了」的扯皮。这一步看着笨,实际是最省时间的做法。
4. 避坑与排查:五个真实翻车点
4.1 现象:串口能收但帧长总是 0 或满缓冲
原因通常是 IDLE 标志没清干净,中断反复进,或者 DMA 计数读取时机不对。清 IDLE 必须「读 SR 再读 DR」,只读 SR 不清标志;另外DMA_GetCurrDataCounter要在 DMA 停之前读,DMA 停了计数可能被硬件复位。解决:把清标志的两行读操作写成volatile局部变量,防止编译器优化掉;读计数放在清标志之后、重启 DMA 之前。
4.2 现象:标准向量对不上,但代码看着没错
八成是字节序。AES 标准按字节数组定义,如果底层实现把 16 字节当成 4 个 32 位字处理,小端芯片上字内字节顺序会反。解决:在aes_crypt_block入口和出口各做一次字节序转换,或者干脆全程按字节操作,别用 32 位字优化。测试阶段优先保证正确性,性能后面再说。
4.3 现象:加密正常,解密出来尾部多几个字节
CBC 解密时 IV 用错,或者填充方式不一致。加密端如果做了 PKCS#7 填充,解密端必须按同样规则去填充,否则尾部会多出填充字节。解决:协议里明确写死填充规则,测试工程建议不做填充、要求整组,把填充逻辑留给业务层,避免算法层和协议层耦合。
4.4 现象:连续发几帧后只收到第一帧
DMA 普通模式下搬满缓冲就停,IDLE 中断里没重启 DMA,或者重启时没重置计数。解决:每次 IDLE 处理完都执行「关 DMA → 设计数 → 开 DMA」三步,顺序不能省。如果用了循环模式,则要检查读指针和写指针的追赶逻辑,别让新数据覆盖还没处理完的旧帧。
4.5 现象:中断里跑加解密,偶发死机或数据错乱
AES 运算耗时在几十微秒到几百微秒量级,中断里跑会拉长中断响应,和别的中断抢资源。解决:中断里只做「算长度、置标志、存长度」,加解密放主循环,用标志位驱动。测试工程数据量小可以偷懒,但要知道这是隐患,产品化必须改。
5. 进阶:把测试工程改成可复用的加密通信模块
测试跑通之后,下一步是把它变成能复用的模块,而不是每次新项目都从头抄。我的做法是抽三层:底层aes_crypt_block只管单组运算,不碰串口;中间层aes_stream_process管分组、填充、模式串联;上层comm_frame_parse管帧头帧尾、长度字段、校验。三层之间用固定接口,换芯片只改底层,换协议只改上层。
验证方法上,除了标准向量,我还会做一轮「回环自测」:串口收到的数据先加密再解密,和原始数据逐字节比对,连续跑一千帧看有没有偶发不一致。这个测试能抓出缓冲越界、计数错位这类间歇性 bug,比单帧测试靠谱得多。参数上注意两点:接收缓冲要大于「最大帧长 + 一组」,给填充和帧头留余量;DMA 缓冲和业务缓冲分开,别让 DMA 直接写业务正在读的内存。
还有一个具体技巧:在回传加密结果时,帧头带上原始长度和模式标识,上位机拿到就能自己判断该用哪种方式解密,联调时不用两边对协议对半天。这个字段只占两个字节,省下的沟通成本远超这点开销。
血泪经验是,AES 这类算法本身不难,难的是它和串口、DMA、中断搅在一起之后的时序问题。从那以后我每次接这类链路,都强制先跑标准向量自检、再跑回环压测,两步都过了才接真实业务,后悔药没处买。希望帮到你。
本文还有配套的精品资源,点击获取