STM32WL33x AES-GCM实战:硬件加密密文与Tag生成全流程
2026/8/31 22:34:10 网站建设 项目流程

从 STM32WL33x 上跑 AES-GCM 开始,你大概率遇到过这种怪事:HAL_CRYP_Encrypt 调用返回 HAL_OK,密文也出来了,但用 Python 或 OpenSSL 一解,全是乱码;Tag 就更不用说了,怎么比对都对不上。这篇文章就把 STM32WL33x 的 AES 外设在 GCM 模式下产生有效 ciphertext 和 16 字节 tag 的完整流程讲透,重点说清楚寄存器阶段切换、字节序配置、AAD 处理这几个最容易踩坑的环节。无论你现在是用 CubeMX 生成的 HAL 代码,还是打算直接操作寄存器,照着这条路走都能调通。

这套方法适合谁?正好卡在“硬件加密结果和软件参考实现不一致”的嵌入式开发者,也适合第一次在 STM32 无线 SoC 上做认证加密的朋友。看完你不仅能修好自己的代码,还能掌握一套通用的验证思路:芯片输出的密文和 Tag,必须能和 OpenSSL、Python cryptography 这类标准实现逐字节对上,对不上就先查字节序,再查阶段机,问题基本迎刃而解。

1. 先弄清楚 GCM 到底在算什么东西

1.1 Ciphertext、Tag、AAD 三者的关系

AES-GCM 是典型的 AEAD 算法,也就是“加密同时认证”。它内部混合了两种机制:一部分是 AES-CTR 模式的流加密,负责把明文变成密文;另一部分是 GHASH 这个多项式哈希,负责生成认证标签 Tag。Tag 的作用是让接收方确认密文没有被篡改,同时确认密钥和 nonce 正确。

这里有个初学者特别容易搞混的点:AAD(Additional Authenticated Data,附加认证数据)和 Tag 的关系。AAD 是那些“不需要加密但必须防篡改”的字段,比如协议头、地址、帧计数。AAD 不参与加密,所以它不会出现在密文里,但它的每一个字节都会参与 GHASH 运算,最终影响 Tag。所以你在单片机上算 Tag,必须把 AAD 原封不动地送进 AES 外设,漏一个字节、错一个字节,Tag 就不对。

GHASH 的细节不展开,你只需要记住一个直观比喻:Tag 相当于给“AAD + 密文”算出来的一串校验指纹,但指纹不是简单的 CRC,而是基于 AES 加密后的一个密钥流派生出来的。这个密钥流里面的第一个块,算法称之为 J0。GCM 的 nonce(也就是 IV)经过特定处理后得到 J0,J0 一方面用来加密 Tag,另一方面充当计数器模式的起点。

1.2 STM32WL33x AES 外设的 GCM 阶段机

STM32WL33x 集成的 AES 外设和 STM32L4/WL 系列属于同一套设计思路,它不像软件库那样一步到位给你算完,而是把 GCM 分成了四个阶段,由 AES_CR 寄存器的 GCMPH 字段控制:

  • 00 初始化阶段:写密钥和 IV(J0),硬件生成 GHASH 子密钥 H。
  • 01 AAD 阶段:把 AAD 数据一块一块喂进去,硬件累加 GHASH 状态。
  • 10 数据阶段:写入明文读出密文(加密时),或写入密文读出明文(解密时),同时继续更新 GHASH 状态。
  • 11 最终阶段:硬件把所有计算结果汇合,输出最终的 16 字节 Tag。

为什么硬件要搞出这么繁琐的阶段机?因为 GCM 本来就是一个流式算法。数据可以分块送来,GHASH 的累加状态可以一点一点推进,硬件没必要等你把所有数据都备齐才开始。AAD 和数据可以交替处理,阶段机保证了状态切换的确定性。你手动操作时,最常犯的错就是没有按顺序切换 GCMPH,或者写完一段数据后没等 CCF 标志位就切到下一阶段,导致硬件算出来的中间状态是错的。

HAL 库的好处是你不需要手动切 GCMPH,HAL_CRYP_Encrypt 内部已经按顺序把四个阶段全跑完了。但这也带来一个副作用:如果库里某个参数设置错了,你根本不知道错在哪个阶段。所以我强烈建议,遇到问题时按阶段机拆解排查,后面第 4 节的寄存器参考流程会专门讲这个。

2. 用 HAL 库写出第一个可用的 AES-GCM 加密例程

2.1 CubeMX 里怎么配置 AES 外设

在 STM32CubeMX 中启用 AES,我建议按下面这套参数来:

  • Algorithm 选 GCM(或者 AES-GCM,看固件包命名)。
  • KeySize 选 128 位;如果协议要求 256 位也可以,但要保证对端是一致的。
  • DataType 选 CRYP_BYTE_SWAP,这是 HAL 库里最常用、也最容易和软件参考实现对上的字节序配置。
  • OperatingMode 在初始化时选 Encrypt,解密场景后面再切。

如果你打算用 LL 库,要注意 STM32WL33x 的 LL 驱动对 GCM 的支持可能不完整,很多底层寄存器开关没有专门封装。我的建议是:GCM 这种需要状态机配合的模式,优先用 HAL,毕竟 HAL 库把阶段切换、标志位等待都包好了,你只需要填好上下文。

2.2 代码实现:密钥、IV、AAD、明文全流程

下面是一份可以直接抄的 HAL 加密示例。密钥、IV、AAD 我都给成了 volatile 数组,方便你在调试器里实时观察。

#include "stm32wl33x_hal.h" /* 以下测试数据来自 2023 年 NIST 风格建议向量,方便和 Python 对拍 */ static const uint8_t key[16] = { 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C }; static const uint8_t iv[12] = { 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 }; static const uint8_t aad[] = {'H', 'e', 'a', 'd', 'e', 'r'}; static const uint8_t plaintext[] = "Hello, STM32WL33x GCM!"; static uint8_t ciphertext[64]; static uint8_t tag[16]; static CRYP_HandleTypeDef hcryp; int aes_gcm_encrypt_example(void) { __HAL_RCC_AES_CLK_ENABLE(); hcryp.Instance = AES; hcryp.Init.DataType = CRYP_BYTE_SWAP; hcryp.Init.KeySize = CRYP_KEYSIZE_128B; hcryp.Init.OperatingMode = CRYP_ALGOMODE_ENCRYPT; hcryp.Init.Algorithm = CRYP_AES_GCM; hcryp.Init.KeyWriting = CRYP_KEY_WRITE_ENABLE; HAL_CRYP_Init(&hcryp); hcryp.Context.Init = hcryp.Init; hcryp.Context.Header = (uint8_t *)aad; hcryp.Context.HeaderSize = sizeof(aad); hcryp.Context.IV = (uint8_t *)iv; hcryp.Context.IVSize = sizeof(iv); hcryp.Context.Key = (uint8_t *)key; if (HAL_CRYP_Encrypt(&hcryp, (uint8_t *)plaintext, strlen((const char *)plaintext), ciphertext, 1000) != HAL_OK) { return -1; } memcpy(tag, hcryp.Context.Tag, 16); return 0; }

几个关键点解释一下。

  • Context.Header是 AAD 指针,HeaderSize是 AAD 字节数。AAD 可以为 NULL、长度 0,但是只要协议里用了 AAD,这里就必须传。
  • IVSize必须填 12,也就是 96 位。STM32 的 AES 硬件只原生支持 96 位 nonce,GCM 标准里其他长度的 nonce 需要额外做一次 GHASH 运算才能得到 J0,硬件不帮你做。所以协议设计时老老实实定 12 字节 IV。
  • HAL_CRYP_Encrypt的第二个参数是输入数据指针,GCM 模式下输入可以是任意长度,不需要凑 16 字节。
  • 加密完成后,Tag 存放在hcryp.Context.Tag数组里,直接 memcpy 出来即可。

2.3 拿到结果后的第一件事:和标准实现对拍

加密跑通不等于正确。你需要在 PC 端用标准库算一份参考输出,然后和芯片输出逐字节对比。我习惯用 Python 的 cryptography 库,命令少,结果直观。

from cryptography.hazmat.primitives.ciphers.aead import AESGCM key = bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c') nonce = bytes.fromhex('cafebabe0001020304050607') aad = b'Header' plaintext = b'Hello, STM32WL33x GCM!' ct = AESGCM(key).encrypt(nonce, plaintext, aad) print("ciphertext:", ct[:-16].hex()) print("tag :", ct[-16:].hex())

注意AESGCM.encrypt返回的是“密文 + 16 字节 Tag”拼接串,所以取前len(ct)-16是密文,后 16 字节是 Tag。对着打印结果检查芯片输出,完全一致就说明 HAL 配置正确。

我实际测试中,第一次跑不对的概率非常高。这时候不用慌,80% 是字节序问题,下面专门展开说。

3. 最常见的问题:为什么结果不对

3.1 端序问题——这一条解决 80% 的“密文不对”

STM32 的 AES 外设,数据寄存器是 32 位的,但算法定义中的测试向量都是按字节流给出的。硬件内部怎么把字节流排成 32 位字,由 AES_CR 里的 DATATYPE 字段决定。

HAL 库里常见的配置是CRYP_BYTE_SWAP,它内部对应的是将每个 32 位字内的 4 个字节做反转写入寄存器。举个例子,如果字节流前 4 个字节是2B 7E 15 16,那么你往 DINR 寄存器写入的 32 位数值应该是0x16157E2B,相当于把前四个字节调了个头。

很多朋友第一次写的时候拿const uint32_t*直接强转指针,或者按大端方式手动拼接,结果自然和标准库对不上。解决方式有两种:

  • 方案一:坚持用CRYP_BYTE_SWAP,然后保证所有进入 AES 外设的字节流(密钥、IV、AAD、明文)都按“每 4 字节一组,组内字节反转”的方式写入。HAL 库内部其实帮你处理了大部分,但你要确保从协议层拿到的缓冲区,和 PC 端标准库用的是同一种字节解释。
  • 方案二:自定义一个pack32函数,不管什么 DATATYPE 都用最清晰的方式拼接,然后统一设置 DATATYPE。

我推荐你用一个简单的打包函数来消除歧义:

static uint32_t pack32(const uint8_t *buf) { return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) | ((uint32_t)buf[2] << 8) | ((uint32_t)buf[3]); }

这个函数对应的是“寄存器高字节先收到 buf[0]”的规则。配合CRYP_BYTE_SWAP使用时,如果你发现密文还是不对,就换一种配置试试:把DataType改成CRYP_DATA_ORDER(有的库里叫CRYP_LITTLE_ENDIAN),打包函数也相应替换。与其纸上谈兵猜 DATATYPE 的四种排列,不如直接用一组短向量做二分定位。

3.2 IV 不是 12 字节导致的坑

GCM 标准支持任意长度的 IV,但 STM32 硬件 AES 只支持 96 位 IV。如果你在 HAL 里把Context.IVSize填成 16、8 或其他值,轻则返回错误,重则硬件行为不确定。

我见过有人把 16 字节的随机数直接塞进Context.IV,然后 HAL 返回 HAL_OK,但密文怎么都不对。打开参考手册才发现,硬件在初始化阶段只读 IVR0~IVR2 这 3 个寄存器作为 nonce,IVR3 必须是0x00000002,也就是 J0 的最低 32 位。你给了 16 字节 IV,硬件只取了前 12 字节,后 4 字节被固定值为 2,双方语义完全错位。

正确做法是协议设计阶段就把 nonce 固定为 12 字节。如果上游协议传下来的 IV 超过 12 字节,可以取前 12 字节,但这样做会降低安全性,最好和协议方确认。

3.3 Tag 应该在哪个阶段读

HAL 库帮我们省了阶段切换,但很多人不知道 Tag 是从哪里读出来的。在寄存器级流程中,Tag 是在最后一个阶段(GCMPH=11)计算出来的,你需要等 CCF 标志位置 1,然后连续读 4 次 DOUTR,凑成 16 字节。

HAL 库里则简单得多:HAL_CRYP_Encrypt返回后,Tag 已经放在hcryp.Context.Tag里。但有一点要提醒:有些早期版本的 HAL 库,需要你在调用HAL_CRYP_Encrypt之前先执行HAL_CRYP_Encrypt的某种前置初始化,否则Context.Tag可能是空的。我建议升级到和 STM32WL33x 配套的最新 Cube 固件包,这种坑基本被修掉了。

3.4 数据长度不是 16 倍数怎么办

AES 对单个分组的处理永远按 128 位来,但 GCM 的 CTR 模式本身是流式加密,不需要像 CBC 那样做 PKCS#7 填充。最后不足 16 字节的尾块,只取加密密钥流的前几个字节做异或。

所以你在 HAL 里传任意长度的明文,理论上都合理。但 STM32 AES 外设的块接口要求你写数据时按块写满,不足 16 字节的部分要处理成“部分块阶段”。HAL 库内部已经处理了这些,不需要你操心。如果你是寄存器操作,就需要在最后一个块读 DOUTR 时,只取需要的字节数,不能把整个 16 字节都当成有效密文。

3.5 解密时 Tag 怎么校验

解密比加密多一个步骤:你不仅要把密文恢复成明文,还要校验 Tag。HAL 里,解密模式通常也是用HAL_CRYP_Decrypt,它内部会同样计算一遍 Tag,但要注意这个函数在很多版本里是“重新计算 Tag”,而不是“自动比对并返回成功/失败”。也就是说,它把计算结果存在Context.Tag里,你需要自己把收到的 Tag 和计算结果做常量时间比较。

常量时间比较很重要,不能用memcmp,因为常规 memcmp 在发现第一个不同字节时就会提前返回,这个时间差异足以让攻击者逐字节猜出 Tag。可以用一个简单函数:

static int tag_equal(const uint8_t *a, const uint8_t *b, size_t len) { uint8_t diff = 0; for (size_t i = 0; i < len; i++) { diff |= a[i] ^ b[i]; } return diff == 0; }

3.6 常见问题速查表

现象最可能原因排查方向
密文和 Tag 全不对DATATYPE 字节序设置和预期不符用短向量换 DataType 配置,配合 pack32 函数重试
密文对,Tag 不对AAD 没传或传错检查 Context.Header 和 HeaderSize 是否与协议一致
密文对,Tag 对,但解密失败IV 两边不一致确认发送端和接收端使用相同 nonce,且 nonce 未复用
HAL 返回超时时钟没开启,或 CCF 标志没清检查 __HAL_RCC_AES_CLK_ENABLE,检查中断优先级
Tag 是空的HAL 版本问题升级固件包,或改用寄存器流程读 Tag
解密输出乱码明文长度或分组统计错误确认解密时输入长度和加密时一致

这张表覆盖了我在现场遇到过的九成问题。剩下的一成,基本是时钟配置、DMA 配置或者内存对齐问题,比较好处理。

4. 从 HAL 到寄存器:理解硬件才能真正避免坑

4.1 GCM 的四个 GCMPH 状态

HAL 把 GCM 流程封装成了一个黑盒,黑盒能跑通当然好,可一旦遇到“参考向量对不上”这种问题,你手里没有能逐阶段观察的工具,就只能干瞪眼。所以我建议每个做 STM32 AES 的人都至少看一遍寄存器级流程。

GCMPH 字段在 AES_CR 寄存器里,两个 bit 决定当前处于哪一个阶段:

GCMPH阶段你要做的事
00初始化阶段写入 KEYR0~3 和 IVR0~2,IVR3 固定写 0x00000002
01AAD 阶段按 16 字节块写入 AAD,直到所有 AAD 处理完
10数据阶段写入明文/密文块,读出密文/明文块
11最终阶段等待 CCF,读 DOUTR 得到 Tag

每个阶段完成后,硬件都会置起 CCF 标志位,软件需要清掉这个标志才能进入下一阶段。有的朋友在数据阶段写一个块等一个 CCF,清了标志继续写下一块,这是对的;但在最后一轮,很多人忘了等最终阶段的 CCF,直接去读 DOUTR,读回来的自然不是 Tag。

4.2 一个极简寄存器级参考流程

下面这个函数只描述流程,不处理边界细节,目的就是让你看清 GCMPH 是怎么切换的。

void aes_gcm_encrypt_reg(const uint8_t *key, const uint8_t *iv, const uint8_t *aad, uint32_t aad_len, const uint8_t *input, uint8_t *output, uint32_t len, uint8_t *tag) { /* 1. 关闭 AES,配置 GCM 加密,128 位密钥 */ AES->CR = 0; AES->CR = AES_CR_MODE_ENCRYPT | AES_CR_CHMOD_GCM; /* 注意:具体 CHMOD 数值、DATATYPE 按参考手册填写 */ /* 2. 初始化阶段:GCMPH = 00 */ AES->CR &= ~AES_CR_GCMPH; /* 写密钥 KEYR0~3 */ AES->KEYR0 = pack32(&key[0]); AES->KEYR1 = pack32(&key[4]); AES->KEYR2 = pack32(&key[8]); AES->KEYR3 = pack32(&key[12]); /* 写 IV:IVR0~2 为 nonce,IVR3 固定为 2 */ AES->IVR0 = pack32(&iv[0]); AES->IVR1 = pack32(&iv[4]); AES->IVR2 = pack32(&iv[8]); AES->IVR3 = 0x00000002; AES->CR |= AES_CR_EN; while ((AES->SR & AES_SR_CCF) == 0) {} AES->SR = AES_SR_CCF; /* 3. AAD 阶段:GCMPH = 01 */ AES->CR = (AES->CR & ~AES_CR_GCMPH) | (0x1 << AES_CR_GCMPH_Pos); for (uint32_t i = 0; i < aad_len; i += 16) { uint8_t block[16] = {0}; memcpy(block, &aad[i], (aad_len - i) > 16 ? 16 : (aad_len - i)); AES->DINR = pack32(&block[0]); AES->DINR = pack32(&block[4]); AES->DINR = pack32(&block[8]); AES->DINR = pack32(&block[12]); while ((AES->SR & AES_SR_CCF) == 0) {} AES->SR = AES_SR_CCF; } /* 4. 数据阶段:GCMPH = 10 */ AES->CR = (AES->CR & ~AES_CR_GCMPH) | (0x2 << AES_CR_GCMPH_Pos); for (uint32_t i = 0; i < len; i += 16) { uint8_t block[16] = {0}; memcpy(block, &input[i], (len - i) > 16 ? 16 : (len - i)); AES->DINR = pack32(&block[0]); AES->DINR = pack32(&block[4]); AES->DINR = pack32(&block[8]); AES->DINR = pack32(&block[12]); while ((AES->SR & AES_SR_CCF) == 0) {} /* 读取 DOUTR 得到密文块,此处应连续读 4 次 */ AES->SR = AES_SR_CCF; } /* 5. 最终阶段:GCMPH = 11,读 Tag */ AES->CR = (AES->CR & ~AES_CR_GCMPH) | (0x3 << AES_CR_GCMPH_Pos); while ((AES->SR & AES_SR_CCF) == 0) {} /* 连续读 4 次 DOUTR,组装成 16 字节 Tag */ AES->SR = AES_SR_CCF; /* 关闭 AES */ AES->CR = 0; }

这个代码我故意省略了很多细节,比如pack32到底按什么字节序、DOUTR 读取后怎么拆成字节数组。因为你一旦开始手动操作寄存器,真正要参考的是 STM32WL33x 参考手册中“AES GCM example”那一节,里面给的时序图比任何人在博客里写的都准确。

4.3 硬件加速下别忘了安全边界

硬件 AES 外设把速度提上去了,但密码学上的安全纪律不能丢。三个最容易忽略的点:

第一,nonce 绝对不要复用。GCM 和所有 CTR 类模式一样,一旦相同的密钥下出现相同的 nonce,攻击者可以把两段密文异或,直接恢复出明文 XOR 明文,密钥形同虚设。STM32WL33x 作为无线 SoC,如果它给射频数据帧做加密,nonce 至少要包含一个发送端独有的帧计数器。

第二,Tag 必须是 16 字节,不要因为协议里的“可选校验”就随便截断成 4 字节或 8 字节。截断 Tag 会显著降低伪造难度,除非你有专门的带宽约束论证,否则别做这个优化。

第三,密钥管理。STM32WL33x 可能有 OTP 或 RDP 等级保护的密钥存储,不要把 AES 密钥裸写在.rodata里然后声称设备安全。密钥一旦从 Flash 泄露,GCM 算法再安全也是白搭。

回到最开始的问题:怎么产生有效的 ciphertext 和 Tag?我的答案很简单,先让芯片输出和标准库对拍一致,再回头审视每一个可能引入差异的环节。字节序不对就换配置,AAD 不对就查上下文,IV 长度不对就改协议,Tag 读不出来就查阶段标志。整条链路里,硬件是可靠的,你的配置才是最容易出问题的地方。按照这个方法走一遍,你很快就能让 STM32WL33x 流出和云端服务器完全一致的密文和 Tag。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询