Internos do IL2CPP: Um tour pelo código gerado

Esta é a segunda postagem do blog na série IL2CPP Internals. Nesta postagem, investigaremos o código C++ gerado pelo il2cpp.exe. Ao longo do caminho, veremos como os tipos gerenciados são representados no código nativo, daremos uma olhada nas verificações de tempo de execução usadas para dar suporte à máquina virtual .NET, veremos como os loops são gerados e muito mais!
Entraremos em um código muito específico da versão que certamente mudará em versões posteriores do Unity. Ainda assim, os conceitos permanecerão os mesmos.
Exemplo de projeto
Usarei a versão mais recente do Unity disponível, 5.0.1p1, para este exemplo. Como no primeiro post desta série, começarei com um projeto vazio e adicionarei um arquivo de script. Desta vez, ele tem o seguinte conteúdo:
using UnityEngine;
public class HelloWorld : MonoBehaviour {
private class Important {
public static int ClassIdentifier = 42;
public int InstanceIdentifier;
}
void Start () {
Debug.Log("Hello, IL2CPP!");
Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);
var importantData = new [] {
new Important { InstanceIdentifier = 0 },
new Important { InstanceIdentifier = 1 } };
Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);
Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);
try {
throw new InvalidOperationException("Don't panic");
}
catch (InvalidOperationException e) {
Debug.Log(e.Message);
}
for (var i = 0; i < 3; ++i) {
Debug.LogFormat("Loop iteration: {0}", i);
}
}
}Vou criar esse projeto para WebGL, executando o editor Unity no Windows. Selecionei a opção Development Player nas configurações de compilação, para que possamos obter nomes relativamente bons no código C++ gerado. Também defini a opção Enable Exceptions (Ativar exceções) nas configurações do WebGL Player como Full (Completo).
Visão geral do código gerado
Após a conclusão da compilação do WebGL, o código C++ gerado estará disponível no diretório Temp\StagingArea\Data\il2cppOutput no diretório do meu projeto. Quando o editor for fechado, esse diretório será excluído. No entanto, enquanto o editor estiver aberto, esse diretório permanecerá inalterado, portanto, podemos inspecioná-lo.
O utilitário il2cpp.exe gerou vários arquivos, mesmo para esse pequeno projeto. Vejo 4625 arquivos de cabeçalho e 89 arquivos de código-fonte C++. Para controlar todo esse código, gosto de usar um editor de texto que funcione com Exuberant CTags. Normalmente, o CTags gera rapidamente um arquivo de tags para esse código, o que facilita a navegação.
Inicialmente, o senhor pode ver que muitos dos arquivos C++ gerados não são do código de script simples, mas são a versão convertida do código nas bibliotecas padrão, como mscorlib.dll. Conforme mencionado no primeiro post desta série, o backend de script do IL2CPP usa o mesmo código de biblioteca padrão que o backend de script do Mono. Observe que convertemos o código em mscorlib.dll e em outros conjuntos de biblioteca padrão sempre que o il2cpp.exe é executado. Isso pode parecer desnecessário, pois esse código não é alterado.
No entanto, o backend de script IL2CPP sempre usa a remoção de código de bytes para diminuir o tamanho do executável. Portanto, mesmo pequenas alterações no código do script podem fazer com que muitas partes diferentes do código da biblioteca padrão sejam usadas ou não, dependendo da situação. Portanto, precisamos converter o assembly mscorlib.dll todas as vezes. Estamos pesquisando maneiras melhores de fazer compilações incrementais, mas ainda não temos nenhuma boa solução.
Como o código gerenciado é mapeado para o código C++ gerado
Para cada tipo no código gerenciado, o il2cpp.exe gerará um arquivo de cabeçalho para a definição C++ do tipo e outro arquivo de cabeçalho para as declarações de método do tipo. Por exemplo, vamos examinar o conteúdo do tipo UnityEngine.Vector3 convertido. O arquivo de cabeçalho para o tipo chama-se UnityEngine_UnityEngine_Vector3.h. O nome é criado com base no nome do conjunto, UnityEngine.dll, seguido do namespace e do nome do tipo. O código tem a seguinte aparência:
// UnityEngine.Vector3
struct Vector3_t78
{
// System.Single UnityEngine.Vector3::x
float ___x_1;
// System.Single UnityEngine.Vector3::y
float ___y_2;
// System.Single UnityEngine.Vector3::z
float ___z_3;
};O utilitário il2cpp.exe converteu cada um dos três campos de instância e fez uma pequena alteração no nome para evitar conflitos e palavras reservadas. Ao usar sublinhados à esquerda, estamos usando alguns nomes reservados no C++, mas até agora não vimos nenhum conflito com o código da biblioteca padrão do C++.
O arquivo UnityEngine_UnityEngine_Vector3MethodDeclarations.h contém as declarações de método para todos os métodos do Vector3. Por exemplo, o Vector3 substitui o método Object.ToString:
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTRObserve o comentário, que indica o método gerenciado que essa declaração nativa representa. Muitas vezes, acho útil pesquisar os arquivos na saída em busca do nome do método gerenciado nesse formato, especialmente para métodos com nomes comuns, como ToString.
Observe algumas coisas interessantes sobre todos os métodos convertidos pelo il2cpp.exe:
- Essas não são funções de membro em C++. Todos os métodos são funções livres, em que o primeiro argumento é o ponteiro "this". Para funções estáticas em código gerenciado, o IL2CPP sempre passa um valor de NULL para esse primeiro argumento. Ao sempre declarar métodos com o ponteiro "this" como o primeiro argumento, simplificamos o código de geração de métodos no il2cpp.exe e tornamos a invocação de métodos por meio de outros métodos (como delegados) mais simples para o código gerado.
- Todo método tem um argumento adicional do tipo MethodInfo* que inclui os metadados sobre o método que é usado para coisas como a invocação de métodos virtuais. O backend de scripting do Mono usa trampolins específicos da plataforma para passar esses metadados. Para o IL2CPP, decidimos evitar o uso de trampolins para facilitar a portabilidade.
- Todos os métodos são declarados como extern "C" para que o il2cpp.exe possa, às vezes, mentir para o compilador C++ e tratar todos os métodos como se tivessem o mesmo tipo.
- Os tipos são nomeados com o sufixo "_t". Os métodos são nomeados com o sufixo "_m". Os conflitos de nomes são resolvidos com a adição de um número exclusivo a cada nome. Esses números mudarão se algo no código do script do usuário for alterado, portanto, o senhor não pode depender deles de uma compilação para outra.
Os dois primeiros pontos implicam que todo método tem pelo menos dois parâmetros, o ponteiro "this" e o ponteiro MethodInfo. Esses parâmetros extras causam sobrecarga desnecessária? Embora eles claramente aumentem a sobrecarga, não vimos nada até agora que sugira que esses argumentos extras causem problemas de desempenho. Embora possa parecer que sim, a análise de perfil mostrou que a diferença de desempenho não é mensurável.
Podemos pular para a definição desse método ToString usando Ctags. Ele está no arquivo Bulk_UnityEngine_0.cpp. O código nessa definição de método não se parece muito com o código C# no método Vector3::ToString(). No entanto, se o senhor usar uma ferramenta como o ILSpy para refletir o código do método Vector3::ToString(), verá que o código C++ gerado é muito semelhante ao código IL.
Por que o il2cpp.exe não gera um arquivo C++ separado para as definições de método para cada tipo, como faz para as declarações de método? Esse arquivo Bulk_UnityEngine_0.cpp é bem grande, com 20.481 linhas na verdade! Descobrimos que os compiladores C++ que estávamos usando tinham problemas com um grande número de arquivos de origem. A compilação de quatro mil arquivos .cpp levou muito mais tempo do que a compilação do mesmo código-fonte em 80 arquivos .cpp. Portanto, o il2cpp.exe agrupa as definições de métodos para tipos em grupos e gera um arquivo C++ por grupo.
Agora, volte ao arquivo de cabeçalho de declarações de métodos e observe esta linha próxima ao topo do arquivo:
#include "codegen/il2cpp-codegen.h"O arquivo il2cpp-codegen.h contém a interface que o código gerado usa para acessar os serviços de tempo de execução do libil2cpp. Discutiremos algumas maneiras pelas quais o tempo de execução é usado pelo código gerado posteriormente.
Prólogos de métodos
Vamos dar uma olhada na definição do método Vector3::ToString(). Especificamente, ele tem um prólogo comum que é emitido em todos os métodos pelo il2cpp.exe.
StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);
static bool Vector3_ToString_m2315_init;
if (!Vector3_ToString_m2315_init)
{
ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);
Vector3_ToString_m2315_init = true;
}A primeira linha desse prólogo cria uma variável local do tipo StackTraceSentry. Essa variável é usada para rastrear a pilha de chamadas gerenciadas, de modo que o IL2CPP possa relatá-la em chamadas como Environment.StackTrace. A geração de código dessa entrada é, na verdade, opcional e, nesse caso, é ativada pela opção --enable-stacktrace passada para o il2cpp.exe (já que defini a opção Enable Exceptions nas configurações do WebGL Player como Full). Para funções pequenas, descobrimos que a sobrecarga dessa variável tem um impacto negativo no desempenho. Portanto, para o iOS e outras plataformas em que podemos usar informações de rastreamento de pilha específicas da plataforma, nunca emitimos essa linha no código gerado. Para o WebGL, não temos suporte a rastreamento de pilha específico da plataforma, portanto, é necessário permitir que as exceções de código gerenciado funcionem corretamente.
A segunda parte do prólogo faz a inicialização preguiçosa de metadados de tipo para qualquer matriz ou tipos genéricos usados no corpo do método. Portanto, o nome ObjectU5BU5D_t4 é o nome do tipo System.Object[]. Essa parte do prólogo é executada apenas uma vez e geralmente não faz nada se o tipo já tiver sido inicializado em outro lugar, portanto, não vimos nenhuma implicação adversa no desempenho desse código gerado.
Mas esse código é seguro para threads? E se dois threads chamarem Vector3::ToString() ao mesmo tempo? Na verdade, esse código não é problemático, pois todo o código no tempo de execução do libil2cpp usado para a inicialização de tipos é seguro para ser chamado de vários threads. É possível (talvez até provável) que a função il2cpp_codegen_class_from_type seja chamada mais de uma vez, mas o trabalho real que ela faz ocorrerá apenas uma vez, em um thread. A execução do método não continuará até que essa inicialização seja concluída. Portanto, esse prólogo de método é seguro para threads.
Verificações de tempo de execução
A próxima parte do método cria uma matriz de objetos, armazena o valor do campo x de Vector3 em um local, depois coloca o local em caixa e o adiciona à matriz no índice zero. Aqui está o código C++ gerado (com algumas anotações):
// Create a new single-dimension, zero-based object array
ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));
// Store the Vector3::x field in a local
float L_1 = (__this->___x_1);
float L_2 = L_1;
// Box the float instance, since it is a value type.
Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);
// Here are three important runtime checks
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);
ArrayElementTypeCheck (L_0, L_3);
// Store the boxed value in the array at index 0
*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;As três verificações de tempo de execução não estão presentes no código IL, mas são injetadas pelo il2cpp.exe.
- O código NullCheck lançará uma NullReferenceException se o valor da matriz for null.
- O código IL2CPP_ARRAY_BOUNDS_CHECK lançará uma IndexOutOfRangeException se o índice da matriz não estiver correto.
- O código ArrayElementTypeCheck lançará uma ArrayTypeMismatchException se o tipo do elemento que está sendo adicionado à matriz não estiver correto.
Essas três verificações de tempo de execução são todas garantias fornecidas pela máquina virtual .NET. Em vez de injetar código, o backend de script do Mono usa um mecanismo de sinalização específico da plataforma para lidar com essas mesmas verificações de tempo de execução. Para o IL2CPP, queríamos ser mais agnósticos em relação à plataforma e oferecer suporte a plataformas como o WebGL, em que não há mecanismo de sinalização específico da plataforma, portanto, o il2cpp.exe injeta essas verificações.
Mas essas verificações de tempo de execução causam problemas de desempenho? Na maioria dos casos, não observamos nenhum impacto adverso no desempenho e eles oferecem os benefícios e a segurança exigidos pela máquina virtual .NET. No entanto, em alguns casos específicos, estamos vendo que essas verificações levam a um desempenho prejudicado, especialmente em loops apertados. Estamos trabalhando agora em uma maneira de permitir que o código gerenciado seja anotado para remover essas verificações de tempo de execução quando o il2cpp.exe gerar código C++. Fique atento a isso.
Campos estáticos
Agora que vimos como são os campos de instância (no tipo Vector3), vamos ver como os campos estáticos são convertidos e acessados. Encontre a definição do método HelloWorld_Start_m3, que está no arquivo Bulk_Assembly-CSharp_0.cpp em minha compilação. A partir daí, vá para o tipo Important_t1 (no arquivoAssemblyU2DCSharp_HelloWorld_Important.h):
struct Important_t1 : public Object_t
{
// System.Int32 HelloWorld/Important::InstanceIdentifier
int32_t ___InstanceIdentifier_1;
};
struct Important_t1_StaticFields
{
// System.Int32 HelloWorld/Important::ClassIdentifier
int32_t ___ClassIdentifier_0;
};Observe que o il2cpp.exe gerou uma estrutura C++ separada para manter o campo estático para esse tipo, já que o campo estático é compartilhado entre todas as instâncias desse tipo. Portanto, em tempo de execução, haverá uma instância do tipo Important_t1_StaticFields criada, e todas as instâncias do tipo Important_t1 compartilharão essa instância do tipo de campos estáticos. No código gerado, o campo estático é acessado da seguinte forma:
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);Os metadados de tipo para Important_t1 contêm um ponteiro para a única instância do tipo Important_t1_StaticFields, e essa instância é usada para obter o valor do campo estático.
Exceções
As exceções gerenciadas são convertidas pelo il2cpp.exe em exceções C++. Escolhemos esse caminho para evitar novamente soluções específicas de plataforma. Quando o il2cpp.exe precisa emitir código para gerar uma exceção gerenciada, ele chama a função il2cpp_codegen_raise_exception.
O código em nosso método HelloWorld_Start_m3 para lançar e capturar uma exceção gerenciada tem a seguinte aparência:
try
{ // begin try (depth: 1)
InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));
InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);
il2cpp_codegen_raise_exception(L_17);
// IL_0092: leave IL_00a8
goto IL_00a8;
} // end try (depth: 1)
catch(Il2CppExceptionWrapper& e)
{
__exception_local = (Exception_t8 *)e.ex;
if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))
goto IL_0097;
throw e;
}
IL_0097:
{ // begin catch(System.InvalidOperationException)
V_1 = ((InvalidOperationException_t7 *)__exception_local);
NullCheck(V_1);
String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);
Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);
// IL_00a3: leave IL_00a8
goto IL_00a8;
} // end catch (depth: 1)Todas as exceções gerenciadas são agrupadas no tipo Il2CppExceptionWrapper do C++. Quando o código gerado captura uma exceção desse tipo, ele descompacta a representação C++ da exceção gerenciada (que tem o tipo Exception_t8). Nesse caso, estamos procurando apenas uma InvalidOperationException, portanto, se não encontrarmos uma exceção desse tipo, uma cópia da exceção C++ será lançada novamente. Se encontrarmos o tipo correto, o código saltará para a implementação do manipulador de captura e gravará a mensagem de exceção.
Goto!?!!
Esse código traz um ponto interessante. O que esses rótulos e instruções goto estão fazendo ali? Essas construções não são necessárias na programação estruturada! No entanto, o IL não tem conceitos de programação estruturados, como loops e instruções if/then. Por ser de nível inferior, o il2cpp.exe segue os conceitos de nível inferior no código gerado.
Por exemplo, vamos examinar o loop for no método HelloWorld_Start_m3:
IL_00a8:
{
V_2 = 0;
goto IL_00cc;
}
IL_00af:
{
ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));
int32_t L_20 = V_2;
Object_t * L_21 =
Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);
NullCheck(L_19);
IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);
ArrayElementTypeCheck (L_19, L_21);
*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;
Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);
V_2 = ((int32_t)(V_2+1));
}
IL_00cc:
{
if ((((int32_t)V_2) < ((int32_t)3)))
{
goto IL_00af;
}
}Aqui, a variável V_2 é o índice do loop. Começa com o valor 0 e, em seguida, é incrementado na parte inferior do loop nesta linha:
V_2 = ((int32_t)(V_2+1));A condição final do loop é então verificada aqui:
if ((((int32_t)V_2) < ((int32_t)3)))Enquanto V_2 for menor que 3, a instrução goto salta para o rótulo IL_00af, que é o topo do corpo do loop. Talvez o senhor consiga adivinhar que o il2cpp.exe está gerando código C++ diretamente do IL, sem usar uma representação de árvore de sintaxe abstrata intermediária. Se o senhor adivinhou isso, está certo. O senhor também deve ter notado que, na seção Verificações de tempo de execução acima, alguns dos códigos gerados têm a seguinte aparência:
float L_1 = (__this->___x_1);
float L_2 = L_1;Claramente, a variável L_2 não é necessária aqui. A maioria dos compiladores de C++ pode otimizar essa atribuição adicional, mas gostaríamos de evitar emiti-la. Atualmente, estamos pesquisando a possibilidade de usar um AST para entender melhor o código IL e gerar um código C++ melhor para casos que envolvem variáveis locais e loops for, entre outros.
Conclusão
Acabamos de arranhar a superfície do código C++ gerado pelo backend de script IL2CPP para um projeto muito simples. Se ainda não tiver feito isso, recomendo que o senhor examine o código gerado em seu projeto. Enquanto o senhor explora, lembre-se de que o código C++ gerado terá uma aparência diferente em versões futuras do Unity, pois estamos trabalhando constantemente para melhorar o desempenho da compilação e do tempo de execução do backend de script IL2CPP.
Ao converter o código IL em C++, conseguimos obter um bom equilíbrio entre código portátil e de alto desempenho. Podemos ter muitos dos bons recursos de código gerenciado que são fáceis de usar pelo desenvolvedor e, ao mesmo tempo, obter os benefícios do código de máquina de qualidade que o compilador C++ oferece para várias plataformas.
Em posts futuros, exploraremos mais códigos gerados, incluindo chamadas de métodos, compartilhamento de implementações de métodos e wrappers para chamadas a bibliotecas nativas. Mas, da próxima vez, depuraremos alguns dos códigos gerados para uma compilação do iOS de 64 bits usando o Xcode.
