产线上多一张写号工装、一个操作员、一台扫码枪,一天写不了两千台,还可能写重——这套流程我见过不止一次。MAC地址就六个字节,小到在BOM里只值几分钱,可它决定了设备在二层网络里的身份:一旦撞车,轻则DHCP拿不到地址、重则链路时通时断,回到实验室复现还得靠运气。这几年做嵌入式设备,我越来越倾向于把MAC地址从"生产资产"变成"计算产物",也就是在嵌入式设备无存储MAC生成方案里聊的那套思路——不占Flash、不占EEPROM、不加工位,直接拿芯片自带的UID喂给一个确定性哈希算法,现场算出MAC。同一块板子、同一份固件,永远算出同一个地址;换块板子,地址自然就变了。这篇文章写给正在纠结要不要上写号工装、或者已经被MAC冲突坑过一次的固件工程师和硬件负责人,从位域规则、熵的计算、哈希选型一路讲到代码实现和产线抽检,能抄的部分我都写成可直接编译的形态。
1. 为什么"无存储"这件事值得认真做
1.1 一张写号工位的隐性成本账
先说清楚为什么要绕开"出厂写MAC"这条路。表面上看,写号只是加一道工序,实际上它牵连的是一整条链:需要一台能连内网的工装电脑、一个能读到条码的扫码枪、一套防重复的中央数据库、一个知道自己在干什么的操作员,以及一套"写完之后怎么验证"的流程。任何一个环节断掉,产线上就会出现一批"看起来正常"的设备。我见过最典型的翻车方式,是工装软件崩溃后操作员直接重启继续写,结果从第1736台开始,MAC地址又从头写了一遍。
更麻烦的是数据库本身。你要保证"分配出去就不回收",这意味着一张只增不减的表,以及一套跨工厂、跨代工厂同步的机制。代工厂的网络隔离、数据延迟、人员流动,都会让这张表变成单点故障。所以无存储方案真正的价值不是省下那几秒钟的烧录时间,而是把地址的唯一性来源从"流程"转移到"物理事实":芯片的UID出厂时就已经固化在硅片里,它不会因为工装崩溃而重来一遍。
当然,无存储不是万能药。它适合的是批量中等、成本敏感、生产分散的产品;如果你是百万级年产量、且客户对OUI号段有强制要求,那还是老老实实走正规注册和写号流程,别在这上面省钱。
1.2 MAC地址的两个保留位,是这套方案的合法性基础
很多人对MAC地址的理解停留在"六字节、全球唯一",但真正让无存储方案站得住脚的,是48位里的两个标志位。第一个字节的最低位(bit0)是组播位,0表示单播、1表示组播;第二位(bit1)是本地管理位,0表示这个地址由注册管理机构统一分配(也就是常说的OUI段),1表示这个地址由使用者自行管理。
这两个位是整个方案的钥匙。只要我们把第一字节的bit1置1,就等于向全世界声明:"这个地址是我自己管的,我不占用公共号段。"交换机和路由器对本地管理地址完全一视同仁,协议栈不会因为它是"自造的"就拒绝。剩下的46位可以自由支配,这就是我们能拿到的全部熵。
注意:bit1置1之后,这个地址就不能再当成"厂商身份"用了。有些客户会用MAC前三位做设备资产识别,本地管理地址的前三位是固定的(形如 02:xx:xx),识别能力会下降。这一点在方案评审阶段就要跟产品线讲清楚,别等出货了再返工。
1.3 三种常见做法的横向对比
把可选路径摆在一起看,取舍就很清楚了。下面这张表是我在几个项目里反复用过的对照,成本按万级产量粗算:
| 方案 | 存储开销 | 单台成本增量 | 冲突风险 | 产线复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 外挂EEPROM/Flash写号 | 几字节到几KB | 芯片成本+写号工时 | 极低(有数据库兜底) | 高,需工装与数据库 | 大批量、有OUI要求 |
| OTP区一次性烧写 | 芯片内置OTP | 工时+良率损失 | 极低 | 中,需专用烧写器 | 中大批量 |
| 主Flash参数区保存 | 无额外器件 | 近乎为零 | 中,依赖首次上电生成 | 低 | 小批量、可返修 |
| UID确定性哈希(本方案) | 零 | 零 | 可控(取决于位数) | 极低 | 分散生产、成本敏感 |
第四种方案唯一的软肋是冲突概率,而这个概率是可以用数学算清楚的,不是玄学。后面第3章我会把公式和实测数据都摆出来。
1.4 无存储方案必须同时满足的四个硬指标
不是随便拿UID异或一下就完事。一个能进量产固件的方案,至少要过四关。第一是确定性:同一块板、同一版固件,无论冷启动多少次、无论什么时候算,结果必须字节级一致,不能掺入随机数或时间戳。第二是唯一性:在可预期的设备总量内,冲突概率要低到能接受,这个"低"要有量化依据。第三是可追溯:出了问题能从MAC反推回设备批次,至少能定位到UID。第四是零依赖:不能要求额外的存储介质、不能依赖网络、不能依赖产线工具,代码要能在裸机环境里跑。
这四条里最容易翻车的是第一条。我见过有人在哈希里混入了HAL库返回的随机数种子,本意是"增强随机性",结果设备每次重启MAC都变,路由器的DHCP租约表里堆了几十条僵尸记录。确定性哈希的核心恰恰是"不许随机",随机性应该来自UID本身的物理差异,而不是每次计算的输入。
2. 从UID到MAC:整条映射链路怎么搭
2.1 UID到底能提供多少熵
先泼一盆冷水:不是所有芯片的UID都值得信任。市面上常见的做法是96位(12字节)UID,通常是晶圆批次号、X/Y坐标、日期码的组合,理论上全球唯一。但我也遇到过读出来前8字节全0、只有后4字节有值的情况;还有同一批次抽样100颗,发现低16位完全一致的(大概率是坐标位宽不够,同一晶圆相邻die撞了)。所以在设计链路之前,必须先做UID质量评估。
评估方法很土但有效:从产线抽300颗同批次的板子,把UID读出来存成CSV,做三件事——统计非零字节数、统计每个字节位的翻转率、做一次全量去重。如果发现某个字节在整批里恒定不变,那它就是无效熵,参与哈希只会浪费算力。这一步花半天,能避免后面几个月的扯皮。
提示:如果UID质量实在堪忧(比如只有32位有效),不要硬上。退回到"UID + Flash出厂校准值 + 芯片型号"的组合输入,熵能补回来一些。校准值是每颗芯片单独测出来的,天然带差异。
2.2 五段式映射链路的设计
我把整条链路拆成五段,每段职责单一,方便单独测试:采集 → 归一化 → 域分离 → 哈希混合 → 位域封装。采集层负责从芯片读出原始字节;归一化层做有效性校验(拒绝全0、全FF、以及已知的无效模式),无效时返回错误码而不是静默兜底;域分离层把"这块板子的第几个网络接口"编码进去;哈希混合层做雪崩扩散;位域封装层负责设置本地管理位、清掉组播位、按需处理特殊协议格式。
这样分层的好处是,将来换芯片只改采集层,换哈希算法只改混合层,加一个新接口只加一个domain常量。我在一个四网口网关项目里就是靠这套分层,把"每个网口一个MAC"的需求在半天内接完的,没有改动任何底层代码。
2.3 域分离:一块板子有多个网络接口怎么办
这是实践中最容易出事的地方。一块板子同时有以太网、Wi-Fi STA、Wi-Fi AP、蓝牙,如果四个接口算出同一个MAC,会发生什么?桥接场景下交换机会疯狂漂移MAC表,Wi-Fi AP的BSSID和STA地址相同会导致关联失败,蓝牙地址和以太网地址相同在某些协议栈里直接触发断言。所以域分离不是可选项,是必选项。
我的做法是给每个逻辑接口分配一个字节的domain ID,作为哈希输入的一部分,同时换一个不同的初始常量。这样即使两个接口的UID完全相同(同一块板),算出来的结果也是彻底不相关的两个值。domain ID的分配要有规矩,我一般按下面的方式规划,留出扩展空间:
| domain 值 | 接口含义 | 备注 |
|---|---|---|
| 0x00 | 以太网口 0 | 主网口 |
| 0x01–0x07 | 以太网口 1–7 | 多网口设备按序分配 |
| 0x10 | Wi-Fi STA | 上行连接 |
| 0x11 | Wi-Fi AP | 热点模式,BSSID |
| 0x20 | 蓝牙 | 需转成随机静态地址格式 |
| 0x30 | 虚拟/调试接口 | 仅内部使用 |
规划好之后写进头文件,并且冻结。任何一次domain值的变更,都意味着已有设备的MAC会变,等于把现网设备的身份重置了一遍。
2.4 哈希输出的位分配策略
46位可用空间怎么切,有两种思路。第一种是"全用":把46位全部交给哈希输出的低位,第一字节只用6位参与,剩下40位铺满后五字节。第二种是"留OUI":前3字节固定成一个伪厂商前缀(第一字节置0x02),只有后24位随UID变化。
第二种看起来更像"正规MAC",但熵只有24位,也就是1677万个地址空间。按生日悖论,设备量到4000台左右,冲突概率就爬到接近5%了,这个数字对任何量产产品都是灾难。所以我的选择很明确:能榨多少位就榨多少位,46位全用。地址长得不像正规厂商没关系,功能上完全等价。
如果你的客户强制要求前三位是他们注册的号段,那就只能退到24位NIC,此时必须配套做产线抽检和冲突检测,不能裸奔。这一点在方案选型会上要提前说破。
3. 哈希算法怎么选,别拍脑袋
3.1 四种候选算法的实际表现
嵌入式环境选哈希,看的是三件事:代码体积、执行时间、雪崩效应。我实测过四种,都在Cortex-M0+ @48MHz上跑过,输入固定12字节UID加1字节domain:
| 算法 | 代码体积(-O2) | 单次耗时 | 雪崩效应 | 实现难度 | 结论 |
|---|---|---|---|---|---|
| 简单异或折叠 | 约40字节 | <1μs | 极差 | 极低 | 不推荐,相邻UID输出高度相关 |
| CRC32 | 约120字节 | 约3μs | 一般(线性) | 低 | 可用作辅助,不建议单独用 |
| FNV-1a 64位 | 约80字节 | 约2μs | 良好 | 低 | 推荐作为主混合 |
| MurmurHash3 128位 | 约600字节 | 约15μs | 优秀 | 中 | 资源富裕时可选 |
简单异或折叠的问题在于它是纯线性的:UID只差一个bit,输出的差异也只集中在一两个bit上,落到MAC上就是"同一批设备的地址长得很像",虽然不一定冲突,但会给后续的冲突检测和日志分析带来麻烦。CRC32同理,它本质是多项式除法,线性性质决定了它对输入差异的扩散不够。
我最后选的是FNV-1a 64位 + fmix64终混的组合。FNV-1a负责把12字节输入压成64位,fmix64(就是murmur3的finalizer那几步异或-乘-异或)负责把64位再彻底打散一遍。两者加起来代码不到150字节,耗时3μs以内,雪崩效应接近理想值。这个组合我在三个项目上用过,产线抽检几千台没有出现过地址聚集。
3.2 冲突概率必须算出来,不能"感觉差不多"
生日悖论的近似公式是 P ≈ 1 - e^(-n²/2N),n是设备数、N是地址空间大小。把这个公式套到不同位数上,结果差异巨大:
| 有效位数 | 地址空间 N | 1000台冲突概率 | 1万台 | 10万台 |
|---|---|---|---|---|
| 24位 | 1.68×10⁷ | 2.9% | 约95% | 近乎必然 |
| 32位 | 4.29×10⁹ | 0.012% | 1.15% | 约69% |
| 40位 | 1.10×10¹² | 4.5×10⁻⁵% | 0.0045% | 0.45% |
| 46位 | 7.04×10¹³ | 7.1×10⁻⁷% | 7.1×10⁻⁵% | 0.0071% |
这组数字是我方案评审时必放的。24位那行最能说服人——很多"用哈希生成后三字节"的方案就死在24位上,一万台设备几乎必然撞车。而46位在10万台规模下冲突概率是万分之0.7,已经是可接受的范围,再叠加产线抽检就能兜住。
注意:这里的"冲突概率"是理论下限,前提是哈希输出在地址空间内均匀分布。如果你的哈希有偏、或者UID本身在某些位上恒定不变,实际概率会高于表中数值。所以UID质量评估不能省。
3.3 域分离的实现细节:换种子还是拼输入
域分离有两种实现:一是把domain字节拼到输入末尾一起哈希,二是用domain去派生一个不同的初始常量(seed)。前者实现简单,后者在输入长度受限时更方便。
我倾向于两者都用:seed用基准常量异或上domain派生,同时domain字节也拼进输入。听起来冗余,但这样即使某个domain值恰好让seed退化成0(FNV的初始值不能为0,否则第一个字节的扩散会变差),拼进输入的部分还能补救。FNV-1a的标准初始值是0xcbf29ce484222325,我在代码里做了保护,seed算出0时强制回退到这个常量。
3.4 一个容易被忽略的问题:算法版本号
哈希算法一旦出货就不能改,因为改了MAC就变。但代码总会被维护、被重构、被人"顺手优化一下"。我的做法是在头文件里硬编码一个MAC_ID_ALGO_VERSION,并且在生成函数里把这个版本号也拼进哈希输入。
听起来很怪——版本号参与哈希,那不就是自己给自己挖坑?其实不是。版本号的作用是一旦算法需要变更(比如发现严重缺陷),新版本号会让所有设备算出全新的MAC,这是一次性的、可控的破坏,而不是某些设备变了、某些没变的混乱局面。同时,这个机制也防止了"某个工程师悄悄把FNV换成CRC32然后忘了说"这种情况。
4. 代码实现:一个可直接编译的模块
4.1 接口设计
模块对外只暴露三个函数和一个结构体,平台相关的UID读取用弱符号留给移植层实现。这样同一份代码可以在不同芯片上复用。
/* mac_id.h */ #ifndef MAC_ID_H #define MAC_ID_H #include <stdint.h> #include <stddef.h> #define MAC_ID_ALGO_VERSION 1u #define MAC_ID_OK 0 #define MAC_ID_ERR_UID_INVALID (-1) #define MAC_ID_ERR_ARG (-2) #define MAC_ID_DOMAIN_ETH0 0x00u #define MAC_ID_DOMAIN_ETH1 0x01u #define MAC_ID_DOMAIN_WIFI_STA 0x10u #define MAC_ID_DOMAIN_WIFI_AP 0x11u #define MAC_ID_DOMAIN_BLE 0x20u typedef struct { uint8_t b[6]; } mac_id_t; /* 由移植层实现:读取芯片唯一ID,成功返回0 */ int platform_read_uid(uint8_t out[12]); int mac_id_from_uid(const uint8_t uid[12], uint8_t domain, mac_id_t *out); int mac_id_to_str(const mac_id_t *mac, char buf[18]); int mac_id_check_local_unicast(const mac_id_t *mac); #endif4.2 核心实现
下面是完整实现,注意每一处位操作的位置,错一位结果就完全不同。
/* mac_id.c */ #include "mac_id.h" #include <string.h> #define FNV_OFFSET_BASIS 0xcbf29ce484222325ull #define FNV_PRIME 0x100000001b3ull static uint64_t fnv1a64(const uint8_t *p, size_t n, uint64_t seed) { uint64_t h = seed; for (size_t i = 0; i < n; ++i) { h ^= (uint64_t)p[i]; h *= FNV_PRIME; } return h; } /* murmur3 finalizer:把64位彻底打散 */ static uint64_t fmix64(uint64_t k) { k ^= k >> 33; k *= 0xff51afd7ed558ccdull; k ^= k >> 33; k *= 0xc4ceb9fe1a85ec53ull; k ^= k >> 33; return k; } static int uid_is_valid(const uint8_t uid[12]) { uint8_t acc_and = 0xFFu, acc_or = 0x00u; for (int i = 0; i < 12; ++i) { acc_and &= uid[i]; acc_or |= uid[i]; } if (acc_or == 0x00u) return 0; /* 全 0 */ if (acc_and == 0xFFu) return 0; /* 全 FF */ return 1; } int mac_id_from_uid(const uint8_t uid[12], uint8_t domain, mac_id_t *out) { if (!uid || !out) return MAC_ID_ERR_ARG; if (!uid_is_valid(uid)) return MAC_ID_ERR_UID_INVALID; /* 1. 用 domain 和算法版本派生种子 */ uint8_t tag[2] = { domain, (uint8_t)MAC_ID_ALGO_VERSION }; uint64_t seed = fnv1a64(tag, sizeof(tag), FNV_OFFSET_BASIS); if (seed == 0) seed = FNV_OFFSET_BASIS; /* 2. 12 字节 UID 做主混合 */ uint64_t h = fnv1a64(uid, 12, seed); /* 3. 终混,保证雪崩 */ h = fmix64(h); /* 4. 位域封装:46 位有效熵 */ uint8_t b0 = (uint8_t)((h >> 40) & 0xFCu); b0 &= (uint8_t)~0x01u; /* bit0 = 0,单播 */ b0 |= 0x02u; /* bit1 = 1,本地管理 */ /* 蓝牙随机静态地址:最高两位置 1 */ if (domain == MAC_ID_DOMAIN_BLE) { b0 |= 0xC0u; } out->b[0] = b0; out->b[1] = (uint8_t)(h >> 32); out->b[2] = (uint8_t)(h >> 24); out->b[3] = (uint8_t)(h >> 16); out->b[4] = (uint8_t)(h >> 8); out->b[5] = (uint8_t)(h); return MAC_ID_OK; } int mac_id_to_str(const mac_id_t *mac, char buf[18]) { static const char hex[] = "0123456789ABCDEF"; if (!mac || !buf) return MAC_ID_ERR_ARG; for (int i = 0; i < 6; ++i) { buf[i * 3 + 0] = hex[(mac->b[i] >> 4) & 0x0F]; buf[i * 3 + 1] = hex[mac->b[i] & 0x0F]; buf[i * 3 + 2] = (i == 5) ? '\0' : ':'; } return MAC_ID_OK; } int mac_id_check_local_unicast(const mac_id_t *mac) { if (!mac) return MAC_ID_ERR_ARG; if (mac->b[0] & 0x01u) return 0; /* 组播,不合规 */ if (!(mac->b[0] & 0x02u)) return 0; /* 非本地管理 */ return 1; }几个关键点值得单独说明。第一,b0先& 0xFC再置位,是为了保证不管哈希输出什么,bit0 一定是 0、bit1 一定是 1,这两个位的语义优先级高于熵。第二,蓝牙分支用|= 0xC0,这是蓝牙规范里随机静态地址的要求,最高两位必须为 11,否则对端会拒绝。第三,12字节UID全FF被判定为无效——有些芯片在未编程状态下UID读出来是FF,这种板子如果算出MAC,很可能整批都一样。
4.3 移植层与错误处理策略
移植层只做一件事:把芯片的唯一ID读进12字节数组。读不到就返回错误,上层自己决定怎么办。我一般的兜底顺序是:UID无效 → 尝试从参数区读工装备份序列号 → 还是拿不到就返回错误、让设备进入降级模式(不启动网络)。
注意:绝对不要在全0 UID时"随便给个固定值"。这种兜底一旦进了产线,会造出一批MAC完全相同的设备,排查成本比多一台不良品高两个数量级。
4.4 单元测试用例设计
这类纯函数模块最值得写测试。我在项目里固定跑下面这组用例,每次改动哈希或位域都回归一遍:
| 用例 | 输入 | 期望 | 说明 |
|---|---|---|---|
| 1 | 正常12字节UID,domain=0x00 | 返回OK,bit1=1,bit0=0 | 基本功能 |
| 2 | 全0 UID | 返回 ERR_UID_INVALID | 拒绝无效输入 |
| 3 | 全FF UID | 返回 ERR_UID_INVALID | 拒绝未编程芯片 |
| 4 | UID仅第12字节不同 | 输出6字节全变 | 验证雪崩效应 |
| 5 | domain 0x00 vs 0x01 | 输出完全不同 | 验证域分离 |
| 6 | domain=0x20 | 首字节最高两位为1 | 蓝牙地址格式 |
| 7 | 同一输入连算1000次 | 输出恒定 | 验证确定性 |
| 8 | 1000个连续递增UID | 输出无重复、无地址聚集 | 批量唯一性抽样 |
用例8最容易被跳过,但价值最高。做法很简单:写个小程序模拟1000个UID(比如每次加1),全部生成MAC后排序找重。十几行Python就能跑完,比在生产线上发现撞车便宜太多。如果发现输出呈现明显的规律性(比如前几位一直不变),说明哈希的雪崩不够,得换算法。
# 快速验证脚本示例 seen = {} for i in range(100000): uid = i.to_bytes(12, 'little') mac = gen_mac(uid, 0x00) if mac in seen: print("collision:", hex(i), hex(seen[mac])) break seen[mac] = i4.5 上层协议栈的接入方式
生成出来的MAC要真正用起来,还得接到协议栈。裸机上的轻量级TCP/IP协议栈一般在网络接口结构体里有个hwaddr数组,初始化时把六字节拷进去、长度设成6就够了。带操作系统的环境通常用命令行设置,比如ip link set dev eth0 down、ip link set dev eth0 address 02:AA:BB:CC:DD:EE、ip link set dev eth0 up。这套命令我在调试时用得很多,注意顺序必须是先down再改再up,否则某些网卡驱动会忽略这次修改。
还有一点:改完MAC之后DHCP客户端要重启,否则它可能拿着旧租约继续用。这个坑我踩过一次,现象是"MAC明明改了,抓包还是旧地址",查了半天才发现是DHCP守护进程没重启。
5. 落地踩坑手册:从实验室到产线
5.1 UID本身的坑
最大的坑是"假设UID唯一"。我遇到过三种翻车:一是某款芯片在未烧录固件时UID读出来全0,烧录后才有值,如果生成MAC的代码跑在烧录之前,就会拿到无效值;二是某些型号的UID在开启读保护后不可读,返回总线错误,需要提前在选项字节里确认;三是同一晶圆相邻die的UID低位相同,抽样300颗发现两组重复。
应对办法前面说过:抽样验证 + 有效性校验 + 错误不静默。再补一条:把UID和生成的MAC一起打印到启动日志里,产线工装只要扫一遍日志就能做重复检测。这个log不到20字节,但救过我好几次。
5.2 网络层的连锁反应
MAC变了会牵动一连串东西。路由器上的静态IP绑定失效、DHCP租约表里的旧记录变成僵尸、交换机的MAC地址表需要重新学习、如果配了端口安全策略还可能直接封端口。所以MAC必须在第一次联网之前就确定下来,并且之后永不变更,不能"第一次上电随机生成然后存Flash"——那种方案在Flash擦除、固件恢复出厂之后会变成新设备。
IPv6这边还有个细节。用MAC生成EUI-64链路本地地址时,需要把第一字节的bit1取反(MAC里1代表本地管理,EUI-64里1代表全局)。很多人直接拼接MAC导致算出来的地址格式不对。不过现在主流系统默认都开了隐私扩展,这个坑出现的频率低了很多,但如果你的设备要做链路本地发现,还是得处理。
5.3 字节序:一个能让整批设备报废的细节
MAC地址在报文里是按字节顺序传输的,02:11:22:33:44:55里的02是最先发出去的字节。如果你图省事,把六个字节拼成一个uint64_t再memcpy出去,在小端机器上得到的顺序就是反的,地址变成55:44:33:22:11:02。这个错误在实验室里可能测不出来(对端也是同样的错误实现,互相能通),一到产线接上正规交换机就全乱。
我的规矩是:MAC永远用uint8_t[6]存,永远不用整型转换。打印、比较、赋值全部走字节数组,一条捷径都不走。
5.4 量产抽检脚本怎么写
无存储方案不代表可以不做抽检,恰恰相反,抽检是这套方案唯一的兜底。我的做法是让产线工装在上电自检时把UID和MAC打进日志,测试完成后统一收集,跑一次去重。
# 从产线日志中提取 "UID=..., MAC=..." 行,找重复 grep -o 'MAC=[0-9A-F:]*' line.log | sort | uniq -d去重结果为空才算这批通过。抽样比例我一般定在首件全检、量产每500台抽20台、换批次必检。这套流程跑下来,成本几乎为零,但能挡住绝大部分系统性错误。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 多台设备MAC相同 | UID全0/全FF未拦截,或哈希输入漏了domain | 打印UID,检查有效性校验分支 |
| 设备重启后MAC变化 | 哈希里混入了随机数或时间戳 | 全局搜索rand/time调用 |
| 以太网和Wi-Fi地址相同 | 未做域分离 | 检查domain常量是否传对 |
| 蓝牙无法被扫描到 | 地址未置随机静态格式(最高两位非11) | 检查BLE分支的0xC0掩码 |
| 交换机端口被禁用 | MAC落入组播地址范围(bit0=1) | 检查b0的bit0清零逻辑 |
| 抓包显示地址字节序颠倒 | 用整型转换导致大小端问题 | 改用uint8_t[6]存取 |
| IPv6链路本地地址异常 | EUI-64未翻转U/L位 | 检查地址拼接逻辑 |
| 固件升级后MAC变了 | 算法或domain被修改 | 检查MAC_ID_ALGO_VERSION与domain表 |
5.6 几条不在文档里的经验
第一,把domain常量和算法版本写进产测项。工装启动时读一次MAC,和预期值比对,不一致就报警。这条规则挡住过一次"某工程师为了调试临时改了domain值忘了改回来"的事故。
第二,不要用MAC做授权密钥。哈希算法是公开的,知道一台设备的MAC就能反推出UID,进而算出同批次其他设备的MAC。如果产品的license绑定MAC,一定要在服务端再做一层签名校验。
第三,给售后留一条反查通道。在设备日志里同时打印UID和MAC,售后拿到MAC就能查到UID,进而定位批次。这个成本几乎为零,但能把返修定位时间从半天压到五分钟。
第四,也是最实在的一条:这套方案的上限由UID质量决定,不由哈希算法决定。花时间在选哈希上,不如花半天抽样验证UID。我见过太多项目在哈希上反复折腾,最后发现是UID低16位恒定不变——换个再好的算法也救不回来。
最后分享一个小技巧。如果你的产品同时有有线和无线,且客户坚持要MAC前三位看起来"像正规厂商",可以把有线口用本地管理地址(46位熵),无线口用一个固定的伪OUI前缀加24位哈希。无线侧设备量通常小于有线侧,冲突概率还能接受,而且AP的BSSID和STA地址本来就分属不同domain,互不影响。这个折中方案我在两个项目上用过,客户没意见,冲突也没发生过——当然,前提是抽检脚本一直在跑。