Julho de 2015

Número 7 do Volume 30

Windows com C++: Componentes de Tempo de Execução do Windows

Por Kenny Kerr | Julho de 2015

Kenny KerrDurante os próximos meses, vou explorar os conceitos básicos do tempo de execução do Windows. O objetivo é desmembrar as abstrações de nível superior que os desenvolvedores usam em várias projeções de linguagem e cadeias de ferramentas para examinar como o tempo de execução do Windows funciona em uma interface binária de aplicativo (ABI), o limite entre aplicativos e os componentes binários dos quais eles dependem para acessar serviços do sistema operacional.

De certa forma o tempo de execução do Windows é apenas a evolução do COM, que era efetivamente um padrão binário para reutilização de códigos, e continua a ser uma maneira comum de criar aplicativos complexos e componentes do sistema operacional. Ao contrário do COM, no entanto, o tempo de execução do Windows é mais estritamente concentrado e é usado principalmente como a base para a API do Windows. A tendência é que os desenvolvedores de aplicativos passem a usar o tempo de execução do Windows como um consumidor dos componentes do sistema operacional, diminuindo a necessidade de escreverem seus próprios componentes. No entanto, uma boa compreensão de como todas as diferentes e sofisticadas abstrações são implementadas e projetadas em várias linguagens de programação pode ajudá-lo a escrever aplicativos mais eficientes e a diagnosticar problemas de interoperabilidade e desempenho.

Um dos motivos pelos quais poucos desenvolvedores compreendem como funciona o tempo de execução do Windows (além da raridade da sua documentação) é porque as ferramentas e as projeções de linguagem acabam por esconder a plataforma subjacente. O que pode ser natural para um desenvolvedor de C# não facilita a vida para um desenvolvedor de C++ que deseja realmente saber o que está acontecendo nos bastidores. Então, vamos começar escrevendo um simples componente de tempo de execução do Windows com C++ Padrão usando o prompt de comando do desenvolvedor do Visual Studio 2015.

Vou começar com um DLL simples e tradicional que exporta algumas funções. Se você quiser acompanhar, crie uma pasta Exemplo e, dentro dela, crie alguns arquivos de origem, começando com Sample.cpp:

C:\Sample>notepad Sample.cpp

A primeira coisa a fazer é dar um jeito descarregar a DLL, que chamarei de componente daqui em diante. O componente deve dar suporte a consultas de descarregar por meio de uma chamada de função exportada, DllCanUnloadNow, e é o aplicativo que controla o descarregamento com a função CoFreeUnusedLibraries. Não vamos perder muito tempo com isso porque essa era a mesma maneira de descarregar componentes no COM clássico. Como o componente não é vinculado de forma estática ao aplicativo (com um arquivo de biblioteca, por exemplo) mas, ao invés disso, é carregado dinamicamente por meio da função LoadLibrary, é preciso que eventualmente o componente seja descarregado de alguma forma. Apenas o componente realmente sabe quantas referências pendentes estão sendo mantidas para que o tempo de execução COM possa chamar a função DllCanUnloadNow para determinar se é seguro descarregar. Os aplicativos também podem executar essa manutenção por conta própria usando as funções CoFreeUnusedLibraries ou CoFreeUnusedLibrariesEx. A implementação no componente é bastante simples. Eu preciso de um bloqueio para controlar quantos objetos estão ativos:

static long s_lock;

Cada objeto pode simplesmente incrementar esse bloqueio em seu construtor e reduzir em seu destruidor. Para manter isso bem simples, vou criar uma pequena classe ComponentLock:

struct ComponentLock
{
  ComponentLock() noexcept
  {
    InterlockedIncrement(&s_lock);
  }
  ~ComponentLock() noexcept
  {
    InterlockedDecrement(&s_lock);
  }
};

Assim, todos os objetos que devem impedir que o componente descarregue podem simplesmente incorporar um ComponentLock como uma variável de membro. A função DllCanUnloadNow agora pode ser implementada de maneira bem simples:

HRESULT __stdcall DllCanUnloadNow()
{
  return s_lock ? S_FALSE : S_OK;
}

Há realmente dois tipos de objetos que você pode criar em um componente: alocadores de ativação (chamados de alocadores de classes no COM clássico) e as instâncias reais de uma classe específica. Vou implementar uma classe "Hen" (galinha) simples e vou começar definindo uma interface IHen para que a galinha possa cacarejar:

struct __declspec(uuid("28a414b9-7553-433f-aae6-a072afe5cebd")) __declspec(novtable)
IHen : IInspectable
{
  virtual HRESULT __stdcall Cluck() = 0;
};

Essa é uma interface comum de COM que, por acaso, deriva de IInspectable em vez de diretamente de IUnknown. Posso então usar o modelo da classe Implements que descrevi na edição de dezembro de 2014 (msdn.com/magazine/dn879357) para implementar esta interface e fornecer a implementação real da classe Hen dentro do componente:

struct Hen : Implements<IHen>
{
  ComponentLock m_lock;
  virtual HRESULT __stdcall Cluck() noexcept override
  {
    return S_OK;
  }
};

Uma alocador de ativação é simplesmente uma classe C++ que implementa a interface IActivationFactory. Essa interface IActivationFactory fornece o único método ActivateInstance, que é análogo à interface IClassFactory do COM clássico e o método CreateInstance. A interface do COM clássico é realmente um pouco superior, já que permite que o chamador solicite uma interface específica diretamente, enquanto o IActivationFactory do tempo de execução do Windows retorna um ponteiro de interface IInspectable de maneira simples. O aplicativo, em seguida, é responsável por chamar o método IUnknown QueryInterface para recuperar uma interface mais útil para o objeto. De qualquer forma, ele faz com que o método ActivateInstance seja implementado de uma maneira muito simples:

struct HenFactory : Implements<IActivationFactory>
{
  ComponentLock m_lock;
  virtual HRESULT __stdcall ActivateInstance(IInspectable ** instance)
    noexcept override
  {
    *instance = new (std::nothrow) Hen;
    return *instance ? S_OK : E_OUTOFMEMORY;
  }
};

O componente permite que os aplicativos recuperem um alocador de ativação específico por meio da exportação de outra função chamada DllGetActivationFactory. Isso, novamente, é análogo à função exportada DllGetClassObject que suporta o modelo de ativação do COM. A principal diferença é que a classe desejada é especificada com uma cadeia de caracteres ao invés de um GUID:

HRESULT __stdcall DllGetActivationFactory(HSTRING classId,
   IActivationFactory ** factory) noexcept
{
}

Um HSTRING é um identificador que representa um valor imutável de cadeia de caracteres. Esse é o identificador de classe, talvez, "Sample.Hen" e indica qual alocador de ativação deve ser retornado. Nesse momento, há inúmeros motivos para as chamadas para DllGetActivationFactory falharem, então vou começar limpando a variável do alocador com um nullptr:

*factory = nullptr;

Agora, preciso obter o buffer de backup para o identificador de classe HSTRING:

wchar_t const * const expected = WindowsGetStringRawBuffer(classId, nullptr);

Então, posso comparar esse valor com todas as classes que meu componente venha a implementar. Por enquanto, há apenas uma:

if (0 == wcscmp(expected, L"Sample.Hen"))
{
  *factory = new (std::nothrow) HenFactory;
  return *factory ? S_OK : E_OUTOFMEMORY;
}

Caso contrário, voltarei um HRESULT indicando que a classe solicitada não está disponível:

return CLASS_E_CLASSNOTAVAILABLE;

Esse é todo o C++ necessário para deixar esse simples componente pronto para execução, mas há ainda um pouco mais de trabalho para realmente criar uma DLL para esse componente e, então, descrevê-la para os incômodos compiladores C# que não sabem como analisar arquivos de cabeçalho. Para criar uma DLL, é necessário envolver o vinculador, especificamente a capacidade dele definir as funções exportadas da DLL. Eu poderia usar o especificador da Microsoft dllexport __declspec, que é específico do compilador, mas esse é um dos raros casos em que eu prefiro falar diretamente com o vinculador e fornecer um arquivo de definição de módulo com a lista de exportações. Eu acho que essa abordagem é menos propensa a erros. Então, voltemos ao console para o segundo arquivo de origem:

C:\Sample>notepad Sample.def

Esse arquivo DEF precisa apenas de uma seção denominada EXPORTS, que lista as funções a serem exportadas:

EXPORTS
DllCanUnloadNow         PRIVATE
DllGetActivationFactory PRIVATE

Agora, posso fornecer o arquivo de origem C++ junto com esse arquivo de definição de módulo para o compilador e vinculador para gerar a DLL e, então, usar um simples e oportuno arquivo em lote para criar o componente e colocar todos os artefatos de compilação em uma subpasta:

C:\Sample>type Build.bat
@md Build 2>nul
cl Sample.cpp /nologo /W4 /FoBuild\ /FeBuild\Sample.dll /link /dll /def:Sample.def

Vou deixar a linguagem de script do arquivo em lotes fazer a sua mágica e me concentrar nas opções do compilador Visual C++. A opção /nologo suprime a exibição da faixa de direitos autorais. A opção também é encaminhada para o vinculador. A opção /W4 é indispensável para informar o compilador para exibir mais avisos sobre erros comuns de codificação. Não há nenhuma opção /FoBuild. O compilador tem essa convenção de leitura difícil em que os caminhos de saída seguem a opção, neste caso /Fo, sem espaço. Mesmo assim, a opção /Fo é usada para forçar o compilador a despejar o arquivo de objeto na subpasta de compilação. É a única saída de compilação que não usa a mesma pasta de saída do executável definido com a opção /Fe como padrão. A opção /link informa ao compilador que argumentos subsequentes devem ser interpretados pelo vinculador. Isso evita a necessidade de chamar o vinculador como uma etapa secundária e, ao contrário do compilador, as opções do vinculador diferenciam maiúsculas de minúsculas e empregam um separador entre o nome de uma opção e um valor, como é o caso da opção /def, que indica o arquivo de definição de módulo a ser usado.

Agora, posso criar meu componente de maneira simples, e a subpasta de compilação resultante contém vários arquivos, mas somente um deles é importante. Naturalmente, é o executável Sample.dll, que pode ser carregado no espaço de endereço do aplicativo. Mas isso não é suficiente. Um desenvolvedor de aplicativos precisa saber, de alguma maneira, o que o componente contém. Um desenvolvedor de C++ provavelmente ficaria satisfeito com um arquivo de cabeçalho, incluindo a interface IHen, mas isso não é exatamente útil. O tempo de execução do Windows trabalha o conceito de projeções de linguagem, em que um componente é descrito de forma que diferentes linguagens possam descobrir e projetar seus tipos em seus modelos de programação. Nos próximos meses, explorarei a projeção de linguagem, mas por hora, vamos fazer esse exemplo funcionar em um aplicativo C#, por ser o mais convincente. Como já mencionei, os compiladores C# não sabem como analisar arquivos de cabeçalho C++, e portanto, preciso fornecer alguns metadados para deixar o compilador C# contente. Preciso produzir um arquivo WINMD que contenha os metadados CLR que descrevam meu componente. Isso não é tão simples, porque os tipos nativos que podem ser usados para a ABI do componente frequentemente ficam muito diferentes ao serem projetados em C#. Felizmente, o compilador Microsoft IDL foi realocado para produzir um arquivo WINMD, dado um arquivo IDL que use algumas palavras-chave novas. Então, voltemos ao console para nosso terceiro arquivo de origem:

C:\Sample>notepad Sample.idl

Primeiro, preciso importar a definição da interface de pré-requisitos IInspectable:

import "inspectable.idl";

Em seguida, posso definir um namespace para os tipos do componente. Ele deve corresponder ao próprio nome do componente:

namespace Sample
{
}

Agora, é preciso definir a interface IHen que foi definida anteriormente em C++, desta vez como uma interface IDL:

[version(1)]
[uuid(28a414b9-7553-433f-aae6-a072afe5cebd)]
interface IHen : IInspectable
{
  HRESULT Cluck();
}

É o bom e velho IDL e, se você já usou IDL para definir componentes do COM, nada disso é novidade. Todos os tipos de tempos de execução do Windows devem, no entanto, definir um atributo de versão. Antigamente, isso era opcional. Todas as interfaces também devem derivar diretamente de IInspectable. Não há, efetivamente, heranças de interface no tempo de execução do Windows. Existem algumas consequências negativas disso, e falarei sobre elas próximos meses.

E, finalmente, preciso definir a própria classe Hen usando a nova palavra-chave do runtimeclass:

[version(1)]
[activatable(1)]
runtimeclass Hen
{
  [default] interface IHen;
}

Novamente, é necessário o atributo da versão. O atributo ativável, embora não seja obrigatório, indica que essa classe pode ser ativada. Nesse caso, ele indica que a ativação padrão tem suporte por meio do método IActivationFactory ActivateInstance. Uma projeção de linguagem deve apresentar isso como um construtor padrão C++ ou C#, ou qualquer um que faça sentido para uma linguagem específica. Finalmente, o atributo padrão antes da palavra-chave da interface indica que IHen é a interface padrão para a classe Hen. A interface padrão é a interface que toma o lugar de parâmetros e tipos de retorno quando esses tipos especificam a própria classe. Já que a ABI somente realiza trocas em interfaces COM e a classe Hen não é propriamente uma interface, a interface padrão é seu representante no nível de ABI.

Há muito mais para ser explorado aqui, mas isso é tudo por enquanto. Agora posso atualizar meu arquivo em lotes para produzir um arquivo WINMD que descreve meu componente:

@md Build 2>nul
cl Sample.cpp /nologo /W4 /FoBuild\ /FeBuild\Sample.dll /link /dll /def:Sample.def
"C:\Program Files (x86)\Windows Kits\10\bin\x86\midl.exe" /nologo /winrt /out %~dp0Build /metadata_dir "c:\Program Files (x86)\Windows Kits\10\References\Windows.Foundation.FoundationContract\1.0.0.0" Sample.idl

Novamente, deixarei o arquivo em lotes fazer sua mágica e me concentrar nas novas opções do compilador MIDL. A opção /winrt é a chave, e indica que o arquivo IDL contém tipos de tempo de execução do Windows ao invés de definições tradicionais de interface do COM ou estilo RPC. A opção /out apenas garante que o arquivo WINMD reside na mesma pasta que o DLL, o que é necessário para a cadeia de ferramentas C#. A opção /metadata_dir informa ao compilador onde ele pode localizar os metadados que foram usados para criar o sistema operacional. Enquanto escrevo isso, o Windows SDK para Windows 10 ainda está sendo finalizado, e é preciso ter cuidado para invocar o compilador MIDL que seja fornecido com o SDK do Windows e não o fornecido pelo caminho no prompt de comando de ferramentas do Visual Studio.

Executar o arquivo em lotes agora produz o Sample.dll e Sample.winmd, aos quais posso então fazer referência a partir de um aplicativo Universal C# do Windows e usar a classe Hen como se fosse um projeto qualquer da biblioteca CLR:

Sample.Hen h = new Sample.Hen();
h.Cluck();

O tempo de execução do Windows baseia-se nos fundamentos do COM e C++ padrão. Concessões foram feitas para dar suporte ao CLR e facilitar para os desenvolvedores de C# usarem a nova API do Windows sem a necessidade de quaisquer componentes de interoperabilidade. O tempo de execução do Windows é o futuro da API do Windows.

Eu apresentei, especificamente, o desenvolvimento de um componente de tempo de execução do Windows a partir da perspectiva do COM clássico e suas raízes no compilador C++, para que você possa compreender de onde veio essa tecnologia. No entanto, essa abordagem pode rapidamente se tornar impraticável. Na verdade, o compilador MIDL fornece muito mais do que apenas o arquivo WINMD e podemos usá-lo, entre outras coisas, para gerar a versão canônica da interface IHen em C++. Espero que você me acompanhe no próximo mês para explorarmos um fluxo de trabalho mais confiável para a criação de componentes de tempo de execução do Windows e também resolvermos alguns problemas de interoperabilidade durante o processo.


Kenny Kerr* é programador de computador, autor da Pluralsight e Microsoft MVP, e mora no Canadá. Ele mantém um blog em kennykerr.ca e pode ser seguido no Twitter em twitter.com/kennykerr.*

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Larry Osterman