量产发货后的第一个深夜,群里突然开始刷屏:一批设备连不上MQTT服务器,报认证失败,而且失败的批次很集中。我当时第一反应是服务器配置出问题了,结果查了一圈EMQX日志,发现是设备上报的ClientID在同一批里几乎一样,只有后几位不同。再往下查,原因让人哭笑不得——固件里把芯片UID当成普通字符串,用strlen去截长度,结果有的芯片UID里带有0x00字节,字符串在中间就断了。那一刻我意识到,很多人(包括以前的我)对UID的理解还停留在“一串ID”的层面,根本没把它当成MCU出厂时烙下的一枚电子指纹。
这篇文章就想把这事聊透:UID到底是什么、为什么不能当字符串处理、怎么用它做防抄板,以及怎么把UID变成MQTT一机一密的种子。适合做物联网设备、嵌入式产品量产、或者正在折腾私有云MQTT接入的工程师参考。
1. UID到底是什么:芯片出厂刻好的“电子指纹”
1.1 一次不成功的“字符串匹配”,让我重新翻手册
先说上面那个翻车案例。当时固件里的逻辑很简单:读取STM32的UID,转成十六进制字符串,然后用这个字符串做ClientID的一部分。听起来没问题对吧?但问题出在读取UID之后,代码直接把它当成char*处理,想省一步格式化。
实际上芯片UID在内存里是一段原始二进制数据,不是ASCII字符串。它里面可能出现的0x00、0xFF、不可打印字符,随便哪个都能让strlen、strcmp这类函数当场失灵。一个批次里可能有几十台设备的UID中间带0x00,于是这些设备的ClientID在服务端看来是同一个,认证自然全挂。
所以第一课很简单:UID是字节序列,不是字符串。任何想把它当字符串用的行为,都是在赌自己运气好。
1.2 主流MCU的UID位置与读取方式
不同芯片厂对UID的称呼不太一样,有的叫Unique ID,有的叫Device Serial Number,有的叫Lot Number,但本质都一样:芯片生产时写入的一段唯一标识。关键是它的存放位置和读取方式,不同芯片差异很大。
| 芯片系列 | 读取方式 | 长度 |
|---|---|---|
| STM32F1 | 0x1FFFF7E8 起,连续3个32位寄存器 | 96位 |
| STM32F4/H7 | 0x1FFF7A10 起,连续3个32位寄存器 | 96位 |
| GD32F1 | 0x1FFFF7E8(兼容ST布局) | 96位 |
| ESP32/ESP32-S3 | esp_efuse_mac_get_default() 读MAC,或读eFuse区域 | 48位MAC / 额外eFuse位 |
| NXP LPC | IAP命令读取128位UUID | 128位 |
| 国产小众MCU | 参考各自Reference Manual,常放在信息区 | 64~128位不等 |
需要提醒的是,芯片型号不一样,UID地址和长度都不一样,哪怕同一个厂商不同系列也不能想当然。比如STM32F1和STM32F4的UID地址都不同,移植代码时如果直接抄寄存器地址,读出来的可能是芯片别的信息,甚至是全0xFF。所以拿到一个新平台,第一件事是去最新版Reference Manual里搜“Unique device identifier”,确认地址和位宽。
1.3 为什么直接当字符串用会出问题
前面提到0x00会截断字符串,这还算好理解的。还有几个容易踩的坑:
- 大小端不一致。STM32读取UID的三个32位字,F1/F4系列的字节序在不同参考手册版本里描述有差异,尤其是跨系列移植时,如果把三个Word顺序拼错,不同批次甚至同批次设备之间的“唯一性”都会受影响。
- 格式化结果不唯一。同样的UID字节,用
%02X和%02x转出来大小写不同。如果一端用来派发生成凭证、另一端用来校验,大小写不一致就会导致认证失败。 - 不可见字符。直接把二进制字节塞进MQTT的ClientID或Topic里,可能在传输、日志、数据库存储中产生各种看不见的字符,轻则日志乱码,重则认证失败。
防呆做法是封装一个统一的读取+格式化函数,所有上层代码只认“固定长度的小写十六进制字符串”,屏蔽底层的字节顺序和二进制细节。
// 以STM32为例,统一定义:输出24位小写hex字符串 void get_device_uid_hex(char *out, int out_len) { uint32_t uid[3]; uid[0] = *(volatile uint32_t *)0x1FFFF7E8; uid[1] = *(volatile uint32_t *)0x1FFFF7EC; uid[2] = *(volatile uint32_t *)0x1FFFF7F0; // 固定字节序:先高后低,逐字节输出 for (int i = 0; i < 3; i++) { uint8_t *p = (uint8_t *)&uid[i]; for (int j = 0; j < 4; j++) { snprintf(out + i * 8 + j * 2, out_len - (i * 8 + j * 2), "%02x", p[3 - j]); } } }这个封装函数,后面所有防抄板、一机一密的逻辑都基于它,不直接碰底层寄存器。这个习惯能省掉后面一大半Debug时间。
2. 防抄板:硬件“指纹”不是用来防神仙,是用来提高抄板成本
2.1 防抄板的第一性原理
很多人觉得防抄板就是把固件藏好,用JTAG封锁、Flash读保护就能高枕无忧。但现实是,脱机烧录器、芯片解密服务这些东西在行业里早就不是秘密。固件一旦被完整提取,复制出来的板子只要元器件一致,跑起来一模一样,这就是最直接的“抄板”。
防抄板的核心逻辑不是让固件永远不被读出来,而是让固件和某块具体硬件绑定,换一块板子就无法正常工作。芯片UID恰好是那个最天然的“硬件身份证”。固件在启动时检查当前运行的芯片UID是不是“自己人”,如果不是,就拒绝执行关键逻辑。
听起来简单,但实现里有几个细节决定它到底能不能扛住破解。
2.2 可落地的UID校验方案:校验、绑定、失败策略
一个能用的方案至少包含三步。
第一步,读取UID并计算校验值。不要把UID明文直接存在Flash里和读取值做memcmp,因为抄板者用二进制对比工具很容易找到这个比对点,然后patch掉跳转指令。常见做法是对UID做哈希或加密变换,把变换后的“指纹摘要”存放在内部Flash或OTP区域。校验时重新对当前UID做同样的变换,再和存好的摘要比对。
第二步,绑定关键逻辑。校验通过才初始化业务代码,不通过就进错误处理。如果你是做认证设备的,可以不让Wi-Fi/4G模组正常启动;如果你是做控制器的,可以让PWM输出被钳制在安全值。绑定点要散落在程序不同位置,不要只做一个大检查点。
第三步,设计失败策略。这里特别重要。发现UID不匹配时,直接while(1)死循环是最笨的做法,因为抄板者一抓一个准,反汇编里搜死循环简直不要太简单。更好的做法是进入“功能降级模式”,比如:
- 正常运行,但每隔一段时间随机复位;
- 正常运行,但核心算法精度下降;
- 前面几天正常,某次特定计数后开始报错。
这样做的好处是,抄板者很难判断是硬件问题还是软件问题,也很难定位到校验逻辑。
下面是一个简化的代码骨架:
int check_hardware_signature(void) { char uid_hex[25]; uint8_t digest[32]; uint8_t stored[32]; get_device_uid_hex(uid_hex, sizeof(uid_hex)); // 对UID做HMAC或HASH,用内部存储的密钥 mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), (uint8_t *)uid_hex, strlen(uid_hex), internal_secret, sizeof(internal_secret), digest); // 读取内部Flash/OTP里的授权摘要 read_stored_signature(stored, sizeof(stored)); if (memcmp(digest, stored, 32) != 0) { return ERR_BAD_HARDWARE; // 进入功能降级分支 } return 0; }2.3 让防抄更可靠的三层加固
只做UID软件校验,对懂底层的老手来说还是有机会被patch掉的。真正想让抄板成本高到不划算,建议叠加三层:
- 开启MCU读保护(RDP)。STM32可以设置RDP Level 1甚至Level 2。Level 2一旦开启,调试口彻底关闭,同时禁止从SRAM/系统存储器Boot,等于封死了绝大多数常规读取手段。代价是芯片也“锁死”了,以后没法再调试,所以固件必须充分验证后再烧Level 2。
- UID + Flash区域绑定。把UID的摘要和CRC、Flash校验值一起算进某段关键配置的校验链,让改任何一个字节都会导致配置失效。
- 外部加密芯片配合。如果产品利润空间允许,可以加一颗独立的安全芯片(ATSHA204A、SE05X、SMEC98SP这类),内部有真正的安全密钥存储和硬件摘要运算,固件端的校验密钥不需要存在MCU Flash里。这样可以做到即使固件被完整读出来,抄板者也拿不到有效密钥,动手成本直线上升。
需要说明的是,所有这些手段都是“提高抄板成本”,不是“绝对无法破解”。做产品保护时心里要有数,别把防抄板方案当成永固金汤,否则一旦被绕过去,挫败感会非常大。
3. 一机一密:给每台设备发单独的MQTT“身份证”
3.1 公共密码的问题:一台泄露全网裸奔
如果你的MQTT接入方案是所有设备共用同一个ClientID、同一个用户名密码,那固件被逆向或者设备被拆解读出凭证后,攻击者可以拿这套凭证去连接服务器,冒充任意设备,甚至批量控制整个产品线。
一机一密的思路是:每台设备使用独立的MQTT三元组(ClientID、Username、Password),一个凭证泄露,最多影响这一台设备,不会波及其他设备。对服务器侧来说,也可以在发现异常后单独吊销这一台的凭证。
这里就轮到UID出场了——它是设备出厂就有的唯一标识,天然适合作为生成一机一密三元组的种子。
3.2 两种可行的“一机一密”生成路线
| 对比项 | 离线派生(设备本地生成) | 云端预分配(服务器批量下发) |
|---|---|---|
| 实现位置 | 固件内计算 | 生产工具/云端接口 |
| 是否需要联网 | 不需要 | 首次配网时需要 |
| 安全性 | 依赖固件内的密钥 | 密钥只存在云端,泄露面小 |
| 适合场景 | 私有MQTT、小批量产品 | 公有物联网平台、大批量产品 |
离线派生适合自己搭EMQX这类私有MQTT服务的场景。它的好处是部署简单,设备只要拿UID算出三元组就行,不需要额外和生产系统交互。但要注意,派生用的密钥(Secret)存在固件里,一旦固件被逆向,攻击者可以自己写一个程序批量生成任意UID的凭证。因此Secret要尽量放在安全的存储区域,能配合前面提到的加密芯片最好。
云端预分配则是把派生动作放到服务器上。生产时,服务器为每台设备的UID生成三元组,写进设备Flash或者生产数据库。设备出厂后再从本地Flash读取。好处是攻击者即使完全逆向固件,也拿不到派生密钥,每个设备的凭证都是独立生成的,服务器吊销、审计都很方便。
3.3 用UID派生的具体规则设计
我常用的派生规则长这样:
ClientID: "dev_" + UID_HEX Username: UID_HEX 或 "prod_" + UID_HEX Password: HMAC_SHA256(DeviceSecret, UID_HEX).substring(0, 32)这里有个关键点:一机一密的Password一定不要直接用UID本身,而是要用UID参与HMAC运算后的结果。如果直接用UID当密码,抄板者只要读到UID就能登录,一机一密就名存实亡了。HMAC的密钥DeviceSecret放在设备安全区,或者由云端预分配。
在EMQX一侧,你可以用HTTP Auth插件配置一个鉴权回调,服务器收到连接请求后把username、password、clientid转发给你的后端接口,后端按同样规则重新计算一遍,验证通过就返回200,否则返回403。
3.4 平台注意事项:ClientID长度、字符集、cleanSession
用UID做ClientID时,有几个平台相关的坑必须提前确认:
- ClientID长度限制。老版本EMQX默认只允许23字节,后来版本放开到256字节并可配置。如果你用的是128位UID,转成hex就是32个字符,再用前缀扩展一下,很容易超过老版限制。要么换新版本并调大
max_clientid_length,要么对UID做哈希后截断使用。 - 字符集。MQTT协议规范里ClientID建议使用服务端允许的字符集,但实践中最好只用
[0-9a-zA-Z_-],避免冒号、斜杠、空格之类在日志和数据库里搞事。 - cleanSession标志。如果设备频繁重连且希望离线消息不丢,需要根据业务设置
clean_session为false,但要注意同时开启持久会话后,服务端会为每个ClientID保存会话状态,一机一密场景下大量设备会导致服务器内存上升,要提前规划好QoS和会话策略。
4. 完整示例:STM32与ESP32双平台跑通一机一密与防抄板
4.1 STM32:读取UID、字符串化与密钥派生
在STM32上,我习惯把前面的get_device_uid_hex()和HMAC派生封装成独立模块,方便不同项目复用。下面是一段基于mbedtls的派生示例:
#include "mbedtls/md.h" static const uint8_t device_secret[16] = { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 }; void derive_mqtt_credentials(char *client_id, int cid_len, char *username, int user_len, char *password, int pwd_len) { char uid_hex[25]; uint8_t digest[32]; get_device_uid_hex(uid_hex, sizeof(uid_hex)); snprintf(client_id, cid_len, "dev_%s", uid_hex); snprintf(username, user_len, "prod_%s", uid_hex); mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), device_secret, sizeof(device_secret), (uint8_t *)uid_hex, strlen(uid_hex), digest); for (int i = 0; i < 16; i++) { snprintf(password + i * 2, pwd_len - i * 2, "%02x", digest[i]); } }这段代码把派生逻辑全部收进一个函数,上层MQTT连接模块只需要调用它获取三元组,不需要关心UID是几位、底层的HMAC长什么样。
4.2 ESP32:基于MAC地址的设备指纹与MQTT连接
ESP32没有像STM32那样直接暴露的UID寄存器,但它有出厂烧录的eFuse MAC地址,48位,全球唯一,完全可以当设备指纹用。读取很简单:
#include "esp_efuse.h" #include "esp_mac.h" uint8_t mac[6]; esp_efuse_mac_get_default(mac); char uid_hex[13]; snprintf(uid_hex, sizeof(uid_hex), "%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);拿到UID后,可以用ESP-IDF自带mbedtls做同样的HMAC派生,然后配置esp_mqtt_client:
esp_mqtt_client_config_t mqtt_cfg = { .broker.address.uri = "mqtts://your-broker.example.com:8883", .credentials.client_id = client_id, .credentials.username = username, .credentials.authentication.password = password, }; esp_mqtt_client_handle_t client = esp_mqtt_client_init(&mqtt_cfg); esp_mqtt_client_start(client);注意我用的是mqtts://,也就是TLS加密连接。一机一密解决的是“凭证泄露范围控制”,而TLS解决的是“传输过程不被窃听”。两个是叠加关系,不是二选一。不带TLS的情况下,用户名密码在局域网里明文裸奔,抓包就能拿到,那前面所有设计都白做了。
4.3 EMQX侧配置HTTP认证
EMQX 5.x可以在Dashboard里配置“认证”里的HTTP认证。简单来说,你设置一个后端接口地址,EMQX在设备连接时把username、password、clientid作为POST参数发过去。后端按相同派生规则重新计算,返回200表示通过,403表示拒绝。
后端接口的伪代码逻辑:
# FastAPI/Flask 示例逻辑 def check_mqtt_auth(username, password, clientid): uid_hex = username.replace("prod_", "") expect_pwd = derive_password(uid_hex) # 同样的HMAC-SHA256算法 if password != expect_pwd: return 403 # 可选:检查clientid前缀、UID是否在允许列表 return 2004.4 验证一机一密是否生效
配置完了别急着收工,一定要做交叉验证:
- 用设备A的UID生成三元组,然后把这个三元组填到设备B的配置里,尝试连接,确保服务端返回认证失败;
- 用设备B的原始凭证连接,确认成功;
- 在服务器上把设备A的凭证标记禁用,确认设备A重连时被拒,设备B不受影响;
- 抓包确认传输过程不是明文,TLS握手正常。
这套流程全部跑通,一机一密才算真正落地。
5. 这套玩法里的坑,我帮你踩完了
5.1 字节序:数组拼接顺序翻车
前面提过,STM32读取UID的三个32位字,不同设备上直接打印寄存器值,看起来可能是0x12345678 0x9ABCDEF0 ...,但要注意芯片内部对这三个字的排列和手册图示是否一致。我见过有同事按Word[0]最高字节、Word[2]最低字节的顺序拼字符串,结果同一颗芯片用两种方式读出来完全不同的“UID”,还找了半天硬件问题。
解决方法是:在封装函数里固定一个字节序,并且用几颗已知UID的芯片做“黄金样本”回归测试,确保固件升级后生成结果不变。如果产品跨了几种MCU型号,还要在文档里明确记录这个字节序约定。
5.2 大小写、长度和ClientID限制:老EMQX卡了23字节
把96位UID转成hex是24个字符,加上dev_前缀就28个了。如果用的是老版本EMQX且没改配置,设备连接时会直接被踢掉。这个坑非常经典,我甚至见过有人为此把UID截断成16字节再转hex,导致不同设备ClientID碰撞,后连的设备把先连的设备踢下线。
建议方案:
- 升级EMQX到新版本,并在
emqx.conf里把max_clientid_length调到128或更大; - 或者不用完整UID,而是对UID做SHA-256后截取32位hex作为ClientID,这样长度可控,碰撞概率极低;
- 统一用小写hex,避免大小写和日志、数据库里的字符串比对不一致。
5.3 UID全零/重复芯片的罕见问题
理论上UID是全球唯一的,但我确实在某批国产芯片上遇到过两个问题:一是极少数芯片读出全0xFF或全0x00,二是同批次里出现重复UID。后来查明,前者是芯片配置位没烧录好,后者是芯片厂的小概率质量问题。
产品里最好加一道自检:如果UID是全零、全0xFF,或者和已知出厂UID列表冲突,直接判定为“设备异常”,拒绝入网。这样虽然解决不了芯片质量问题,但至少不会让异常设备带着错误身份混进平台,导致后台统计数据错乱。
5.4 防抄板失败策略别太“暴力”
最后再说一次失败策略。如果你做的是医疗、消防这类安全等级很高的设备,发现UID不匹配应该立刻进入安全模式,这是对的;但如果是消费类电子产品,直接死机或者无限重启,售后热线会被打爆。更合理的做法是先把产品主功能跑起来,在后台埋一个“设备标识异常”的上报日志,或者在一段时间后随机失灵。这个思路可能听起来不够“硬核”,但工程上最重要的是平衡安全性和可维护性。
我在实际项目里,一般会把异常识别次数做累加,连续N次异常才进入降级模式,并且降级模式会保留基础状态上报能力,方便远程排查问题。这样既让抄板者没法舒舒服服用你的产品,又不至于误杀正常用户设备。
UID这个东西,说到底就是硬件给软件留的一扇暗门。用好了,它是防抄板的护城河、一机一密的种子;用不好,它就是个随时引爆的字符串大坑。希望这篇文章能帮你绕过那些我踩过的雷,在自己的产品里把这枚电子指纹真正用起来。