ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

跨平台获取MAC地址与CPU序列号:设备指纹与授权绑定实践

跨平台获取MAC地址与CPU序列号:设备指纹与授权绑定实践 1. 硬件标识这件事先把需求想明白1.1 MAC地址和CPU序列号各自代表什么做设备识别、授权绑定、终端准入这类需求的同学绕不开两个东西MAC地址和CPU序列号。很多人一上来就写代码结果线上跑一半发现数据对不上回头再补坑代价很大。我先把这两个标识的脾气讲清楚后面写代码才不会踩雷。MAC地址是网卡的物理地址48位通常写成六组十六进制比如A4:5E:60:3B:1C:8F。它由 IEEE 统一分配前三个字节叫 OUI标识厂商后三个字节是厂商自己编的流水号。CPU序列号则是处理器出厂时写入的一段标识信息Intel 早期的 Pentium III 上确实有一段可读的 96 位 PSN但后来因为隐私争议被取消了所以你今天在大多数 x86 机器上拿到的所谓“CPU序列号”其实是 CPUID 指令返回的签名加特性位拼接并不是全球唯一的那串数字。这一点必须先说透不然后面拿着BFEBFBFF000306A9这种值去当唯一设备号用早晚出事。关键结论MAC 地址可以改、可以被虚拟网卡伪造、可以被随机化CPU 序列号在 x86 上基本不具备唯一性。两者更适合作为设备指纹的组成部分而不是唯一的、不可篡改的身份凭证。理解这层你的方案设计才会稳。1.2 常见的设备指纹与授权绑定场景我接触过的需求大致分几类。第一类是软件授权绑定用户买了一份授权希望限制只能在一台机器上激活这时候通常会用 MAC CPU 特征 主板信息做一个组合指纹。第二类是终端资产盘点企业里要统计有多少台设备接入了内网靠 MAC 地址做去重比较直接。第三类是日志与审计安全日志里记录设备标识方便溯源。这几类场景对精度的要求完全不同。资产盘点用 MAC 就够了反正内网里网卡基本稳定授权绑定就得组合多个字段还要考虑用户换网卡、加内存、虚拟机迁移的情况。我见过最坑的一种做法是直接用第一块网卡的 MAC 做唯一 ID结果用户插了个 USB 网卡网卡顺序一变绑定的机器全废了。还有一个现实问题虚拟化环境。你现在部署的服务很大一部分跑在 VMware、KVM、Hyper-V 里。虚拟机的 MAC 前缀是固定的比如00:0C:29和00:50:56是 VMware08:00:27是 VirtualBox52:54:00是 QEMU/KVM。热词里问“00:0c 29 开头的 mac 地址 都是虚拟机吗”答案是基本可以这么判断这个 OUI 段就是分给 VMware 的。如果你的系统用 MAC 做设备唯一标识那么同一台物理机上开两台虚拟机MAC 天然就是不同的这没问题但如果做“一机一授权”虚拟机的可复制性会让你的绑定策略形同虚设。所以选型之前先问自己三个问题这个标识用来做什么精度要求多高运行环境里有没有虚拟机和容器答案不同实现方式差得很远。2. WindowsWMI 与 IP Helper API 两条路线2.1 用 WMI 一次性拿到网卡与处理器信息Windows 上获取硬件信息最省事的入口是 WMI。它有现成的类和属性几乎不用碰底层 API写几行 PowerShell 就能验证。# 查询第一块已启用的物理网卡 MAC Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter IPEnabled TRUE | Select-Object Description, MACAddress, IPAddress # 查询处理器信息 Get-CimInstance -ClassName Win32_Processor | Select-Object Name, ProcessorId, NumberOfCores对应的 C# 代码大概是这个样子using System.Management; public static string GetMacByWmi() { var query new ManagementObjectSearcher( SELECT MACAddress FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled TRUE); foreach (ManagementObject mo in query.Get()) { var mac mo[MACAddress]?.ToString(); if (!string.IsNullOrEmpty(mac)) return mac; // 返回形如 A4:5E:60:3B:1C:8F } return string.Empty; }WMI 的好处是门槛低、字段齐全Win32_Processor的ProcessorId直接对应 CPUID 指令的结果。但要注意IPEnabled TRUE这个过滤条件会把断网状态下禁用的网卡漏掉如果你需要拿到所有网卡就得换个查询方式用Win32_NetworkAdapter类取MACAddress属性同时通过NetConnectionID判断它是不是物理网卡。注意WMI 查询在服务账户、计划任务、Session 0 隔离的环境里可能出现权限不足或者返回空集的情况。部署到 Windows 服务里的时候一定要先验证一下当前账户能不能拿到数据。WMI 的另一个坑是速度慢。它走的是 WBEM 体系第一次调用会初始化 COM 组件几百毫秒很常见。如果你的程序在启动时高频调用建议把结果缓存起来别每次刷新界面都查一遍。2.2 IP Helper API更底层也更可靠的 MAC 获取如果嫌 WMI 重或者你的程序是纯 C/C 写的不想引 WMI 依赖那就直接用 IP Helper API。核心函数是GetAdaptersAddresses它能列出所有网卡适配器及其物理地址。#include winsock2.h #include iphlpapi.h #include stdio.h #pragma comment(lib, iphlpapi.lib) #pragma comment(lib, ws2_32.lib) void PrintMacAddresses() { ULONG bufLen 15000; PIP_ADAPTER_ADDRESSES addrs (PIP_ADAPTER_ADDRESSES)malloc(bufLen); ULONG ret GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST, NULL, addrs, bufLen); if (ret ERROR_BUFFER_OVERFLOW) { free(addrs); addrs (PIP_ADAPTER_ADDRESSES)malloc(bufLen); ret GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST, NULL, addrs, bufLen); } if (ret ! NO_ERROR) { free(addrs); return; } for (PIP_ADAPTER_ADDRESSES p addrs; p; p p-Next) { if (p-IfType IF_TYPE_SOFTWARE_LOOPBACK) continue; // 跳过回环 if (p-PhysicalAddressLength 6) { printf(%ws - %02X:%02X:%02X:%02X:%02X:%02X\n, p-FriendlyName, p-PhysicalAddress[0], p-PhysicalAddress[1], p-PhysicalAddress[2], p-PhysicalAddress[3], p-PhysicalAddress[4], p-PhysicalAddress[5]); } } free(addrs); }这段代码有几个关键点值得展开。第一GetAdaptersAddresses采用“两次调用”模式第一次传个预估长度如果缓冲区不够会返回ERROR_BUFFER_OVERFLOW并告诉你实际需要多大再重新分配。千万别只调一次就完事网卡多的机器上必炸。第二PhysicalAddressLength一定要判等于 6因为有些虚拟适配器比如某些隧道网卡也会返回地址但长度不是 6直接按 6 个字节去读会越界。第三过滤回环和隧道接口IfType字段可以帮你区分。这个方案比 WMI 快得多通常几毫秒就返回适合高频调用。代价是要处理 C 层的内存和结构体稍不注意就内存泄漏或者读越界。2.3 CPU 序列号的真相与 CPUID 取法前面说过现代 x86 CPU 没有真正意义上的唯一序列号。那Win32_Processor.ProcessorId到底是什么它其实是 CPUID 指令叶子 1 返回的 EDX 和 EAX 拼接而成的一个字符串。EAX 里是处理器签名包含步进、型号、家族信息EDX 是特性标志位比如有没有 SSE、有没有超线程。#include intrin.h void GetCpuId() { int cpuInfo[4] {0}; __cpuid(cpuInfo, 1); printf(EAX %08X, EDX %08X\n, cpuInfo[0], cpuInfo[3]); // 拼成 ProcessorId 的格式EDX 在前EAX 在后 printf(ProcessorId %08X%08X\n, cpuInfo[3], cpuInfo[0]); }这段代码在 MSVC 上直接编译就行__cpuid是编译器内置函数。如果是 GCC/Clang用__get_cpuid。为什么同一批次的机器 ProcessorId 会一模一样因为同一型号、同步进的 CPU这个签名完全相同。我在实际项目里试过一个机房里二十台同配置的 Dell 服务器ProcessorId 全是BFEBFBFF000306A9这种值。所以千万别把它当唯一标识它只能用来判断 CPU 型号和特性。真正要组合设备指纹得把 MAC、主板序列号、磁盘序列号、CPU 型号这些拼在一起做哈希。如果你确实需要 Windows 上的硬件唯一标识更靠谱的是主板序列号WMI 里查Win32_BaseBoard的SerialNumber或者磁盘序列号查Win32_DiskDrive的SerialNumber。这几个字段的唯一性和稳定性都比 CPU 序列号强。3. Linux从 /proc 与 /sys 里挖数据3.1 三条命令确认 MAC 地址Linux 上拿 MAC 地址比 Windows 简单得多因为内核已经把信息暴露成了文件。最常用的三条命令# 方法一ip 命令 ip link show # 方法二从 sysfs 直接读 cat /sys/class/net/eth0/address # 方法三ifconfig部分新系统需要装 net-tools ifconfig -a/sys/class/net/目录下的每个子目录对应一个网络接口里面的address文件就是 MAC 地址格式是小写的a4:5e:60:3b:1c:8f。用程序读取的话直接fopen然后fgets就行连 ioctl 都不用。#include stdio.h int read_mac(const char *ifname, char *out, size_t outlen) { char path[128]; snprintf(path, sizeof(path), /sys/class/net/%s/address, ifname); FILE *fp fopen(path, r); if (!fp) return -1; if (fgets(out, outlen, fp) NULL) { fclose(fp); return -1; } fclose(fp); // 去掉尾部的换行 for (char *p out; *p; p) { if (*p \n) { *p \0; break; } } return 0; }如果想拿到所有网卡的地址遍历/sys/class/net/目录下的条目即可。用opendir加readdir跳过.和..每个条目名就是接口名。另一个方案是ioctl配合SIOCGIFHWADDR这是比较传统的方式#include sys/ioctl.h #include net/if.h #include string.h #include unistd.h #include sys/socket.h int get_mac_ioctl(const char *ifname, unsigned char mac[6]) { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) return -1; struct ifreq ifr; memset(ifr, 0, sizeof(ifr)); strncpy(ifr.ifr_name, ifname, IFNAMSIZ - 1); if (ioctl(fd, SIOCGIFHWADDR, ifr) 0) { close(fd); return -1; } memcpy(mac, ifr.ifr_hwaddr.sa_data, 6); close(fd); return 0; }读 sysfs 更轻ioctl 更通用。我一般优先用 sysfs因为读文件不涉及系统调用权限容器里也通常可用。3.2 CPU 信息在 /proc/cpuinfo 里能挖到什么Linux 上 CPU 信息主要在/proc/cpuinfo。多核机器上每个逻辑核都有一段靠processor字段区分。常见的字段有vendor_id厂商、model name型号、cpu MHz、cache size、flags指令集特性。# 提取型号 grep -m1 model name /proc/cpuinfo # 统计物理核数去重 physical id core id grep physical id /proc/cpuinfo | sort -u | wc -l # 提取序列号字段ARM 平台才有意义 grep -i serial /proc/cpuinfo这里有个大坑在 x86 平台上/proc/cpuinfo里根本没有 CPU 序列号字段你grep serial出来是空的。很多人以为是自己命令写错了其实是硬件层面就不提供。只有在 ARM 架构比如树莓派、部分国产 ARM 服务器的/proc/cpuinfo里才有Serial字段形如00000000abcdef12这个才是真正意义上比较接近唯一序列号的东西。提示如果你的程序要跑在国产化环境里ARM 平台占比不低写代码时把/proc/cpuinfo的 Serial 字段解析逻辑加上能让设备识别的精度上一个台阶。解析/proc/cpuinfo的通用写法是逐行读遇到空行表示一段结束。如果只想要第一段第一个逻辑核的信息读到第一个空行就可以停。字段格式是key : value冒号两边可能有空格分割的时候要用strtok或者手动跳过空格别写死一个冒号加一个空格。3.3 dmidecode 与权限、容器环境的边界想要更完整的硬件信息dmidecode是绕不过去的工具它能读出 BIOS、主板、内存、处理器插槽的详细信息。# 查看处理器信息需要 root sudo dmidecode -t processor # 查看主板序列号 sudo dmidecode -t baseboarddmidecode -t processor的输出里有个ID字段这就是 DMI 层面的处理器标识。但在虚拟机上这个字段经常是全零或者一段固定的占位值因为虚拟化层并没有真正传递物理 CPU 的信息。我实测过 VMware 和 KVM 的虚拟机ID字段要么是00 00 00 00 ...要么干脆不显示。所以拿它当唯一标识虚拟化环境下直接翻车。权限问题是另一个坎。dmidecode读取的是/dev/mem或者DMI表必须 root 才能跑。你的程序如果以普通用户运行这一步会直接失败。常见做法是降级优先读/proc/cpuinfo和/sys只有拿到 root 权限时才尝试dmidecode拿不到就跳过别让程序崩溃。容器环境更麻烦。Docker 容器里默认看不到宿主机的 DMI 信息/proc/cpuinfo显示的也是宿主机的 CPU 型号但/sys/class/net/里通常只有eth0和lo而且这个eth0的 MAC 是 veth pair 分配的容器重启就变。所以千万不要在容器里用 MAC 地址做持久化设备标识这是很多人踩过的大坑。还有一点某些云厂商的虚拟机 MAC 是随机分配的重建实例就会变。如果你的授权绑定依赖 MAC用户重新创建实例后授权失效客诉能把你淹没。遇到云环境建议用实例 ID 之类的元数据接口来补充标识。4. 跨平台封装统一接口的取舍4.1 接口抽象与数据清洗写跨平台代码第一件事是把平台相关的东西隔离到各自的实现文件里对外只暴露统一接口。我习惯定义成这样一个结构typedef struct { char mac[18]; // a4:5e:60:3b:1c:8f char cpu_id[64]; // CPUID 或 Serial char cpu_model[128]; // 型号名 char board_serial[64]; // 主板序列号可选 } DeviceInfo; int get_device_info(DeviceInfo *info);Windows 实现里调 WMI 或 IP HelperLinux 实现里读 sysfs 和 cpuinfo两边都填这个结构体。主程序完全不关心底层怎么拿的。数据清洗有两个细节必须做。第一统一大小写和分隔符。Windows 的 WMI 返回A4:5E:60:3B:1C:8FLinux 的 sysfs 返回a4:5e:60:3b:1c:8f如果不统一后面比较或者做哈希的时候会当两台机器。我一般统一转成大写冒号分隔。第二过滤无效地址。全零地址00:00:00:00:00:00、全 F 地址、回环接口lo、隧道接口都要排除掉否则取到第一块网卡恰好是虚拟网卡数据就是垃圾。排序问题也要处理。一台机器可能有多块网卡取哪一块我的做法是按接口名排序后取第一块物理网卡并且把“物理”的判断标准写清楚Linux 上通过/sys/class/net/iface/device是否存在来判断存在说明有对应的硬件设备是物理网卡Windows 上通过适配器类型过滤排除IF_TYPE_SOFTWARE_LOOPBACK和隧道类型。这个判断逻辑一定要写进注释不然后面维护的人根本不知道你为什么不取第一块。4.2 各方案横向对比方案平台优点缺点适用场景WMI 查询Windows字段全、代码短慢、依赖 COM、服务账户可能失败启动时取一次、桌面程序GetAdaptersAddressesWindows快、无额外依赖C 层内存管理繁琐高频调用、C/C 程序CPUID 指令Windows/Linux无系统调用、极快同型号值相同、无唯一性判断 CPU 型号与特性/sys/class/netLinux极轻、容器可用只有网络接口信息脚本、容器内取 MAC/proc/cpuinfoLinux无需权限、字段全x86 无序列号型号识别、ARM 取 SerialdmidecodeLinux信息最全需要 root、虚拟机无效物理服务器资产盘点这张表是我踩了几年坑总结出来的选型的时候对着场景挑就行。核心原则能用文件读的别用命令能用轻量 API 的别用重量框架能组合多个字段的别依赖单一字段。还有一点值得强调任何从系统拿到的硬件信息都要做异常兜底。文件读不到、命令执行失败、返回空字符串这些情况在真实环境里太常见了。代码里每个获取函数末尾都要有个默认返回值调用方判断为空时走降级逻辑而不是直接崩。5. 常见问题与排查实录5.1 MAC 地址异常全零、虚拟网卡与随机化排查 MAC 问题先按这个顺序过一遍。第一看看是不是拿到了00:00:00:00:00:00这通常是网卡驱动没加载或者接口未启用。第二看看是不是拿到了虚拟网卡比如 Docker 的 veth、虚拟机的虚拟适配器。第三也是最容易忽略的MAC 地址随机化。Windows 10 之后有个“随机硬件地址”功能开启后 Wi-Fi 每次连接都会用随机 MAC。Linux 上 NetworkManager 也有类似的wifi.scan-rand-mac-address和ethernet.cloned-mac-address配置。如果你的程序依赖 MAC 做绑定用户开了随机化每次重启都是新设备绑定直接失效。这种情况你没法从代码层面解决只能在产品层面提示用户关闭随机化或者改用别的标识。# 检查 Linux 上是否有克隆 MAC 配置 nmcli connection show 有线连接 | grep cloned-mac-address热词里还提到“虚拟卡 mac 地址有纯数字怎么设置”“mac 地址修改”这两个都是用户侧的操作跟程序获取无关但说明 MAC 被修改是很普遍的事。程序里如果拿 MAC 当唯一标识一定要有心理准备这个值是可变的。5.2 CPU 序列号拿不到或为空这个问题的排查路径很清晰。先确认平台x86 就别指望/proc/cpuinfo有 Serial 字段。再看是不是虚拟机虚拟机的 DMI ID 基本无效。最后看权限dmidecode要 root。如果业务上确实需要唯一标识我的建议是不要死磕 CPU 序列号改成组合指纹。取 MAC、主板序列号、磁盘序列号、CPU 型号这四项拼成一个字符串做 SHA-256取前 16 位当设备 ID。这样即使用户换了网卡其他三项还在指纹只是变了不是全废配合容错策略比如允许两项匹配就算同一台设备就能做到比较稳的识别。import hashlib def build_device_fingerprint(mac, board_serial, disk_serial, cpu_model): raw f{mac}|{board_serial}|{disk_serial}|{cpu_model}.lower() return hashlib.sha256(raw.encode()).hexdigest()[:16]这个思路的关键在于熵要足够。单看 MAC 只有 48 位但加上主板和磁盘序列号之后碰撞概率就极低了。当然如果你只想要一个稳定但不唯一的标识直接用 MAC 哈希也行看你的业务对稳定性要求多高。5.3 常见问题速查表现象可能原因排查动作MAC 全是 0网卡未启用/驱动未加载ip link看状态ls /sys/class/net/取到的 MAC 每次变随机化或克隆 MAC 开启检查系统网络配置中的随机化选项Linux 下 grep serial 为空x86 平台本就没有换 ARM 平台验证或改用组合指纹WMI 返回空集合权限不足/Session 0 隔离换账户测试或改 IP Helper API虚拟机 CPU ID 全零虚拟化层未透传 DMI属正常现象改用其他字段容器内 MAC 重启就变veth pair 动态分配不要在容器内用 MAC 做持久标识同批次机器 ProcessorId 相同CPUID 签名本就一致属正常现象不能当唯一标识dmidecode 报权限错误需要 root降级到读 /proc或提升权限这张表建议打印出来贴在工位上遇到问题按行查能省下大量时间。我自己的经验是八成的问题都出在“拿到了值但没做有效性校验”这一步所以每个获取函数的第一行校验逻辑比获取逻辑本身还重要。6. 从项目里踩出来的几条经验先说一个反直觉的结论获取硬件信息这件事难点从来不在“怎么拿”而在“拿到之后怎么用”。写个函数把 MAC 和 CPU 信息读出来半天就能搞定真正让项目稳定的是前面那一堆边界判断和降级逻辑。我个人的几条实操心得都是真金白银换来的。第一永远不要假设某个字段一定拿得到。文件可能不存在命令可能没权限返回值可能是空的。每个获取点都写兜底日志里把失败原因记清楚出问题的时候能一眼看出是环境问题还是代码问题不用远程连上去一行行试。第二把平台相关的代码物理隔离。Windows 一个文件Linux 一个文件头文件里只放接口声明。这样移植的时候改动范围可控也方便写单元测试。第三缓存结果别每次调用都重新读。硬件信息不会频繁变程序启动时读一次存到内存里UI 刷新的时候用缓存性能提升很明显。Windows 上 WMI 尤其慢缓存收益最大。还有个小技巧值得分享调试阶段写一个“信息导出”按钮把当前设备拿到的所有标识、每个值的来源、获取是否成功全部打印出来。用户报问题的时候让他点一下导出的文本直接发过来比在电话里问“你执行一下 ipconfig 看看”效率高太多。这个按钮我在好几个项目里都加过省下的沟通成本难以估量。如果这套方案后续要扩展我的建议是往“设备指纹服务”的方向走。把识别逻辑单独拿出来做成一个模块对外提供“计算当前设备指纹”的能力内部维护一组特征提取器每个提取器负责一个字段支持开关和权重配置。这样不管是加一个新的识别维度还是调整容错策略都只动一个提取器不影响其他部分。跨平台、抗变化、易扩展这三条做到位设备识别这块基本就不会再出大的幺蛾子。
返回列表