Compiladores. versão α 0.002



Documentos relacionados
Programação Estruturada. Programação Estruturada. Idéias Básicas da Programação Estruturada

Máquina virtual para o projecto da disciplina de compiladores. 1 Descrição. 1.1 Organização da máquina. 11 de Setembro de 2006

CAPÍTULO 2. Grafos e Redes

INTRODUÇÃO À LINGUAGEM C++

TÉCNICAS DE PROGRAMAÇÃO

ARQUITECTURA DE COMPUTADORES CAPÍTULO II AULA X

9 Comandos condicionais

Algoritmos e Estruturas de Dados I 01/2013. Estruturas Condicionais e de Repetição (parte 2) Pedro O.S. Vaz de Melo

5 - Vetores e Matrizes Linguagem C CAPÍTULO 5 VETORES E MATRIZES

1. SINTAXE DA LINGUAGEM ASSEMBLY

Filas. A ordem de saída corresponde diretamente à ordem de entrada dos elementos. Fila de caixa bancário

4.1. UML Diagramas de casos de uso

Programação. MEAer. Bertinho Andrade da Costa. Instituto Superior Técnico. Introdução ao Pré-Processador. 2011/2012 1º Semestre

Informática de Gestão 1º ano / 1º semestre Ano letivo: 2014/2015. Visual Basic VBA

Regras Métodos Identificadores Variáveis Constantes Tipos de dados Comandos de atribuição Operadores aritméticos, relacionais e lógicos

Capítulo SETE Números em Ponto Fixo e Ponto Flutuante

Tabela de Símbolos. Análise Semântica A Tabela de Símbolos. Principais Operações. Estrutura da Tabela de Símbolos. Declarações 11/6/2008

Laboratório de Banco de Dados II Aula 1. Stored Procedures

Tarefa Orientada 18 Procedimentos armazenados

Aula 14: Instruções e Seus Tipos

Linguagem de Montagem 2. Operações e Operandos

4.4. UML Diagramas de interacção

Implementando uma Classe e Criando Objetos a partir dela

CONCEITOS BÁSICOS PARA A CONSTRUÇÃO DE ALGORITMOS PARA COMPUTADORES. Isac Aguiar isacaguiar.com.br isacaguiar@gmail.com

Estrutura de Dados Básica

Notas de Cálculo Numérico

Especificação Operacional.

Técnicas de Computação Paralela Capítulo III Design de Algoritmos Paralelos

Folha de Cálculo TECNOLOGIAS DA T IINF CO RM 1 A 0 ÇÃO E COMUNICAÇÃO TIC 10

Capítulo 7 Nível da Linguagem Assembly

Linguagem e Técnicas de Programação I Tipos de dados, variáveis e constantes. Prof. MSc. Hugo Souza Material desenvolvido por: Profa.

Programação I. Departamento de Engenharia Rural Centro de Ciências Agrárias

UNIDADE 6 - PROGRAMAÇÃO MODULAR

Manual do Gestor da Informação do Sistema

Conjunto de instruções e modos de. aula 4. Profa. Débora Matos

c. Técnica de Estrutura de Controle Teste do Caminho Básico

Capítulo 7. Topologia Digital. 7.1 Conexidade

Nível da Arquitetura do Conjunto das Instruções

Complexidade de Algoritmos

UML (Unified Modelling Language) Diagrama de Classes

Possui como idéia central a divisão de um universo de dados a ser organizado em subconjuntos mais gerenciáveis.

Introdução à Programação. Armazenamento de Grande Quantidade de Informação Usando Vetores

3 Estratégia para o enriquecimento de informações

Visão Artificial Para a Indústria. Manual do Utilizador

Hugo Pedro Proença, 2007

Algoritmos e Programação

Classificação e Pesquisa de Dados

Orientação a Objetos

Linguagem de Programação JAVA. Técnico em Informática Professora Michelle Nery

Computadores de Programação (MAB353)

Base de dados I. Uma base de dados é um simples repositório de informação relacionado com um determinado assunto ou finalidade

CAPÍTULO 7 NÍVEL DE LINGUAGEM DE MONTAGEM

Conceitos básicos da linguagem C

APROG - Civil. Excel. Técnicas de pesquisa de informação em tabelas. Instituto Superior de Engenharia do Porto

E/S CPU. Memória (Instruções e dados) 2 PARADIGMA IMPERATIVO. Instruções e dados. Resultados das operações. Unidade lógica e aritmética

Tarefa 18: Criar Tabelas Dinâmicas a partir de Listas de Excel

compreender a importância de cada estrutura de controle disponível na Linguagem C;

Descrição do Produto. Altus S. A. 1

Arquitetura de processadores: RISC e CISC

Resolução eficaz de problemas: quatro exemplos

Programação Funcional. Aula 5. Funções Recursivas. José Romildo Malaquias. Departamento de Computação Universidade Federal de Ouro Preto 2011.

P r o g r a m a ç ã o d e C o m p u t a d o r e s 1 o S e m P r o f. A n d r é A m a r a n t e L u i z L A B 5 tag %2d while printf PE1:

Java Como Programar, 8/E

Organização de Computadores 1

PROGRAMAÇÃO DE UM MICROPROCESSADOR

Criar um formulário do tipo Diálogo modal ; Alterar a cor de fundo de um formulário; Inserir botões de comando e caixas de texto;

3.1 Definições Uma classe é a descrição de um tipo de objeto.

Como enviar e receber correio eletrónico utilizando o Gmail

Organização de Computadores Software

Pedro Ribeiro 2014/2015

8. Perguntas e Respostas

AULA 1: PARADIGMAS DE PROGRAMAÇÃO

INSTITUTO POLITÉCNICO DE BRAGANÇA ESCOLA SUPERIOR DE TECNOLOGIA E DE GESTÃO. Engenharia Electrotécnica. Microprocessadores. 2ºano - 1ºsemestre

Capítulo 4 Gerenciamento de Memória

Representação de Algoritmos - Linguagens de Programação

1. Criar uma nova apresentação

Fundamentos de Teste de Software

Microcontroladores e Interfaces 3º Ano Eng. Electrónica Industrial

1.2 Uma linguagem de programação muito simples

LÓGICA DE PROGRAMAÇÃO. Professor Celso Masotti

5. Uma lousa denominada EPI (registrador de endereço de próxima instrução).

PARLAMENTO EUROPEU. Comissão dos Assuntos Jurídicos PE v01-00

Facturação Guia do Utilizador

Sistemas Operacionais. Curso Técnico Integrado Profa: Michelle Nery

Programação WEB I Estruturas de controle e repetição

Curso de Iniciação ao Access Basic (I) CONTEÚDOS

Alteração do POC (Decreto de Lei nº. 35/2005) no sispoc

Linguagem C: variáveis, operadores, entrada/saída. Prof. Críston Algoritmos e Programação

Bases de Dados 2007/2008. Aula 8

Modos de entrada/saída

ÍNDICE. Delphi... 3 CAPÍTULO 1 INTRODUÇÃO CAPÍTULO 2 INSTALANDO O DELPHI... 10

Aula 4 Estatística Conceitos básicos

Aula 12: Funções. Pré-requisitos: Todas as aulas anteriores deste módulo. 1. Aproveitando Códigos no Programa

Aula 4 Conceitos Básicos de Estatística. Aula 4 Conceitos básicos de estatística

O modelo do computador

Conjunto de instruções. O Conjunto de Instruções. Conjunto de instruções. Instruções típicas. Instruções típicas. Instruções típicas

Transcrição:

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(e 1,, e n ). Um nodo tem k filhos q 1 q k se a execução do procedimento em causa invoca directamente os procedimentos q 1 q k (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 fp 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. var. loc. k fp + k 1. } Variáveis locais fp 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 param 1 fp n 3 val. retorno fp n 4. 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 Código Intermédio 3.1 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 ::= E 1 + E 2 é uma regra gramatical, escreveremos code(e1 + E2) = code(e 1 ) code(e 2 ) para especificar o valor da função code sobre a árvore de sintaxe abstracta que corresponde a soma de duas expressões. Se C 1 e C 2 representam duas sequências de instruções então C 1 C 2 representam a sequência de instrução obtida concatenando C 2 no fim de C 1. 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 Pilha sp pc Condição PUSHI n P [sp] := n 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 E 1 + E 2 E 1 E 2 E 1 E 2 E 1 /E 2 O código correspondente é dado por: code(inteiro) code(e 1 + E 2 ) code(e 1 E 2 ) code(e 1 E 2 ) code(e 1 /E 2 ) = P U SHI inteiro = code(e 1 ) code(e 2 ) ADD = code(e 1 ) code(e 2 ) SUB = code(e 1 ) code(e 2 ) MUL = code(e 1 ) code(e 2 ) DIV 3.4 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: O código correspondente é definido por: E ::= id 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: e a geração de código seguinte: I ::= ɛ I ::= I 1 A; A ::= id := E code(ɛ) = [] code(i 1 A; ) = code(i 1 ) 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[e 1 ] := E 2 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]) code(id[e 1 ] := E 2 ) = PUSHGP PUSHI adr(id) code(e) ADD LOADN = PUSHGP PUSHI adr(id) code(e 1 ) ADD code(e 2 ) 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 m i, índice maximal M i e por tamanho n i = M i m i + 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[i 1, i 2 ] é arquivado no endereço a + ((i 1 m 1 ) n 2 + (i 2 m 2 )) k Aqui também podemos pre-calcular parte da expressão re-escrevendo-a em: ((i 1 n 2 + i 2 ) k + a (m 1 n 2 + m 2 ) k 14

Quando encontramos uma expressão id[e 1, E 2 ] 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 I I ::= if E then I endif ::= if E then I 1 else I 2 endif ::= 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 I 1 else I 2 endif) = code(e) JZ new false code(i 1 ) JUMP new next LABEL new false code(i 2 ) 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 false 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 false 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: O código gerado correspondente é: B ::= B 1 or B 2 B ::= B 1 and B 2 B ::= not B 1 B ::= verdade B ::= falso B ::= E 1 relop E 2 code bool(b 1 or B 2, e v, e f ) = code bool(b 1, e v, new e) LABEL new e code bool(b 2, e v, e f ) code bool(b 1 and B 2, e v, e f ) = code bool(b 1, new e, e f ) LABEL new e code bool(b 2, e v, e f ) code bool(not B 1, e v, e f ) = code bool(b 1, e f, e v ) code bool(verdade, e v, e f ) = JUMP e v code bool(falso, e v, e f ) = JUMP e f code bool(e 1 relope 2, e v, e f ) = code(e 1 ) code(e 2 ) code(relop) JZ e f JUMP e v 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 V 1 : S 1 case V 2 : S 2 end. case V n : S n Antes de mais devemos nos assegurar da semântica duma tal expressão. Uma semân- 16

tica natural é: if E = V 1 then S 1 else if E = V 2 then S 2. else if E = V n then S n Não é, no entanto, a semântica da instrução switch da linguagem C. Nesta linguagem executa-se todas as instruções S i ;... ; S n a partir do primeiro i tal que E = V i. É a presença duma instrução break num S i que permite a saída do switch. if E = V 1 then goto l 1 elseif E = V 2 then goto l 2. elseif E = V n then goto l n l 1 : S 1. l n : break : Podemos optimizar a compilação se os valores dos v i 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 l 1,..., JUMP l n. Seguem-se então o código de cada ramo da estrutura condicional LABEL l i code(s i ), 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 SUB. 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: S n DUP DUP PUSHI n INFEQ JZ f im PUSHI 1 SUPEQ JZ f im JUMPI inicio LABEL fim 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 fp 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 fp 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 fp 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: D s ::= D s D; ɛ D ::= var id : T proc id(d s ) D s begin I end fun id(ds) : T D s 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 fp 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(e 1,..., e n ), temos de calcular o código de e 1,..., e n 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 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 {{ 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(d s1 ) D s2 begin I end) = LABEL label id PUSHN tamanho(d s2 ) 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 } {{ 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 G removendo este nodo e as arestas correspondentes. Se G 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 m x para arquivar x e introduz-se para cada utilização de x uma nova variável x i. Se o valor de x é utilizado numa expressão, começamos por colocar em x i o valor arquivado no endereço m x da memória. Se x é actualizada então substitua-se x por x i e propaga-se esta instrução de actualização de m x na memória pelo valor de x i. A duração de vida das variáveis x i é 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. Instrucções Variáveis Vivas g := mem [j+12] k, j h := k - 1 j, g, k f := g * h j, g, h e := mem[j+8] j, f m := mem[j+16] j, f, e b := mem[f] m, f, e c := e+8 m, b, e d := c m, b, c k := m+4 m, b, d j := b 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 Variáveis Vivas g := mem [j+12] k, j h := k - 1 j, g, k f := g * h j, g, h e := mem[j+8] j, f m 1 := mem[j+16] j, f, e mem[m] := m 1 m 1, f, e b := mem[f] f, e c := e+8 b, e d := c b, c m 2 := mem[m] b, d k := m 2 +4 m 2, b, d j := b b, d, k d, k, j O nodo m de grau 5 transformou-se em dois nodos m 1 e m 2 de grau 2. Podemos examinar de novo os nodos do novo grafo na ordem seguinte: h, c, m 2, m 1, 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