Windows注册表EDID 128字节解析与VC显示器信息读取

简介:这份面向VC++开发者与Windows系统编程学习者的示例工程,围绕从系统注册表读取显示器EDID二进制数据并解析设备参数展开,可用于系统监测、硬件诊断或显示器配置管理等场景,也可作为理解Windows内部机制的练习素材。压缩包共21个文件、约2.38MB,以h头文件、cpp源文件为主,辅以rc资源脚本、vcproj工程文件和sln解决方案等,构成可直接参考的MFC工程结构。已有1713人学习。源码重点演示注册表定位、EDID数据结构划分、二进制读取与信息转换,读者可据此梳理显示器制造商、型号、分辨率等参数的解析流程,并学习C++注册表操作与二进制数据处理思路,适合有一定Windows编程基础的人对照研究。

1. 一块屏幕的全部身份信息,就藏在注册表那 128 个字节里

在 VC 里做显示器设备信息模块时,人们的第一反应通常是GetMonitorInfo或EnumDisplayDevices,但拿到的往往只有分辨率和一句“通用即插即用监视器”。真正的厂商 ID、产品码、序列号、生产周、物理尺寸、色度坐标,这些 API 一个字都不给。反直觉的地方在于:这些信息早在设备被枚举的那一刻,就被 Windows 完整复制进了注册表,以一段 128 字节的 EDID 二进制存放在 Device Parameters 下面。会用 EDID 的人不少,但能从字节偏移讲清它为什么这样排列、解析时会在哪一步出错的人不多。下面从一个键路径开始,把这条链路拆开。

2. 注册表里的 EDID:这 128 字节由谁写入、何时写入

在动手写代码之前,得先弄清楚这份数据是谁放进注册表的。它不是某个程序主动写进去的,而是 PnP 管理器在设备枚举阶段从显示适配器那边取到原始描述符之后,顺手以缓存的形式保存到注册表。这个因果关系很重要:它决定了这份数据什么时候是最新的、什么时候可能已经过期,也决定了读取它为什么比直接询问硬件更稳定。

2.1 键路径的三级结构:Enum\DISPLAY 下的型号键与实例键

完整的路径长这样:

HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\{型号键}\{实例键}\Device Parameters └─ EDID REG_BINARY (基础块 128 字节,或 128 的整数倍) └─ EDID_OVERRIDE REG_BINARY (可选,用户手工覆盖)

第一级DISPLAY是设备类名,对应显示器这一类设备。第二级是型号键,命名规则是“厂商 PnP ID 前缀 + 产品码的十六进制”,例如虚构的ABC1234;它代表的是“哪一款”,不区分具体是哪一台。第三级是实例键,形如5&1a2b3c4d&0&UID4352,代表的是“这一台物理设备插在这块显卡的这个输出口上”。

实例键里有两段信息值得注意。前半段5&1a2b3c4d&0是父设备节点(也就是显卡输出)的枚举器哈希,后半段UIDxxxx是系统给这个输出口分配的序号。很多解析程序之所以分不清“哪台显示器接在哪个口上”,就是因为只看了型号键、没有看 UID。这份对应关系不是装饰,它是后面把注册表实例和实际显示输出对上的唯一依据。

2.2 写入时机:PnP 枚举、监视器 INF 与那段被缓存的原始描述符

流程大致是这样:显示适配器驱动在初始化时通过 DDC/CI 通道向显示器读取 EDID,把 128 字节的基础块(有时还有扩展块)返回给 PnP 管理器;PnP 管理器据此生成一个监视器设备节点,并加载监视器类驱动;同时,原始字节被写进Device Parameters\EDID。监视器 INF 里那些“型号名”“厂商名”字符串,本质上也是从这段字节里读出来再匹配出来的。

这带来两个直接后果。第一,这份数据是缓存,不会实时刷新:显示器热插拔时会重建实例键,但如果线材没变、显示器没换,而显示器固件被写过,注册表里的旧值不会自动更新。第二,它比 API 更“原始”:WmiMonitorID返回的是已经被系统解释过的字符串数组,而注册表里是未经加工的描述符。想做严格解析或者做兼容性排查,只能以这份原始字节为准。

常见的实现思路是:不依赖任何第三方库,直接用RegOpenKeyExW打开实例键、RegQueryValueExW读二进制。这样做的代价是要自己处理长度和校验,好处是零依赖,并且能在老系统上运行。

2.3 覆盖与残留:EDID_OVERRIDE、BadEDID 与同一型号的重复实例

Device Parameters下除了EDID,还可能存在EDID_OVERRIDE。这是给“显示器 EDID 损坏导致系统识别错误”准备的补救机制:手工放一份正确的 128 字节进去,系统读取时会优先使用覆盖值。写解析器时要知道这个优先级,否则会出现“我读到的字节和我手工覆盖的不一样”的困惑;反过来,如果产品要做诊断工具,这个键必须纳入读取范围。

还有BadEDID之类的记录键,用来保存校验失败的历史值。它的存在说明系统自己也做过校验,并且校验失败时会退回默认模式(很多机器上表现为 1024×768)。这解释了另一个常见现象:显示器明明支持 1920×1080,系统却只能给出 1024×768,问题多半不在驱动,而在那 128 字节里校验位没通过。

至于重复实例,属于正常现象。同一台显示器换过接口、换过线,甚至同一块显卡的不同输出口各插一台同型号显示器,都会在Enum\DISPLAY下留下多份键。历史残留的键不会立刻被删除,所以枚举出三份、实际只接了两台,是很常见的现象。解析器必须能容忍这种情况,不能假设“键的数量 = 显示器数量”。

3. 逐字节拆 EDID:位域、时序与字符串描述符的排列规则

拿到BYTE[128]之后,真正的活才开始。EDID 的结构是“定长头部 + 定长字段 + 四个 18 字节描述符槽位”的经典嵌入式布局,每个字节基本都被用满,没有对齐留白。这意味着任何一处偏移算错,后面全部字段都会跟着错位;这种错误不会抛异常,只会安静地解析出错误结果。

3.1 头部与厂商 PnP ID:15 bit 里的三个字母

前 8 个字节是固定的魔数00 FF FF FF FF FF FF 00,用来快速判定位。紧接着的 2 个字节是厂商 ID,用的是一种很省空间的压缩编码:三个大写字母,每个字母减0x40得到 1~26 的值,各占 5 bit,拼成 15 bit,写在大端序的两个字节里。

以虚构厂商ABC为例:A=1、B=2、C=3,拼成二进制00001 00010 00011,即0x0443,落盘时按大端写成04 43。反解就是三次右移加掩码:

// manufacturer 是 EDID 偏移 8 处的两个字节,大端 static void DecodePnpId(const BYTE* manufacturer, char out[4]) { WORD v = (WORD)((manufacturer[0] << 8) | manufacturer[1]); // 大端还原 out[0] = (char)(((v >> 10) & 0x1F) + 'A' - 1); // 第 1 个字母占 bit14..10 out[1] = (char)(((v >> 5) & 0x1F) + 'A' - 1); // 第 2 个字母占 bit9..5 out[2] = (char)(( v & 0x1F) + 'A' - 1); // 第 3 个字母占 bit4..0 out[3] = '\0'; }

这里最容易犯的错是把这两个字节按小端理解。EDID 整体是小端(产品码、序列号、像素时钟都是),唯独厂商 ID 是大端,原因在于它是“位流打包”而不是整数,前面几个字节按位顺序写入,所以高位字节在前。我一般会在这个函数旁边写一行注释说明这一点,否则后来维护的人一定会把它“修正”成小端。

再往后是 2 字节产品码、4 字节序列号(都可能为 0,表示厂商没写)、1 字节生产周、1 字节生产年。年份是相对值:实际年份 = 1990 + 该字节。生产周为0表示未指定,为0xFF表示这一年是型号年而非生产年——这两种特殊值在报表里最好单独显示,不要硬套成“第 255 周”。

3.2 视频输入定义、物理尺寸与 gamma:最容易被跳过的一段

偏移 20 开始的三个字节,是很多解析器直接跳过的一段,但对诊断接口类型很有用。

videoInputDefinition的最高位是数字/模拟开关。为 1 时,低 4 位表示接口类型(DVI、HDMI-a、HDMI-b、MDDI、DisplayPort 等),中间几位表示色深;为 0 时,表示模拟信号的同步方式与电平标准。把这一位和后面的时序字段对照,就能看出“显示器声称支持的分辨率”和“当前实际上走的接口带宽”是否匹配——很多“4K 只能跑到 30Hz”的问题,根源就在这里。

接下来两个字节是水平和垂直的物理尺寸,单位厘米。对角线英寸数是sqrt(h² + v²) / 2.54,注意是开方后除,不是分别换算再相加。投影仪和一些工控屏会把这两个字节填 0,表示“尺寸由投影距离决定”,此时不能算出 0 英寸这种荒谬结果,要单独处理。

再往后是 gamma 字节:gamma = (值 + 100) / 100,0xFF表示由扩展块定义。紧随其后的featureSupport是按位分布的:低两位表示待机、挂起、断电三种节能模式,bit1 表示首选时序,bit0 表示连续频率,bit4-3 表示显示类型(RGB 彩色、灰度、非 RGB)。首选时序这一位决定了系统默认分辨率,因此这一位如果被厂商填错,就会出现“显示器支持 2K,开机却是 1280×1024”的故障。

3.3 详细时序描述符:像素时钟除以总像素,才是刷新率

18 字节的 DTD(Detailed Timing Descriptor)是 EDID 里信息密度最高的部分,128 字节里占了 4 个槽位,也就是 72 字节。它的字段被拆成“低 8 位 + 高位散落在另一个字节的低 4 位”这种典型的省位设计:

偏移长度含义
02像素时钟,单位 10 kHz,小端
21水平有效像素低 8 位
31水平 blanking 低 8 位
41高 4 位为水平有效高 4 位,低 4 位为水平 blanking 高 4 位
51垂直有效行数低 8 位
61垂直 blanking 低 8 位
71高 4 位为垂直有效高 4 位,低 4 位为垂直 blanking 高 4 位
8~92水平同步偏移、水平同步脉宽低 8 位
101高 4 位为垂直同步偏移低 4 位,低 4 位为垂直同步脉宽低 4 位
111各位段的高 2 位,按 HSync 偏移、HSync 脉宽、VSync 偏移、VSync 脉宽依次排列
171标志位:交错、立体声、同步类型

刷新率不是直接存的,要算:

// dtd 指向 18 字节描述符起始处 int pixelClock10kHz = dtd[0] | (dtd[1] << 8); // 单位 10 kHz int hActive = dtd[2] | ((dtd[4] & 0xF0) << 4); // 有效像素 int hBlank = dtd[3] | ((dtd[4] & 0x0F) << 8); // 水平空白 int vActive = dtd[5] | ((dtd[7] & 0xF0) << 4); // 有效行 int vBlank = dtd[6] | ((dtd[7] & 0x0F) << 8); // 垂直空白 double hTotal = hActive + hBlank; double vTotal = vActive + vBlank; double refreshHz = (pixelClock10kHz * 10000.0) / (hTotal * vTotal);

用一组常见数值验算:像素时钟 148.5 MHz(14850个 10kHz 单位)、1920×1080、hTotal=2200、vTotal=1125,算出来是 60.0 Hz。这个公式看起来平淡,但它解释了一个很实际的现象:同样标称 4K 的两台显示器,一台 DTD 里像素时钟是 594 MHz,另一台是 297 MHz,那后者在满分辨率下就只能跑 30 Hz。带宽限制体现在这一个整数上,跟线材质量、显卡型号都无关。

3.4 文本描述符:0xFC / 0xFF / 0xFE 与那个 0x0A 结尾

四个描述符槽位不是每个都放时序。当某槽位的前两字节为0x0000且第三字节非 0 时,这个槽位就是“显示描述符”,第四字节是标签:

  • 0xFC显示器名称,也就是系统里显示的那个型号字符串
  • 0xFF序列号字符串
  • 0xFE无特定含义的 ASCII 文本,常见于工控屏
  • 0xFD频率范围限制,用于连续频率显示器
  • 0xFB附加白点,0xFA附加标准时序

正文从第 6 字节开始,共 13 字节。厂商的填充习惯是:有效字符后跟0x0A(换行),剩余位置填0x20(空格)。所以直接把这 13 字节当字符串输出,末尾一定拖着一串空格,中间还可能夹一个换行。正确的做法是遇到0x0A就截断,再对结果做一次尾部去空白。

还有个更隐蔽的坑:这 13 字节并非严格 ASCII。部分显示器会写入高位字节,按 UTF-8 解会变成乱码,按本地代码页解反而正常。我一般会先按CP_ACP转一次宽字符,如果结果里出现替换字符,再退回逐字节过滤可打印字符。

4. VC 侧实现:从 RegEnumKeyEx 到结构体映射的完整链路

前面都是纸面推演,落到代码上有三个必须解决的工程问题:怎么把两层键枚举干净、怎么让 128 字节不经过手工偏移就变成结构体、以及怎么判断读到的这份数据到底能不能信。

4.1 两级枚举与二进制读取:RegQueryValueEx 的长度陷阱

枚举逻辑是标准的嵌套RegEnumKeyExW。第一层枚举型号键,第二层枚举实例键,然后在实例键下尝试打开Device Parameters读EDID。注意有的实例键下根本没有Device Parameters子键,必须容忍打开失败并继续。

// 读取所有显示器实例的 EDID 原始字节 bool CollectAllEdid(std::vector<std::vector<BYTE>>& results) { HKEY hRoot = nullptr; if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Enum\\DISPLAY", 0, KEY_READ, &hRoot) != ERROR_SUCCESS) return false; for (DWORD i = 0; ; ++i) { wchar_t modelKey[256]; DWORD cchModel = 256; if (RegEnumKeyExW(hRoot, i, modelKey, &cchModel, nullptr, nullptr, nullptr, nullptr) != ERROR_SUCCESS) break; // ERROR_NO_MORE_ITEMS HKEY hModel = nullptr; if (RegOpenKeyExW(hRoot, modelKey, 0, KEY_READ, &hModel) != ERROR_SUCCESS) continue; for (DWORD j = 0; ; ++j) { wchar_t instKey[256]; DWORD cchInst = 256; if (RegEnumKeyExW(hModel, j, instKey, &cchInst, nullptr, nullptr, nullptr, nullptr) != ERROR_SUCCESS) break; // 拼出 ...\{实例}\Device Parameters,读 EDID wchar_t path[512]; swprintf_s(path, L"%s\\%s\\Device Parameters", modelKey, instKey); HKEY hParam = nullptr; if (RegOpenKeyExW(hModel, path, 0, KEY_READ, &hParam) != ERROR_SUCCESS) continue; DWORD type = 0, cb = 0; // 第一次调用只问长度:EDID 可能是 128、256、384 字节 if (RegQueryValueExW(hParam, L"EDID", nullptr, &type, nullptr, &cb) == ERROR_SUCCESS && type == REG_BINARY && cb >= 128) { std::vector<BYTE> buf(cb); if (RegQueryValueExW(hParam, L"EDID", nullptr, &type, buf.data(), &cb) == ERROR_SUCCESS) { buf.resize(cb); results.push_back(std::move(buf)); } } RegCloseKey(hParam); } RegCloseKey(hModel); } RegCloseKey(hRoot); return !results.empty(); }

这段代码里最关键的是“两次调用”的模式:RegQueryValueExW传nullptr时只返回所需长度。如果直接开一个固定 128 字节的缓冲区去读,遇到带扩展块的显示器(长度 256),函数会返回ERROR_MORE_DATA,而某些写法会忽略这个返回码,结果就是只拿到前 128 字节、后面全是未初始化内存——校验和必然不过,然后开始怀疑人生。cb读完之后还要resize一次,因为第二次调用会把cb改成实际写入长度。

4.2 结构体映射与字节序:把 128 字节直接落进内存

比手工写偏移更省事的做法,是用#pragma pack(1)定义一个完全对应的结构体,让编译器帮你算偏移:

#pragma pack(push, 1) struct EDID_BASE_BLOCK { BYTE header[8]; // 00 FF FF FF FF FF FF 00 BYTE manufacturer[2]; // 大端,5bit x 3 压缩的 PnP ID WORD productCode; // 小端 DWORD serialNumber; // 小端,可能为 0 BYTE weekOfManufacture; // 0=未指定,0xFF=型号年 BYTE yearOfManufacture; // 实际年份 = 1990 + 该值 BYTE edidVersion; BYTE edidRevision; BYTE videoInputDefinition; // bit7: 1=数字 0=模拟 BYTE horizontalSizeCm; BYTE verticalSizeCm; BYTE gamma; // (值 + 100) / 100 BYTE featureSupport; BYTE chromaticity[10]; BYTE establishedTimings[3]; BYTE standardTimings[16]; BYTE descriptors[72]; // 4 个 18 字节槽位 BYTE extensionCount; // 扩展块数量 BYTE checksum; // 前 127 字节之和的低 8 位取反加一 }; #pragma pack(pop) static_assert(sizeof(EDID_BASE_BLOCK) == 128, "EDID base block must be 128 bytes");

static_assert那一行不是凑数,它是唯一能在编译期守住这件事的东西:以后有人加字段、删字段、或者不小心去掉pack(1),编译会立刻失败,而不是等到运行时解析出一堆乱值。这段结构体里有一个坑必须强调:#pragma pack(1)只保证紧凑排列,不保证整数按小端解释。在 x86/x64 上二者一致所以能跑,但如果哪天代码被移植到别处,WORD/DWORD字段会全部变成反的。稳妥的写法是结构体里只放BYTE数组,读取时用显式的lo | (hi << 8)拼装。

4.3 校验和与扩展块:第 127 字节决定这份数据可不可信

EDID 每个 128 字节块的最后 1 字节是校验和,规则很简单:整块 128 字节按无符号累加,低 8 位必须为 0。实现只需要四行,但它是整个解析流程里性价比最高的四行——先校验再解析,可以挡掉绝大多数“解析出奇怪值”的故障。

偏移 126 的extensionCount表示后面还有几个 128 字节的扩展块。总长度 =(1 + extensionCount) * 128。常见扩展块标签:0x02是 CEA-861,0x10是视频时序块,0x40是显示信息块,0x70是 DisplayID。CEA-861 块里还有自己的详细时序和 HDMI 相关数据块,做 4K/HDMI 相关功能时必须往里继续拆,否则会漏掉基础块里没有的时序。

// 逐块校验,返回可用块数;任意一块失败即整体不可信 int ValidateBlocks(const std::vector<BYTE>& edid) { if (edid.size() < 128 || edid.size() % 128 != 0) return 0; int blocks = (int)(edid.size() / 128); for (int b = 0; b < blocks; ++b) { BYTE sum = 0; for (int k = 0; k < 128; ++k) sum = (BYTE)(sum + edid[b * 128 + k]); if (sum != 0) return b; // 返回第一个坏块的序号,便于定位 } return blocks; }

返回“第一个坏块的序号”而不是简单的 bool,是为了排查方便:如果坏块是 0,说明基础块就有问题,多半是读长度不对;如果坏块是 1,说明基础块好、扩展块坏,常见于厂商写坏扩展块或者系统只缓存了一半。这两种情况的处理方式完全不同。

4.4 交叉验证:用 DeviceID 把注册表实例和显示输出对上线

注册表里的实例键和EnumDisplayDevices返回的DISPLAY_DEVICE.DeviceID是同一套命名。DeviceID的典型形式是DISPLAY\ABC1234\5&1a2b3c4d&0&UID4352,去掉开头的DISPLAY\就是Enum\DISPLAY下面的完整相对路径。这条对应关系是打通“哪个口接了哪台显示器”的关键:

// 用 EnumDisplayDevices 拿到当前活动的输出,回查它的 EDID DISPLAY_DEVICEW dd = { sizeof(dd) }; for (DWORD i = 0; EnumDisplayDevicesW(nullptr, i, &dd, 0); ++i) { if (!(dd.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP)) continue; // 跳过未接显示器的虚拟输出 // dd.DeviceID 形如 DISPLAY\ABC1234\5&...&0&UID4352 // 把 "DISPLAY\" 之后的部分拼到 Enum 路径下,即可直接定位实例键 }

这套流程的价值在于:注册表枚举告诉你“历史上有哪些显示器”,EnumDisplayDevices告诉你“此刻哪些是活动的”,两者一求交,就得到“当前实际在用的显示器及其 EDID”。只做前者会多出残留实例,只做后者拿不到原始字节,两条路一起走才是完整的做法。

5. 排查清单:EDID 读不到、读错、读串的 5 种现场

EDID 解析的失败方式特别安静——不崩、不报错、字段看着也像模像样。下面这几条是实际项目里反复出现的现场,每条都按现象、原因、解决三步走。

5.1 现象一:键在、值空,或者整个 Device Parameters 都找不到

现象:RegOpenKeyExW成功了,Enum\DISPLAY下也确实有实例键,但Device Parameters子键不存在,或者存在却没有EDID值。程序里表现为枚举到 0 台显示器。

原因:实例键存在不代表 EDID 被成功采集过。远程桌面会话、虚拟显示适配器、通过纯 VGA 且不支持 DDC 的线材连接、以及部分 KVM 切换器场景,都可能让适配器读不到描述符。这种情况下系统照样建立设备节点,但EDID值是空的或者压根没写。

解决:不要假设“实例键数量等于可用 EDID 数量”。加一条回退路径:注册表拿不到时改用 WMI 的root\wmi命名空间下的WmiMonitorID,它至少能给出厂商名、型号名和序列号(虽然是以uint16数组形式)。如果 WMI 也空,就明确向界面报告“该输出不支持描述符读取”,而不是显示一堆 0。

5.2 现象二:校验和算出来不是 0

现象:解析出来的分辨率是 0×0,生产年份是 1990,厂商 ID 是三个问号。把 128 字节打印出来看,前半段正常,后半段全是0xCC或0x00。

原因:读取长度不对。最常见的是缓冲区固定 128 字节但实际数据是 256 字节,函数返回ERROR_MORE_DATA被忽略;其次是缓冲区申请了 256 字节但只读进来 128 字节,剩下的是vector默认初始化的 0,于是第二块校验和必然不等于 0。还有一种更隐蔽:读到了EDID_OVERRIDE但用户手工填的字节数不对。

解决:严格按照“先问长度、再分配、再读、再resize”四步走;校验时以 128 为块逐块累加,只要有一块不过就直接判为不可信,不要“先解析看看”。对EDID_OVERRIDE和EDID分别校验,只在覆盖值合法时才采用覆盖值。

5.3 现象三:显示器名称后面拖着一串乱码和空白

现象:解析出的型号名是ABC2716U后面跟着几个空格和一个换行,或者干脆是ABC2716U???。

原因:13 字节的文本区不是以\0结尾的 C 字符串,厂商用0x0A作为结束标记、用0x20填充剩余位置。直接memcpy到char[14]并加\0,就会把填充字符一起吃进去。出现问号则是编码问题:部分显示器写入的不是纯 ASCII。

解决:先扫到第一个0x0A或0x00截断,再从尾部去掉所有0x20;然后按CP_ACP转宽字符,如果转换结果里出现替换字符再逐字节过滤不可打印项。截断和去空白这两步顺序不能反,否则中间的换行会留在字符串里。

5.4 现象四:三台显示器只解析出两台,或者同一台出现两次

现象:界面上的显示器列表数量和实际接的数量对不上,重复项和缺失项同时存在。

原因:Enum\DISPLAY是历史累积的,换线、换口、重装驱动都会留下旧实例键。同一台显示器接在显卡的两个输出口上,会出现两个不同的UID,看起来就像两台。

解决:不要直接枚举注册表就出结果。先用EnumDisplayDevicesW取当前活动输出集合,把每个DeviceID去掉DISPLAY\前缀后拼到Enum\DISPLAY\下定位实例键,再读 EDID。这样天然过滤掉残留实例,也顺带解决了“哪个口接哪台”的映射问题。如果业务上确实需要展示历史设备,把“活动”和“历史”分成两组显示,不要混在一个列表里。

5.5 现象五:x86 下解析正常,x64 下字段全错位

现象:同一份代码、同一台显示器,32 位构建出来的信息完全正确,64 位构建出来的产品码、序列号、尺寸全是乱的。

原因:结构体映射时用了位域或者在结构体里混放了WORD/DWORD,而位域的分配顺序、跨编译单元的对齐策略是实现定义的行为。再叠加#pragma pack写法不一致(有人在头文件里 push 了却没 pop),偏移就会整体漂移。

解决:结构体里只放BYTE数组,所有多字节字段用显式的lo | (hi << 8)拼装,彻底摆脱对齐和字节序的依赖;static_assert(sizeof(...) == 128)必须保留;#pragma pack的 push/pop 成对出现,中间不要#include任何第三方头文件。

6. 设计取舍:为什么值得自己解析这 128 字节

获取显示器信息的路线不止一条,选哪条取决于你要的是“够用”还是“准确”:

路线拿得到什么拿不到什么适用场景
注册表 EDID 原始字节全部字段,含时序、色度、接口类型需要自己解析与校验诊断工具、资产采集、兼容性排查
WMIWmiMonitorID厂商名、型号名、序列号、生产周时序、尺寸、色度、接口类型快速展示型号信息
EnumDisplayDevices+GetMonitorInfo当前分辨率、工作区、设备 ID几乎所有硬件描述信息布局与窗口管理
EDID_OVERRIDE覆盖值人工修正后的正确描述符只在你手工填过时才有修复损坏的显示器固件数据

取舍的核心不在于“哪条更简单”,而在于“错误会以什么形式暴露”。WMI 那条路最省事,但它返回的是已经被系统解释、清洗、必要时还做过截断的字符串,一旦解析错了,你没有任何原始依据可以追查。注册表这条路的代价是要自己处理长度、校验、编码、对齐,好处是每一步都能对回原始的 128 个字节——出问题时能明确回答“是数据本身坏了,还是我的解析错了”。

验证方法我一般用三步。第一步,拿三台不同年代、不同接口的显示器,把原始字节 dump 出来人工比对:头部魔数是否一致、厂商 ID 反解出的三个字母是否和设备标签一致、DTD 算出的刷新率是否和系统设置里的一致。第二步,故意构造坏数据:把某个字节改一位、把长度截成 128 但声称有扩展块、把校验位置零,看程序是拒绝解析还是输出乱值。第三步,和 WMI 的结果做交叉比对,两边型号名不一致时,优先相信能通过校验和的那一份。

还有一个容易被忽略的进阶方向:扩展块。基础块只覆盖到 1920×1080 这一代,4K、HDR、高刷新率的信息大量落在 CEA-861 扩展块的详细时序和厂商数据块里。基础块解析通只能算入门,能不能顺着extensionCount逐块拆下去、在扩展块里正确找到第二个 DTD,才是这套逻辑真正完整的标志。做这块时最好把每块的标签和长度都打印出来,我第一次接扩展块时就是漏了块内的数据块长度字段,把后面的数据当成了时序,结果算出一个 8 Hz 的刷新率,排查了半天才定位到偏移。

从那以后我每次写显示器信息模块,都强制把长度校验和逐块校验放在解析函数的第一行,宁可返回“数据不可信”,也不返回一个看起来正常但其实是错的值。希望帮到你。

本文还有配套的精品资源,点击获取