1. 项目概述为什么需要读取STM32的“身份证”在嵌入式开发中尤其是涉及产品量产、软件授权、设备身份识别或防抄板等场景时我们常常需要一个独一无二、无法篡改的硬件标识。这个标识就像是芯片的“身份证”。对于STM32系列单片机而言这个“身份证”就是它的唯一设备标识符。今天我们就来深入聊聊如何在STM32F103这款经典且应用广泛的芯片上准确、可靠地读取这个ID。你可能会有疑问我直接用个EEPROM存个序列号不就行了为什么非要读芯片内部的ID这里面的区别和意义可大了。首先芯片内部的唯一ID是在生产过程中由ST公司激光刻录或固化在芯片内部的对于每一颗STM32都是全球唯一的无法被用户修改或擦除。这保证了标识的绝对唯一性和不可伪造性。其次它不占用任何用户存储空间Flash或RAM是芯片的“原生属性”。基于这个ID我们可以实现许多高级功能比如生成绑定此硬件的软件激活码防止程序被复制到其他板子上运行在物联网应用中作为设备的唯一MAC地址或设备标识符在量产测试中自动记录每块板子对应的芯片信息实现精准溯源。STM32F103的ID存储在一个特定的内存地址区域读取它本质上就是去访问这个只读的内存区域。听起来简单但实际操作中从地址的理解、数据的读取到格式的转换每一步都有需要注意的细节。接下来我将带你从原理到实践完整走一遍这个过程并分享我在实际项目中积累的实操经验和避坑指南。2. 核心原理与存储结构深度解析要正确读取ID首先得知道它放在哪里、长什么样。STM32F103的唯一ID是一个96位即12字节的数据。根据ST官方参考手册这个ID的存储地址是0x1FFFF7E8到0x1FFFF7F4不同STM32系列地址可能不同F103是这个。这96位数据并不是随意排列的它包含了芯片的多种信息。2.1 ID地址映射与数据含义我们通过指针直接访问这个地址区域。具体来说0x1FFFF7E8开始的4个字节32位通常与芯片的晶圆批次相关。0x1FFFF7EC开始的4个字节是芯片在晶圆上的X坐标。0x1FFFF7F0开始的4个字节是芯片在晶圆上的Y坐标。这X、Y坐标的组合保证了在同一个晶圆批次内每颗芯片的ID也是唯一的。当我们读取时通常将这12个字节作为一个整体即一个96位的唯一标识符。在程序中我们常将其转换为一个16字节的十六进制字符串因为每个字节需要两个十六进制字符表示或者一个24字节的ASCII字符串如果你用%02X格式输出。注意虽然手册给出了地址但强烈建议使用ST标准外设库或HAL库中定义的宏来访问而不是直接写死0x1FFFF7E8。例如在标准外设库中这个地址通常被定义为(uint32_t*)0x1FFFF7E8或通过UID_BASE这样的宏。这样做的好处是如果你的项目未来需要更换到另一款STM32如F4系列其ID地址不同只需更新库文件你的代码无需修改可移植性大大增强。2.2 读取操作的硬件与软件本质从硬件层面看这个ID存储在一块特殊的OTP一次性可编程存储器或类似的只读存储单元中。CPU通过内部总线通常是AHB或APB总线访问这个物理地址。这个过程是只读的任何写操作都是无效的这从硬件上保证了ID的安全性。从软件层面看我们的代码需要做以下几件事声明指针定义一个指向uint32_t或uint8_t类型的指针并将其指向ID的起始地址。由于内存访问最好以32位对齐通常使用uint32_t*指针一次读取4字节效率更高。解引用读取通过指针解引用获取该地址存储的数据。数据重组将读取到的多个32位数据按照我们需要的顺序通常是大端或小端组合成最终的96位ID。格式化输出将二进制数据转换为便于显示、存储或传输的格式如十六进制字符串。这里有一个关键点STM32的内存系统是小端模式。也就是说一个32位数据例如0x12345678存储在内存中低字节0x78在低地址。当我们用uint32_t*指针从0x1FFFF7E8读取时读到的就是0x?? ?? ?? ??这个完整的32位值顺序是符合我们直觉的。但如果我们用uint8_t*指针逐个字节读取就需要关注字节序问题。不过在将ID转换为字符串时我们通常按照地址递增的顺序依次处理每个字节这样得到的字符串顺序是固定的、可预期的。3. 三种实战代码实现与对比理解了原理我们来看代码。我将展示三种不同开发环境下的实现方法标准外设库、HAL库以及纯寄存器操作。你可以根据你的项目情况选择。3.1 基于标准外设库的实现如果你的项目使用的是经典的STM32标准外设库StdPeriph Lib实现如下。首先确保你的工程包含了stm32f10x.h和相关头文件。#include stm32f10x.h #include stdio.h // 用于sprintf /** * brief 读取STM32F103的唯一ID96位 * param uidArray: 用于存储ID的数组至少12字节 * retval None */ void Get_STM32F103_UID(uint8_t *uidArray) { uint32_t *uid_addr (uint32_t*)0x1FFFF7E8; // 使用宏更好如 (uint32_t*)UID_BASE // 读取3个32位数据共96位 uint32_t uid_part0 uid_addr[0]; // 地址: 0x1FFFF7E8 uint32_t uid_part1 uid_addr[1]; // 地址: 0x1FFFF7EC uint32_t uid_part2 uid_addr[2]; // 地址: 0x1FFFF7F0 // 将32位数据分解为字节存入数组 // 注意内存布局这里按小端顺序将低地址字节放在数组前端是常见做法 uidArray[0] (uid_part0 0) 0xFF; uidArray[1] (uid_part0 8) 0xFF; uidArray[2] (uid_part0 16) 0xFF; uidArray[3] (uid_part0 24) 0xFF; uidArray[4] (uid_part1 0) 0xFF; uidArray[5] (uid_part1 8) 0xFF; uidArray[6] (uid_part1 16) 0xFF; uidArray[7] (uid_part1 24) 0xFF; uidArray[8] (uid_part2 0) 0xFF; uidArray[9] (uid_part2 8) 0xFF; uidArray[10] (uid_part2 16) 0xFF; uidArray[11] (uid_part2 24) 0xFF; } /** * brief 将UID数组转换为十六进制字符串 * param uidArray: UID数组 * param strBuffer: 输出字符串缓冲区至少需要25字节12字节*2 结束符 * retval None */ void UID_To_HexString(uint8_t *uidArray, char *strBuffer) { for(int i 0; i 12; i) { sprintf(strBuffer[i*2], %02X, uidArray[i]); // 格式化为大写十六进制 } strBuffer[24] \0; // 添加字符串结束符 } // 使用示例 int main(void) { uint8_t uid[12]; char uidStr[25]; Get_STM32F103_UID(uid); UID_To_HexString(uid, uidStr); // 现在可以通过串口打印 uidStr或者用于其他逻辑 // printf(Chip UID: %s\r\n, uidStr); while(1) { // 主循环 } }实操心得在标准库中直接使用地址指针是最快的方式。但务必注意0x1FFFF7E8这个地址位于系统内存区域不是Flash也不是RAM你的调试器在正常运行时可能无法直接查看这个地址的内容。我建议将读取到的UID通过串口打印出来这是最可靠的验证方式。另外数组uid的字节顺序即哪个字节算作UID的最高位取决于你的应用协议上述代码将uid_part0的最低字节放到了uid[0]这是按内存地址递增的常见顺序与用uint8_t*指针逐个读取的结果一致。3.2 基于HAL库的实现对于使用STM32CubeMX和HAL库的新项目过程类似但我们可以利用HAL库可能已经定义好的宏尽管HAL库本身没有专门读取UID的函数。通常我们需要自己定义地址或者查看stm32f1xx_hal.h中是否有相关定义。#include main.h #include stm32f1xx_hal.h // 如果HAL库未定义我们自己定义UID基地址对于F103 #define UID_BASE_ADDRESS 0x1FFFF7E8U void Get_Chip_UID_HAL(uint8_t *buffer) { uint32_t *pUID (uint32_t *)UID_BASE_ADDRESS; // 一次性读取三个32位字 uint32_t word0 pUID[0]; uint32_t word1 pUID[1]; uint32_t word2 pUID[2]; // 使用联合体(union)或内存拷贝进行转换这里使用更直观的移位操作 buffer[0] (uint8_t)(word0 0); buffer[1] (uint8_t)(word0 8); buffer[2] (uint8_t)(word0 16); buffer[3] (uint8_t)(word0 24); buffer[4] (uint8_t)(word1 0); buffer[5] (uint8_t)(word1 8); buffer[6] (uint8_t)(word1 16); buffer[7] (uint8_t)(word1 24); buffer[8] (uint8_t)(word2 0); buffer[9] (uint8_t)(word2 8); buffer[10] (uint8_t)(word2 16); buffer[11] (uint8_t)(word2 24); } // 或者一个更简洁的版本直接按字节指针访问 void Get_Chip_UID_HAL_Simple(uint8_t *buffer) { uint8_t *pUID (uint8_t *)UID_BASE_ADDRESS; for(int i 0; i 12; i) { buffer[i] pUID[i]; } }注意事项Get_Chip_UID_HAL_Simple函数更加简洁它直接以字节为单位遍历内存。这两种方法在结果上是等效的。在HAL库项目中我更喜欢第二种方法因为它意图更清晰——我们就是要12个连续的字节。同时建议将UID_BASE_ADDRESS的定义放在一个独立的头文件如chip_id.h中方便不同源文件引用和管理。3.3 纯寄存器操作实现如果你在写裸机程序或者追求极致的效率和可控性可以直接操作寄存器。对于读取固定地址内存来说这和上面的指针操作没有本质区别但可以让你更清楚地看到发生了什么。// 假设在一个没有标准库的环境下 #define UID_START_ADDR 0x1FFFF7E8 typedef volatile uint32_t vu32; typedef volatile uint8_t vu8; void Read_Chip_ID_Register(uint8_t uid_out[12]) { // 方法1使用32位访问 vu32* uid_reg (vu32*)UID_START_ADDR; vu32 w0 uid_reg[0]; vu32 w1 uid_reg[1]; vu32 w2 uid_reg[2]; // 将字数据分解到字节数组 *((vu32*)(uid_out[0])) w0; // 注意这依赖于编译器的字节序不推荐。 // 更安全的方法仍是逐字节赋值 uid_out[0] (w0 0) 0xFF; // ... 省略后续赋值同前例 // 方法2直接使用8位指针访问推荐清晰无歧义 // vu8* uid_byte (vu8*)UID_START_ADDR; // for(int i0; i12; i) { // uid_out[i] uid_byte[i]; // } }踩坑记录在早期的一次项目中我曾试图用*((uint32_t*)(uid_out)) w0;这样的内存拷贝来快速赋值结果在不同优化等级的编译下出现了字节序错乱的问题。这是因为这种操作直接操作了内存而编译器的行为可能不一致。血的教训是在处理这种需要精确字节顺序的数据时显式地使用移位和掩码操作或者逐字节拷贝是最安全、可移植性最好的方法不要为了所谓的“优化”而引入不确定性。4. 高级应用与功能扩展仅仅读出ID还不够关键在于怎么用。下面分享几个我在实际产品中用到的扩展应用。4.1 生成设备唯一序列号96位的ID很长直接使用不方便。我们可以通过一个哈希函数如CRC32、MD5或简单的自定义算法将其压缩成一个固定长度的短序列号例如4字节或8字节。#include stdint.h // 一个简单的哈希函数示例FNV-1a变种用于从UID生成一个32位序列号 uint32_t Generate_Serial_From_UID(uint8_t *uid, uint8_t len) { uint32_t hash 2166136261u; // FNV偏移基础值 for(int i0; ilen; i) { hash ^ uid[i]; hash * 16777619u; } return hash; } // 使用示例 uint8_t my_uid[12]; Get_STM32F103_UID(my_uid); uint32_t device_sn Generate_Serial_From_UID(my_uid, 12); // device_sn 可以作为设备的短序列号这个device_sn可以烧录到Flash的某个固定位置作为设备出厂信息的一部分比直接存储96位ID更节省空间。4.2 实现简易软件授权与防复制利用UID可以实现简单的软件绑定。思路是在程序初始化时读取UID然后与一个预先设定的“密钥”或算法进行计算得到一个结果。将这个结果与存储在Flash中的另一个值激活码进行比较只有匹配时软件才进入正常工作模式。#define ACTIVATION_CODE_ADDR 0x0800FC00 // Flash中存储激活码的地址示例 uint8_t Check_Software_License(void) { uint8_t uid[12]; uint32_t calculated_key; uint32_t stored_key; // 1. 读取芯片UID Get_Chip_UID_HAL_Simple(uid); // 2. 使用一个算法和主密钥计算本机的“激活码” calculated_key Your_License_Algorithm(uid, MASTER_SECRET_KEY); // 3. 从Flash指定地址读取之前存储的激活码 stored_key *(uint32_t*)ACTIVATION_CODE_ADDR; // 4. 比较 if(calculated_key stored_key) { return 1; // 授权成功 } else { return 0; // 授权失败可进入演示模式或限制功能 } }这里的Your_License_Algorithm是你自己设计的算法可以结合UID和一个只有你知道的MASTER_SECRET_KEY这个密钥不能出现在客户端代码中否则会被反编译找到进行计算。在量产时你通过一个离线工具根据每颗芯片的UID计算出对应的激活码然后通过烧录器写入到Flash的ACTIVATION_CODE_ADDR地址。这样程序就被绑定到了这块具体的硬件上。重要安全提示这种方法只能防君子不防小人对于有经验的破解者通过反汇编找到算法和比较点仍然可以绕过。但它能有效防止普通的程序复制粘贴显著提高了抄袭的门槛。对于高安全需求建议使用带有硬件加密功能的STM32系列如STM32L5。4.3 在物联网中作为设备标识在MQTT、CoAP等物联网协议中设备需要一个Client ID或Endpoint Name。直接使用UID的十六进制字符串就是一个绝佳的选择保证了全球唯一性。void Get_Device_ClientID(char *clientIdBuffer) { uint8_t uid[12]; char uidStr[25]; Get_STM32F103_UID(uid); UID_To_HexString(uid, uidStr); // 可以添加前缀如 DEV_ sprintf(clientIdBuffer, DEV_%s, uidStr); } // 得到的clientIdBuffer类似DEV_1234567890ABCDEF12345678将这个Client ID用于连接阿里云、AWS IoT、腾讯云等物联网平台可以免去你手动分配和管理设备标识的麻烦。平台后台可以直接用这个ID来区分设备。5. 调试技巧与常见问题排查实录即使原理和代码都清楚了第一次实操时还是可能遇到各种问题。下面是我总结的常见“坑点”和解决方法。5.1 问题一读出来的ID全是0xFF或0x00现象通过调试器查看内存地址0x1FFFF7E8或者串口打印出来的UID全部是0xFFFFFFFF或0x00000000。排查思路地址错误首先确认芯片型号是STM32F103并且你使用的地址是正确的。不同STM32系列的UID地址不同F1是0x1FFFF7E8F4是0x1FFF7A10一定要核对《参考手册》。调试器访问限制在调试模式下某些调试器特别是通过SWD接口可能无法直接访问系统内存区域。最可靠的验证方法不是看调试器的Memory窗口而是通过你的代码将数据打印到串口。在代码中读取UID后通过printf发送到串口用串口助手查看。代码优化检查编译器优化等级。如果读取UID的代码被编译器认为是“无副作用”的无效代码可能会被优化掉。确保存储UID的变量是volatile的或者将读取UID的函数返回值用于实际操作如打印、计算避免被优化。芯片问题极少数情况下可能是芯片本身的问题。换一颗同型号芯片测试一下。5.2 问题二每次上电读出的ID不一致现象程序运行中多次读取UID或者复位后重新读取得到的值不一样。排查思路指针或数组越界这是最常见的原因。检查你的数组是否足够大至少12字节检查指针操作是否越界破坏了相邻内存的数据。栈或堆溢出如果读取UID的函数使用了局部数组并且栈空间不足或者程序其他部分有内存溢出可能会覆盖这块内存。确保栈空间设置合理。缓存问题STM32F103没有数据缓存所以不是缓存一致性问题。但如果你是在有Cache的高端芯片如F7、H7上遇到此问题则需要考虑Cache的影响在访问这类特定内存地址前清空或禁用Cache。软件逻辑错误仔细检查你的读取和转换代码特别是字节序处理部分。用一个已知的模拟UID例如在内存中定义一个常量数组测试你的转换和打印函数确保逻辑正确。5.3 问题三UID转换的字符串格式不对现象读出的字节数据看起来是随机的但转换成字符串后与别人读出的格式不同比如字节顺序反了。解决方案明确你的需求。你需要的是大端序还是小端序的字符串通常按照内存地址从低到高依次排列字节得到的字符串是一种最自然、最常用的格式。编写一个测试函数用一组固定的字节数组如{0x12, 0x34, 0x56, 0x78, ...}输入给你的UID_To_HexString函数看输出是否符合你的预期。记录下你确定的转换规则并在整个项目包括上位机软件中保持一致。5.4 调试与验证最佳实践串口打印是王道无论你用什么调试器将UID以十六进制字符串形式通过串口打印出来是最直观、最不容易出错的方法。用串口助手或逻辑分析仪捕获这个字符串。编写单元测试为你的UID读取和转换函数编写简单的测试代码在开发初期就验证其正确性。对比官方工具如果条件允许使用ST官方的编程软件如ST-LINK Utility连接芯片它通常有一个“芯片信息”或“Unique ID”的查看界面将你读出的ID与工具显示的进行对比这是终极验证。注意供电稳定性在极端不稳定的电源环境下读取系统内存区域可能会出错。确保你的板子在读取ID时供电充足、稳定。读取STM32的芯片ID是一个小而精的必备技能它连接着硬件身份与软件逻辑。掌握它不仅能让你轻松完成设备标识、软件授权等任务更能加深你对单片机内存布局和底层访问的理解。希望这篇详尽的拆解和实战记录能让你在下次需要用到这个功能时信手拈来游刃有余。在实际项目中我习惯将UID读取函数封装成一个独立的模块并提供获取字符串、哈希值等不同格式的接口这样在任何需要的地方都能方便地调用让代码更加整洁和模块化。