Compiladores Geração de Código Intermédio versão α − 0.002 Simão Melo de Sousa Este documento é a adaptação duma tradução do capı́tulo ”Génération de Code Intermédiaire”da sebenta ”Cours de Compilation” de Christine Paulin-Morhing (http://www.lri.fr/~paulin). 1 Introdução Antes de gerar um código/programa particular, é necessário apresentar os problemas que se colocam ao organizar os dados e as instruções dentro dum programa. 1.1 Modelos de execução Aquando da execução de código, uma parte da memória é reservada para as instruções do programa com um apontador para indicar em que ponto (do programa) se encontra a execução. O tamanho do código é conhecido na altura da compilação. Supõe-se que existe um controlo sequencial da execução dum programa. Isto é, que todas as instruções dum programa, excepto instruções de salto, são executadas sequencialmente umas após a outra. Os dados são guardados na memória ou nos registos da máquina (que executa). A memória está organizada em palavras. Esta é acedida via um endereço que é representado por um inteiro. Os valores simples são guardados numa unidade de memória, os valores complexos ou compostos (vectores, estruturas) são guardados em células contı́guas da memória ou em estruturas encadeadas (uma lista poderá ser representada por um elementos associado a um apontador contendo o endereço memória do próximo elemento). Os registos permitam o acesso rápido a dados simples. O número de registos depende da arquitectura alvo e é em geral relativamente pequeno. 1.2 Alocação Certos dados são explicitamente manipulados pelo programa fontes pelo intermédio de variáveis. Outro são criados pelo compilador, por exemplo para conservar os valores de cálculos intermédios ou para guardar ambientes associados a valores funcionais. Alguns dados tem uma duração de vida conhecida na altura da compilação. É assim possı́vel, e até desejável, reutilizar o espaço utilizado. A análise da duração de vida 1 de variáveis deve pelo menos ter em conta das regras de alcance (variable scope) da linguagem fonte. De facto uma variável que deixa de ser visı́vel corresponde em geral a um espaço memória (um dado) inacessı́vel. Esta análise pode igualmente utilisar técnicas de análise de fluxo de controlo (control flow analysis) que permitam detectar variáveis não utilizadas. As variáveis globais do programa compilado podem ser alocadas para endereços fixos pelo compilador, na condição de conhecer o tamanho de cada uma delas. A gestão das variáveis locais, dos blocos e dos procedimentos, ou ainda do armazenamento dos valores intermédios prestam-se bem a uma gestão de alocação da memória baseada em pilhas. Outros dados, pelo contrário, não tem uma duração de vida conhecida na fase de compilação. É o caso por exemplo quando se manipula apontadores. Alguns objectos alocados não corresponderão directamente a um valor, mas serão acessı́veis pelo intermédio do seu endereço, podendo este estar associado a uma variável. Estes dados serão alocados, em geral, numa outra parte da memória, designada de heap. Para não desperdiçar este espaço, o programador necessitará recorrer a (a) comandos que permitam uma gestão explı́cita da memória (como libertar espaço) ou (b) um programa de recuperação de memória designado geralmente de GC (para Garbage Collector ). 1.3 Variáveis Uma variável é um identificador que aparece num programa e que está associada a um objeto manipulado pelo programa, em geral arquivado na memória. Distingue-se o valor esquerdo da variável que representa o endereço memória a partir do qual é arquivada o valor do objecto, do valor direito, que representa o valor do objecto. Algumas linguagens como o ML distiguam explicitamente os dois tipos de objectos. A linguagem PASCAL é a posição sintáctica da variável que determina se deve ser interpretada como um valor esquerdo ou valor direito. A passagem de parâmetro nas funções e procedimentos podem ser feitos com base no valor direito ou esquerdo dos objecto manipulados. Ambiente e estado. É designado por ambiente a associação de nomes a endereços memória. Esta ligação pode ser representada por um conjunto finito de pares formados por um identificador e por um endereço. Um ambiente define uma função parcial que associa um endereço a um identificador. Designa-se por estado uma função parcial dos endereços memória para valores. Quando se atribui um valor a uma variável só o estado se altera. Quando se invoca uma função ou a um procedimento possuindo parâmetros, o ambiente muda. A variável introduzida corresponde a uma noção ligação entre um nome e um endereço. 1.4 Semântica Para construir uma tradução correcta é essencial conhecer as regras de avaliação da linguagem fonte, ou seja a sua semântica. Estas regras são frequentemente apresentadas em termos de transformadores de estado e/ou de ambiente. 2 1.5 Procedimentos e funções Um procedimento tem um nome, parâmetros, contêm declarações de variáveis ou de procedimentos locais e possui um corpo. Uma função é um procedimento que retorna um valor. Os parâmetros formais são variáveis locais ao procedimento que serão instanciados pelos parâmetros efectivos quando o procedimento for invocado. O procedimento pode declarar variáveis locais que serão inicializadas no corpo do procedimento. O espaço memória alocado para os parâmetros pode em geral ser libertado quando a execução sai do procedimento. No entanto não se pode, em geral, controlar o número de vezes que um procedimento será invocado nem quando. Por isso não se pode em geral estabelecer estaticamente o endereço das variáveis contidas num procedimento. Estes endereços poderão ser calculadas relativamente ao estado da pilha de operandas na altura da invocação. Por exemplo: program main var j : int; procedure p(i,j:int); {print(i,j);if i+j>0 then j:=j-1; p(i-2,j); print(i,j);p(j,i)} {read(j); p(j,j); print(j)} O número de execução de p depende do valor lido em entrada. Em cada entrada no procedimento p, duas novas variáveis i e j são alocadas. No esquema seguinte representase a execução deste programa tendo em conta a leitura de o valor 1. Os objectos da pilha são representados após cada instrução que modifica o estado. A pilha cresce aqui de cima para baixo, os valores correntes de i e j são separados. A execução realiza-se da esquerda para a direita e de baixo para cima. As pilhas são numeradas seguindo ordem da execução (ver figura 1). 1.6 Organização do Ambiente A organização do ambiente de execução depende do que a linguagem autorisa: • procedimentos recursivos • processamento de variáveis locais • referências para nomes locais • modos de passagem de parâmetros • existência de dados de tamanhos variáveis • capacidade em devolver procedimentos ou aceitar procedimentos como parâmetros • alocação/desalocação dinâmica de dados pelo programador 3 Figura 1: Chamadas a funções e procedimento, um exemplo. 4 2 Tabelas de Activação Um dos aspectos importantes na organização da memória é a gestão das chamadas aos procedimentos (ou funções). Estas são efectuadas pelo intermédio duma tabela de activação (frame ou activation records em inglês). Esta tabela é um pedaço de memória que está atribuida à chamada do procedimento e devolvida à saı́da deste. A tabela contém toda a informação necessária a execução do programa e arquiva todos os dados que deverão ser restabelecidos a saı́da do procedimento. Para facilitar a comunicação entre os diferentes tipos de códigos compilados a partir de linguagens fontes diferentes (por exemplo para poder chamar procedimentos de baixo nı́vel a partir de linguagens de alto nı́vel), não é raro que seja recomendado a existência dum formato particular de tabela de activação para uma dada arquitectura. 2.1 Dados por conservar Quando um procedimento é invocado, o fluxo de controlo do código é modificado. No caso de o retorno normal do procedimento (a execução do procedimento não provocou levantamento de excepção, nenhuma instrução de salto (de tipo goto) levou a execução a sair do procedimento), a execução continua na instrução que segue a chamada ao procedimento. O program counter na altura da chamada deve então ser arquivado de forma a que a execução possa continuar normalmente após o retorno. Sabe-se que os dados locais ao procedimento organizam-se na pilha de operandos a partir de um endereço que é determinada na execução e em geral salvaguardado num registo. Quando um novo procedimento é chamado, este valor muda. É assim igualmente necessário guardar este valor que poderá ser restaurado ao fim da execução do procedimento invocado. 2.2 Passagem de parâmetros Os parâmetros formais dum procedimento são variáveis que são inicializadas aquando da chamada ao procedimento. Existem várias maneiras de efectuar esta inicialização. Imaginemos o procedimento p com um parâmetro formal x que é invocado com o parâmetro efectivo e. Vamos explorar aqui as diferentes formas de efectuar esta passagem. Passagem por valor: Neste tipo de passagem, x é uma nova variável alocada localmente pelo procedimento cujo valor é o resultado da avaliação de e. Após o fim do procedimento, as modificações a que x foi submetida deixam de ser visı́veis. Na ausência de apontadores, as únicas variáveis modificadas são as variáveis não locais ao procedimento em questão e que constam explicitamente nas instruções deste último. É necessário reservar um espaço proporcional ao tamanho dos parâmetros o que pode ser custoso no caso dos vectores. Passagem por referência: Calcula-se o valor esquerdo da expressão e (se e não tem valor esquerdo, então cria-se uma variável que se inicializa com o valor direito de e e utiliza-se o valor esquerdo desta variável). O procedimento aloca uma variável x que é inicializada pelo valor esquerdo de e. Qualquer referência a x no corpo do procedimento é interpretada como uma operação sobre o objecto situado no 5 endereço guardado em x. Este tipo de passagem ocupa um espaço independente do tamanho do parâmetro. Passagem por nome: Trata-se duma substituição textual dentro do corpo do procedimento dos parâmetros formais pelos parâmetros efectivos. Este tipo de passagem, baseado num mecanismo de macro, pode provocar problemas de captura de variáveis. Exemplo: swap(x,y:int) var z:int {z:=x; x:=y; y:=z} Se z e t são variáveis globais do programa, swap(z,t) não tera o comportamento esperado. Pode-se renomear as variáveis locais por forma a não interagir com as variáveis que aparecem nos argumentos. De facto o nome da variável local z, desde que seja diferente de x e de y, não tem influência sobre o código do procedimento. Mas isto não chega, por exemplo a passagem por nome de swap(i,a[i]) não retorna o resultado esperado. Este método é no entanto útil para a compilação de procedimentos de pequeno tamanho onde o custo da gestão de chamadas a procedimentos é importante. Passagem por Copy-Restore Durante a chamada ao procedimento, o valor direito de e serve para inicializar a variável x. À saı́da do procedimento, o valor direito de x serve para a actualização do valor esquerdo de e. Este método pode ter, em casos particulares, comportamentos diferentes do passagem por referência, como o ilustra o exemplo seguinte: program main; var a : integer; procedure p(x:integer); {x:=2; a:=0} {a:=1;p(a);write(a)} Avaliação pregiçosa (lazy evaluation - call by need ) Nas linguagens funcionais, distinguese as linguagens estritas das linguagens ditas preguiçosas. Numa linguagem estrita os valores dos argumentos são avaliados antes de serem passadas em parâmetros duma função. É o caso de linguagens como OCaml e SML. Nas linguagens preguiçosas, a expressão passada em parâmetro à função f só será avaliada se f realmente precisa do valor da expressão (origem do nome call by need ). Imaginemos que a função f (x) = t esteja declarada e que se pretende calcular f (e). Desde que se precise do valor de x então o calculo de e é realizado. Apesar de t utilizar várias vezes x, é importante que o cálculo de e se faça uma única vez. A expressão e é passada em parâmetro com o seu ambiente (estrutura arquivando 6 todas as variáveis utilizadas por e), o que evita os fenómenos de captura de variáveis. Este mecanismo é assim diferente do mecanismo de substituição textual utilizado no esquema de passagem de parâmetro anteriormente descrito. A linguagem Haskell utiliza a avaliação preguiçosa. Este tem a vantagem de só realizar os cálculos úteis. No entanto, a necessidade de passar ambientes associados aos parâmetros induz um custo. Mais, é difı́cil neste tipo de linguagem de prever o momento onde efeitos lateral eventuais presentes nas expressões passadas terão lugar (o que induz vários comportamentos possı́veis para os programas, o que é indesejável). Por isso Haskell é uma linguagem funcional pura sem efeitos laterais, o que não impede a linguagem de ser eficaz e de utilização a escala industrial. 2.3 Acesso às variáveis locais Um procedimento pode aceder às suas variáveis locais que se encontram na sua tabela de activação, e às variáveis globais que se encontram num endereço conhecido na altura da compilação. Nas linguagens funcionais ou de tipo Pascal, os procedimentos podem ser aninhados e o corpo dum procedimento p pode aceder a variáveis declaradas num procedimento q que englobe p. É assim necessário aceder à tabela de activação de q que adequadamente se encontra na pilha de chamadas por baixo da tabela de activação de p. Infelizmente não é possı́vel determinar estaticamente (sem executar) a posição da tabela de activação de q em relação a de p. É assim necessária a utilização de mecanismos de encadeamentos de tabelas de activação para encontrar a variável adequada. Árvore de nı́vel das declarações Supõe-se que se tem uma linguagem funcional ou imperativo no qual os procedimentos, funções e variáveis podem ser arbitrariamente aninhados. O porte das variáveis (variable scope) pode ser calculada de forma estática seguindo as regras habituais de visibilidade. O nı́vel duma declaração (procedimentos, funções ou variáveis) é o número de procedimentos ou funções debaixo dos quais esta é definida. O programa principal tem um nı́vel 0, as variáveis globais definidas no programa principal terão o nı́vel 1. Por exemplo: program main var t:int procedure p(x,y:int); var z : int procedure q(i:int) var u : int begin u:=z+t+i; if x=u or y=u then x else if u < x then p(u,x) else if y < u then p(y,u) else q(i+1) end begin read(z); q(z) end begin read(t);p(t,t) end 7 Podemos calcular de forma estática os nı́veis de cada identificador. Introduz-se um certo número de definição ligadas às declarações, um identificador designa indiferentemente um procedimento, uma função ou uma variável: dir-se-á que p é o pai dum identificador de y se y é declarada dentro de p. Dir-se-á que p é ancião de y se p é y ou pai dum ancião de y. Dir-se-á de dois identificadores que são irmãos se têm o mesmo pai. Se o corpo dum procedimento p faz referência a um identificador então as regras de porte são tais que este é (a) ou uma declaração local e p (b) ou um ancião de p (por exemplo o próprio p) (c) ou um irmão dum ancião de p. Árvore de Activação Interessamo-nos agora pelas execuções possı́veis dum programa. Para tal introduzimos a noção de árvore de activação. Os nodos desta árvore representam as chamadas aos procedimentos p(e1 , · · · , en ). Um nodo tem k filhos q1 · · · qk se a execução do procedimento em causa invoca directamente os procedimentos q1 · · · qk (este procedimentos podem eles próprios chamar outros procedimentos...). A raı́z da árvore é a execução do programa principal. Exemplo: Dar as árvores de activação do programa anterior no caso das variáveis lidas valerem sucessivamente 1, 1 e 0. Durante uma execução dir-se-á dum procedimento que este é activo se a execução se encontra entre o inı́cio e o fim do corpo do dito procedimento. Nota-se assim que a ordem de chamada não corresponde necessariamente a ordem das declarações. No entanto demonstra-se que quando um procedimento está activo então todos os seus anciões são igualmente activos (demonstração por indução sobre a árvore de activação). A execução do programa principal define a raı́z da árvore e este é ancião dele próprio. Seja p um procedimento activo, esta foi activada por um procedimento q na árvore de activação cujo todos os anciões são activos (hipótese de indução). Agora, por razões de visibilidade, sabemos que ou q é o pai de p ou ou p é o irmão dum ancião de q. Nos dois casos todos os anciões de p estão activos. Todos os procedimentos activos têm as suas tabelas de activação reservadas em memória com todas as suas variáveis locais. Se um procedimento activa se refere a uma variável y, esta foi declarada localmente por um dos seus anciões (potencialmente por ele próprio). O grau de parentesco deste ancião é conhecido na altura da compilação. Encadeando cada tabela de activação dum procedimento p com a tabela de activação do seu pai, encontra-se facilmente seguindo o número de vezes adequadas de indirecções o local onde a variável se encontra arquivada. Exemplo: No exemplo anterior o programa pode chamar p mas não q. p pode invocar q mas q não pode invocar p. 2.4 Organização da tabela de activação Se, por suposição, o endereço do bloco de activação local é dado por um registo f p e o contador de instrução (program counter ) é designado por pc então uma organização possı́vel da tabela de activação pode ser a seguinte: 8 valores intermédios .. . fp → var. loc. k .. . fp + k − 1 param 1 val. retorno .. . fp − n − 3 fp − n − 4 } Variáveis locais var. loc. 1 fp + 0 fp do procedimento pai fp-1 fp do procedimento invocador fp-2 pc na altura da chamada fp − 3 param n fp − 4 .. } Argumentos . De facto, certos valores como o valor de retorno (no caso de funções), ou até mesmo os parâmetros, são frequentemente arquivados nos registos. Passagem de parâmetros de tamanho variável Algumas linguagens, como o Pascal, autorizam a passagem por valor d’argumentos de tipo vector cujo tamanho é igualmente um parâmetro. O tamanho não é então conhecido na altura da compilação. Para compilar o corpo do procedimento, decide-se de arquivar num local particular o endereço do vector. Os dados de tamanho variável podem então ser alocados no topo da pilha. Invocador-Invocado As operações por efectuar durante a invocação dum procedimento estão partilhados entre o procedimento que invoca e o procedimento invocado. O código gerado pelo invocador deve ser escrito para cada invocação enquanto o código escrito no invocado só aparece uma só vez. O invocador efectua a reserva para o valor de retorno no caso das funções e avalia os parâmetros efectivos do procedimento. Também pode se encarregar de salvaguardar o valor dos registos correntes (program counter, endereço do bloco de activação) e de calcular o endereço do bloco de activação do pai do procedimento invocador. O invocado inicializa os seus dados locais e começa a execução. Na altura do retorno, o invocado pode eventualmente colocar o resultado da avaliação num local reservado pelo invocador e restaura os registos. 3 3.1 Código Intermédio Introdução Utiliza-se frequentemente um código intermédio independente da máquina/arquitectura alvo. Isto permite factorizar grande parte da tarefa de compilação e de tornar o compilador mais facilmente adaptável. A escolha duma linguagem intermédia é muito importante. Este deve ser suficientemente rico para permitir uma codificação confortável das operações da linguagem fonte 9 sem criar demasiadas longas sequências de código. Este deve ser igualmente relativamente limitado para que a implementação final não seja demasiada custosa. Vamos descrever agora as operações duma máquina com pilhas (stack based machine) particular e explicaremos a seguir como gerar o código associado a construções particulares da linguagens em questão. Notação: Descreveremos a linguagem a partir de regras gramaticais. Especificaremos uma função code que aceita como parâmetro uma árvore de sintaxe abstracta da linguagem e devolve uma sequência de instruções de código intermédio (da máquina de pilhas). Para clarificar a apresentação utilizaremos notações em sintaxe concreta para representar a sintaxe abstracta. Por exemplo se E ::= E1 + E2 é uma regra gramatical, escreveremos code(E1 + E2) = · · · code(E1 ) · · · code(E2 ) para especificar o valor da função code sobre a árvore de sintaxe abstracta que corresponde a soma de duas expressões. Se C1 e C2 representam duas sequências de instruções então C1 |C2 representam a sequência de instrução obtida concatenando C2 no fim de C1 . A lista vazia de instruções é representada por []. 3.2 Princı́pios de base duma máquina de pilhas particular Dispomos duma máquina que contém uma zona de código C, um registo pc (program counter ) que contém o endereço da próxima instrução por executar. as instruções têm um nome seguido eventualmente de um ou dois argumentos. Estes argumentos serão em geral inteiros ou etiquetas (label ) simbólicas indicando as instruções do código que serão traduzidas em inteiros numa fase de pre-análise. A máquina contém uma pilha P que permite arquivar valores, um registo sp (stack pointer ) que aponta para para a primeira célula livre da pilha. Um outro registo gp (global pointer ) aponta para para a base da pilha (local onde serão arquivados as variáveis globais). Esta pilha pode conter inteiros, flutuantes, ou endereços. Por convenção guardaremos dados de grande tamanho como as strings ou vectores num espaço suplementar. Neste caso a pilha só arquivará o endereço destes dados no referido espaço. Apresentamos, para cada instrução da máquina, as modificações por considerar no estado da pilha e dos diferentes registos. Por exemplo: Código PUSHI n Pilha P [sp] := n sp pc Condição sp + 1 pc + 1 n inteiro Significa que a linguagem intermédia possui um comando PUSHI que espera um argumento inteiro n. Se a execução deste comando tem lugar num estado em que a pilha vale P , em que o registo que aponta para o topo da pilha vale sp e o contador de instrução vale pc então a execução do referido comando resulta num estado em que o endereço sp da pilha tem o valor n e os contadores sp e pc foram incrementados de um. Esta instrução só se executa correctamente se a condições anunciadas são verificadas. No caso contrário verifica-se o despoletar de um erro de execução. 10 3.3 Expressões aritméticas A máquina só permite a execução de uma operação aritmética sobre os dois valores presentes no topo da pilha. Estes dois valores são, durante a execução, retiradas da pilha e o resultado da operação é colocado no topo da pilha. Código Pilha sp pc Condição ADD P [sp − 2] := P [sp − 2] + P [sp − 1] sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros SUB P [sp − 2] := P [sp − 2] − P [sp − 1] sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros MUL P [sp − 2] := P [sp − 2] × P [sp − 1] sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros DIV P [sp − 2] := P [sp − 2]/P [sp − 1] sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros, se divisor nulo então erro De forma similar existe um conjunto de instruções dedicadas à aritmética de flutuantes: FADD, FSUB, FMUL, FDIV e PUSHF. Comparações O valores booleanos para o resultado de comparações podem ser representados por inteiros. O inteiro 1 representará o valor verdade e 0 o falso. A igualdade corresponderá a mesma instrução, quer se fale de comparar inteiros, flutuantes ou endereços. Código Pilha sp pc Condição INF P [sp − 2] := (P [sp − 2] < P [sp − 1])?1 : 0 sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros INFEQ P [sp − 2] := (P [sp − 2] ≤ P [sp − 1])?1 : 0 sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros SUP P [sp − 2] := (P [sp − 2] > P [sp − 1])?1 : 0) sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros SUPEQ (P [sp − 2] := (P [sp − 2] ≥ P [sp − 1])?1 : 0) sp − 1 pc + 1 P [sp − 2], P [sp − 1] inteiros EQUAL P [sp − 2] := (P [sp − 2] = P [sp − 1])?1 : 0) sp − 1 pc + 1 P [sp − 2], P [sp − 1] simultaneamente inteiros, flutuantes, endereços De forma similar a máquina disponibiliza um conjunto de instruções para flutuantes: FINF, FINFEQ, FSUP, FSUPEQ. Geração de código A representação de expressões aritméticas sob a forma binária permite gerar de forma simples código permitindo o cálculo de expressões aritméticas. O invariante por preservar descreve-se da seguinte forma: após a execução de code(E) (E expressão aritmética) a partir dum estado onde sp = n, os valores de P [m] onde m < n não foram modificados, o valor da expressão E é calculada e encontra-se em P [n] e sp = n + 1. Se supomos que as expressões aritméticas são geradas pela gramática: 11 E ::= inteiro|E1 + E2 |E1 − E2 |E1 × E2 |E1 /E2 O código correspondente é dado por: code(inteiro) code(E1 + E2 ) code(E1 − E2 ) code(E1 × E2 ) code(E1 /E2 ) 3.4 = P U SHI inteiro = code(E1 )|code(E2 )|ADD = code(E1 )|code(E2 )|SU B = code(E1 )|code(E2 )|M U L = code(E1 )|code(E2 )|DIV Variáveis Variáveis globais Se a linguagem fonte permite a memorisação determinados valores em variáveis então será necessário recorrer mecanismos que permitam, na máquina, o arquivo e o acesso a valores. Estes mecanismso assentam em duas instruções que permitam (a) a atribuição dum valor calculado na pilha para um espaço reservado para as variáveis globais; (b) colocar no topo da pilha de operandos um valor contida numa variável global. Se a é um endereço na pilha e n um inteiro então a + n designa o endereço localizado n céliulas acima de a. Estes dois comandos são: Código Pilha sp pc Condição PUSHG n P [sp] := P [gp + n] sp + 1 pc + 1 n inteiro, gp + n < sp STOREG n P [gp + n] := P [sp − 1] sp − 1 pc + 1 n inteiro, gp + n < sp O inteiro associado a cada variável id atribuı́do será calculado na altura da análise sintáctica ou semântica e associada ao identificador, por exemplo, dentro da tabela de sı́mbolos e designada por adr(id). É preciso cuidar que os cálculos intermédios não removam os valores arquivados. É assim importante reservar atempadamente o espaço para todas as variáveis globais antes de iniciar os primeiros cálculos. O apontador de topo de pilha deverá inicialmente apontar para a primeira célula livre que está a seguir ao espaço reservado para as variáveis globais. Para este efeito utilizaremos uma instrução que arquiva n valores nulos (iguais a 0) na pilha. Introduz-se igualmente a instrução dual que permite fazer recuar o apontador para a pilha de n espaços para baixo, o que equivale de facto em apagar n valores da pilha. Código Pilha sp pc Condição PUSHN n P [sp + i] := 0, ∀i.sp ≤ i < sp + n sp + n pc + 1 n inteiro POPN n sp − n pc + 1 n inteiro Admitimos agora que a nossa gramática de expressões é enriquecida da seguinte forma para poder acceder às variáveis globais: E ::= id O código correspondente é definido por: code(id) = PUSHG adr(id) no caso de uma variável de tamanho unitário. Senão, se representa uma dado arquivado sobre k palavras, por: 12 code(id) = PUSHG adr(id) PUSHG adr(id) + 1 · · · PUSHG adr(id) + (k − 1) Consideremos agora uma linguagem cujas únicas instruções são sequências de atribuições. A análise semântica deve determinar para cada variável global id um espaço (relativamente a gp) designado por adr(id) onde o valor correspondente será arquivado. Acrescentamos um sı́mbolo não-terminal à gramática para representar as instruções da linguagem. Extendemos a seguir a função code em conformidade. Assim, temos a gramática: I ::= I ::= I1 A; A ::= id := E e a geração de código seguinte: code() = [] code(I1 A; ) = code(I1 )|code(A) code(id := E) = code(E)|STOREG adr(id) No caso dum dado codificado sobre k palavras, utilizaremos para codificar id := E as instrucções: code(E)|STOREG adr(id) + (k − 1)| · · · |STOREG adr(id) O invariante por conservar é que o código associado a uma sequência de instruções começa e termina com um apontador de pilha por cima do conjunto dos valores globais. Vectores Em geral, o número de células da pilha nos quais um dado é representado depende do tipo do dado ( inteiro, real, vector, estrutura). Supõe-se que este tamanho é conhecido na fase de compilação e é designada por tamanho(id). Os vectores são representados por sequências de células adjacentes na pilha. Se o vector t tem ı́ndices entre m e M , começa no endereço a e contém valores de tamanho k, então este ocupa um espaço de tamanho (M − m + 1) × k. Para calcular o endereço correspondente a uma expressão t[E] é necessário calcular o valor de n de E e aceder ao endereço a + (n − m) × k. Se conhecemos o valor de m na fase de compilação então podemos avaliar parcialmente esta expressão pre-calculando a − m × k e arquivar este valor em t.val. Precisar-se-á posterior e simplesmente calcular t.val + n × k. Os comandos PUSHG, STOREG não permitam aceder a um endereço conhecida na altura da compilação. Assim para aceder a vectores precisaremos de um acesso parametrizado por um valor calculada durante a execução. Em regra geral, o endereço ao qual precisamos de aceder depende dum endereço de base a que não é necessariamente conhecido estaticamente (caso dos vectores de tamanho variável) e dum salto (offset) n que é igualmente calculado durante a execução. Extendemos assim os comandos PUSHG, STOREG, que operavam a partir dum endereço fixo gp e dum salto estático n em argumento, em comandos LOADN e STOREN que procuram estas informações dinamicamente na pilha. 13 Figura 2: LOADN e STOREN Código Pilha sp pc Condição LOADN P [sp − 1] := P [P [sp − 2] + P [sp − 1]] sp − 1 pc + 1 STOREN P [P [sp − 3] + P [sp − 2]] := P [sp − 1] sp − 3 pc + 1 Podemos representar graficamente o comportamento destes dois comandos concretizando a pilha e numerando as células por inteiros k, k + 1, · · ·: ver figura 2 Podemos notar que estas instruções poderiam ser decompostas num comando que permita o acesso a um elemento da pilha via um endereço arquivado na pilha, e num comando que permita realizar cálculos sobre os endereços. Acrescentamos à nossa linguagem os vectores que supomos serem de uma só dimensão. Temos assim as duas novas produções seguintes: E ::= id[E] I ::= id[E1 ] := E2 Para gerar código, devemos poder colocar na pilha um endereço. Definimos assim a instrucção PUSHGP que permite arquivar o endereço do apontador global na pilha. Código Pilha sp pc Condição PUSHGP P [sp] := gp sp + 1 pc + 1 Supondo que o endereço de base onde é arquivado cada vector é conhecido, o código gerado é: code(id[E]) = PUSHGP|PUSHI adr(id)|code(E)|ADD|LOADN code(id[E1 ] := E2 ) = PUSHGP|PUSHI adr(id)|code(E1 )|ADD|code(E2 )|STOREN Os vectores bi-dimensionais podem ser arquivados via linearização em linha ou ainda em coluna. Se cada dimensão tem por ı́ndice minimal mi , ı́ndice maximal Mi e por tamanho ni = Mi − mi + 1, então se o arquivo é realizado em linha então teremos em primeiro o vector t[1, i] seguido de t[2, i], . . . O elemento t[i1 , i2 ] é arquivado no endereço a + ((i1 − m1 ) × n2 + (i2 − m2 )) × k Aqui também podemos pre-calcular parte da expressão re-escrevendo-a em: ((i1 × n2 + i2 ) × k + a − (m1 × n2 + m2 ) × k 14 Quando encontramos uma expressão id[E1 , E2 ] será necessário calcular o offset por aplicar a partir da base onde está arquivado id para poder aceder ao elemento designado. Podemos generalizar este processo aos vectores de dimensão p. No caso de vectores cujo tamanho não é conhecido na altura da compilação, não será possı́vel realizar este processo de pre-compilação. Outros comandos de acesso Podemos igualmente precisar de ter acesso a um dado na pilha referenciado de forma estática, a partir dum endereço arquivado na pilha. Tal processo é possı́vel com os comandos LOAD e STORE seguintes: Código Pilha sp pc Condição LOAD n P [sp − 1] := P [P [sp − 1] + n] sp pc + 1 n inteiro STORE n P [P [sp − 2] + n] := P [sp − 1] sp − 2 pc + 1 n inteiro 3.5 Instrucções condicionais Para compilar expressões condicionais e ciclos iremos precisar de comandos de salto na zona de instrucções. As instrucções serão designadas por endereços simbólicos inseridos no código. Código Pilha sp pc Condição JUMP label sp label JZ label sp − 1 (P [sp − 1] = 0)?label : pc + 1 Para compilar expressões, é necessário adoptar uma convenção para a representação dos boleanos. Existem diferentes opções, podemos representar o falso por 0 e o valor verdade por qualquer inteiro positivo ou utilizar somente o valor 1. Vamos a seguir ver como gerar código para expressões condicionais e ciclos: I ::= if E then I endif I ::= if E then I1 else I2 endif I ::= while E do I done Introduz-se um comando suplementar na linguagem intermédio LABEL label que introduz um endereço simbólico label correspondente ao número da instrução, sem modificar o estado da pilha. Só o program counter é incrementado. Exercı́cio: Dar um sistema de atributo para a linguagem intermédia que permite a remoção das instruções LABEL e de substituir os endereços simbólicos por endereços inteiros. code(if E then I endif ) = code(E) | JZ new next | code(I) | LABEL new next code(if E then I1 else I2 endif ) = code(E) | JZ new f alse | code(I1 ) | JUMP new next | LABEL new f alse | code(I2 ) | LABEL new next code(while E do I done) = LABEL new loop | code(E) | JZ new next | code(I) | JUMP new loop | LABEL new next Para esta compilação, é necessário criar de cada vez novas etiquetas new f alse, new next e new loop. 15 Compilação de expressões boleanas As expressões booleanas, digamos por exemplo E, servem frequentemente para operações de controlo. Podemos assim considerar sistematicamente duas etiquetas E true e E f alse e compilar a expressão boleana E sem calcular o seu valor mas decidindo que o resultado da execução de E deve se concluir em pc = E true quando E é verdade, ou em pc = E f alse caso contrário. Definimos então uma função code bool que aceita em parâmetro uma expressão boleana e duas etiquetas e que devolve o código de controlo. Começemos por uma gramática de expressões booleanas: B B B B B B ::= ::= ::= ::= ::= ::= B1 or B2 B1 and B2 not B1 verdade f also E1 relop E2 O código gerado correspondente é: code bool(B1 or B2 , ev , ef ) code code code code code = code bool(B1 , ev , new e)|LABEL new e| code bool(B2 , ev , ef ) bool(B1 and B2 , ev , ef ) = code bool(B1 , new e, ef )|LABEL new e| code bool(B2 , ev , ef ) bool(not B1 , ev , ef ) = code bool(B1 , ef , ev ) bool(verdade, ev , ef ) = JUMP ev bool(f also, ev , ef ) = JUMP ef bool(E1 relopE2 , ev , ef ) = code(E1 )|code(E2 )|code(relop)|JZ ef |JUMP ev Neste código, new e representa uma etiqueta criada para o efeito. code(relop) representa o operador relacional utilizado. Nota: Numa linguagem que possibilita os efeitos laterais nas expressões, calcular ou não as sub-expressões duma expressão booleana pode resultar em comportamentos radicalmente diferentes. Compilação de expressões com branching múltiplo Tratam-se de expressões de tipo switch, case ou match que permitam o branching múltiplo consoante os diferentes valores duma expressão parâmetro. Estes valores são geralmente de tipo numérico e conhecidos na fase de compilação. switch E begin case V1 : S1 case V2 : S2 .. . case Vn : Sn end Antes de mais devemos nos assegurar da semântica duma tal expressão. Uma semân- 16 tica natural é: if E = V1 then S1 else if E = V2 then S2 .. . else if E = Vn then Sn Não é, no entanto, a semântica da instrução switch da linguagem C. Nesta linguagem executa-se todas as instruções Si ; . . . ; Sn a partir do primeiro i tal que E = Vi . É a presença duma instrução break num Si que permite a saı́da do switch. if elseif .. . E = V1 then goto l1 E = V2 then goto l2 elseif l1 : .. . E = Vn then goto ln S1 ln : break : Sn Podemos optimizar a compilação se os valores dos vi são todos distintos num intervalo [k + 1, k + n]. Cria-se então no código uma tabela de branching que se inicia por LABEL inicio e que integra sucessivamente as instruções JUMP l1 , . . ., JUMP ln . Seguem-se então o código de cada ramo da estrutura condicional LABEL li | code(Si ), seguida eventualmente duma instrucção JUMP f im. É desta forma necessário dispor duma instrução de salto indexado que notaremos JUMPI. Esta instrução aceita uma etiqueta e devolve o controlo à instrução cujo número é o valor numérico da etiqueta mais o valor do topo da pilha. Código Pilha sp pc Condição JUMPI label sp − 1 label + P [sp − 1] Insere-se então o código de E : code(E) do qual é necessário subtrair a constante k : PUSHI k|SU B. Testa-se se o valor obtido está entre 1 e n e efectua-se o branching indexado correspondente, senão a instrução é ignorada. Como o valor no topo da pilha deve ser utilizada para duas comparações e o salto indexado, é preciso utilizá-la 3 vezes e, por isso, duplicá-lo deu as vezes com a ajuda da instrução DUP. Obtém-se o código seguinte: DUP|DUP |PUSHI n|INFEQ|JZ f im |PUSHI 1|SUPEQ|JZ f im |JUMPI inicio |LABEL f im Exercı́cio: Modificar o esquema para o caso em que a intrucção switch inclua um caso por defeito. 3.6 Invocar procedimentos A invocação de procedimentos tem dois efeitos. O primeiro é de modificar o decorrer sequencial da execução, o segundo é de criar um espaço para os dados locais. 17 Sub-rotinas Imaginemos que queremos reutilizar em vários locais a mesma porção de código I. Para tal isola-se I e atribuimo-lhe uma etiqueta. Podemos então substituir todas as cópias de I por uma instrução de salto para I. O problema é conseguir voltar de I para o bom local (a instrução que segue o salto para I) e proceder ao resto da execução. Se temos várias utilizações da rotina então não podemos colocar simplesmente uma instrução de salto estático. É assim necessário, antes do salto para I, arquivar o valor do apontador de programa ( o valor do registo pc, program counter ) e restaura-lo no fim da execução de I. Como as chamadas a sub-rotinas podem ser aninhadas, é necessário tornar possı́vel o arquivo dum número arbitrário de tais valores. Esta informação poderia ser guardada na pilha de operandos, mas a máquina que utilizamos aqui tem uma pilha especialmente dirigida ao processamento deste tipo de informação. As instruções CALL e RETURN permitam o correcto processamento do apontador de programa. A instrução CALL toma em parâmetro um endereço no código. O contador de instrução coloca-se então na posição especificada sendo o seu antigo valor arquivado. A instrução RETURN descobre o antigo valor do apontador de programa e atribuı́-lhe o valor seguinte (isto é, aponta agora para a instrucção que segue). Acrescenta-se então à linguagem definições e invocação de sub-rotinas: D ::= proc p; begin I end I ::= call p code(proc p; begin I end) = LABEL label p|code(I)|RETURNcode(call p) = CALL label p label p é uma etiqueta única associada ao pocedimento p. Procedimentos com parâmetros Se o procedimento tem parâmetros então o papel deste procedimento é permitir a gestão na pilha do espaço necessário para estes parâmetros (reserva e acesso). Como só se consegue fazer referência a células da pilha a partir do endereço de base, é necessário dispor dum registo suplementar f p que será actualizado ao inı́cio da invocação do procedimento e que permitirá referenciar os valores locais. Na saı́da do procedimento este espaço deverá ser liberto. A chamada e o retorno dos procedimentos pode ser percebido como uma modificação do valor de pc e da atribuição dum endereço de base para as variáveis locais. No momento do retorno do procedimento, o valor de pc deve ser incrementado de 1 e o valor de f p restituı́da ao valor que tinha antes da invocação, visto as chamadas poderem ser aninhadas. A pilha ficará truncada de toda a parte alocada a seguir à chamada do procedimento. O papel dos comandos CALL e RETURN será exactamente assegurar o correcto arquivo de f p e pc. Instrucções de manipulação de dados locais A linguagem contém instruções para manipular dados referenciadas a partir do apontador f p. Às instruções PUSHGP, STOREG, PUSHG correspondem as instruções PUSHFP, STOREL, PUSHL que têm o mesmo comportamento só que sobre f p e não sobre gp. 18 Tabela de activação No caso de uma alocação estática, a tabela de activação será constituı́do (a) dos parâmetros do procedimento que serão instanciados no momento da chamada (b) do endereço da tabela de activação do procedimento chamadora (c) do local para as variáveis locais. A gramática para as declarações tem a forma seguinte: Ds ::= Ds D; | D ::= var id : T |proc id(Ds ) Ds begin I end| f un id(Ds) : T Ds E Podemos calcular, por exemplo, por atributos, o nı́vel de cada sı́mbolos de procedimentos, função ou variável, que é o número de procedimentos ou funções sob o qual este se encontra definido. Este valor corresponde a profundidade do sı́mbolo na árvore de declarações. O programa principal tem um nı́vel igual a 0. O offset associada a uma variável é o inteiro que se deve adicionar a f p para obter o endereço de base da variável. Exercı́cio: Definir um sistema de atributos para calcular os nı́veis e offset das declarações. No momento duma chamada por valor de q(e1 , . . . , en ), temos de calcular o código de e1 , . . ., en que serão empilhados, como também temos de calcular o endereço da tabela de activação do invocador. Para tal olhamos para os nı́veis respectivos de q e do procedimento p que chamou q. O dado importante aqui é o nı́vel relativo n que é igual ao nı́vel de p menos o nı́vel de q e que é sempre maior ou igual a −1 (= −1 quando q é declarado dentro de p). Calculo do endereço do vector de activação pai Quando p chama q então o cálculo do endereço do pai de q faz-se pela sequencial de instrução seguinte: PUSHFP | |LOAD{z − 1} n+1 vezes Podemos notar que a sequência de instrução PUSHFP | LOAD n é equivalente a PUSHL n Quando este registo é actualizado, podemos executar a invocação com o comando CALL q. O inı́cio deste procedimento deve reservar o espaço para as variáveis locais seguida da execução do código. No fim da execução do procedimento, o comando RETURN é invocada. Esta removerá da pilha os valores locais mas não os parâmetros. O invocador poderá então retirar da pilha os parâmetros assim como o endereço do pai. Se o procedimento é uma função que retorna um valor, então a localização deste último deverá ser devidamente reservado antes da chamada do procedimento. Escolhe-se normalmente a utilização do primeiro espaço livre da pilha. Cálculo do acesso a uma variável Se dentro dum procedimento p se pretende aceder a uma variável x calcula-se ainda uma diferença n entre o nı́vel np de p e nx, o nı́vel de x, que é superior a −1. Conhece-se a offset dx de x. O código para aceder ao valor direito de x é assim parametrizado pelo nı́vel np do procedimento dentro do qual se está: code var(x, np) = PUSHFP| |LOAD{z − 1} |LOAD dx np−nx+1 vezes 19 Passagem de parâmetros por valor No modo de passagem por valor teremos a compilação de corpos de procedimentos seguinte: code(proc id(Ds1 ) Ds2 begin I end) = LABEL label id | PUSHN tamanho(Ds2 ) | code(I) | RETURN A invocação faz-se pelos comandos seguintes: LE ::= E | LE, E A ::= call id(LE) A geração de código duma sequência de expressão é assim somente a concatenação dos diferentes códigos. O código associado à invocação dum procedimento q de nı́vel pq é parametrizada pelo nı́vel np do procedimento invocador: code call(call q(LE), np) = code(LE) | PUSHFP | LOAD | {z − 1} | np−nq+1 vezes CALL label id | POP tamanho(LE) + 1 Exercı́cio: Escrever o código de compilação e de invocação duma função. Passagem de parâmetros por referência No lugar de empilhar os valores das expressões, empilha-se os seus valores esquerdos (isto é endereços). Os acessos às variáveis fazem-se então via uma indirecção. Passagem duma função em parâmetro Para compilar um procedimento que aceita uma função em parâmetro, são necessários duas informações: o endereço da função e o endereço do vector de activação do pai do procedimento. Exemplo program main procedure b(function h(n:int):inte); begin print(h(2)) end procedure c; var m:int; function f(n:int) : int; begin f:=m+n end begin m:=0; b(f) end begin c end Para executar o código do procedimento é necessário conhecer o endereço do código deste procedimento assim como o endereço da tabela de activação do procedimento. Quando c deve executar b(f ), este calcula a ligação de activação du pai estático de f (aqui c) como se este o chamasse e passa-o a b que poderá o utilizar na altura da invocação de f . 20 Retornar uma função como valor Algumas linguagens permitam devolver funções como valores. No entanto se a linguagem autoriza o acesso a variáveis não locais, o problema que se coloca é o da persistência dos valores assim manipulados. Exemplo let f(x) = let g(y) = x+y in g let h = f(3) let j = f(4) h(5)+j(7) O valor duma função é designado de fecho (closure) e é formada por um apontador para o seu código assim como por um ambiente que associa valores a todas as variáveis utilizadas no corpo da função e que não são locais na altura da definição da função. 4 Utilização de registos Os registos permitan arquivar informações e manipula-las de forma rápida. No entanto estes registos existam normalmente em número pequeno. Convêm assim geri-los da melhor forma. Uma forma de o fazer é de introduzir para cada valor intermédio uma nova variável e de construir um grafo de interferência: os nodos são as variáveis e temos um arco entre x e y se x e y não podem ser arquivados no mesmo registo (i.e. as duas variáveis estão vivas na mesma altura). Podemos igualmente considerar os registos como os nodos deste grafo e ligar a existência dos arcos como o facto de certos valores não poderem ser arquivados no registo particular. Assim, determinar uma repartição das variáveis sobre k registos resume-se em encontrar uma k-coloração do grafo. Visto este problema ser NP-Completo em geral, utiliza-se uma aproximação. Se este grafo é vazio, então a coloração é evidente. Se o grafo contempla um nodo x de grau estritamente inferior a k então constroi-se um novo grafo G0 removendo este nodo e as arestas correspondentes. Se G0 pode ser colorido então podemos encontrar uma cor para x, diferente de todos os seus vizinhos e acrescenta-lo. Nota-se que remover x de G pode diminuir o grau de outros nodos e tornar assim a coloração possı́vel. Se o grafo só comporta nodos de grau superior a k então escolhe-se e remove-se um nodo x e procura-se colorir o grafo resultante. Se for possı́vel, olha-se para o número de cores utilizadas para colorir os vizinhos de x. Se existe uma cor disponı́vel então é possı́vel colorir x e este é de novo acrescentado ao grafo, senão a coloração não é possı́vel e x tem de ser arquivado na memória. Para tal escolhe-se um espaço mx para arquivar x e introduz-se para cada utilização de x uma nova variável xi . Se o valor de x é utilizado numa expressão, começamos por colocar em xi o valor arquivado no endereço mx da memória. Se x é actualizada então substitua-se x por xi e propaga-se esta instrução de actualização de mx na memória pelo valor de xi . A duração de vida das variáveis xi é pequena, estas não interfiram com as outras variáveis. É necessário modificar o grafo de interferência e recomeçar a coloração. O grafo de interferência pode igualmente ser utilizado para remover as instruções move entre registos 21 associadas a instruções x := y. Podemos identificar x e y desde que x e y não são vivas simultaneamente. No entanto, esta identificação terá como resultados o aumento do grau do nodo xy (resultado da identificação entre x e y), o que poderá fazer com que a colorização falhe. Efectuar uma deslocação nos registos é menos custoso do que um acesso à memória. Assim far-se-á a identificação só em alturas em que se consegue guarantir a preservação da colorização. É o caso quando o conjunto de vizinhos de xy contempla têm estritamente menos de k nodos de grau inferior a k. Exemplo (A. Appel) Considere-se o programa seguinte formado de atribuições simples e de acessos à memória. Indicamos na segunda coluna o conjunto de variáveis simultaneamente vivas no ponto de programa anterior, sabendo que após a execução deste código as variáveis d, k e j são vivas. g h f e m b c d k j Instrucções := mem [j+12] := k - 1 := g * h := mem[j+8] := mem[j+16] := mem[f] := e+8 := c := m+4 := b Variáveis Vivas k, j j, g, k j, g, h j, f j, f, e m, f, e m, b, e m, b, c m, b, d b, d, k d, k, j Podemos olhar para os nodos do grafo nesta ordem: m, c, b, f , e, j, d, k, h e g. e constatamos que em cada instante cada nodo tem um grau inferior ou igual a 3. Podemos assim realizar uma colorização com 4 cores e obtemos por exemplo a atribuição de cores seguintes: m = 1, c = 2, b = 34,f = 24, e = 4, j = 3, d = 4, k = 1, h = 1 e g = 2. Se dispúnhamos só de 3 registos, poderı́amos recomeçar a análise com os nodos na ordem seguinte (os nodos em negrito são os que tem um grau superior a 3 e que podem assim colocar problemas): b, d, j, e, f, m, k, g, c e h. Uma tentativa ed coloração pode ser: b = 1, d = 2, j = 1, e = 2, f = 3 , m, k, g, c e h não permitam a coloração de m. Podemos então escolher arquivar m na memória no endereço M . Devemos assim transformar o programa: 22 Instrucções g := mem [j+12] h := k - 1 f := g * h e := mem[j+8] m1 := mem[j+16] mem[M] := m1 b := mem[f] c := e+8 d := c m2 := mem[M] k := m2 +4 j := b Variáveis Vivas k, j j, g, k j, g, h j, f j, f, e m1 , f, e f, e b, e b, c b, d m2 , b, d b, d, k d, k, j O nodo m de grau 5 transformou-se em dois nodos m1 e m2 de grau 2. Podemos examinar de novo os nodos do novo grafo na ordem seguinte: h, c, m2 , m1 , f , e, b, d, k, ge j e não permanecem mais dificuldades. Este tipo de mecanismo de colorização pode igualmente ser utilizado para minimizar o número de espaços memória utilizados no arquivo em memória das variáveis. O objectivo será assim a minimização do número de move mesmo se isto aumente o número de células memória utilizada. 5 Alocação na Heap Na codificação que acabamos de abordar, os novos valores por calcular ao longo da execução são alocados na pilha. Este princı́pio funciona porque o que é alocado durante a invocação deixa de ser acessı́vel no fim do procedimento. Este princı́pio não consegue cobrir todos os casos possı́veis. Por exemplo, se a linguagem permite retornar valores funcionais, então vimos que o valor devolvido deve conter uma representação do ambiente no qual a função se encontra definida. Este ambiente que deve sobreviver ao fim do procedimento que a define incluı́ no entanto valores alocados na tabela de activação do procedimento ”definidor”. 5.1 Representação de Dados Estruturados No exemplos anteriores, alocamos para cada variável um espaço correspondente ao tamanho do referido dado. Assim a passagem de vectores em parâmetros necessita que sejam alvos de cópia. Seria mais eficiente manipular os objectos de tamanho importante pelo intermédio dos seus endereços. No caso dos vectores em OCaml, por exemplo, existe um momento em que o vector é criado (por exemplo, explicitamente por [|1; 2; 3|] ou via o comando Array.create) e um valor do tipo vector é simplesmente o endereço dum vector. Naturalmente o vector pode ser alocado no corpo duma função e ser devolvido como resultado da função. Neste caso é necessário que este seja alocado numa zona de dados persistente. Alocar tais dados na zona das variáveis globais não é de todo uma boa ideia. 23 Pois a duração de vida de tais objectos é em geral limitada, é assim inútil reservar espaço memória durante toda a execução do programa. 5.2 Alocação dinámica Trata-se de criar dinamicamente novos espaços memória durante a execução do programa, espaços que ficarão acessı́veis pelo programa sem, no entanto, ficarem directamente ligados a variáveis. A duração de vida destes objectos não é forçosamente ligada a duração de vida dos procedimentos. Reserva-se assim espaços para esses objectos preferencialmente num local designado de heap. É possı́vel que células memórias reservadas desta forma deixam de ser acessı́veis durante a execução do programa. Distingue-se nas linguagens os dados directos tais como os inteiros que serão alocados e manipulados na pilha de dados dos dados cuja manipulação se faz pelo intermédio dum endereço na memória. Uma tal manipulação é necessária quando a linguagem contêm funções polimórficas que podem manipular dados de tamanho variável durante a execução. 5.3 Alocação / Desalocação explı́cita Linguagens como Pascal ou C oferecem primitivas para alocar ou desalocar partes de memória (new/dispose em Pascal, malloc/free em C). Se todos os dados por alocar têm o mesmo tamanho então é possı́vel criar uma lista encadeada na qual se poderá procurar um novo espaço ou ao contrário liberar uma célula. Reserva-se o espaço que engloba o espaço necessário para o dado por alocar mais o espaço para o endereço do dado seguinte na pilha dos espaços disponı́veis. Se é preciso alocar objectos de tamanho diferente então deve-se usar a heap. A desalocação dum dado não liberta necessariamente um espaço em memória suficiente para os dados seguintes, a heap transforma-se em gruyère. Isto é, mesmo se o espaço livre total é suficiente, este pode estar de tal forma fraccionado que a alocação pode falhar. A alocação/desalocação explı́cita torna a programação mais pesada e faz correr o risco (a) de utilizar desnecessariamente espaço para dados inacessı́veis; ou (b) ao contrário remover dados acessı́veis. Os erros de comportamento e de execução resultantes são, além disso, de difı́cil diagnóstico. 5.4 Alocação / Desalocação implı́cita Linguagens como o LISP, ML ou JAVA escolheram a via da alocação dinâmica. A execução será levada a alocar memória e encarregar-se-á da sua recuperação. É o mecanismo de ”recuperação de lixo”: Garbage collector. Vários métodos para a recuperação de memória existam. Contador de referências Cada célula dinamicamente alocada incluı́ um contador que indica quantos referencias estão a apontar para ela. Quando se faz um new(p), a célula em questão vê o seu contador inicializado a um. Uma atribuição entre apontadores da forma t:=u onde t é uma expressão que deve se avaliar num valor esquerdo x que representa um apontador e cujo valor direito é um endereço para uma célula m. A expressão u representa igualmente um apontador que se avalia num valor ditreito que é um endereço 24 para a célula n. A célula m que era previamente acessı́vel por x deixou de o ser, logo o seu contador é decrementado de um. Por outro lado, a célula n é agora acessı́vel a partir de x logo o seu contador é incrementado de 1. as células cujo contador é nulo podem ser recuperadas (libertas). No entanto existem situações em que duas células apontam uma para a outra e ficam assim ambas os seus contadores com o valor 1 mesmo se ambas ficaram inacessı́veis. Este método, além desta situação, é igualmente guloso em termos de tempo de execução. Exemplo type list = Nil | Cons of int * list ref let x = Cons(7,ref Nil) in let y = Cons(9,ref x) in let t = Cons(10,ref y) in let Cons(_,z)=x in z:=y;; Mark and Sweep Trata-se aqui de partir das variáveis da pilha que são acessı́veis pelo programa e de marcar todas as células assim utilizadas, e isso de forma recursiva. Quando o processo termina, uma segunda execução permite libertar todos os espaços inacessı́veis. Esta recuperação é custosa em termos de tempo, o que é problemático para as aplicações em tempo real. Tem também o inconveniente de fragmentar a memória. Por outro lado utiliza um processo recursivo para marcar a memória que pode conduzir a um memory overflow. Para evitar utilizar espaço memória suplementar para gear as chamadas recursivas pode-se efectuar um percurso em profundidade das ligações da memória e inverter as ligações entre células memórias para memorizar as zonas que restam por percorrer. Stop and copy Para evitar os problemas de fragmentação podemos decidir de sempre alocar linearmente os dados. quando uma zona se encontra cheia, para-se e copia-se toda a parte útil numa segunda zona de forma contı́gua. Liberta-se a seguir toda a zona previamente ocupada. Este método tem o inconveniente de só poder efectivamente ocupar metade do espaço reservado. Métodos mixtos Os GCs modernos utilizam um compromisso entre estas diferentes métodos. Por exemplo caml-light utiliza um GC (elaborado por Damien Doligez) deste tipo. O espaço memória é separado em duas zonas. a primeira é designada de velha é organizada com a ajuda duma lista de locais acessı́veis. Esta zona poderá ser extendida dinamicamente se necessário. A outra zona, designada de nova está organizada como um vector linear de tamanho fixo. As células são alocadas linearmente no vector. Quando este vector se encontra cheio. Parde tamanho fixo-se o processo e copia-se todos os objectos úteis (os que são acessı́veis a partir das variáveis da pilha, dos registos, dos objectos da zona velha, estes estando referenciados numa tabela de referências) na zona velha. Esta etapa chama-se um GC menor. Faz-se em seguida uma fase de GC maior. Esta consiste num mark and sweep incremental da zona velha. Para marcar estes objectos utiliza-se uma colorização. Os nodos são brancos quando não foram visitados, cinzentos quando 25 foram visitados mas não os seus filhos, pretos quando foram visitados e os filhos também. Guarda-se uma pilha de nodos cinzentos, quando não resta nenhum nodo cinzento a marcação/coloração terminou. Os nodos que ficaram com a cor branca podem ser libertos para a lista de células livres da zona velha. Para gerir o GC, a representação de CAML reserva um bit sobre cada palavra para distinguir os endereços (que o GC deve seguir) dos inteiros. A heap é organizada em zonas correspondentes às diferentes estruturas alocadas (vectores, tipo de dados estruturados, fecho, etc.). O cabeçalho de cada zona reserva-se dois bits para a marcação (branco, cinzento e preto) do GC. 26