Inicialização de assemblies mistos

Os desenvolvedores do Windows devem sempre estar atentos ao bloqueio do carregador quando executar o código durante DllMain. No entanto, há alguns problemas adicionais a serem considerados ao lidar com assemblies de modo misto C++/CLI.

O código dentro de DllMain não deve acessar o CLR (Common Language Runtime) do .NET. Isso significa que DllMain não deve fazer chamadas para funções gerenciadas, seja direta ou indiretamente; nenhum código gerenciado deve ser declarado ou implementado em DllMain; e nenhuma coleta de lixo ou carregamento automático de biblioteca deve ocorrer dentro de DllMain.

Causas do bloqueio do carregador

Com a introdução da plataforma .NET, há dois mecanismos distintos para carregar um módulo de execução (EXE ou DLL): um para Windows, usado para módulos não gerenciados e outro para o CLR, que carrega assemblies .NET. O problema do carregamento de DLL misto gira em torno do carregador do sistema operacional Microsoft Windows.

Quando um assembly que contém apenas constructos .NET é carregado em um processo, o próprio carregador do CLR pode fazer todas as tarefas de carregamento e inicialização necessárias. No entanto, para carregar assemblies mistos que podem conter código nativo e dados, o carregador do Windows também deve ser usado.

O carregador do Windows garante que nenhum código possa acessar código ou os dados nessa DLL antes de ser inicializado. E o carregador garante que nenhum código possa carregar de maneira redundante a DLL enquanto ele é parcialmente inicializado. Para fazer isso, o carregador do Windows usa uma seção crítica de processo global (geralmente chamada de "bloqueio do carregador") que impede o acesso não seguro durante a inicialização do módulo. Como resultado, o processo de carregamento fica vulnerável a muitos cenários clássicos de deadlock. Para assemblies mistos, os dois cenários a seguir aumentam o risco de deadlock:

  • Primeiro, se os usuários tentarem executar funções compiladas para a MSIL (linguagem intermediária da Microsoft) quando o bloqueio do carregador estiver mantido (em DllMain ou em inicializadores estáticos, por exemplo), isso poderá causar deadlock. Considere o caso em que a função MSIL referencia um tipo em um assembly que ainda não foi carregado. O CLR tentará carregar automaticamente esse assembly, podendo exigir que o carregador do Windows bloqueie o bloqueio do carregador. Um deadlock ocorre, já que o bloqueio do carregador já está mantido pelo código anterior na sequência de chamadas. No entanto, executar a MSIL sob o bloqueio do carregador não garante que ocorrerá um deadlock. É isso que dificulta o diagnóstico e a correção desse cenário. Em algumas circunstâncias, como quando a DLL do tipo de referência não contém constructos nativos e todas as suas dependências não contêm constructos nativos, o carregador do Windows não precisa carregar o assembly .NET do tipo referenciado. Além disso, o assembly necessário ou suas dependências nativas/.NET mistas podem já ter sido carregados por outro código. Consequentemente, pode ser difícil prever o deadlock e isso pode variar dependendo da configuração do computador de destino.

  • Em segundo lugar, ao carregar DLLs nas versões 1.0 e 1.1 do .NET Framework, o CLR presumiu que o bloqueio do carregador não foi mantido e tomou várias ações inválidas sob o bloqueio do carregador. Presumir que o bloqueio do carregador não está mantido é uma suposição válida para DLLs puramente .NET. Mas como as DLLs mistas executam rotinas de inicialização nativas, elas exigem o carregador nativo do Windows e, consequentemente, o bloqueio do carregador. Portanto, mesmo que o desenvolvedor não estivesse tentando executar nenhuma função MSIL durante a inicialização da DLL, ainda haveria uma pequena possibilidade de deadlock não determinístico no .NET Framework versões 1.0 e 1.1.

Todo o não determinismo foi removido do processo de carregamento de DLLs mistas. Isso foi realizado com estas alterações:

  • O CLR não faz mais falsas presunções ao carregar DLLs mistas.

  • A inicialização não gerenciada e gerenciada é feita em dois estágios separados e distintos. A inicialização não gerenciada ocorre primeiro (via DllMain) e a inicialização gerenciada ocorre posteriormente, por meio de um constructo .cctor com suporte para .NET. Esta última é completamente transparente para o usuário, a menos que /Zl ou /NODEFAULTLIB seja usado. Para obter mais informações, confira/NODEFAULTLIB (Ignorar bibliotecas) e /Zl (Omitir nome da biblioteca padrão).

O bloqueio do carregador ainda pode ocorrer, mas agora ocorre de forma reproduzível e é detectado. Se DllMain contiver instruções MSIL, o compilador gerará o Aviso do compilador (nível 1) C4747. Além disso, o CRT ou o CLR tentarão detectar e relatar tentativas de executar a MSIL sob o bloqueio do carregador. A detecção de CRT resulta no diagnóstico do tempo de execução C Run-Time Error R6033.

O restante deste artigo descreve os cenários restantes para os quais a MSIL pode ser executada sob o bloqueio do carregador. Ele mostra como resolver o problema em cada um desses cenários e técnicas de depuração.

Cenários e soluções alternativas

Há várias situações diferentes nas quais o código de usuário pode executar a MSIL sob o bloqueio do carregador. O desenvolvedor deve garantir que a implementação do código de usuário não tente executar instruções MSIL em cada uma dessas circunstâncias. As subseções a seguir descrevem todas as possibilidades com uma discussão sobre como resolver problemas nos casos mais comuns.

DllMain

A função DllMain é um ponto de entrada definido pelo usuário para uma DLL. A menos que o usuário especifique o contrário, DllMain será invocado sempre que um processo ou thread for anexado ou desanexado da DLL contentora. Como essa invocação pode ocorrer enquanto o bloqueio do carregador estiver mantido, nenhuma função fornecida pelo usuário DllMain deverá ser compilada para MSIL. Além disso, nenhuma função na árvore de chamada com raiz em DllMain pode ser compilada para MSIL. Para resolver os problemas aqui, o bloco de código que define DllMain deve ser modificado com #pragma unmanaged. O mesmo deve ser feito para cada função chamada por DllMain.

Nos casos em que essas funções devem chamar uma função que exige uma implementação da MSIL para outros contextos de chamada, é possível usar uma estratégia de duplicação em que cria um .NET e uma versão nativa da mesma função.

Como alternativa, se DllMain não for necessário ou se não precisar ser executado sob o bloqueio do carregador, você poderá remover a implementação DllMain fornecida pelo usuário, o que elimina o problema.

Se DllMain tentar executar a MSIL diretamente, isso resultará no Aviso do compilador (nível 1) C4747. No entanto, o compilador não pode detectar casos em que DllMain chama uma função em outro módulo que, por sua vez, tenta executar a MSIL.

Para obter mais informações sobre esse cenário, consulte Impedimentos ao diagnóstico.

Inicializando objetos estáticos

Inicializar objetos estáticos pode resultar em deadlock se um inicializador dinâmico for necessário. Casos simples (como quando você atribui um valor conhecido em tempo de compilação a uma variável estática) não exigem inicialização dinâmica e, portanto, não há risco de deadlock. Entretanto, algumas variáveis estáticas são inicializadas por chamadas de função, invocações de construtor ou expressões que não podem ser avaliadas em tempo de compilação. Todas essas variáveis exigem que o código seja executado durante a inicialização do módulo.

O código a seguir mostra exemplos de inicializadores estáticos que exigem inicialização dinâmica: uma chamada de função, construção de objeto e uma inicialização de ponteiro. (Esses exemplos não são estáticos, mas presume-se que tenham definições no escopo global, o que tem o mesmo efeito.)

// dynamic initializer function generated
int a = init();
CObject o(arg1, arg2);
CObject* op = new CObject(arg1, arg2);

Esse risco de deadlock depende se o módulo contentor é compilado com /clr e se a MSIL será executada. Especificamente, se a variável estática for compilada sem /clr (ou estiver em um bloco #pragma unmanaged) e o inicializador dinâmico necessário para inicializá-la resultar na execução de instruções MSIL, poderá ocorrer deadlock. Isso ocorre porque, para módulos compilados sem /clr, a inicialização de variáveis estáticas é executada por DllMain. Por outro lado, as variáveis estáticas compiladas com /clr são inicializadas por .cctor após a conclusão do estágio de inicialização não gerenciado e o bloqueio do carregador ter sido liberado.

Há várias soluções para o problema de deadlock causado pela inicialização dinâmica de variáveis estáticas. Elas estão organizadas aqui quase em ordem de tempo necessário para corrigir o problema:

  • O arquivo de origem contendo a variável estática pode ser compilado com /clr.

  • Todas as funções chamadas pela variável estática podem ser compiladas para o código nativo usando a diretiva #pragma unmanaged.

  • Clone manualmente o código do qual a variável estática depende, fornecendo uma versão .NET e uma versão nativa com nomes diferentes. Os desenvolvedores podem chamar a versão nativa a partir de inicializadores estáticos nativos e chamar a versão .NET em outro lugar.

Funções fornecidas pelo usuário que afetam a inicialização

Há várias funções fornecidas pelo usuário cujas bibliotecas dependem para inicialização durante a inicialização. Por exemplo, ao sobrecarregar globalmente operadores em C++, como os operadores new e delete, as versões fornecidas pelo usuário são usadas em todos os lugares, inclusive na inicialização e destruição da Biblioteca Padrão C++. Como resultado, a Biblioteca Padrão C++ e os inicializadores estáticos fornecidos pelo usuário invocarão todas as versões fornecidas pelo usuário desses operadores.

Se as versões fornecidas pelo usuário forem compiladas para MSIL, esses inicializadores tentarão executar instruções MSIL enquanto o bloqueio do carregador é mantido. Uma malloc fornecida pelo usuário tem as mesmas consequências. Para resolver esse problema, qualquer uma dessas sobrecargas ou definições fornecidas pelo usuário deve ser implementada como código nativo usando a diretiva #pragma unmanaged.

Para obter mais informações sobre esse cenário, consulte Impedimentos ao diagnóstico.

Localidades personalizadas

Se o usuário fornecer uma localidade global personalizada, essa localidade será usada para inicializar todos os fluxos de E/S futuros, incluindo fluxos que são inicializados estaticamente. Se esse objeto de localidade global for compilado para MSIL, as funções de membro de objeto-localidade compiladas para MSIL poderão ser invocadas enquanto o bloqueio do carregador é mantido.

Há três opções para resolver esse problema:

Os arquivos de origem que contêm todas as definições globais de fluxo de E/S podem ser compilados usando a opção /clr. Ela impede que os inicializadores estáticos desses arquivos sejam executados sob bloqueio do carregador.

As definições da função de localidade personalizada podem ser compiladas para o código nativo usando a diretiva #pragma unmanaged.

Evite definir a localidade personalizada como a localidade global até que o bloqueio do carregador seja liberado. Em seguida, configure explicitamente os fluxos de E/S criados durante a inicialização com a localidade personalizada.

Impedimentos ao diagnóstico

Em alguns casos, é difícil detectar a origem dos deadlocks. As subseções a seguir discutem esses cenários e meios de contornar essas questões.

Implementação em cabeçalhos

Em determinados casos, implementações de função dentro de arquivos de cabeçalho podem complicar o diagnóstico. Funções embutidas e códigos de modelo exigem que as funções sejam especificadas em um arquivo de cabeçalho. A linguagem C++ especifica a Regra de Uma Definição, que força todas as implementações de funções com o mesmo nome a serem semanticamente equivalentes. Como consequência, o vinculador C++ não precisa fazer considerações especiais ao mesclar arquivos-objeto que têm implementações duplicadas de uma determinada função.

Nas versões do Visual Studio anteriores ao Visual Studio 2005, o vinculador simplesmente escolhe a maior dessas definições semanticamente equivalentes. Isso serve para acomodar declarações de encaminhamento e cenários em que diferentes opções de otimização são usadas para arquivos de origem diferentes. O vinculador cria um problema para DLLs nativas e .NET mistas.

Como o mesmo cabeçalho pode ser incluído por arquivos C++ com /clr habilitado e desabilitado ou um #include pode ser encapsulado dentro de um bloco #pragma unmanaged, é possível ter as versões MSIL e nativas das funções que fornecem implementações em cabeçalhos. As implementações nativas e de MSIL têm semânticas diferentes para inicialização no bloqueio do carregador, o que viola efetivamente a regra de uma definição. Consequentemente, quando o vinculador escolhe a maior implementação, ele pode escolher a versão MSIL de uma função, mesmo que ela tenha sido explicitamente compilada para código nativo em outro lugar usando a diretiva #pragma unmanaged. Para garantir que uma versão MSIL de um modelo ou função embutida nunca seja chamada no bloqueio do carregador, todas as definições de cada função chamada no bloqueio do carregador devem ser modificadas com a diretiva #pragma unmanaged. Se o arquivo de cabeçalho for de terceiros, a maneira mais fácil de fazer essa alteração é efetuar push e encaixar a diretiva #pragma unmanaged ao redor da diretiva #include para o arquivo de cabeçalho problemático. (Confira gerenciado, não gerenciado para ver um exemplo.) No entanto, essa estratégia não funciona para cabeçalhos que contêm outro código que deve chamar diretamente APIs do .NET.

Como uma conveniência para os usuários que lidam com o bloqueio do carregador, o vinculador escolherá a implementação nativa em vez da gerenciada quando apresentado com ambas. Esse padrão evita os problemas acima. No entanto, há duas exceções a essa regra nesta versão devido a dois problemas não resolvidos com o compilador:

  • A chamada para uma função embutida é por meio de um ponteiro de função estática global. Esse cenário é instável porque as funções virtuais são chamadas por meio de ponteiros de função globais. Por exemplo,
#include "definesmyObject.h"
#include "definesclassC.h"

typedef void (*function_pointer_t)();

function_pointer_t myObject_p = &myObject;

#pragma unmanaged
void DuringLoaderlock(C & c)
{
    // Either of these calls could resolve to a managed implementation,
    // at link-time, even if a native implementation also exists.
    c.VirtualMember();
    myObject_p();
}

Diagnóstico no modo de depuração

Todos os diagnósticos de problemas de bloqueio do carregador devem ser feitos com builds de depuração. Os builds de versão podem não produzir diagnósticos. Além disso, as otimizações feitas no modo Versão podem mascarar algumas das MSIL em cenários de bloqueio do carregador.

Como depurar problemas de bloqueio do carregador

O diagnóstico que o CLR gera quando uma função MSIL é invocada faz com que o CLR suspenda a execução. Isso, por sua vez, faz com que o depurador de modo misto do Visual C++ também seja suspenso ao executar o depurador em processo. No entanto, ao anexar ao processo, não é possível obter uma pilha de chamadas gerenciadas para o depurador usando o depurador misto.

Para identificar a função MSIL específica que foi chamada sob o bloqueio do carregador, os desenvolvedores devem concluir as seguintes etapas:

  1. Verificar se os símbolos para mscoree.dll e mscorwks.dll estão disponíveis.

    É possível disponibilizar os símbolos de duas maneiras. Primeiro, os PDBs para mscoree.dll e mscorwks.dll podem ser adicionados ao caminho de pesquisa de símbolos. Para adicioná-los, abra a caixa de diálogo de opções de caminho de pesquisa de símbolos. (No menu Ferramentas, escolha Opções. No painel esquerdo da caixa de diálogo Opções, abra o nó Depuração e escolha Símbolos.) Adicione o caminho aos arquivos PDB mscoree.dll e mscorwks.dll à lista de pesquisa. Esses PDBs são instalados em %VSINSTALLDIR%\SDK\v2.0\symbols. Escolha OK.

    Em segundo, os PDBs para mscoree.dll e mscorwks.dll podem ser baixados no Servidor de Símbolos da Microsoft. Para configurar o Servidor de Símbolos, abra a caixa de diálogo de opções de caminho de pesquisa de símbolos. (No menu Ferramentas, escolha Opções. No painel esquerdo da caixa de diálogo Opções, abra o nó Depuração e escolha Símbolos.) Adicione esse caminho de pesquisa à lista de pesquisa: https://msdl.microsoft.com/download/symbols. Adicione um diretório de cache de símbolo à caixa de texto do cache do servidor de símbolos. Escolha OK.

  2. Defina o modo de depurador para o modo somente nativo.

    Abra a grade Propriedades do projeto de inicialização na solução. SelecionePropriedades de Configuração>Depuração. Defina a propriedade Tipo de Depurador como Somente Nativo.

  3. Inicie o depurador (F5).

  4. Quando o diagnóstico /clr for gerado, escolha Repetir e, em seguida, escolha Interromper.

  5. Abra a janela de pilha de chamadas. (Na barra de menus, escolha Depurar>Windows>Pilha de Chamadas.) O inicializador estático ou problemático DllMain é identificado com uma seta verde. Se a função problemática não for identificada, realize as etapas a seguir para encontrá-la.

  6. Abra a janela Imediato (na barra de menus, escolha Depurar>Windows>Imediato.)

  7. Insira .load sos.dll na janela Imediato para carregar o serviço de depuração SOS.

  8. Insira !dumpstack na janela Imediato para obter uma listagem completa da pilha interna /clr.

  9. Localize a primeira instância (mais próxima da parte inferior da pilha) de _CorDllMain (se DllMain causa o problema) ou _VTableBootstrapThunkInitHelperStub ou GetTargetForVTableEntry (se um inicializador estático causa o problema). A entrada de pilha logo abaixo dessa chamada é a invocação da função implementada pela MSIL que tentou executar sob o bloqueio do carregador.

  10. Vá para o arquivo de origem e o número de linha identificados na etapa anterior e corrija o problema usando os cenários e soluções descritos na seção Cenários.

Exemplo

Descrição

O exemplo a seguir mostra como evitar o bloqueio do carregador movendo o código de DllMain para o construtor de um objeto global.

Nesse exemplo, há um objeto gerenciado global cujo construtor contém o objeto gerenciado que estava originalmente em DllMain. A segunda parte desse exemplo referencia o assembly, criando uma instância do objeto gerenciado para invocar o construtor de módulo que faz a inicialização.

Código

// initializing_mixed_assemblies.cpp
// compile with: /clr /LD
#pragma once
#include <stdio.h>
#include <windows.h>
struct __declspec(dllexport) A {
   A() {
      System::Console::WriteLine("Module ctor initializing based on global instance of class.\n");
   }

   void Test() {
      printf_s("Test called so linker doesn't throw away unused object.\n");
   }
};

#pragma unmanaged
// Global instance of object
A obj;

extern "C"
BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved) {
   // Remove all managed code from here and put it in constructor of A.
   return true;
}

Esse exemplo demonstra problemas na inicialização de assemblies mistos:

// initializing_mixed_assemblies_2.cpp
// compile with: /clr initializing_mixed_assemblies.lib
#include <windows.h>
using namespace System;
#include <stdio.h>
#using "initializing_mixed_assemblies.dll"
struct __declspec(dllimport) A {
   void Test();
};

int main() {
   A obj;
   obj.Test();
}

Este código produz a seguinte saída:

Module ctor initializing based on global instance of class.

Test called so linker doesn't throw away unused object.

Confira também

Assemblies mistos (nativos e gerenciados)