2015 年 8 月

第 30 卷,第 8 期

借助 C++ 进行 Windows 开发 - 含有 MIDL 的 Windows 运行时组件

作者 Kenny Kerr | 2015 年 8 月

Kenny Kerr我在 2015 年 7 月的专栏 (msdn.microsoft.com/magazine/mt238401) 中引入了 Windows 运行时 (WinRT) 组件的概念,并将其作为 COM 编程范例的发展。尽管 Win 32 将 COM 搁置一旁,Windows 运行时却将 COM 放在首要位置。Windows 运行时是 Win32 的后续版本,后者成为 Windows API 的泛称,因为它包括多种不同的技术和编程模型。Windows 运行时提供了一致且统一的编程模型,但为了让 Windows 运行时帮助 Microsoft 内外开发人员取得成功,需要提供更好的工具来开发 WinRT 组件并从应用中使用这些组件。

Windows SDK 为了满足这一需求所提供的主要工具就是 MIDL 编译器。在 7 月的专栏中,我演示了 MIDL 编译器如何生成绝大多数语言投射需要的 Windows 运行时元数据 (WINMD) 文件以使用 WinRT 组件。当然,长期在 Windows 平台上从事开发工作的任何开发人员都会注意到,MIDL 编译器还会生成 C 或 C++ 编译器可以直接使用的代码。事实上,MIDL 本身对 WINMD 文件格式一无所知。这主要是关于分析 IDL 文件以及为 C 和 C++ 编译器生成代码,以便支持 COM、远程过程调用 (RPC) 开发和代理 DLL 的生产。MIDL 编译器是机器历史上一个如此重要的环节,以致开发 Windows 运行时的工程师选择不冒中断它的风险,转为开发仅对 Windows 运行时负责的“子编译器”。开发人员并非都知道这个秘密方法,也并不需要知道,但这一方法有助于解释 MIDL 编译器的实际工作原理。

让我们来看一些 IDL 源代码并查看 MIDL 编译器究竟起了什么作用。下面是定义一个经典 COM 接口的 IDL 源文件:

C:\Sample>type Sample.idl
import "unknwn.idl";
[uuid(e21df825-937d-4b0b-862e-e411b57e280e)]
interface IHen : IUnknown
{
  HRESULT Cluck();
}

经典 COM 没有强烈的命名空间概念,因此,IHen 接口简单地在文件范围进行了定义。IUnknown 的定义也必须在使用之前导入。然后,我只需通过 MIDL 编译器传递此文件来生成许多项目。

C:\Sample>midl Sample.idl
C:\Sample>dir /b
dlldata.c
Sample.h
Sample.idl
Sample_i.c
Sample_p.c

dlldata.c 源文件包含几个宏,可为代理 DLL 实现必要的导出。Sample_i.c 包含 IHen 接口的 GUID,以防万一您还在使用缺少对将 GUID 附加到类型的 uuid __declspec 提供支持的 25 年前的编译器。然后是 Sample_p.c,包含代理 DLL 的封送处理说明。我会暂时忽略这些,而是重点介绍 Sample.h,其中包含一些使用起来非常方便的内容。如果您忽略所有旨在帮助 C 开发人员使用 COM(恐怖!)的可怕宏,您会发现以下内容:

MIDL_INTERFACE("e21df825-937d-4b0b-862e-e411b57e280e")
IHen : public IUnknown
{
public:
  virtual HRESULT STDMETHODCALLTYPE Cluck( void) = 0;
};

它不是一流的 C++,但在预处理后,它相当于一个从 IUnknown 继承并添加其自身一个纯虚拟函数的 C++ 类。这非常方便,因为它意味着您不必进行手动编写,那样可能会引入接口的 C++ 定义与其他工具和语言可能使用的原始 IDL 定义之间的不匹配。因此,这就是 MIDL 编译器为 C++ 开发人员所提供内容的本质,以 C++ 编译器可以直接使用这些类型的方式生成 IDL 源代码的转换。

现在,让我们返回讨论 Windows 运行时。我将稍微更新 IDL 源代码以符合 WinRT 类型更严格的要求:

C:\Sample>type Sample.idl
import "inspectable.idl";
namespace Sample
{
  [uuid(e21df825-937d-4b0b-862e-e411b57e280e)]
  [version(1)]
  interface IHen : IInspectable
  {
    HRESULT Cluck();
  }
}

WinRT 接口必须直接从 IInspectable 继承,并且命名空间部分用于将类型与实现组件相关联。如果我尝试像以前一样进行编译,就会遇到以下问题:

.\Sample.idl(3) : error MIDL2025 : syntax error : expecting an interface name or DispatchInterfaceName or CoclassName or ModuleName or LibraryName or ContractName or a type specification near "namespace"

MIDL 编译器无法识别命名空间关键字,并会放弃。/winrt 命令行选项就是用来解决这个问题的。它会告诉 MIDL 编译器将命令行直接传递给 MIDLRT 编译器以预处理 IDL 源文件。MIDLRT 编译器就是第二个编译器,它需要我已在 7 月份的专栏中提及的 /metadata_dir 命令行:

C:\Sample>midl /winrt Sample.idl /metadata_dir
  "C:\Program Files (x86)\Windows Kits ..."

作为这一解决方案的进一步证据,请仔细查看 MIDL 编译器输出,您就会明白我的意思了:

C:\Sample>midl /winrt Sample.idl /metadata_dir "..."
Microsoft (R) 32b/64b MIDLRT Compiler Engine Version 8.00.0168
Copyright (c) Microsoft Corporation. All rights reserved.
MIDLRT Processing .\Sample.idl
.
.
.
Microsoft (R) 32b/64b MIDL Compiler Version 8.00.0603
Copyright (c) Microsoft Corporation. All rights reserved.
Processing C:\Users\Kenny\AppData\Local\Temp\Sample.idl-34587aaa
.
.
.

我已经删除某些依赖关系的处理以便突出显示关键点。在退出前,使用 /winrt 选项调用 MIDL 可执行文件,会盲目地将命令行传递到 MIDLRT 可执行文件。MIDLRT 先分析 IDL 以生成 WINMD 文件,但它还会生成其他临时 IDL 文件。此临时 IDL 文件是对原始文件的转换,以原始 MIDL 编译器可以接受的方式替换了所有特定于 WinRT 的关键字(如命名空间)。然后,MIDLRT 再次调用 MIDL 可执行文件,但不使用 /winrt 选项,而使用临时 IDL 文件的位置,因此,它能生成与以前完全相同的 C 和 C++ 标头原始集以及源文件。

删除原始 IDL 文件中的命名空间,并在临时 IDL 文件中修饰 IHen 接口的名称,方法如下所示:

interface __x_Sample_CIHen : IInspectable
.
.
.

在使用预处理的输出调用 MIDL 时,这实际是(给定 MIDLRT 使用的 /gen_namespace 命令行选项的)MIDL 编译器解释的类型名称的编码形式。然后,原始 MIDL 编译器可以直接进行处理,而无需具备 Windows 运行时的专业知识。这只是一个示例,但这能够让您了解全新工具如何充分利用现有技术来完成工作。如果您感兴趣,想了解其工作方式,您可能会仔细浏览 MIDL 编译器生成的临时文件夹,结果发现上一个示例中的这些 Sample.idl 34587aaa 文件缺失。MIDLRT 可执行文件会在运行后非常小心地进行清理,但 MIDL 如果包括 /savePP 命令行选项时,则不会删除这些临时预处理器文件。无论如何,这样引发了更多的预处理,并且生成的 Sample.h 现在具有甚至 C++ 编译器将会识别为一个命名空间的内容:

namespace Sample {
  MIDL_INTERFACE("e21df825-937d-4b0b-862e-e411b57e280e")
  IHen : public IInspectable
  {
  public:
    virtual HRESULT STDMETHODCALLTYPE Cluck( void) = 0;
  };
}

然后,我可以同以前一样实现此接口,确信编译器会选取我的实现以及我在 IDL 中进行编码的原始定义之间的任何差异。另一方面,如果您只需要 MIDL 来生成 WINMD 文件,且并不需要 C 或 C++ 编译器的所有源文件,您可以使用 /nomidl 命令行选项避免所有的额外生成项目。通过 MIDL 可执行文件将此选项以及其余所有选项传递到 MIDLRT 可执行文件。然后,在 MIDLRT 已经生成 WINMD 文件后,它会再次跳过调用 MIDL 的最后一步。在使用由 MIDL 生成的 Windows 运行时 ABI 时,包含 /ns_prefix 命令行选项也是惯例,以便生成的类型和命名空间包含在 “ABI”命名空间内,以下所示:

namespace ABI {
  namespace Sample {
    MIDL_INTERFACE("e21df825-937d-4b0b-862e-e411b57e280e")
    IHen : public IInspectable
    {
    public:
      virtual HRESULT STDMETHODCALLTYPE Cluck( void) = 0;
    };
  }
}

最后,我应该提醒您的是,MIDL 和 MIDLRT 都不足以生成充分描述组件类型的独立 WINMD 文件。如果您碰巧引用了外部类型(通常由操作系统定义的其他类型,迄今描述的过程生成的 WINMD 文件),必须仍与面向的 Windows 版本的主要元数据文件进行合并。我将说明此问题。

我将从 IDL 命名空间入手,描述实现此接口的 IHen 接口和可激活的 Hen 类,如图 1 所示。

图 1 IDL 中的 Hen 类

namespace Sample
{
  [uuid(e21df825-937d-4b0b-862e-e411b57e280e)]
  [version(1)]
  interface IHen : IInspectable
  {
    HRESULT Cluck();
  }
  [version(1)]
  [activatable(1)]
  runtimeclass Hen
  {
    [default] interface IHen;
  }
}

然后,我将使用 7 月份专栏中描述的相同技术来实现它,只不过现在我可以依赖于 MIDL 编译器提供的 IHen 定义。现在,在 WinRT 应用中,我只需创建 Hen 对象并调用 Cluck 方法。我将使用 C# 来说明该等式的应用端:

public void SetWindow(CoreWindow window)   
{
  Sample.IHen hen = new Sample.Hen();
  hen.Cluck();
}

SetWindow 方法是由 C# 应用提供的 IFrameworkView 实现的一部分。(我在 2013 年 8 月的专栏中描述了 IFrameworkView,您可以在 msdn.microsoft.com/magazine/jj883951 中进行查阅。) 这当然适用。C# 完全依赖于描述该组件的 WINMD 元数据。另一方面,它确实可以使与 C# 客户端共享本机 C++ 代码变得轻而易举。无论如何,在大多数情况下都可以做到这一点。出现的一个问题是,如果您引用我刚才提到过的外部类型。让我们将 Cluck 方法更新为需要 CoreWindow 作为参数。CoreWindow 是由操作系统定义的,因此,无法在我的 IDL 源文件中对其进行定义。

首先,我将 IDL 更新为依赖于 ICoreWindow 接口。我只需导入定义,如下所示:

import "windows.ui.core.idl";

然后,我将 ICoreWindow 参数添加到 Cluck 方法:

HRESULT Cluck([in] Windows.UI.Core.ICoreWindow * window);

MIDL 编译器会将该“导入”转换为其生成的标头内的 #include of windows.ui.core.h,因此只需更新我的 Hen 类实现:

virtual HRESULT __stdcall Cluck(ABI::Windows::UI::Core::ICoreWindow *) 
  noexcept override
{
  return S_OK;
}

现在,我可以像以前一样编译该组件,并将其发送给应用开发人员。C# 应用开发人员通过对应用的 CoreWindow 的引用,尽职尽责地更新 Cluck 方法调用,如下所示:

public void SetWindow(CoreWindow window)
{
  Sample.IHen hen = new Sample.Hen();
  hen.Cluck(window);
}

遗憾的是,C# 编译器现在会提示错误:

error CS0012: The type 'ICoreWindow' is defined in an assembly
  that is not referenced.

您知道,C# 编译器无法识别相同的接口。C# 编译器并不满足于只是一个匹配的类型名称,并且无法与具有相同名称的 Windows 类型建立连接。与 C++ 不同,C# 在很大程度上依赖于二进制类型信息来建立关联。若要解决此问题,我可以使用 Windows SDK 提供的其他工具,撰写或合并来自 Windows 操作系统的元数据以及我的组件的元数据,正确地将 ICoreWindow 解析为操作系统的主元数据文件。此工具称为 MDMERGE:

c:\Sample>mdmerge /i . /o output /partial /metadata_dir "..."

MIDLRT 和 MDMERGE 可执行文件对其命令行参数非常挑剔。您需要将命令行参数设置正确以使其正常工作。在这种情况下,我不能只是通过将 /i(输入)和 /o(输出)选项指向同一文件夹来就地更新 Sample.winmd,因为完成后,MDMERGE 实际上会删除输入 WINMD 文件。/partial 选项通知 MDMERGE 查找 /metadata_dir 选项提供的元数据中未解析的 ICoreWindow 接口。这称为引用元数据。因此,MDMERGE 可用于合并多个 WINMD 文件,但在这种情况下,我只将其用于解析操作系统类型的引用。

这样,引用 ICoreWindow 接口时,生成的 Sample.winmd 会正确地指向 Windows 操作系统的元数据,而且 C# 编译器得以满足,将编译编写的应用。下个月,请和我一起继续从 C++ 的角度来探索 Windows 运行时。


Kenny Kerr 是一名加拿大计算机程序员,他是 Pluralsight 的一名作者,也是一位 Microsoft MVP。他的博客网址是 kennykerr.ca,您可以通过 Twitter twitter.com/kennykerr 关注他。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Larry Osterman