Comecei este blog falando sobre geração dinâmica de código executável. Entretanto, ao receber feedback dos amigos, percebi que um problema anterior precisava ser superado: Como programar usando Intermediate Language (IL, para os íntimos)? Esta é a motivação primária para essa série.
Como disse um colega na DotNetArchitects (sem edições):
Não acho que vou precisar mexer com IL por enqto, mas é sempre útil a gente saber o que acontece dentro do capô do carro pra dirigir melhor
![]()
A propósito, para quem tiver interesse em consultar a thread de discussões sobre essa série no grupo, clique aqui.
Nessa parte pretendo introduzir conceitos relacionados ao tratamento de memória (esse recurso tão escasso e tão difícil de entender nesses tempos de Garbage Collector). Por fim, mostro mais um exemplo simples de IL.
IL funciona sob o conceito de máquina virtual (que o povo do Java não pegue no meu pé por isso. O conceito existe há muito mais tempo que o Java
). Em palavras simples, a linguagem é baseada em uma arquitetura de computador imaginária (não fiel a como as coisas de fato acontecem no computador).
A máquina virtual do .net foi projetada para garantir consistência de tipos (type-safe) e isso é um dos principais fatores que torna o JIT tão eficiente na hora de converter o código IL para código nativo da máquina.
Em alto-nível, temos variáveis locais, atributos de instância, atributos estáticos, variáveis globais (medo!), além de dados que foram passados como argumentos para os métodos.
Embora estejamos habituados a pensar nesses dados de maneiras diferentes, como tipos diferentes de memória, por baixo do capô (gostei da expressão), estas formas de dados são tratadas como do mesmo tipo em posições diferentes de memória.
O trabalho de converter nossos “tipos de memória” em dados alocados na memória passa a ser preocupação do compilador.
Os designers da máquina virtual do IL foram extremamente felizes ao levar a abstração dos “tipos de memória” das linguagens de nível mais alto para o nível mais baixo. IL mantém a abstração dos “tipos de memória” e é aqui que programar com IL começa a ficar bem mais simples do que trabalhar com outros ASSEMBLYs (como o do 8086).
Em IL, não trabalhamos apenas com uma região de endereçamento contínuo de memória para acomodar qualquer dado (como ocorre na máquina, de verdade). Temos regiões separadas. A estratégia de uso da memória física real só é definida mais tarde, pelo JIT, quando o código de fato precisa “parar de brincar” e fazer alguma coisa (Diga-se de passagem, esse é um dos princípios mais belos de organização e arquitetura hardware/software que já vi. O JIT pode otimizar a forma como os dados são acomodados conforme cada contexto de execução).
Observe o diagrama que segue:
Nesse diagrama (o pessoal da DotNetArchitects vai adorar isso), as caixas representam as áreas de memória que o IL reconhece e as setas indicam os possíveis caminhos por onde os dados podem se mover.
É possível ter uma idéia da importância de uma área conhecida como evaluation stack. Qualquer transferência de dados entre as diversas áreas sempre acontece por meio dela. Aliás, essa é a única área da memória em que o programa consegue modificar (diretamente) os dados.
Como um todo, o diagrama mostra que há várias “áreas de memória” disponíveis para serem usadas durante a execução de um método. Vamos tentar descrever cada uma:
Memória exclusiva do método:
Todas as caixas presentes nesta área representam porções de memória que ficam disponíveis apenas durante o escopo de execução deste. Sendo que, após a sua conclusão, são liberadas para o Garbage Collector (esse incompreendido [merece uma série só para ele]);
Outras áreas da memória disponível:
Há também duas áreas que precisam ser consideradas durante nosso desenvolvimento em IL. Estas áreas estão destinadas a objetos que sobrevivem fora do método (São nossos atributos de classe, variáveis globais, elementos estáticos).
É importante ressaltar que toda vez que um método é “chamado”, para todos os efeitos, este recebe uma “memória exclusiva para execução do método” limpa. Tanto o heap gerenciado, quanto os atributos estáticos ficam disponíveis sempre, mas os valores contidos no Evaluation Stack, na Tabela de variáveis local, na tabela de argumentos do método ou no pool de memória dinâmica são efetivamente visíveis apenas durante a execução do método relacionado.
Showtime
! Agora que já sabemos um pouco mais sobre como funciona a gestão da memória no IL chegou a hora de mostrar isso na prática. Para fazer isso, vamos fazer um pequeno programa IL que soma duas constantes numéricas.
Importante: Tá bom, eu sei que um programa que soma dois números, constates ainda por cima, não representa, exatamente, um grande desafio técnico. Mas é preciso ser paciente, estamos partindo de conceitos simples, em alto nível, para IL. E isso implica em uma boa quantidade de reaprendizado em conceitos simples (temos que aprender de novo a fazer coisas que já sabíamos fazer). Em breve estaremos escrevendo algo mais “desafiador”, acredite!
O procedimento para criação do programa é o mesmo mostrado no post anterior. Resolvi chamar o arquivo fonte de somaints.il! Se sinta a vontade para dar outro nome, se desejar.
Nesse código, está destacada em negrito o que foi alterado do exemplo HelloWorld. A declaração do método Main(), está idêntica e as outras alterações não destacadas são as triviais trocas de nome do módulo e do assembly.
// somaints.il - somando dois números inteiros
.assembly extern mscorlib {}
.assembly somaints
{
.ver 1:0:0:0
}
.module somaints.exe
.method static void Main() cil managed
{
.entrypoint
.maxstack 2 ldstr "Soma de 25 e 236: " call void [mscorlib]System.Console::Write(string) ldc.i4.s 25 ldc.i4 236 add call void [mscorlib]System.Console::WriteLine(int32) ret }
Bem, estamos agora em condições de entender o propósito da diretiva .maxstack: Ela determina quanto grande precisará ser nossa Evaluation Stack. Ou seja, quantos valores, no máximo, entendemos que precisaram estar na pilha em um mesmo momento de execução. Para nosso (tímido) método estático Main(), entendemos que esse valor é 2. Por isso:
.maxstack 2
Essa diretiva (lembrando que, em il, todas as diretivas começam com ponto), vai fazer com que o ilasm.exe oriente o JIT considere que nunca haverá mais de dois valores na Evaluation Stack durante a execução desse método.
Observe que o tamanho da Evaluation Stack é informado em quantidade de elementos e não em bytes.
Quando o método Main() é iniciado, a Evaluation Stack está vazia e preparada para receber dois objetos. Depois da instrução ldstr ser executada a Evaluation Stack terá um elemento – a referência para a string carregada.
ldstr "Soma de 25 e 236: "
Curiosidade: Embora o código de a impressão de que a string foi carregada na pilha, de fato a string já foi carregada junto com o assembly e está na área de memória correspondente aos metadados do módulo. Quando a instrução ldstr é executada ocorre uma cópia dessa string para o string pool (assunto para outra série de posts
), e é a referência dessa cópia que é adicionada a evaluation stack.
A próxima instrução executada é a chamada para o métod estático Console.Write:
call void [mscorlib]System.Console::Write(string)
Esta instrução indica que será chamado o método Console.Write. Também indica que o método requer uma string e devolverá void. Como mencionado antes, os parâmetros para o método são pegos na Evaluation Stack. Desde que o método Write possua uma sobrecarga com apenas um parâmetro, e esse seja string, este valor será retirado do topo da pilha e como este método não retorna nada, nada será colocado no lugar.
Importante: É importante entender que os parametros passados para um método serão sempre retirados da evaluation stack do método chamador e adicionadas a tabela de argumentos do método invocado. Aliás, isso é uma regra geral do IL: Sempre que uma instrução utilizar um valor presente na pilha, este será retirado (popped) dela.
Depois da execução dessas instruções, estamos novamente com nossa pilha vazia e, a essa altura da execução, uma mensagem foi “escrita” no console.
Nossas próximas duas instruções são:
ldc.i4.s 25 ldc.i4 236
Estas duas instruções parecem ter diferentes sintaxes, mas fazem basicamente a mesma coisa: adicionam um valor constante numérico (que está logo após a instrução) na evaluation stack. Entretanto, é importante destacar que ldc.i4.s e ldc.i4 são mnemônicos para duas diferentes instruções. A razão para usar dois comandos diferentes é criar nosso assembly tão pequeno quanto possível – ldc.i4.s carrega um inteiro sinalizado de um byte de comprimento, logo, os valores carregados precisam estar entre (-128, 127). Por outro lado, ldc.i4 carrega um inteiro de quatro bytes (menos restritivo mas com maior consumo).
Logo após essas duas instruções serem executadas, temos dois valores na evaluation stack: 25 e 236. Agora a pilha está no seu limite de capacidade, estipulado em .maxstack!
Por fim, vamos realizar a soma:
add
A instrução add retira dois valores da evaluation stack (do mesmo tipo), realiza a soma, e coloca o resultado de volta na evaluation stack.
Depois da execução dessas instruções, nossa evaluation stackcontém o valor 261.
call void [mscorlib]System.Console::WriteLine(int32)
A chamada ao método estático Console.WriteLine segue o mesmo princípio já explicado para o método Console.Write.
Nesse post, podemos entender mais alguns conceitos fundamentais do funcionamento do IL:
Sei que esse post foi um pouco mais pesado. Ele apresentou muitos conceitos importantes do funcionamento do IL. Nosso próximo post tratará um pouco mais de como o IL trata os tipos de dados e também será um pouco “pesado”. Depois disso, só alegria.
Espero que você, leitor amigo, continue firme da opinião de que IL não é nenhum “bicho-de-sete-cabeças”.
Até ![]()
Pingback: Tweets that mention IL 101–Parte 2 « Elemar DEV -- Topsy.com
Pingback: IL 101–Parte 7 « Elemar DEV
Pingback: IL 101–Parte 8 « Elemar DEV
Pingback: IL 101–Parte 9 (Boxing e Unboxing) « Elemar DEV
Pingback: IL 101–Parte 10 (Delegates e eventos) « Elemar DEV
Pingback: Caramba, por que você não criou um struct? « Elemar DEV
Pingback: Como se fosse a primeira vez… « Elemar DEV
Pingback: Garbage Collection – Parte 1 – O Heap Gerenciado « Elemar DEV