Como faço para passar objetos de classe, especialmente objetos STL, para e de uma DLL C ++?
Meu aplicativo precisa interagir com plug-ins de terceiros na forma de arquivos DLL e não posso controlar com qual compilador esses plug-ins são criados. Estou ciente de que não há ABI garantida para objetos STL e estou preocupado em causar instabilidade em meu aplicativo.
Respostas:
A resposta curta a esta pergunta é não . Como não há ABI C ++ padrão (interface binária do aplicativo, um padrão para convenções de chamada, empacotamento / alinhamento de dados, tamanho do tipo, etc.), você terá que passar por muitos obstáculos para tentar impor uma maneira padrão de lidar com a classe objetos em seu programa. Não há nem mesmo uma garantia de que funcionará depois de passar por todos esses obstáculos, nem há garantia de que uma solução que funciona em uma versão do compilador funcionará na próxima.
Basta criar uma interface C simples usando
extern "C"
, já que a C ABI é bem definida e estável.Se você realmente, realmente quer passar objetos C ++ em um limite DLL, é tecnicamente possível. Aqui estão alguns dos fatores que você deve levar em consideração:
Empacotamento / alinhamento de dados
Dentro de uma determinada classe, os membros de dados individuais geralmente serão colocados de maneira especial na memória para que seus endereços correspondam a um múltiplo do tamanho do tipo. Por exemplo, um
int
pode ser alinhado a um limite de 4 bytes.Se sua DLL for compilada com um compilador diferente do EXE, a versão da DLL de uma determinada classe pode ter uma embalagem diferente da versão do EXE, portanto, quando o EXE passa o objeto de classe para a DLL, a DLL pode não ser capaz de acessar corretamente um dado membro de dados dentro dessa classe. A DLL tentaria ler a partir do endereço especificado por sua própria definição da classe, não pela definição do EXE, e como o membro de dados desejado não está realmente armazenado lá, resultariam em valores de lixo.
Você pode contornar isso usando a
#pragma pack
diretiva do pré - processador, que forçará o compilador a aplicar pacotes específicos. O compilador ainda aplicará o empacotamento padrão se você selecionar um valor de empacotamento maior do que aquele que o compilador teria escolhido , portanto, se você escolher um valor de empacotamento grande, uma classe ainda pode ter empacotamento diferente entre os compiladores. A solução para isso é usar o#pragma pack(1)
, que forçará o compilador a alinhar os membros de dados em um limite de um byte (essencialmente, nenhum empacotamento será aplicado). Esta não é uma boa ideia, pois pode causar problemas de desempenho ou até mesmo travar em determinados sistemas. No entanto, ele irá assegurar a coerência na maneira como os membros de dados do seu classe estão alinhados na memória.Reordenação de membros
Se sua classe não for de layout padrão , o compilador pode reorganizar seus membros de dados na memória . Não existe um padrão de como isso é feito, portanto, qualquer reorganização de dados pode causar incompatibilidades entre os compiladores. Passar dados de um lado para outro para uma DLL exigirá classes de layout padrão, portanto.
Convenção de chamada
Existem várias convenções de chamada que uma determinada função pode ter. Essas convenções de chamada especificam como os dados devem ser passados para as funções: os parâmetros são armazenados em registradores ou na pilha? Em que ordem os argumentos são colocados na pilha? Quem limpa os argumentos deixados na pilha após o término da função?
É importante que você mantenha uma convenção de chamada padrão; se você declarar uma função como
_cdecl
, o padrão para C ++, e tentar chamá-la usando_stdcall
coisas ruins acontecerão ._cdecl
é a convenção de chamada padrão para funções C ++, entretanto, isso é algo que não quebrará, a menos que você deliberadamente o quebre especificando um_stdcall
em um lugar e um_cdecl
em outro.Tamanho do tipo de dados
De acordo com esta documentação , no Windows, a maioria dos tipos de dados fundamentais têm os mesmos tamanhos, independentemente de seu aplicativo ser de 32 ou 64 bits. No entanto, como o tamanho de um determinado tipo de dados é imposto pelo compilador, não por qualquer padrão (todas as garantias padrão são
1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)
), é uma boa ideia usar tipos de dados de tamanho fixo para garantir a compatibilidade do tamanho do tipo de dados sempre que possível.Problemas de heap
Se sua DLL se vincular a uma versão diferente do tempo de execução C do que seu EXE, os dois módulos usarão heaps diferentes . Este é um problema especialmente provável, visto que os módulos estão sendo compilados com diferentes compiladores.
Para atenuar isso, toda a memória terá que ser alocada em um heap compartilhado e desalocado do mesmo heap. Felizmente, o Windows fornece APIs para ajudar com isso: GetProcessHeap permitirá que você acesse o heap do EXE do host, e HeapAlloc / HeapFree permitirá que você aloque e libere memória dentro desse heap. É importante que você não use o normal
malloc
/free
pois não há garantia de que funcionará da maneira que você espera.Problemas de STL
A biblioteca padrão C ++ tem seu próprio conjunto de questões ABI. Não há garantia de que um determinado tipo de STL seja disposto da mesma maneira na memória, nem há garantia de que uma determinada classe de STL tenha o mesmo tamanho de uma implementação para outra (em particular, compilações de depuração podem colocar informações de depuração extras em um determinado tipo de STL). Portanto, qualquer contêiner STL terá que ser descompactado em tipos fundamentais antes de ser passado pelo limite da DLL e reembalado do outro lado.
Nome mutilado
Sua DLL provavelmente exportará funções que seu EXE desejará chamar. No entanto, os compiladores C ++ não possuem uma maneira padrão de alterar os nomes das funções . Isso significa que uma função nomeada
GetCCDLL
pode ser mutilada_Z8GetCCDLLv
no GCC e?GetCCDLL@@YAPAUCCDLL_v1@@XZ
no MSVC.Você já não poderá garantir a vinculação estática à sua DLL, pois uma DLL produzida com GCC não produzirá um arquivo .lib e a vinculação estática de uma DLL no MSVC requer um. Vincular dinamicamente parece uma opção muito mais limpa, mas a mutilação de nomes atrapalha: se você tentar usar
GetProcAddress
o nome mutilado errado, a chamada falhará e você não conseguirá usar sua DLL. Isso requer um pouco de hackeamento para ser contornado e é um dos principais motivos pelos quais passar classes C ++ através de um limite de DLL é uma má ideia.Você precisará construir sua DLL e, em seguida, examinar o arquivo .def produzido (se houver; isso irá variar com base nas opções do projeto) ou usar uma ferramenta como Dependency Walker para encontrar o nome mutilado. Em seguida, você precisará escrever seu próprio arquivo .def, definindo um alias não mutilado para a função mutilada. Como exemplo, vamos usar a
GetCCDLL
função que mencionei um pouco mais adiante. No meu sistema, os seguintes arquivos .def funcionam para GCC e MSVC, respectivamente:GCC:
MSVC:
Reconstrua sua DLL e examine novamente as funções que ela exporta. Um nome de função não fragmentado deve estar entre eles. Observe que você não pode usar funções sobrecarregadas desta forma : o nome da função não fragmentada é um alias para uma sobrecarga de função específica, conforme definido pelo nome mutilado. Observe também que você precisará criar um novo arquivo .def para sua DLL toda vez que alterar as declarações de função, pois os nomes mutilados serão alterados. Mais importante ainda, ao ignorar a mutilação de nome, você está substituindo todas as proteções que o vinculador está tentando oferecer em relação a problemas de incompatibilidade.
Todo esse processo é mais simples se você criar uma interface para sua DLL seguir, já que você terá apenas uma função para definir um alias, em vez de precisar criar um alias para cada função em sua DLL. No entanto, as mesmas advertências ainda se aplicam.
Passar objetos de classe para uma função
Este é provavelmente o mais sutil e mais perigoso dos problemas que afetam a passagem de dados entre compiladores. Mesmo que você lide com todo o resto, não há um padrão de como os argumentos são passados para uma função . Isso pode causar travamentos sutis sem motivo aparente e sem uma maneira fácil de depurá-los . Você precisará passar todos os argumentos por meio de ponteiros, incluindo buffers para quaisquer valores de retorno. Isso é desajeitado e inconveniente e é mais uma solução alternativa de hacky que pode ou não funcionar.
Juntando todas essas soluções alternativas e desenvolvendo algum trabalho criativo com modelos e operadores , podemos tentar passar objetos com segurança através de um limite de DLL. Observe que o suporte a C ++ 11 é obrigatório, assim como o suporte para
#pragma pack
e suas variantes; O MSVC 2013 oferece esse suporte, assim como as versões recentes do GCC e do clang.A
pod
classe é especializada para todos os tipos de dados básicos, de forma queint
serão automaticamente agrupadosint32_t
,uint
serão agrupadosuint32_t
etc. Isso tudo ocorre nos bastidores, graças aos operadores=
e sobrecarregados()
. Omiti o resto das especializações de tipo básico, uma vez que são quase inteiramente iguais, exceto pelos tipos de dados subjacentes (abool
especialização tem um pouco de lógica extra, uma vez que é convertida em aint8_t
e, em seguida,int8_t
é comparada a 0 para converter de volta parabool
, mas isso é bastante trivial).Também podemos agrupar tipos STL dessa maneira, embora exija um pouco de trabalho extra:
Agora podemos criar uma DLL que faz uso desses tipos de pod. Primeiro, precisamos de uma interface, portanto, teremos apenas um método para descobrir a mutilação.
Isso apenas cria uma interface básica que a DLL e os chamadores podem usar. Observe que estamos passando um ponteiro para a
pod
, não parapod
ele mesmo. Agora precisamos implementar isso no lado da DLL:E agora vamos implementar a
ShowMessage
função:Nada muito sofisticado: isso apenas copia o passado
pod
em um normalwstring
e o mostra em uma caixa de mensagem. Afinal, este é apenas um POC , não uma biblioteca de utilitários completa.Agora podemos construir a DLL. Não se esqueça dos arquivos .def especiais para contornar a alteração do nome do vinculador. (Observação: a estrutura CCDLL que realmente criei e executei tinha mais funções do que a que apresento aqui. Os arquivos .def podem não funcionar como esperado.)
Agora, para um EXE para chamar a DLL:
E aqui estão os resultados. Nosso DLL funciona. Alcançamos com sucesso problemas anteriores de STL ABI, anteriores problemas C ++ ABI, anteriores problemas de mutilação e nossa DLL MSVC está funcionando com um EXE GCC.
Em conclusão, se você absolutamente deve passar objetos C ++ através dos limites de DLL, é assim que você faz. No entanto, nada disso funcionará com a sua configuração ou com a de qualquer outra pessoa. Tudo isso pode falhar a qualquer momento e provavelmente será interrompido no dia anterior ao agendamento de um lançamento principal do software. Este caminho está cheio de hacks, riscos e idiotices gerais pelos quais eu provavelmente deveria ser atirado. Se você seguir esse caminho, teste com extrema cautela. E realmente ... simplesmente não faça isso.
fonte
@computerfreaker escreveu uma ótima explicação de por que a falta de ABI impede a passagem de objetos C ++ pelos limites de DLL em geral, mesmo quando as definições de tipo estão sob controle do usuário e a mesma sequência de token exata é usada em ambos os programas. (Existem dois casos que funcionam: classes de layout padrão e interfaces puras)
Para os tipos de objetos definidos no C ++ Standard (incluindo aqueles adaptados da Standard Template Library), a situação é muito, muito pior. Os tokens que definem esses tipos NÃO são os mesmos em vários compiladores, pois o padrão C ++ não fornece uma definição de tipo completa, apenas os requisitos mínimos. Além disso, a pesquisa de nome dos identificadores que aparecem nessas definições de tipo não resolve o mesmo. Mesmo em sistemas onde há uma ABI C ++, tentar compartilhar esses tipos entre os limites do módulo resulta em um comportamento indefinido massivo devido a violações da regra de definição única.
Isso é algo com que os programadores do Linux não estavam acostumados a lidar, porque o libstdc ++ do g ++ era um padrão de fato e praticamente todos os programas o usavam, satisfazendo assim o ODR. A libc ++ do clang quebrou essa suposição, e então o C ++ 11 veio com mudanças obrigatórias para quase todos os tipos de biblioteca padrão.
Apenas não compartilhe tipos de biblioteca padrão entre os módulos. É um comportamento indefinido.
fonte
Algumas das respostas aqui tornam a aprovação nas aulas de C ++ realmente assustadora, mas gostaria de compartilhar um ponto de vista alternativo. O método C ++ virtual puro mencionado em algumas das outras respostas, na verdade, acabou sendo mais limpo do que você imagina. Eu construí todo um sistema de plugins em torno do conceito e está funcionando muito bem há anos. Eu tenho uma classe "PluginManager" que carrega dinamicamente as dlls de um diretório especificado usando LoadLib () e GetProcAddress () (e os equivalentes do Linux, portanto, o executável para torná-lo plataforma cruzada).
Acredite ou não, este método é tolerante mesmo se você fizer algumas coisas malucas como adicionar uma nova função no final de sua interface virtual pura e tentar carregar dlls compilados na interface sem essa nova função - eles carregarão muito bem. Claro ... você terá que verificar um número de versão para ter certeza de que seu executável apenas chama a nova função para dlls mais recentes que implementam a função. Mas a boa notícia é: funciona! Então, de certa forma, você tem um método rudimentar para desenvolver sua interface ao longo do tempo.
Outra coisa legal sobre interfaces virtuais puras - você pode herdar quantas interfaces quiser e nunca terá o problema do diamante!
Eu diria que a maior desvantagem dessa abordagem é que você precisa ter muito cuidado com os tipos que passa como parâmetros. Nenhuma classe ou objeto STL sem envolvê-los com interfaces virtuais puras primeiro. Sem structs (sem passar pelo vodu do pacote de pragma). Apenas tipos primários e ponteiros para outras interfaces. Além disso, você não pode sobrecarregar as funções, o que é um inconveniente, mas não um obstáculo.
A boa notícia é que, com um punhado de linhas de código, você pode criar classes e interfaces genéricas reutilizáveis para envolver strings STL, vetores e outras classes de contêiner. Alternativamente, você pode adicionar funções à sua interface como GetCount () e GetVal (n) para permitir que as pessoas percorram as listas.
As pessoas que criam plug-ins para nós acham muito fácil. Eles não precisam ser especialistas no limite ABI ou algo assim - eles apenas herdam as interfaces nas quais estão interessados, codificam as funções que suportam e retornam false para aquelas que não suportam.
A tecnologia que faz todo esse trabalho não se basear em nenhum padrão até onde eu sei. Pelo que percebi, a Microsoft decidiu fazer suas tabelas virtuais dessa forma para que eles pudessem fazer COM, e outros criadores de compiladores decidiram seguir o exemplo. Isso inclui GCC, Intel, Borland e a maioria dos outros grandes compiladores C ++. Se você está planejando usar um compilador incorporado obscuro, essa abordagem provavelmente não funcionará para você. Teoricamente, qualquer empresa de compiladores poderia mudar suas tabelas virtuais a qualquer momento e quebrar coisas, mas considerando a enorme quantidade de código escrito ao longo dos anos que depende dessa tecnologia, eu ficaria muito surpreso se algum dos principais jogadores decidisse quebrar a classificação.
Portanto, a moral da história é ... Com exceção de algumas circunstâncias extremas, você precisa de uma pessoa responsável pelas interfaces que possa garantir que o limite ABI permaneça limpo com tipos primitivos e evite sobrecarga. Se você concordar com essa estipulação, então eu não teria medo de compartilhar interfaces para classes em DLLs / SOs entre compiladores. Compartilhar aulas diretamente == problemas, mas compartilhar interfaces virtuais puras não é tão ruim.
fonte
Você não pode transmitir objetos STL com segurança através dos limites de DLL, a menos que todos os módulos (.EXE e .DLLs) sejam construídos com a mesma versão do compilador C ++ e as mesmas configurações e sabores do CRT, o que é altamente restritivo e claramente não é o seu caso.
Se você deseja expor uma interface orientada a objetos de sua DLL, você deve expor interfaces puras C ++ (que é semelhante ao que COM faz). Considere a leitura deste artigo interessante sobre CodeProject:
Você também pode querer expor uma interface C pura no limite da DLL e, em seguida, construir um wrapper C ++ no site do chamador.
Isso é semelhante ao que acontece no Win32: o código de implementação do Win32 é quase C ++, mas muitas APIs do Win32 expõem uma interface C pura (também há APIs que expõem interfaces COM). Em seguida, ATL / WTL e MFC envolvem essas interfaces C puras com classes e objetos C ++.
fonte