Elemar DEV

Tecnologia e desenvolvimento .net

IL 101–Parte 2

Motivação

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.

Sobre esta parte

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 e sua máquina virtual

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 Smiley piscando). 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.

Sobre os tipos de memória

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.

Como a máquina virtual do IL organiza a memória

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:

memory

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.

Mais informações sobre o modelo de memória do IL

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]);

  • Tabela de variáveis local: Onde as variáveis criadas dentro do método são armazenadas. Como vou mostrar mais tarde, em IL, essas variáveis devem ser declaradas logo no início do método;
  • Tabela de argumentos do método: Onde ficam armazenadas as variáveis que foram passadas para o método como argumentos. Também é nessa área da memória que fica armazenada a referência para this (Me para quem vem do VB.net Smiley piscando);
  • Pool de memória dinâmica: Esta é a área de memória disponível para alocação dinâmica. Ou seja, para objetos/valores que não puderam ser previstos durante o processo de compilação. A diferença principal, entre os objetos contidos nessa área e os armazenados na tabela de variáveis local é, principalmente, que a quantidade de memória necessária somente poderá ser determinada em runtime. (como matrizes, por exemplo);
  • Evaluation Task: É, com certeza, a mais crucial área de memória da máquina IL porque é a única área onde operações computacionais podem, de fato, ser executadas. Por exemplo, para somar dois números, primeiro eles devem ser “carregados” nessa pilha. Para usar o valor de uma variável, primeiro seu valor precisa ser “carregado” na stack. Mesmo quando desejamos chamar um outro método, a passagem dos parâmetros ocorre pela “carga” dos respectivos valores na stack (veja o artigo anterior, para um exemplo disso com o método estático WriteLine da classe Console). Outro ponto importante: O nome pilha não veio por acaso. O único dado acessível é sempre o que está no topo da pilha (para acessar um valor adicionado anteriormente na pilha, antes é necessário retirar os  dados que tenham sido adicionados depois).

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).

  • Heap gerenciado: Onde ficam armazenados as instâncias de objetos (reference-type) ou boxed value types que não pertencem ao escopo de um método. Geralmente, esses objetos não são acessados diretamente, e sim através de um objeto ponteiro que os referencia.
  • Atributos estáticos: Instâncias de objetos relacionados a atributos estáticos (toda a aplicação).

É 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.

Exemplo – Fazendo a adição de dois números

Showtime Smiley piscando! 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
}

O Tamanho da Evaluation Stack

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.

Usando o método Console.Write

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 Smiley de boca aberta), 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.

Fazendo a soma (finalmente)

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.

Conclusões

Nesse post, podemos entender mais alguns conceitos fundamentais do funcionamento do IL:

  • IL trabalha no conceito de máquina virtual;
  • A abstração de “áreas de memória” usado implicitamente em mais alto nível é preservado no IL;
  • A evaluation stack é o mecanismo central para manipulação de dados na memória;
  • Todos os métodos tem um escopo de dados bem definido que é liberado ao fim da execução.

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é Smiley piscando

8 Comentários em “IL 101–Parte 2

  1. Pingback: Tweets that mention IL 101–Parte 2 « Elemar DEV -- Topsy.com

  2. Pingback: IL 101–Parte 7 « Elemar DEV

  3. Pingback: IL 101–Parte 8 « Elemar DEV

  4. Pingback: IL 101–Parte 9 (Boxing e Unboxing) « Elemar DEV

  5. Pingback: IL 101–Parte 10 (Delegates e eventos) « Elemar DEV

  6. Pingback: Caramba, por que você não criou um struct? « Elemar DEV

  7. Pingback: Como se fosse a primeira vez… « Elemar DEV

  8. Pingback: Garbage Collection – Parte 1 – O Heap Gerenciado « Elemar DEV

Deixe uma resposta

Preencha os seus dados abaixo ou clique em um ícone para log in:

WordPress.com Logo

Você está comentando usando sua conta WordPress.com. Sair / Mudar )

Imagem do Twitter

Você está comentando usando sua conta Twitter. Sair / Mudar )

Foto do Facebook

Você está comentando usando sua conta Facebook. Sair / Mudar )

Conectando a %s

Informação

Publicado às 28/07/2010 por em Sem categoria e marcado , .

Estatísticas

  • 428,321 hits
%d bloggers like this: