Internos do IL2CPP: Wrappers de P/Invoke

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 Min
Internos do IL2CPP: Wrappers de P/Invoke
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.
Esta é a sexta postagem da série IL2CPP Internals. Nesta postagem, exploraremos como o il2cpp.exe gera métodos e tipos de wrapper usados para interoperar entre o código gerenciado e o nativo. Especificamente, veremos a diferença entre tipos blittable e non-blittable, entenderemos o marshaling de strings e matrizes e aprenderemos sobre o custo do marshaling.

Já escrevi uma boa quantidade de código de interoperabilidade gerenciado para nativo, mas obter declarações p/invoke corretas em C# ainda é difícil, para dizer o mínimo. Entender o que o tempo de execução está fazendo para organizar meus objetos é um mistério ainda maior. Como o IL2CPP faz a maior parte do seu empacotamento no código C++ gerado, podemos ver (e até mesmo depurar!) seu comportamento, o que proporciona uma visão muito melhor para a solução de problemas e a análise de desempenho.

Esta postagem não tem o objetivo de fornecer informações gerais sobre marshaling e interoperabilidade nativa. Esse é um tópico amplo, grande demais para um post. A documentação do Unity discute como os plug-ins nativos interagem com o Unity. Tanto a Mono quanto a Microsoft fornecem muitas informações excelentes sobre o p/invoke em geral.

Como em todas as publicações desta série, exploraremos códigos que estão sujeitos a alterações e que, na verdade, provavelmente mudarão em uma versão mais recente do Unity. Entretanto, os conceitos devem permanecer os mesmos. Considere tudo o que foi discutido nesta série como detalhes de implementação. No entanto, gostamos de expor e discutir detalhes como esse quando é possível!

A configuração

Para esta postagem, estou usando o Unity 5.0.2p4 no OSX. Vou criar para a plataforma iOS, usando um valor de "Arquitetura" de "Universal". Criei meu código nativo para esse exemplo no Xcode 6.3.2 como uma biblioteca estática para ARMv7 e ARM64.

O código nativo tem a seguinte aparência:

#include <cstring>
#include <cmath>

extern "C" {
int Increment(int i) {
return i + 1;
}

bool StringsMatch(const char* l, const char* r) {
return strcmp(l, r) == 0;
}

struct Vector {
float x;
float y;
float z;
};

float ComputeLength(Vector v) {
return sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}

void SetX(Vector* v, float value) {
v->x = value;
}

struct Boss {
char* name;
int health;
};

bool IsBossDead(Boss b) {
return b.health == 0;
}

int SumArrayElements(int* elements, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += elements[i];
}
return sum;
}

int SumBossHealth(Boss* bosses, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += bosses[i].health;
}
return sum;
}

}

O código de script no Unity está novamente no arquivo HelloWorld.cs. É assim:

void Start () {
Debug.Log (string.Format ("Using a blittable argument: {0}", Increment (42)));
Debug.Log (string.Format ("Marshaling strings: {0}", StringsMatch ("Hello", "Goodbye")));

var vector = new Vector (1.0f, 2.0f, 3.0f);
Debug.Log (string.Format ("Marshaling a blittable struct: {0}", ComputeLength (vector)));
SetX (ref vector, 42.0f);
Debug.Log (string.Format ("Marshaling a blittable struct by reference: {0}", vector.x));

Debug.Log (string.Format ("Marshaling a non-blittable struct: {0}", IsBossDead (new Boss("Final Boss", 100))));

int[] values = {1, 2, 3, 4};
Debug.Log(string.Format("Marshaling an array: {0}", SumArrayElements(values, values.Length)));
Boss[] bosses = {new Boss("First Boss", 25), new Boss("Second Boss", 45)};
Debug.Log(string.Format("Marshaling an array by reference: {0}", SumBossHealth(bosses, bosses.Length)));
}

Cada uma das chamadas de método nesse código é feita no código nativo mostrado acima. Examinaremos a declaração do método gerenciado para cada método, conforme veremos mais adiante neste post.

Por que precisamos de uma mobilização?

Uma vez que o IL2CPP já está gerando código C++, por que precisamos de marshaling de C# para código C++? Embora o código C++ gerado seja nativo, a representação dos tipos em C# difere do C++ em vários casos, portanto, o tempo de execução do IL2CPP deve ser capaz de converter as representações de ambos os lados. O utilitário il2cpp.exe faz isso tanto para tipos quanto para métodos.

No código gerenciado, todos os tipos podem ser categorizados como blittable ou não-blitáveis. Os tipos blittable têm a mesma representação em código gerenciado e nativo (por exemplo, byte, int, float). Os tipos não blittable têm uma representação diferente no código gerenciado e nativo (por exemplo, bool, string, tipos de matriz). Dessa forma, os tipos blittable podem ser passados diretamente para o código nativo, mas os tipos não blittable exigem alguma conversão antes de serem passados para o código nativo. Muitas vezes, essa conversão envolve uma nova alocação de memória.

Para informar ao compilador de código gerenciado que um determinado método está implementado em código nativo, a palavra-chave extern é usada no C#. Essa palavra-chave, juntamente com um atributo DllImport, permite que o tempo de execução do código gerenciado encontre a definição do método nativo e o chame. O utilitário il2cpp.exe gera um método C++ de acompanhamento para cada método externo. Esse wrapper executa algumas tarefas importantes:

- Ele define um typedef para o método nativo que é usado para invocar o método por meio de um ponteiro de função.

- Ele resolve o método nativo pelo nome, obtendo um ponteiro de função para esse método.

- Ele converte os argumentos de sua representação gerenciada para sua representação nativa (se necessário).

- Ele chama o método nativo.

- Ele converte o valor de retorno do método de sua representação nativa para sua representação gerenciada (se necessário).

- In converte qualquer argumento out ou ref de sua representação nativa para sua representação gerenciada (se necessário).

A seguir, daremos uma olhada nos métodos wrapper gerados para algumas declarações de métodos externos.

Marshalização de um tipo blittable

O tipo mais simples de wrapper externo lida apenas com tipos blittable.

[DllImport("__Internal")]
private extern static int Increment(int value);



In the Bulk_Assembly-CSharp_0.cpp file, search for the string “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
extern "C" int32_t HelloWorld_Increment_m3 (Object_t * __this /* static, unused */, int32_t ___value, const MethodInfo* method)
{
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
static PInvokeFunc _il2cpp_pinvoke_func;
if (!_il2cpp_pinvoke_func)
{
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
if (_il2cpp_pinvoke_func == NULL)
{
il2cpp_codegen_raise_exception(il2cpp_codegen_get_not_supported_exception("Unable to find method for p/invoke: 'Increment'"));
}
}

int32_t _return_value = _il2cpp_pinvoke_func(___value);

return _return_value;
}

Primeiro, observe o typedef para a assinatura da função nativa:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

Algo semelhante aparecerá em cada uma das funções de wrapper. Essa função nativa aceita um único int32_t e retorna um int32_t.

Em seguida, o wrapper encontra o ponteiro de função adequado e o armazena em uma variável estática:

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

Aqui, a função Increment vem, na verdade, de uma instrução externa (no código C++):

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}

No iOS, os métodos nativos são vinculados estaticamente em um único binário (indicado pela string "__Internal" no atributo DllImport), portanto, o tempo de execução do IL2CPP não faz nada para procurar o ponteiro da função. Em vez disso, essa instrução extern informa ao vinculador para encontrar a função adequada no momento da vinculação. Em outras plataformas, o tempo de execução do IL2CPP pode realizar uma pesquisa (se necessário) usando um método API específico da plataforma para obter esse ponteiro de função.

Na prática, isso significa que , no iOS, uma assinatura p/invoke incorreta no código gerenciado aparecerá como um erro de vinculador no código gerado. O erro não ocorrerá em tempo de execução. Portanto, todas as assinaturas p/invoke precisam estar corretas, mesmo que não sejam usadas em tempo de execução.

Por fim, o método nativo é chamado por meio do ponteiro de função e o valor de retorno é devolvido. Observe que o argumento é passado para a função nativa por valor, portanto, qualquer alteração em seu valor no código nativo não estará disponível no código gerenciado, como seria de se esperar.

Marshalização de um tipo não blittable

As coisas ficam um pouco mais emocionantes com um tipo não blittable, como string. Lembre-se de um post anterior que as cadeias de caracteres no IL2CPP são representadas como uma matriz de caracteres de dois bytes codificados via UTF-16, prefixados por um valor de comprimento de 4 bytes. Essa representação não corresponde às representações char* ou wchar_t* das cadeias de caracteres em C no iOS, portanto, temos que fazer alguma conversão. Se observarmos o método StringsMatch (HelloWorld_StringsMatch_m4 no código gerado):

DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);

Podemos ver que cada argumento de string será convertido em um char* (devido à diretiva UnmangedType.LPStr).

typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);

A conversão é semelhante a esta (para o primeiro argumento):

char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);

Um novo buffer de caracteres com o comprimento adequado é alocado, e o conteúdo da string é copiado para o novo buffer. É claro que, depois que o método nativo é chamado, precisamos limpar esses buffers alocados:

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

Portanto, a transferência de um tipo não blittable como a string pode ser dispendiosa.

Marshalização de um tipo definido pelo usuário

Tipos simples como int e string são bons, mas e quanto a um tipo mais complexo, definido pelo usuário? Suponha que queiramos organizar a estrutura Vector acima, que contém três valores de float. Acontece que um tipo definido pelo usuário é blittable se e somente se todos os seus campos forem blittable. Portanto, podemos chamar o ComputeLength (HelloWorld_ComputeLength_m5 no código gerado) sem precisar converter o argumento:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;

Observe que o argumento é passado por valor, exatamente como no exemplo inicial, quando o tipo de argumento era int. Se quisermos modificar a instância do Vector e ver essas alterações no código gerenciado, precisaremos passá-la por referência, como no método SetX (HelloWorld_SetX_m6):

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 *, float);

Vector_t1 * ____v_marshaled = { 0 };
Vector_t1  ____v_marshaled_dereferenced = { 0 };
____v_marshaled_dereferenced = *___v;
____v_marshaled = &____v_marshaled_dereferenced;

float _return_value = _il2cpp_pinvoke_func(____v_marshaled, ___value);

Vector_t1  ____v_result_dereferenced = { 0 };
Vector_t1 * ____v_result = &____v_result_dereferenced;
*____v_result = *____v_marshaled;
*___v = *____v_result;

return _return_value;

Aqui, o argumento Vector é passado como um ponteiro para o código nativo. O código gerado é um pouco complicado, mas basicamente cria uma variável local do mesmo tipo, copia o valor do argumento para a variável local e, em seguida, chama o método nativo com um ponteiro para essa variável local. Depois que a função nativa retorna, o valor na variável local é copiado de volta para o argumento, e esse valor fica disponível no código gerenciado.

Um tipo definido pelo usuário que não pode ser blittable, como o tipo Boss definido acima, também pode ser transferido, mas com um pouco mais de trabalho. Cada campo desse tipo deve ser transformado em sua representação nativa. Além disso, o código C++ gerado precisa de uma representação do tipo gerenciado que corresponda à representação no código nativo.

Vamos dar uma olhada na declaração externa IsBossDead:

[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);

O wrapper desse método é chamado HelloWorld_IsBossDead_m7:

extern "C" bool HelloWorld_IsBossDead_m7 (Object_t * __this /* static, unused */, Boss_t2  ___b, const MethodInfo* method)
{
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (Boss_t2_marshaled);

Boss_t2_marshaled ____b_marshaled = { 0 };
Boss_t2_marshal(___b, ____b_marshaled);
uint8_t _return_value = _il2cpp_pinvoke_func(____b_marshaled);
Boss_t2_marshal_cleanup(____b_marshaled);

return _return_value;
}

O argumento é passado para a função wrapper como tipo Boss_t2, que é o tipo gerado para a estrutura Boss. Observe que ele é passado para a função nativa com um tipo diferente: Chefe_t2_marshaled. Se formos para a definição desse tipo, veremos que ele corresponde à definição da estrutura Boss em nosso código de biblioteca estática C++:

struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};

Novamente, usamos a diretiva UnmanagedType.LPStr no C# para indicar que o campo string deve ser transformado em um char*. Se o senhor estiver depurando um problema com um tipo definido pelo usuário que não pode ser blittable, é muito útil observar o seguinte _marshaled no código gerado. Se o layout do campo não corresponder ao lado nativo, então uma diretiva de marshaling no código gerenciado pode estar incorreta.

A função Boss_t2_marshal é uma função gerada que faz o marshal de cada campo, e a Boss_t2_marshal_cleanup libera qualquer memória alocada durante esse processo de marshal.

Marshalização de um tipo definido pelo usuário não blittable
Marshalização de uma matriz

Por fim, exploraremos como os arrays de tipos blittable e não-blittable são organizados. O método SumArrayElements recebe uma matriz de números inteiros:

[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);

Essa matriz é empacotada, mas como o tipo de elemento da matriz (int) é blittable, o custo para empacotá-la é muito pequeno:

int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((Il2CppCodeGenArray*)___elements);

A função il2cpp_codegen_marshal_array simplesmente retorna um ponteiro para a memória da matriz gerenciada existente e pronto!

No entanto, a transferência de uma matriz de tipos não blittable é muito mais cara. O método SumBossHealth passa uma matriz de instâncias de Boss:

[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);

Seu invólucro precisa alocar um novo array e, em seguida, organizar cada elemento individualmente:

Boss_t2_marshaled* ____bosses_marshaled = { 0 };
size_t ____bosses_Length = 0;
if (___bosses != NULL)
{
____bosses_Length = ((Il2CppCodeGenArray*)___bosses)->max_length;
____bosses_marshaled = il2cpp_codegen_marshal_allocate_array<Boss_t2_marshaled>(____bosses_Length);
}

for (int i = 0; i < ____bosses_Length; i++)
{
Boss_t2  const& item = *reinterpret_cast<Boss_t2 *>(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));
Boss_t2_marshal(item, (____bosses_marshaled)[i]);
}

É claro que todas essas alocações também são limpas depois que a chamada do método nativo é concluída.

Conclusão

O backend de script IL2CPP suporta os mesmos comportamentos de marshalling que o backend de script Mono. Como o IL2CPP produz wrappers gerados para métodos e tipos externos, é possível ver o custo das chamadas de interoperabilidade gerenciadas para nativas. Para tipos blittable, esse custo geralmente não é tão ruim, mas os tipos não blittable podem rapidamente tornar a interoperabilidade muito cara. Como de costume, apenas arranhamos a superfície do marshaling nesta postagem. Explore mais o código gerado para ver como o marshaling é feito para valores de retorno e parâmetros de saída, ponteiros de função nativa e delegados gerenciados e tipos de referência definidos pelo usuário.

Na próxima vez, exploraremos como o IL2CPP se integra ao coletor de lixo.