Internos do IL2CPP: Dicas de depuração para o código gerado

Esta é a terceira postagem do blog na série IL2CPP Internals. Nesta postagem, exploraremos algumas dicas que tornam a depuração do código C++ gerado pelo IL2CPP um pouco mais fácil. Veremos como definir pontos de interrupção, visualizar o conteúdo de cadeias de caracteres e tipos definidos pelo usuário e determinar onde ocorrem exceções.
Ao entrarmos nesse assunto, considere que estamos depurando o código C++ gerado criado a partir do código IL do .NET. Portanto, a depuração provavelmente não será a experiência mais agradável. No entanto, com algumas dessas dicas, é possível obter uma visão significativa de como o código de um projeto Unity é executado no dispositivo de destino real (falaremos um pouco sobre a depuração de código gerenciado no final da postagem).
Além disso, esteja preparado para que o código gerado em seu projeto seja diferente desse código. A cada nova versão do Unity, estamos procurando maneiras de tornar o código gerado melhor, mais rápido e menor.
A configuração
Para esta postagem, estou usando o Unity 5.0.1p3 no OSX. Usarei o mesmo projeto de exemplo da postagem sobre código gerado, mas, desta vez, criarei para o alvo iOS usando o backend de script IL2CPP. Como fiz na postagem anterior, construirei com a opção "Development Player" selecionada, para que o il2cpp.exe gere código C++ com nomes de tipos e métodos baseados nos nomes do código IL.
Depois que o Unity terminar de gerar o projeto do Xcode, posso abri-lo no Xcode (tenho a versão 6.3.1, mas qualquer versão recente deve funcionar), escolher meu dispositivo de destino (um iPad Mini 3, mas qualquer dispositivo iOS deve funcionar) e compilar o projeto no Xcode.
Definição de pontos de interrupção
Antes de executar o projeto, primeiro definirei um ponto de interrupção na parte superior do método Start da classe HelloWorld. Como vimos na postagem anterior, o nome desse método no código C++ gerado é HelloWorld_Start_m3. Podemos usar Cmd+Shift+O e começar a digitar o nome desse método para encontrá-lo no Xcode e, em seguida, definir um ponto de interrupção nele.

Também podemos escolher Debug > Breakpoints > Create Symbolic Breakpoint no XCode e configurá-lo para interromper esse método.

Agora, quando executo o projeto do Xcode, vejo imediatamente a interrupção no início do método.
Podemos definir pontos de interrupção em outros métodos no código gerado dessa forma se soubermos o nome do método. Também podemos definir pontos de interrupção no Xcode em uma linha específica de um dos arquivos de código gerados. Na verdade, todos os arquivos gerados fazem parte do projeto Xcode. Você os encontrará no Project Navigator, no diretório Classes/Native.

Cordas de visualização
Há duas maneiras de visualizar a representação de uma string IL2CPP no Xcode. Podemos visualizar a memória de uma string diretamente ou podemos chamar um dos utilitários de string no libil2cpp para converter a string em uma std::string, que o Xcode pode exibir. Vejamos o valor da string chamada _stringLiteral1 (alerta de spoiler: seu conteúdo é "Hello, IL2CPP!").
No código gerado com o Ctags incorporado (ou usando Cmd+Ctrl+J no Xcode), podemos ir para a definição de _stringLiteral1 e ver que seu tipo é Il2CppString_14:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
De fato, todas as cadeias de caracteres no IL2CPP são representadas dessa forma. Você pode encontrar a definição de Il2CppString no arquivo de cabeçalho object-internals.h. Essas cadeias de caracteres incluem a parte do cabeçalho padrão de qualquer tipo gerenciado no IL2CPP, Il2CppObject (que é acessado por meio do typedef Il2CppDataSegmentString), seguido por um comprimento de quatro bytes e, em seguida, uma matriz de caracteres de dois bytes. As cadeias de caracteres definidas em tempo de compilação, como _stringLiteral1, acabam com uma matriz de chars de comprimento fixo, enquanto as cadeias de caracteres criadas em tempo de execução têm uma matriz alocada. Os caracteres da string são codificados como UTF-16.
Se adicionarmos _stringLiteral1 à janela de observação no Xcode, poderemos selecionar a opção View Memory of "_stringLiteral1" para ver o layout da string na memória.

Em seguida, no visualizador de memória, podemos ver isso:

O membro do cabeçalho da cadeia de caracteres é de 16 bytes, portanto, depois de passarmos por ele, podemos ver que os quatro bytes do tamanho têm um valor de 0x000E (14). O próximo byte após o comprimento é o primeiro caractere da cadeia, 0x0048 ('H'). Como cada caractere tem dois bytes de largura, mas nessa cadeia todos os caracteres cabem em apenas um byte, o Xcode os exibe à direita com pontos entre cada caractere. Ainda assim, o conteúdo da string é claramente visível. Esse método de visualização de cadeia de caracteres funciona, mas é um pouco difícil para cadeias de caracteres mais complexas.
Também podemos visualizar o conteúdo de uma string no prompt do lldb no Xcode. O cabeçalho utils/StringUtils.h nos fornece a interface para alguns utilitários de string no libil2cpp que podemos usar. Especificamente, vamos chamar o método Utf16ToUtf8 no prompt do lldb. Sua interface é semelhante a esta:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Podemos passar o membro chars da estrutura C++ para esse método, e ele retornará uma std::string codificada em UTF-8. Em seguida, no prompt do lldb, se usarmos o comando p, poderemos imprimir o conteúdo da string.
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Visualização de tipos definidos pelo usuário
Também podemos visualizar o conteúdo de um tipo definido pelo usuário. No código de script simples deste projeto, criamos um tipo C# chamado Important com um campo chamado InstanceIdentifier. Se eu definir um ponto de interrupção logo após criarmos a segunda instância do tipo Important no script, poderei ver que o código gerado definiu InstanceIdentifier como um valor 1, conforme esperado.

Portanto, a visualização do conteúdo dos tipos definidos pelo usuário no código gerado é feita da mesma forma que você faria normalmente no código C++ no Xcode.
Quebra de exceções no código gerado
Muitas vezes, eu me pego depurando o código gerado para tentar rastrear a causa de um bug. Em muitos casos, esses erros se manifestam como exceções gerenciadas. Como discutimos na última postagem, o IL2CPP usa exceções C++ para implementar exceções gerenciadas, de modo que podemos interromper quando ocorre uma exceção gerenciada no Xcode de algumas maneiras.
A maneira mais fácil de interromper quando uma exceção gerenciada é lançada é definir um ponto de interrupção na função il2cpp_codegen_raise_exception, que é usada pelo il2cpp.exe em qualquer lugar em que uma exceção gerenciada seja lançada explicitamente.

Se eu deixar o projeto ser executado, o Xcode será interrompido quando o código em Start lançar uma exceção InvalidOperationException. Esse é um lugar em que a visualização do conteúdo da cadeia de caracteres pode ser muito útil. Se eu analisar os membros do argumento ex, verei que ele tem um membro ___message_2, que é uma cadeia de caracteres que representa a mensagem da exceção.

Com um pouco de esforço, podemos imprimir o valor dessa string e ver qual é o problema:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
Observe que a cadeia de caracteres aqui tem o mesmo layout que acima, mas os nomes dos campos gerados são ligeiramente diferentes. O campo chars tem o nome de ___start_char_1 e seu tipo é uint16_t, não uint16_t[]. No entanto, ele ainda é o primeiro caractere de uma matriz, portanto, podemos passar seu endereço para a função de conversão, e descobrimos que a mensagem dessa exceção é bastante reconfortante.
Mas nem todas as exceções gerenciadas são lançadas explicitamente pelo código gerado. O código de tempo de execução do libil2cpp lançará exceções gerenciadas em alguns casos e não chamará il2cpp_codegen_raise_exception para fazer isso. Como podemos capturar essas exceções?
Se usarmos Debug > Breakpoints > Create Exception Breakpoint no Xcode e, em seguida, editarmos o ponto de interrupção, poderemos escolher exceções C++ e interromper quando uma exceção do tipo Il2CppExceptionWrapper for lançada. Como esse tipo de C++ é usado para envolver todas as exceções gerenciadas, ele nos permitirá capturar todas as exceções gerenciadas.

Vamos provar que isso funciona adicionando as duas linhas de código a seguir ao topo do método Start em nosso script:
Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`.
A segunda linha aqui fará com que seja lançada uma NullReferenceException. Se executarmos esse código no Xcode com o ponto de interrupção de exceção definido, veremos que o Xcode realmente interromperá quando a exceção for lançada. No entanto, o ponto de interrupção está no código em libil2cpp, portanto, tudo o que vemos é o código assembly. Se dermos uma olhada na pilha de chamadas, veremos que precisamos subir alguns quadros até o método NullCheck, que é injetado pelo il2cpp.exe no código gerado.

A partir daí, podemos voltar mais um quadro e ver que nossa instância do tipo Important tem, de fato, um valor NULL.

Conclusão
Depois de discutir algumas dicas para depurar o código gerado, espero que você tenha um melhor entendimento sobre como rastrear possíveis problemas usando o código C++ gerado pelo IL2CPP. Recomendo que você investigue o layout de outros tipos usados pelo IL2CPP para saber mais sobre como depurar o código gerado.
Mas onde está o depurador de código gerenciado IL2CPP? Não deveríamos ser capazes de depurar o código gerenciado executado por meio do backend de script IL2CPP em um dispositivo? De fato, isso é possível. Agora temos um depurador de código gerenciado interno de qualidade alfa para o IL2CPP. Ele ainda não está pronto para ser lançado, mas está em nosso roteiro, portanto, fique atento.
A próxima postagem desta série investigará as diferentes maneiras pelas quais o backend de script IL2CPP implementa vários tipos de invocações de métodos presentes no código gerenciado. Examinaremos o custo de tempo de execução de cada tipo de invocação de método.
