☰
Windows注册表EDID解析:获取显示器真实型号与序列号
2026/10/27 17:09:49 网站建设 项目流程

简介:面向使用VC++进行Windows系统编程的开发者,这份项目源码聚焦从注册表读取显示器EDID二进制数据并解析设备信息这一实用场景,适合希望理解硬件信息获取、注册表访问与二进制结构解析的中级学习者参考。压缩包共21个文件,约2.38MB,包含5个cpp与6个h源文件,辅以rc资源脚本、vcproj与sln工程配置、ico图标及ReadMe说明等,便于在Visual Studio中查看工程组织与编译结构。目前已有1713人学习。源码围绕EDID的128字节结构展开,示范通过RegOpenKeyEx、RegQueryValueEx等API定位Video子键、读取EDID值,并将制造商、型号、分辨率、刷新率等字段转换为可读信息;同时给出注册表封装类与对话框界面实现,可帮助读者掌握二进制数据偏移解析、显示能力判断及系统监测、硬件诊断类应用的开发思路。

1. 做显示器盘点被“通用即插即用监视器”卡住的那次

做显示器资产盘点、批量验机或售后核对硬件的人,大多被同一件事卡过:设备管理器里能看到好几台显示器,型号却一律是“通用即插即用监视器”,序列号一栏空着。想用 VC 调 Windows 接口拿真实参数,返回的往往只有一句宽泛的设备描述。真东西其实在注册表里——HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY下每个显示器实例的Device Parameters键里,都存着一块名为EDID的二进制值,128 字节,厂商 PnP ID、产品代码、序列号、物理尺寸、刷新范围全写死在里面。这个项目源码做的就是枚举显示器设备实例、把这块 EDID 抠出来,再按 EDID 协议逐字段解析成可读的显示器信息,附带一个能直接移植的解析类。适合做设备管理、装机验机工具、资产采集脚本的开发者拿去改。

2. 数据链路拆解:从设备枚举到注册表 EDID 二进制值

在动手写解析之前,得先搞清楚这块 128 字节的数据在系统里是怎么放的、能通过哪几条路拿到。我见过不少人一上来就CreateFile打开\\.\DISPLAY1去问显示器型号,结果拿到的是适配器名字,白折腾半天。这个项目的思路是反过来的:先枚举显示器设备实例,再顺着实例路径去注册表里取原始 EDID,最后自己解协议。这条路最稳,因为 EDID 是显示器固件里的原始数据,操作系统只是做了个搬运工。

2.1 注册表里 EDID 到底藏在哪个键

Windows 把每个即插即用设备都挂在HKLM\SYSTEM\CurrentControlSet\Enum下面,显示器属于DISPLAY类。层级大概长这样:

HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY └── ABC1234 ← 硬件ID,前3位是厂商PnP ID └── 5&2f7c1a3&0&UID4352 ← 设备实例ID,每台机器不同 └── Device Parameters ├── EDID ← REG_BINARY,128或256字节 └── ...

键名ABC1234里的ABC就是 EDID 中的厂商 PnP ID,后面几位是产品代码。这个值不是猜的,是系统从 EDID 里反推出来拼的,所以从这里就能先做一次交叉验证。EDID值的类型是REG_BINARY,长度一般是 128 字节,支持扩展的显示器会是 256 字节(前 128 是基础块,后 128 是扩展块)。

真正要读的就是Device Parameters下的这个值。注意路径里有两个容易翻车的点:一是Enum\DISPLAY\...中间这段实例 ID 本身就带反斜杠,拼路径时别再重复加Enum\;二是不同 Windows 版本下CurrentControlSet可能是个符号链接,用RegOpenKeyEx打开时让它自己解析就行,不要手动拼ControlSet001。

2.2 三条获取路径的取舍:SetupAPI、EnumDisplayDevices、WMI

同一个 EDID,Windows 给了至少三条路,项目里我一般优先用 SetupAPI,原因是对比一下就清楚了。

方式拿到的数据优点局限
SetupAPI(GUID_DEVCLASS_MONITOR)设备实例 ID、硬件 ID、可定位注册表键稳定、覆盖所有已安装显示器、能拿到原始 EDID代码略多,要链接setupapi.lib
EnumDisplayDevicesDISPLAY_DEVICE.DeviceID调用简单,一行一个显示器只覆盖当前显示适配器下激活的屏幕,虚拟屏、已断开的历史设备拿不到
WMI(WmiMonitorID类)已解析好的厂商、名称、序列号不用自己解 128 字节依赖 WMI 服务,精简系统或服务被禁用时直接失败

项目选 SetupAPI 做主线,是因为要的是“原始 EDID”而不是“已经解析好的字段”。WMI 给的是加工过的字符串,遇到需要判断色深、刷新范围、扩展块这类细节时就不够用了;而 SetupAPI 拿到实例 ID 之后,注册表键就在手边,原始数据完整。EnumDisplayDevices我一般只用来做辅助校验,比如确认这台显示器当前是否处于活动状态。

2.3 工程目录与模块职责划分

这套源码的目录结构不复杂,核心就是把“读”和“解析”拆开,方便单独测试:

MonitorEdid/ ├── MonitorEdid.sln ├── src/ │ ├── main.cpp // 控制台入口,遍历并打印 │ ├── EdidReader.h/.cpp // SetupAPI枚举 + 注册表读取 │ ├── EdidParser.h/.cpp // 128字节协议解析 │ └── MonitorInfo.h // 输出结构体定义 └── res/

EdidReader只负责“拿到字节流”,EdidParser只负责“把字节流翻译成结构体”,两者之间用一个std::vector<unsigned char>传递。这个拆分看着简单,但后面加扩展块解析、加缓存、换数据源(比如从文件读 EDID dump)时,改动面会小很多。如果原始工程是 MFC 对话框,把EdidParser挂到按钮响应里、把结果填进CListCtrl就行,解析层完全不用动。

3. EDID 协议拆解:128 字节怎么读成显示器参数

EDID 是 VESA 定的标准,1.3 和 1.4 版本在基础块上基本兼容。项目里解析的是基础块 128 字节,字段偏移是固定的,不需要动态判断长度。这一章按字节顺序把关键字段过一遍,每个字段给出偏移和解析方式,后面写代码时直接对着抄。

3.1 头部校验与厂商 PnP ID 的 5 位编码

前 8 个字节是固定头00 FF FF FF FF FF FF 00,不匹配基本可以判定数据无效或读错了键。第 8、9 字节是厂商 ID,大端存储的 16 位值,但编码方式很特殊:三个字母各占 5 位,从高位往低位排。

// edid[8]、edid[9] 是大端存储的16位厂商码 unsigned short mfg = (static_cast<unsigned short>(edid[8]) << 8) | edid[9]; char vendor[4]; vendor[0] = static_cast<char>(((mfg >> 10) & 0x1F) + 'A' - 1); // 高5位 vendor[1] = static_cast<char>(((mfg >> 5) & 0x1F) + 'A' - 1); // 中5位 vendor[2] = static_cast<char>(( mfg & 0x1F) + 'A' - 1); // 低5位 vendor[3] = '\0';

参数说明:每个字母的编码值是“字母序号”,A 对应 1,不是 0,所以解析时要先减 1 再加'A'。举例,0x0443拆出来00001 00010 00011,就是ABC。这个值应该和注册表实例 ID 的前三位一致,不一致说明数据被动过或者读到了别的设备。

产品代码在偏移 10、11,小端:productCode = edid[10] | (edid[11] << 8)。序列号在偏移 12 到 15,小端 32 位。这两个字段经常被忽略,但做资产核对时序列号是唯一标识,比显示器名称靠谱得多。

3.2 物理尺寸、gamma 与特性位

从偏移 16 开始是制造信息和显示参数。制造周在 16,制造年在 17,年份要加 1990 才是真实年份。偏移 18、19 是 EDID 版本和修订号,1.3 和 1.4 在扩展块处理上有差异,解析扩展块前先看这两个字节。

偏移 21、22 是水平和垂直物理尺寸,单位厘米。这两个值用途很大:可以反推屏幕对角线英寸数,做“屏幕多大”的快速判断。计算公式是sqrt(w*w + h*h) / 2.54,注意结果会有 0.1 到 0.3 英寸的误差,因为厂商上报的是取整后的厘米。

double widthCm = edid[21]; double heightCm = edid[22]; double inch = std::sqrt(widthCm * widthCm + heightCm * heightCm) / 2.54;

偏移 23 是 gamma 值,实际 gamma 等于(edid[23] + 100) / 100.0。如果这个字节是0xFF,表示 gamma 定义在扩展块里,基础块里不提供,这时候不要硬算,标成“未知”更诚实。

偏移 20 是视频输入定义,第 7 位区分数字还是模拟输入,数字输入时低 4 位表示接口类型(1 是 DVI、2 是 HDMI-a、5 是 DisplayPort)。偏移 24 是特性支持位,第 4 位表示显示类型(RGB 彩色还是单色),第 1 位表示是否支持首选时序。这些位做设备分类很有用,比如按接口类型统计一批机器的接线情况。

3.3 描述符块:显示器名称、序列号与范围限制

偏移 54 到 125 是 4 个 18 字节的描述符块,这是 EDID 里信息密度最高的部分。每个描述符前 5 个字节是类型标识,常见的四种:

前5字节类型内容
00 00 00 FC 00显示器名称偏移 5 起 13 字节 ASCII 文本
00 00 00 FF 00序列号字符串同上
00 00 00 FE 00无特定字符串同上
00 00 00 FD 00范围限制垂直/水平频率、最大像素时钟

名称描述符是很多人最想要的字段。13 字节文本以0x0A换行符结尾,后面补0x20空格填充。解析时遇0x0A就停,再把尾部空格 trim 掉,否则拿到的字符串会带一串看不见的空格,做匹配时对不上。

for (int i = 0; i < 4; ++i) { const unsigned char* d = edid + 54 + i * 18; if (d[0] == 0 && d[1] == 0 && d[2] == 0 && d[3] == 0xFC) { std::string name; for (int k = 5; k < 18; ++k) { if (d[k] == 0x0A) break; // 换行符截止 if (d[k] >= 0x20 && d[k] <= 0x7E) // 只收可见ASCII name.push_back(static_cast<char>(d[k])); } while (!name.empty() && name.back() == ' ') name.pop_back(); // trim尾部空格 info.name = name; } }

范围限制描述符(0xFD)的字节 5 到 9 分别是:最小垂直频率、最大垂直频率、最小水平频率、最大水平频率、最大像素时钟除以 10(单位 MHz)。做兼容性检查时,把当前分辨率对应的刷新率丢进去比一下,超出范围就说明这块屏在这个模式下是超频跑的。

4. 核心代码落地:读取、校验、解析三段式

协议讲完,落到代码上就是三个步骤:枚举显示器拿到实例 ID、按实例 ID 读注册表里的 EDID 二进制、把字节流解析成结构体。项目里这三步分别对应三个函数,串起来就是一个完整的采集流程。下面按调用顺序写,每段都能单独编译测试。

4.1 用 SetupAPI 枚举显示器并定位实例路径

枚举用SetupDiGetClassDevs,类 GUID 用GUID_DEVCLASS_MONITOR,标志加DIGCF_PRESENT只取当前在位的设备。遍历时用SetupDiGetDeviceInstanceId拿到实例 ID,这个 ID 就是后面拼注册表路径的关键。

#include <windows.h> #include <setupapi.h> #include <devguid.h> #include <vector> #include <string> #pragma comment(lib, "setupapi.lib") // 返回所有在位显示器的设备实例ID,形如 DISPLAY\ABC1234\5&2f7c1a3&0&UID4352 std::vector<std::wstring> EnumMonitorInstanceIds() { std::vector<std::wstring> ids; HDEVINFO h = SetupDiGetClassDevsW(&GUID_DEVCLASS_MONITOR, nullptr, nullptr, DIGCF_PRESENT); if (h == INVALID_HANDLE_VALUE) return ids; SP_DEVINFO_DATA data{}; data.cbSize = sizeof(data); for (DWORD i = 0; SetupDiEnumDeviceInfo(h, i, &data); ++i) { DWORD size = 0; SetupDiGetDeviceInstanceIdW(h, &data, nullptr, 0, &size); // 先问长度 if (size > 0) { std::wstring id(size, L'\0'); if (SetupDiGetDeviceInstanceIdW(h, &data, &id[0], size, &size)) { id.resize(size - 1); // 去掉结尾的\0 ids.push_back(id); } } } SetupDiDestroyDeviceInfoList(h); // 必须释放,否则句柄泄漏 return ids; }

逻辑说明:SetupDiGetDeviceInstanceIdW调用两次是标准做法,第一次传nullptr拿所需缓冲区大小,第二次才真正取数据。参数里的size在成功返回后是包含结尾\0的长度,所以resize要减 1。SetupDiDestroyDeviceInfoList不能漏,循环里反复调用枚举函数时泄漏会很明显。

4.2 读取 EDID 二进制值并做长度与校验和检查

拿到实例 ID 后,拼注册表路径读EDID值。这里有个细节:实例 ID 开头的DISPLAY\已经包含在返回值里了,拼路径时直接接SYSTEM\CurrentControlSet\Enum\后面,不要再补一层。

bool ReadEdid(const std::wstring& instanceId, std::vector<unsigned char>& edid) { std::wstring key = L"SYSTEM\\CurrentControlSet\\Enum\\" + instanceId + L"\\Device Parameters"; HKEY hk; if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, key.c_str(), 0, KEY_READ, &hk) != ERROR_SUCCESS) return false; DWORD type = 0, size = 0; if (RegQueryValueExW(hk, L"EDID", nullptr, &type, nullptr, &size) != ERROR_SUCCESS || type != REG_BINARY || size < 128) { RegCloseKey(hk); return false; } edid.resize(size); LONG r = RegQueryValueExW(hk, L"EDID", nullptr, &type, edid.data(), &size); RegCloseKey(hk); // 无论成功失败都要关 return r == ERROR_SUCCESS; }

同样的“两次查询”套路:第一次拿size,同时校验类型必须是REG_BINARY、长度至少 128。长度小于 128 的直接判失败,不要试图解析半截数据。读出来之后做一次校验和检查:

bool EdidChecksumOk(const std::vector<unsigned char>& e) { if (e.size() < 128) return false; unsigned char sum = 0; for (int i = 0; i < 128; ++i) sum = static_cast<unsigned char>(sum + e[i]); return sum == 0; // 基础块128字节累加应为0 }

校验和是 EDID 自带的完整性机制,128 字节累加模 256 等于 0 才算合规。但实际环境里虚拟显示器、远程桌面的虚拟屏经常写出不合规的 EDID,校验失败时我的做法是记一条警告、继续解析,而不是直接丢弃,后面第 5 章会细说。

4.3 解析成结构体并输出(含厂商ID与描述符提取)

结构体定义把所有要输出的字段集中起来,解析函数一处赋值,UI 或导出层直接读。

struct MonitorInfo { std::string vendor; // 3字母PnP ID unsigned short productCode = 0; unsigned int serial = 0; int year = 0, week = 0; int widthCm = 0, heightCm = 0; double gamma = 0.0; std::string name; // 描述符里的显示器名称 std::string serialStr; // 描述符里的序列号字符串 bool checksumOk = false; }; MonitorInfo ParseEdid(const std::vector<unsigned char>& e) { MonitorInfo info; if (e.size() < 128) return info; // 头部校验 const unsigned char header[8] = {0x00,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0x00}; if (memcmp(e.data(), header, 8) != 0) return info; info.checksumOk = EdidChecksumOk(e); // 厂商ID(5-5-5编码) unsigned short mfg = (static_cast<unsigned short>(e[8]) << 8) | e[9]; info.vendor.push_back(static_cast<char>(((mfg >> 10) & 0x1F) + 'A' - 1)); info.vendor.push_back(static_cast<char>(((mfg >> 5) & 0x1F) + 'A' - 1)); info.vendor.push_back(static_cast<char>(( mfg & 0x1F) + 'A' - 1)); info.productCode = static_cast<unsigned short>(e[10] | (e[11] << 8)); info.serial = e[12] | (e[13] << 8) | (e[14] << 16) | (e[15] << 24); info.week = e[16]; info.year = e[17] + 1990; info.widthCm = e[21]; info.heightCm = e[22]; info.gamma = (e[23] == 0xFF) ? 0.0 : (e[23] + 100) / 100.0; // 4个描述符块 for (int i = 0; i < 4; ++i) { const unsigned char* d = e.data() + 54 + i * 18; if (d[0] || d[1] || d[2]) continue; // 非描述符块跳过 std::string text; for (int k = 5; k < 18; ++k) { if (d[k] == 0x0A) break; if (d[k] >= 0x20 && d[k] <= 0x7E) text.push_back(static_cast<char>(d[k])); } while (!text.empty() && text.back() == ' ') text.pop_back(); if (d[3] == 0xFC) info.name = text; else if (d[3] == 0xFF) info.serialStr = text; } return info; }

逻辑说明:先做头部和长度校验,不通过直接返回空结构体。厂商 ID 和产品代码按前面讲的偏移取值,描述符循环里用d[0] || d[1] || d[2]先过滤掉非描述符块(描述符块前 3 字节固定为 0),再用d[3]判断类型。0xFC是名称、0xFF是序列号字符串,这两类是最常要的,其他类型按需加分支即可。gamma 为0xFF时置 0,输出层显示“扩展块定义”,不要显示成 0 误导人。

主函数里把三步串起来,控制台逐台打印:

int wmain() { for (const auto& id : EnumMonitorInstanceIds()) { std::vector<unsigned char> edid; if (!ReadEdid(id, edid)) continue; MonitorInfo info = ParseEdid(edid); printf("%s | %s | %u | %d-%d | %dx%d cm | %s\n", info.vendor.c_str(), info.name.c_str(), info.serial, info.year, info.week, info.widthCm, info.heightCm, info.checksumOk ? "ok" : "checksum-fail"); } return 0; }

到这里一条完整的采集链路就跑通了。换成 MFC 的话,把printf换成往CListCtrl插行,解析层一行不用改。

5. 排查实录:EDID 读取失败的常见现场

这套代码逻辑不复杂,但在不同机器上跑,翻车的方式五花八门。下面几条是我在实际项目里真遇到过的,按“现象 → 原因 → 解决”记一下,省得下次再查一遍。

5.1 打开注册表返回 ERROR_FILE_NOT_FOUND

现象:EnumMonitorInstanceIds明明返回了非空列表,ReadEdid却全部失败,RegOpenKeyEx返回 2。

原因:路径拼接时把Enum\写重复了,或者实例 ID 前面多带了\\?\前缀。另一种情况是拿EnumDisplayDevices返回的DeviceID去拼,那个字符串格式和 SetupAPI 的实例 ID 不一样,不能混用。

解决:把拼出来的完整路径打印出来,对照注册表编辑器手动看一遍。正确的形态是SYSTEM\CurrentControlSet\Enum\DISPLAY\<硬件ID>\<实例ID>\Device Parameters,DISPLAY只出现一次。

5.2 EDID 读到 256 字节,显示器名称乱码

现象:某些机器读出来的size是 256,按 128 解析名称时出现乱码或者解析到一串看不懂的字符。

原因:把扩展块当成基础块解析了。256 字节是“基础块 + 一个扩展块”,扩展块的结构和基础块完全不同,偏移 54 往后不是描述符。

解决:解析函数里只取前 128 字节,if (e.size() >= 128)之后固定按 128 的偏移读。扩展块要单独判断e[126](扩展块数量)再走另一套解析逻辑,不要混在一个循环里。

5.3 校验和不为 0,但其他字段看着都正常

现象:EdidChecksumOk返回 false,可厂商、名称、尺寸都解析出来了,数据看起来没坏。

原因:虚拟显示器驱动、远程桌面虚拟屏、部分 KVM 切换器写入的 EDID 本身不合规,校验和字节没算对。这种情况在物理显示器上很少见,在虚拟环境里很常见。

解决:校验和只作为“可信度标记”,不作为丢弃依据。解析照常进行,输出时带一个checksumOk标志,让上层决定要不要采信。直接丢弃会导致虚拟屏完全采不到数据,反而更麻烦。

5.4 多屏机器只拿到一台显示器的信息

现象:接了双屏,程序只输出一条记录。

原因:SetupDiGetClassDevs的类 GUID 用错了,用了显示适配器的类而不是显示器的类;或者遍历循环里SP_DEVINFO_DATA没有每次重置cbSize。

解决:类 GUID 必须是GUID_DEVCLASS_MONITOR。另外SP_DEVINFO_DATA在每次SetupDiEnumDeviceInfo前要保证cbSize已赋值,放在循环外初始化一次、循环内不要改,但结构体本身每次由 API 填充,不要手动清空导致cbSize丢失。

5.5 名称描述符尾部带一串空格,字符串比较对不上

现象:解析出的名称是"ABC2716Q ",拿去做==比较永远不相等。

原因:EDID 名称字段固定 13 字节,文本不足时用0x20空格填充,很多实现只按 13 字节截断,没处理填充。

解决:循环里遇0x0A立即 break,循环结束后再while (!name.empty() && name.back() == ' ') pop_back()把尾部空格清干净。这两个动作缺一不可,只做其中一个都清不干净。

6. 二次开发:扩展块解析与多屏映射的改造思路

拿到基础块解析之后,真正做产品化还要往前推两步。第一步是处理扩展块。支持 HDR 的显示器、4K 高刷屏,很多关键参数(色域、亮度、色深)都在扩展块里。改造时在ParseEdid里判断e[126] > 0 && e.size() >= 256,然后把e.data() + 128传给一个独立的ParseExtensionBlock函数。扩展块偏移 0 是 tag(0x02是 CEA-861、0x70是 DisplayID),按 tag 分派,不要一股脑按 CEA 解。这块我一般先只取 CEA 的“数据块集合”里的视频能力字段,够用就行,全量解析成本很高。

第二步是多屏映射。枚举出来的是“设备实例”,但用户关心的是“哪台屏在左边、哪台是主屏”。EnumDisplayDevices能给出适配器与显示器的对应关系,EnumDisplaySettings能拿到当前分辨率和刷新率,把这三份数据按“显示器设备名”关联起来,就能画出“物理位置 → EDID 信息 → 当前模式”的对照表。项目里我是这么串的:

// 伪代码:把EDID信息和当前显示模式关联 for (DWORD i = 0; ; ++i) { DISPLAY_DEVICEW dd{}; dd.cb = sizeof(dd); if (!EnumDisplayDevicesW(nullptr, i, &dd, 0)) break; // dd.DeviceID 可与SetupAPI实例ID做前缀匹配 DEVMODEW dm{}; dm.dmSize = sizeof(dm); if (EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS, &dm)) { // dm.dmPelsWidth / dm.dmPelsHeight / dm.dmDisplayFrequency } }

匹配时别指望字符串完全相等,dd.DeviceID里带的是DISPLAY\...格式,和 SetupAPI 实例 ID 只差大小写和前缀,做不区分大小写的包含匹配更稳。

还有一个容易被忽略的点是缓存。EDID 在显示器不插拔的情况下不会变,每次启动都全量枚举注册表在设备多的机器上会拖慢启动。我一般第一次采集后按实例 ID 做内存缓存,同时监听WM_DEVICECHANGE里的DBT_DEVICEARRIVAL和DBT_DEVICEREMOVECOMPLETE,插拔时只刷新变化的那一台。

最后说个血泪教训:解析函数里所有对edid数组的访问,下标一定要在size()校验之后进行,我早期版本图省事在长度检查前就读了e[126],在某台只上报 100 字节的虚拟屏上直接越界崩溃。从那以后我每次改解析逻辑,都强制先跑一遍“长度不足 128”和“校验和失败”这两个边界用例,再上真机。希望这套拆解能帮你少走点弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询