Windows虚拟显示器MttVDD源码分析 (2) EDID与显示器模拟

175 阅读8分钟

在上一章 配置与设置管理 中,我们了解了 MttVDD 是如何“记住”我们想要创建什么样的显示器的。我们通过修改配置文件,告诉了驱动程序要创建几个显示器,以及它们应该支持哪些分辨率。

但是,仅仅有这些想法还不够。我们如何让 Windows 这个“大管家”相信我们真的有一个显示器,并且愿意和它“对话”呢?这就是本章要解决的核心问题:创造一个足以以假乱真的虚拟显示器身份证

为什么需要“身份证”?

想象一下,你走进一个需要身份验证才能进入的大楼。如果你没有身份证,保安会让你进去吗?当然不会。

Windows 系统对待显示器也是如此。当你把一个真实的显示器插入电脑时,它会立刻向 Windows 出示自己的“身份证”。这张“身份证”告诉 Windows:“你好,我是一台戴尔显示器,我的型号是 U2721DE,我最高支持 2560x1440 分辨率,刷新率最高 60Hz,我还支持 sRGB 色彩空间……”

这张特殊的“身份证”就叫做 EDID(Extended Display Identification Data,扩展显示标识数据)。

对于 MttVDD 创造的虚拟显示器来说,它没有物理实体,自然也拿不出这张“身份证”。因此,驱动程序的核心任务之一,就是凭空伪造一张完美的 EDID,然后郑重地交给 Windows。只有当 Windows 看到并相信了这张 EDID,它才会承认这个虚拟显示器的存在,并在显示设置中把它列出来。

EDID:虚拟显示器的灵魂

EDID 本质上是一块很小的数据,通常是 128 或 256 字节。但这小小的几百个字节里,却包含了显示器的所有关键信息。MttVDD 能否成功模拟显示器,完全取决于它提供的 EDID 是否“真实可信”。

MttVDD 提供了两种生成 EDID 的方式:

  1. 使用内置的默认 EDID:驱动程序代码里已经预先写好了一份通用的 EDID 数据。这是最简单、最可靠的方式,足以应对大多数基本需求。
  2. 使用用户提供的自定义 EDID:如果你想模拟一个特定的、具有高级功能(比如 HDR)的显示器,你可以提供一个自己的 EDID 文件,驱动会加载它。

1. 内置的“万能身份证”

Driver.cpp 文件中,你可以找到一个名为 hardcodedEdid 的变量。它就是一个 std::vector<BYTE>(字节向量),里面存储了 MttVDD 默认的 EDID 数据。

// Driver.cpp

// 一个预先定义好的、通用的 EDID 数据
vector<BYTE> hardcodedEdid =
{
    // 头部信息
    0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 
    // 制造商和产品ID (这里是虚拟的)
    0x36, 0x94, 0x37, 0x13, 0xe7, 0x1e, 0xe7, 0x1e,
    // ... 省略了其他几十个字节的数据 ...
    // 校验和 (最后一个字节,稍后会计算)
    0x8c 
};

这份 EDID 定义了一个通用的虚拟显示器,包含了像 1920x1080、2560x1440 等常见的分辨率和刷新率信息。对于初学者来说,你完全不需要关心它的具体内容,只需要知道这是驱动的“保底”方案。

2. 加载自定义“专属身份证”

MttVDD 的强大之处在于它的可定制性。通过在配置文件 vdd_settings.xml 中将 <CustomEdid> 设置为 true,你可以让驱动加载一个你自己准备的 EDID 文件。

这个文件必须命名为 user_edid.bin,并放在驱动的安装目录(通常是 C:\VirtualDisplayDriver)下。

这个加载过程由 loadEdid 函数负责。它的逻辑非常直接:

// Driver.cpp - loadEdid 函数的简化逻辑

vector<BYTE> loadEdid(const string& filePath) {
    // 检查 "CustomEdidEnabled" 设置是否为 true
    if (customEdid) {
        // ... 尝试从 filePath 读取 user_edid.bin 文件 ...
        if (文件成功读取) {
            vddlog("i", "正在使用自定义 EDID");
            return 文件内容;
        }
    }

    // 如果不使用自定义 EDID,或者文件读取失败,就返回内置的 EDID
    vddlog("i", "正在使用内置的硬编码 EDID");
    return hardcodedEdid;
}

这个设计非常灵活,它优先尝试满足用户的定制需求,同时保证在定制失败时总有一个可靠的备用方案。

内部实现:EDID 的诞生之旅

我们已经知道了 EDID 的来源,但一份原始的 EDID 数据是如何被处理并最终呈现在 Windows 面前的呢?这个过程的核心在一个叫做 maincalc 的函数里。

maincalc:EDID 的“总装车间”

你可以把 maincalc 函数想象成一个“身份证总装车间”。当驱动启动时,它会被调用来完成 EDID 的最后准备工作。

下面是 maincalc 函数的简化工作流程:

sequenceDiagram
    participant Driver as 驱动启动
    participant MainCalc as maincalc()
    participant LoadEdid as loadEdid()
    participant File as user_edid.bin
    participant GlobalVar as 全局EDID变量

    Driver->>MainCalc: 调用,准备EDID
    MainCalc->>LoadEdid: 请求EDID原材料
    alt "CustomEdid" 已启用
        LoadEdid->>File: 尝试读取 user_edid.bin
        File-->>LoadEdid: 返回文件数据 (如果存在)
    end
    LoadEdid-->>MainCalc: 返回EDID数据 (自定义或内置)
    MainCalc->>MainCalc: 计算并填写校验和
    MainCalc->>GlobalVar: 存储最终完成的EDID

这个流程中有个非常关键的步骤:计算校验和 (Checksum)

什么是校验和?

想象一下,你在寄送一份有 127 个数字的重要文件。为了防止对方收到的数字有误,你在文件的末尾附上了第 128 个数字,这个数字是前面所有 127 个数字的总和。接收方收到后,会自己把前 127 个数字加一遍,然后和你附上的总和进行比较。如果两个数对得上,就说明文件在传输过程中没有出错。

EDID 的校验和就是这个“总和”。它确保了 EDID 数据的完整性和正确性。Windows 在接收到 EDID 时,会做的第一件事就是验证校验和。如果校验和错误,Windows 会直接拒绝这份 EDID,认为这个显示器“有问题”。

maincalc 函数会调用 calculateChecksum 函数,严格按照标准计算出正确的校验和,然后把它填写到 EDID 数据的最后一个字节(第 127 字节)上。

// Driver.cpp

int maincalc() {
    // 1. 从 loadEdid 函数获取原始的 EDID 数据
    vector<BYTE> edid = loadEdid(WStringToString(confpath) + "\\user_edid.bin");

    // 2. (可选) 如果未禁用厂商信息伪造,则修改制造商信息
    if (!preventManufacturerSpoof) {
        modifyEdid(edid);
    }
    
    // 3. 计算正确的校验和
    BYTE checksum = calculateChecksum(edid);

    // 4. 将正确的校验和写入 EDID 的最后一个字节
    edid[127] = checksum;
    
    // 5. 将这份完美的 EDID 存入一个全局静态变量,等待被使用
    IndirectDeviceContext::s_KnownMonitorEdid = edid;
    return 0;
}

执行完 maincalc 后,一份合法、有效的“显示器身份证”就准备就绪了,它被存放在 IndirectDeviceContext::s_KnownMonitorEdid 中,随时可以“出示”。

CreateMonitor:向 Windows 出示“身份证”

“身份证”造好了,下一步就是在合适的时机把它交给 Windows。这个时机就是驱动创建虚拟显示器的时候,具体发生在 IndirectDeviceContext::CreateMonitor 函数中。

这个函数负责与 Windows 的 IddCx (Indirect Display Driver Class eXtension) 框架交互,正式“注册”一个新的显示器。

// Driver.cpp - 在 IndirectDeviceContext::CreateMonitor 中

void IndirectDeviceContext::CreateMonitor(unsigned int index) {
    // ... 其他准备工作 ...

    // 准备一个描述显示器信息的结构体
    IDDCX_MONITOR_INFO MonitorInfo = {};
    MonitorInfo.MonitorDescription.Size = sizeof(MonitorInfo.MonitorDescription);
    MonitorInfo.MonitorDescription.Type = IDDCX_MONITOR_DESCRIPTION_TYPE_EDID;

    // 关键步骤:告诉 Windows 我们的“身份证”在哪里,有多大
    MonitorInfo.MonitorDescription.DataSize = static_cast<UINT>(s_KnownMonitorEdid.size());
    MonitorInfo.MonitorDescription.pData = IndirectDeviceContext::s_KnownMonitorEdid.data();
    
    // ... 其他信息填充 ...

    // 正式向 IddCx 提交创建请求,Windows 会读取并解析 pData 指向的 EDID
    IDARG_OUT_MONITORCREATE MonitorCreateOut;
    IddCxMonitorCreate(m_Adapter, &MonitorCreate, &MonitorCreateOut);

    // 通知 Windows "显示器已插入"
    IddCxMonitorArrival(m_Monitor, &ArrivalOut);
}

IddCxMonitorCreateIddCxMonitorArrival 被调用时,Windows 就像那位大楼保安,它会接过我们递上的 s_KnownMonitorEdid 数据,仔细检查一番(包括验证校验和)。一旦确认无误,它就会高兴地认为:“哦,一个新的显示器连接上来了!” 随后,你就能在系统的显示设置里看到这个全新的虚拟显示器了。

总结

在本章中,我们揭开了虚拟显示器如何“欺骗”Windows 的秘密。我们学到了:

  • EDID 是什么:它是显示器的“身份证”,详细描述了显示器的各项能力。
  • 为什么它很重要:没有一个合法有效的 EDID,Windows 根本不会承认虚拟显示器的存在。
  • EDID 的来源MttVDD 可以使用内置的 hardcodedEdid,也可以加载用户提供的 user_edid.bin 文件,这为高级定制提供了可能。
  • EDID 的准备过程maincalc 函数是核心的“总装车间”,它负责加载 EDID 数据并计算至关重要的校验和
  • EDID 的提交CreateMonitor 函数在创建显示器时,通过 IddCx 框架将准备好的 EDID 呈报给 Windows,从而完成虚拟显示器的“注册”。

至此,我们的虚拟显示器已经在 Windows 系统中拥有了合法的“身份”。但是,仅仅被识别还不够,Windows 还需要知道如何与我们的驱动程序进行持续的沟通,例如:当用户更改分辨率时该怎么办?当需要开始传输桌面图像时又该怎么通知驱动?

这些问题都将通过一系列“回调函数”来解决。在下一章,我们将深入了解这些连接驱动与系统的“电话线”——WDF/IddCx 回调。