简介:面向Windows平台C++/MFC开发者与硬件诊断爱好者的VC++示例源码,聚焦从系统注册表读取显示器EDID并解析设备参数。项目以HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Video下的显示设备子键为入口,演示RegOpenKeyEx、RegQueryValueEx等API读取“EDID”二进制值,再按128字节结构拆分固定头部、基本显示信息、附加显示信息与校验块,提取制造商、型号、物理尺寸、色深、刷新率及最大水平/垂直分辨率等规格,并以可读形式呈现,帮助理解Windows显示设备信息的组织方式。压缩包共21个文件,约2.38MB,包含6个h头文件、5个cpp源文件,以及vcproj与sln工程文件、rc资源脚本、ico图标、txt说明和aps/ncb/suo等VC辅助文件,可直接打开工程对照学习MFC对话框与注册表封装代码。已有1713人学习,适合借此掌握注册表访问、二进制数据转换与EDID字段解析,并用于系统监测、硬件诊断或显示器配置管理等场景。
1. 从一块「不认识自己」的屏幕说起:EDID 为什么是显示器信息的源头
把一台陌生的显示器插到 Windows 机器上,不装任何驱动,系统就能报出它的型号、支持的分辨率、甚至生产年份。多数人以为系统「问」了显示器,实际上不是——系统只是从一条两线制的 I2C 总线上,把显示器内部一颗 EEPROM 里的 128 字节原样读出来,再缓存进注册表。这 128 字节就是 EDID(Extended Display Identification Data)。在 VC 项目里做显示器信息采集,绕不开的就是从 Monitor 注册表里把这 128 字节取出来,再按 EDID 规范逐字段解析。它之所以值得搞懂,是因为一切「免驱」的显示能力协商都压在这段二进制上:分辨率上限、刷新率、色域、物理尺寸、甚至显示器名字,全是从里面解出来的。
2. DDC 与 I2C:128 字节是怎么从显示器搬进注册表的
2.1 DDC 通道与 I2C 地址 0x50
EDID 存放在显示器侧的一颗 EEPROM 里,访问通道叫 DDC(Display Data Channel)。DDC 是 VESA 定义的一组协议族,底层跑在 I2C 上。VGA 时代用 DDC2B,DVI、HDMI 沿用了同一套语义,DisplayPort 换成了 AUX 通道,但「读一块固定地址的 EEPROM」这件事没变。
I2C 是两根线:SCL 时钟、SDA 数据。主机是 master,显示器 EEPROM 是 slave,7 位地址固定为0x50,写成 8 位就是写0xA0、读0xA1。读 EDID 的过程就是一个标准的 I2C EEPROM 随机读序列:
- 主机发 START
- 发
0xA0(地址 + 写) - 发
0x00(字地址,EDID 起始偏移) - 发重复 START
- 发
0xA1(地址 + 读) - 连续读 128 字节,每字节主机回 ACK,最后一字节回 NACK
- 发 STOP
这个序列跟普通 24Cxx EEPROM 读一块数据没有区别,VESA 只是把「偏移 0x00 开始读 128 字节」这个用法规范化了。
注意:有些 EEPROM 页大小就是 128 字节,连续读跨页会回卷到页首。Windows 的显示驱动在读取时会自己处理跨页,但如果是在嵌入式侧或自研板卡上直接操作 I2C 控制器,就要在 128 字节边界重新设置字地址,否则读回来的后半段是前半段的重复。
2.2 从 HPD 到注册表:Windows 的 PnP 缓存流程
显示器插入或上电时,接口上的 HPD(Hot Plug Detect)引脚电平会变化。端口驱动或显示驱动收到这个信号后,发起一次 DDC 读取,拿到 128 字节基础块,校验通过后从中解出厂商 ID、产品代码和序列号,交给 PnP 管理器创建设备节点。
最终落盘的位置是注册表:
HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\<PnP ID>\<实例ID>\Device Parameters\EDID拆开看每一级:
DISPLAY是设备类,对应GUID_DEVCLASS_MONITOR<PnP ID>由 EDID 里的三字母厂商 ID 加四位产品代码拼成,比如三个字母加1234<实例ID>通常带总线路径和 UID,形如5&2a3b4c5d&0&UID4352,格式跟 Windows 版本和总线类型有关,不能硬编码Device Parameters下的EDID是REG_BINARY,内容就是原始字节,不做任何转码
这条链路的关键在于:注册表里存的是「原始 EDID」,不是「解析后的字段」。Windows 自己解析一遍用于 PnP 匹配,但把原始数据留了下来,等于给上层应用留了一个可以自己重新解释的口子。VC 项目读注册表的价值就在这里——拿到的是一手数据,解析逻辑完全可控。
2.3 为什么注册表里的 EDID 可能和显示器对不上
这里有几个反复踩过的坑。
KVM 切换器是第一大类。不少 KVM 会自己伪造一份 EDID 给主机,或者透传失败时返回固定值,结果系统读到的是 KVM 的「假身份」,跟实际接的显示器对不上。扩展坞也类似,部分扩展坞会缓存 EDID,显示器换到另一个口后读到旧值。
系统缓存是第二类。Windows 有一套显示器缓存机制,历史连接过的设备节点会保留在Enum\DISPLAY下不删除。如果只是遍历这个键,会把几年前插过的显示器也枚举出来。DIGCF_PRESENT标志就是用来过滤掉这些不在线设备的。
第三类是显示器固件本身的问题。个别型号 EDID 更新后系统没有重新读取,必须拔插一次或重启才刷新;睡眠唤醒后 DDC 读取失败,也会沿用旧缓存。这属于玄学范畴,工程上能做的是在解析前做校验和检查,校验失败时降级而不是直接崩。
3. EDID 二进制解剖:128 字节里到底编码了什么
3.1 头部与厂商 ID 的 5-bit 编码
基础块的布局是固定的,按偏移量列表最清楚:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0-7 | 8 | 固定头00 FF FF FF FF FF FF 00 |
| 8-9 | 2 | 厂商 ID(5-bit × 3,大端) |
| 10-11 | 2 | 产品代码(小端) |
| 12-15 | 4 | 序列号(小端) |
| 16 | 1 | 制造周 |
| 17 | 1 | 制造年(+1990) |
| 18-19 | 2 | EDID 版本 / 修订 |
| 20 | 1 | 视频输入定义 |
| 21-22 | 2 | 水平 / 垂直尺寸(cm) |
| 23 | 1 | Gamma |
| 24 | 1 | 特性支持位 |
| 25-34 | 10 | 色度坐标 |
| 35-37 | 3 | Established Timings |
| 38-53 | 16 | Standard Timings(8 组) |
| 54-125 | 72 | 4 个 18 字节描述符 |
| 126 | 1 | 扩展块数量 |
| 127 | 1 | 校验和 |
厂商 ID 的编码是最容易被忽略的一处。三个字母 A-Z 映射到 1-26,压进 15 个 bit:bit14-10 是第一字母,bit9-5 是第二字母,bit4-0 是第三字母,bit15 保留为 0。比如三个字母分别是 1、2、3,拼出来就是0b0_00001_00010_00011,即0x0843,在 EDID 里按大端存成08 43。
// 从 EDID 第 8、9 字节解出三字母 PnP 厂商 ID // EDID 里是大端存储,x86 上不能直接按 WORD 读 void DecodeManufacturerId(const BYTE* edid, char out[4]) { WORD raw = (WORD)((edid[8] << 8) | edid[9]); // 手动拼大端 out[0] = (char)(((raw >> 10) & 0x1F) + 'A' - 1); out[1] = (char)(((raw >> 5) & 0x1F) + 'A' - 1); out[2] = (char)((raw & 0x1F) + 'A' - 1); out[3] = '\0'; }这段代码里raw >> 10取的是 bit14-10,raw >> 5取 bit9-5,最低 5 位直接掩码。三个字母都减 1 再加'A',是因为编码从 1 开始而不是 0。参数没有别的入口,edid必须至少 128 字节且已经通过头部校验。
3.2 基本参数与标准时序
制造日期两字节:周范围 1-53,年要加 1990。这两个字段在很多工具里显示成「第 32 周 / 2021 年」这种形式,比时间戳更贴近出厂信息。
第 20 字节的视频输入定义决定了后面怎么解释:bit7 为 1 表示数字输入,为 0 表示模拟。数字输入时,bit6-4 是位深(0016bit、0108bit、01110bit),bit3-0 是接口类型(HDMI-a、HDMI-b、MDDI、DisplayPort 等)。模拟输入时这几个位的含义完全不同,混着读会得到荒谬结果。
第 21、22 字节是可见区域的物理尺寸,单位厘米。注意这是面板可见区,不是外框尺寸,不能用它去反推显示器的外形。
第 24 字节的特性位里有 DPMS 支持、首选时序类型、sRGB 默认色域、持续频率等标志,做显示能力判断时会用到。
Established Timings 是三字节位图,覆盖 640×480@60、800×600@60、1024×768@60 这类老分辨率。Standard Timings 是 8 组、每组 2 字节,编码方式是:
- 水平像素 =
(byte1 + 31) * 8,范围 248 到 2288 - byte2 的 bit7-6 是宽高比(00=16:10,01=4:3,10=5:4,11=16:9)
- byte2 的 bit5-0 是刷新率减 60
所以一组0x81 0x80解出来是(0x81 + 31) * 8 = 1216像素宽、16:10、60Hz,对应 1280×800 这一档。字段都是无符号整数,没有负数偏移。
3.3 显示器描述符:名字、序列号和范围限制
从偏移 54 开始的 72 字节被切成 4 个 18 字节块,每个块有两种可能:Detailed Timing Descriptor(DTD)或者 Monitor Descriptor。
判断依据在前两个字节:如果0x0000,就是 Monitor Descriptor;否则是 DTD,前两字节是像素时钟(单位 10kHz,小端)。
Monitor Descriptor 的第 3 字节是 tag:
0xFC:显示器名称0xFF:序列号字符串0xFE:任意 ASCII 字符串0xFD:范围限制(最小/最大垂直频率、水平频率、最大像素时钟)0xFB、0xFA:附加标准时序
名称描述符的内容区从第 5 字节开始,13 字节 ASCII,通常以0x0A换行结尾,不足的部分补空格。解析时要同时处理「换行截断」和「尾部空格裁剪」两种情况,否则拿到的名字后面会拖一串空白。
DTD 的 18 字节里塞了像素时钟、水平/垂直有效像素、消隐、前沿、同步脉宽、图像尺寸和标志位。水平方向的有效像素低 8 位在第 2 字节,高 4 位在第 4 字节的高半字节;垂直方向同理,低 8 位在第 5 字节,高 4 位在第 7 字节。这种高低位拆开存的写法贯穿整个 DTD,逐字段读的时候要非常小心位偏移。
3.4 扩展块与 CEA-861
第 126 字节是扩展块数量。每个扩展块也是 128 字节,紧跟在基础块后面。
扩展块的第 0 字节是 tag:
| tag | 类型 |
|---|---|
0x02 | CEA-861 |
0x10 | VTB-EXT |
0x40 | DI-EXT |
0x50 | LS-EXT |
0x70 | DisplayID |
0xF0 | 块映射 |
CEA-861 是现代显示器最关键的一块。它的第 2 字节是 DTD 起始偏移,第 3 字节是标志位(underscan、音频、YUV 等),从第 4 字节开始是一串「数据块」:视频数据块(VDB)、音频数据块(ADB)、扬声器分配块、VESA DTC 块,以及 HDMI 特有的 HDR 静态元数据块(tag0x06)、HDMI VSDB(tag0x03)。
4K@60、HDR、音频回传这些能力,基本都在 CEA 扩展块的数据块里。只解析基础块的程序,在一台 4K 显示器上会得到「最大 1920×1080」的错误结论——基础块里的 Standard Timings 编码范围根本装不下 4K 的像素数。
4. VC 解析实现:从注册表读取到结构体映射的完整链路
4.1 用 SetupAPI 定位在线显示器
直接遍历Enum\DISPLAY能拿到数据,但会混进历史缓存。更规范的做法是走 SetupAPI,用GUID_DEVCLASS_MONITOR过滤设备类,用DIGCF_PRESENT只取当前在线的。
#include <windows.h> #include <setupapi.h> #include <devguid.h> #include <vector> #pragma comment(lib, "setupapi.lib") // 枚举当前在线的显示器,逐个读 Device Parameters\EDID void EnumMonitorEdid() { HDEVINFO devInfo = SetupDiGetClassDevs( &GUID_DEVCLASS_MONITOR, // 显示器设备类 NULL, NULL, DIGCF_PRESENT); // 只要当前存在的,过滤历史缓存 if (devInfo == INVALID_HANDLE_VALUE) return; SP_DEVINFO_DATA devData = { sizeof(SP_DEVINFO_DATA) }; for (DWORD i = 0; SetupDiEnumDeviceInfo(devInfo, i, &devData); ++i) { // 打开该设备的 Device Parameters 键 HKEY hKey = SetupDiOpenDevRegKey( devInfo, &devData, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ); if (hKey == INVALID_HANDLE_VALUE) continue; DWORD type = 0, size = 0; // 第一次调用只查大小,避免缓冲区不够 if (RegQueryValueExA(hKey, "EDID", NULL, &type, NULL, &size) == ERROR_SUCCESS && type == REG_BINARY && size >= 128) { std::vector<BYTE> edid(size); if (RegQueryValueExA(hKey, "EDID", NULL, &type, edid.data(), &size) == ERROR_SUCCESS) { // 到这里 edid 里就是原始字节,交给后面的解析函数 } } RegCloseKey(hKey); } SetupDiDestroyDeviceInfoList(devInfo); }GUID_DEVCLASS_MONITOR定义在devguid.h,对应的是{4d36e96e-e325-11ce-bfc1-08002be10318}。SetupDiOpenDevRegKey的DICS_FLAG_GLOBAL表示打开全局(硬件)配置而不是当前用户配置,DIREG_DEV表示目标键是Device Parameters。如果换成DIREG_DRV,打开的是Driver键,里面没有EDID值——这是最容易写错的一处。
4.2 读取二进制值与长度判断
注册表值名就是EDID,四个大写字母,类型是REG_BINARY。大小写不敏感,但工程上建议统一写大写。
长度上,基础块固定 128 字节,带扩展块时可能是 256、384、512 甚至更多。size >= 128是硬性下限,但真正决定能解析多少的是edid[126]这个扩展块数量字段:
// 按扩展块数量校验实际数据是否完整 bool CheckEdidLength(const std::vector<BYTE>& edid) { if (edid.size() < 128) return false; int extCount = edid[126]; size_t need = 128 + (size_t)extCount * 128; return edid.size() >= need; }这段逻辑的意义在于:如果驱动只写入了 128 字节,而edid[126]声称有 2 个扩展块,说明数据被截断了,后面按扩展块解析会越界。宁可在这里判定数据不完整,也不要让后面的循环去踩内存。
4.3 描述符与字符串解析
4 个描述符块从偏移 54 开始,每块 18 字节。找显示器名称就是遍历这四块,匹配 tag 为0xFC的那一块。
// 从 4 个描述符块中提取显示器名称,tag = 0xFC bool ParseMonitorName(const BYTE* edid, char out[14]) { for (int i = 0; i < 4; ++i) { const BYTE* d = edid + 54 + i * 18; // Monitor Descriptor 特征:前两字节为 0,第 3 字节为 0,第 4 字节是 tag if (d[0] == 0 && d[1] == 0 && d[2] == 0 && d[3] == 0xFC) { int n = 0; for (int j = 5; j < 18 && n < 13; ++j) { if (d[j] == 0x0A || d[j] == 0x00) break; // 换行或终止符 out[n++] = (char)d[j]; } while (n > 0 && out[n - 1] == ' ') --n; // 裁掉尾部空格填充 out[n] = '\0'; return true; } } return false; }out缓冲区至少 14 字节,因为内容区最长 13 字符再加结束符。j从 5 开始是因为前 5 字节是标识区。如果四块里都没有0xFC,函数返回false,调用方应该准备一个兜底名称,比如用 PnP ID 拼一个,而不是显示空字符串。
4.4 头部与校验和验证
EDID 的校验规则很简单:基础块 128 字节求和,低 8 位必须为 0;每个扩展块同理,各自 128 字节独立校验。
// 校验 EDID 头部魔数和每个 128 字节块的校验和 bool ValidateEdid(const BYTE* edid, size_t size) { if (size < 128) return false; // 头部必须是 00 FF FF FF FF FF FF 00 if (edid[0] != 0x00 || edid[7] != 0x00) return false; for (int i = 1; i < 7; ++i) if (edid[i] != 0xFF) return false; // 基础块校验和 int sum = 0; for (int i = 0; i < 128; ++i) sum += edid[i]; if ((sum & 0xFF) != 0) return false; // 逐个扩展块校验 int extCount = edid[126]; if (128 + (size_t)extCount * 128 > size) return false; for (int e = 0; e < extCount; ++e) { const BYTE* blk = edid + 128 + e * 128; int s = 0; for (int i = 0; i < 128; ++i) s += blk[i]; if ((s & 0xFF) != 0) return false; } return true; }sum & 0xFF == 0是 EDID 校验的定义,不是「接近 0」也不是「等于 256」,必须整除。扩展块数量字段在第 126 字节,这是解析前必须读的元信息,不读它就没法判断数据边界。
5. 避坑与排查:EDID 解析最容易翻车的五个点
5.1 只读了 128 字节,4K 信息全丢
现象:一台支持 4K@60 和 HDR 的显示器,解析出来的最大分辨率却只有 1920×1080。
原因:只处理了基础块。基础块里的 Standard Timings 编码范围装不下 3840 这种像素数,4K 时序和 HDR 元数据全在 CEA-861 扩展块里。
解决:读完基础块后,检查edid[126]的扩展块数量,按 128 字节一块继续读,遇到 tag 为0x02的块再解析 VDB 和 HDR 数据块。不做扩展块解析,就不要声称自己「读懂了 EDID」。
5.2 厂商 ID 解出来是乱码
现象:厂商 ID 位置显示成@K@或类似乱码。
原因:字节序搞反了。edid[8]是高字节、edid[9]是低字节,如果在 x86 上直接把这两个字节当WORD读,小端会把它反过来。
解决:不要用*(WORD*)&edid[8],手动(edid[8] << 8) | edid[9]拼。产品代码和序列号相反,是标准小端,直接读即可。同一个结构里两种字节序并存,是 EDID 最容易翻车的地方之一。
5.3 枚举出五六台显示器,实际只接了一台
现象:机器上只有一台显示器,程序却报告了五六个设备。
原因:Enum\DISPLAY下保留了历史连接过的设备节点,Windows 不会主动清理。直接遍历这个键,拿到的是「曾经存在过」的全集。
解决:用SetupDiGetClassDevs加DIGCF_PRESENT,或者在直接遍历注册表时结合EnumDisplayDevices做交叉验证。只按 PnP ID 匹配也不够——同型号多台显示器的 PnP ID 相同,必须用实例 ID 区分。
5.4 远程桌面和虚拟机里拿不到 EDID
现象:在远程会话或虚拟机里运行程序,EDID值不存在或者长度异常。
原因:虚拟显示适配器没有真实的 DDC 通道,EDID 要么是驱动伪造的固定值,要么干脆没有。远程桌面会话下,本地显示器信息根本不会传进来。
解决:用GetSystemMetrics(SM_REMOTESESSION)判断会话类型,远程会话直接跳过 EDID 读取,走 UI 提示或兜底配置。虚拟机场景下可以检测设备实例路径里是否包含虚拟总线特征,作为降级依据。
5.5 校验和失败但显示器工作正常
现象:ValidateEdid返回false,但显示器显示一切正常。
原因:部分显示器或转接器的 EDID 校验和本身就是错的,出厂就没算对;还有一种是读取过程中被截断,导致求和结果偏离。校验和只是完整性提示,不是「数据可用」的充分条件。
解决:校验失败时记录一条警告,继续按偏移解析能解出的字段,不要直接丢弃整份数据。但如果连续多个块都校验失败,就要怀疑数据源本身有问题——比如 KVM 返回的是伪造值,这时候解析出来的型号可能是错的,需要在 UI 上标注「数据完整性存疑」。
6. 注册表、WMI 与 DXGI:三条取数路径的取舍
VC 项目里拿显示器信息,实际有三条路可走,各自的定位不一样。
| 路径 | 数据来源 | 能拿到什么 | 代价 |
|---|---|---|---|
注册表Enum\DISPLAY | 驱动缓存的原始 EDID | 完整 128 字节 + 扩展块 | 要自己解析、含历史缓存、需要读权限 |
WMIroot\wmi | WMI 类封装 | 语义化字段,如WmiMonitorID、WmiMonitorBasicDisplayParams | 字段有限、依赖 WMI 服务、单次查询较慢 |
DXGI /EnumDisplayDevices | 显示驱动当前状态 | 当前活动输出、桌面坐标、连接状态 | 不给原始 EDID,只给当前模式 |
WMI 的WmiMonitorID里,ManufacturerName、ProductCodeID、SerialNumberID、UserFriendlyName都是UInt16数组,解码方式是每个元素取低字节转 ASCII,这跟直接解 EDID 里的字符串是一回事,只是中间多了一层封装。它的好处是字段名自带语义,不用记偏移量;坏处是字段覆盖不全,而且某些型号上UserFriendlyName会是空数组。
DXGI 的IDXGIOutput::GetDesc返回DXGI_OUTPUT_DESC,里面有DeviceName、DesktopCoordinates、AttachedToDesktop、Monitor,但它描述的是「当前输出状态」,不是「显示器能力」。想知道这台显示器最大支持多少刷新率,DXGI 只能给出驱动当前协商出来的模式列表,而不是 EDID 里声明的完整能力集。
所以合理的组合是:用 DXGI 或EnumDisplayDevices确定「当前有哪些活动输出」,用 SetupAPI 定位对应的设备实例,再读注册表里的原始 EDID 做完整解析,最后用 WMI 的语义字段做一次交叉校验。单走任何一条都会缺一块。
我印象最深的一次,是某开发者在一个模拟项目 X 里用 WMI 拿显示器名称,本地测试全过,到客户现场某个型号上UserFriendlyName返回空数组,UI 上直接显示空白。排查半天才确认是那批显示器的 WMI 提供程序没填这个字段,最后回退到注册表解析 EDID 的0xFC描述符才拿到名字。从那以后我每次做显示器信息采集,都强制走一遍「头部校验 → 基础块解析 → 扩展块数量检查 → 0xFC 名称兜底」这条链路,WMI 只当辅助,不当主数据源。希望帮到你。
本文还有配套的精品资源,点击获取