Instituto Superior de Engenharia do Porto Departamento de Engenharia Informática SISTEMAS OPERATIVOS I Textos de Apoio às Aulas Práticas Pré-processador, compilador e debugger 2004 Jorge Pinto Leite Sugestões e participações de erros para jpl@dei.isep.ipp.pt
Índice Introdução...1 Programa fonte...1 O processo de compilação...2 Pré-processador... 2 Compilador propriamente dito... 2 Assemblador... 3 Linker... 3 Opções do compilador... 4 Debugger...5 Exercício proposto...8 Jorge Pinto Leite i
Introdução A compilação é o processo que permite traduzir em linguagem perceptível pelo computador as instruções que queremos que este execute. Neste capítulo iremos analisar este processo, descrevendo cada uma das suas fases e tomando como base o gcc (GNU C Compiler), um compilador multifacetado que permite a compilação de programas fontes escritos em várias linguagens, nomeadamente C, C++, Objective C, Fortran, etc, estando em desenvolvimento extensões para outras linguagens. Programa fonte O tipo de instrução que um equipamento entende é em linguagem binária, dita executável, a qual não é fácil nem lógica de programar. Uma primeira abordagem foi efectuada com linguagens perceptíveis pelo processador (Assembler), que mau grado ter já um certo interface com o utilizador, continuam a ser pouco intuitivas e complexas, quer de programar, quer de manter. A evolução técnica e as necessidades dos utilizadores levou a sucessivas gerações de linguagens, tendo cada geração mais características avançadas de manipulação de dados e de manuseamento de objectos. Dividem-se assim as linguagens em baixo (as primeiras) ou alto (as últimas) nível. Independentemente do nível de uma determinada linguagem, o que é garantido é que contêm um conjunto de instruções que por si só não são perceptíveis por um equipamento. Duas famílias de linguagens impuseram-se: as interpretadas e as compiladas. As primeiras são traduzidas em linguagem máquina em tempo real, ou seja, cada instrução é traduzida à medida que é executada. Sendo um processo fácil para escrita e validação de um programa, torna-se necessariamente mais lenta na sua execução, pelo que representa actualmente uma menor fatia de mercado. Um exemplo de linguagem interpretada é o Visual-Basic, mas este tipo de linguagens é muito usado em aplicações web (linguagens de scripting em servidores). As segundas são escritas em linguagem perceptível pelo programador, sendo necessário traduzi-las para linguagem máquina, as quais são por sua vez executáveis. Um eventual erro implica a alteração do programa, (re)compilação do mesmo e posterior execução. Um exemplo de linguagem compilada é o C. O programa escrito numa linguagem perceptível pelo utilizador denomina-se por programa fonte. Nos capítulos seguintes usaremos como exemplo o seguinte programa fonte escrito em linguagem C. /* teste.c */ #include <stdio.h> main(void) { int i ; for(i=1 ;i<10 ;i++) Jorge Pinto Leite Página 1 de 8
} printf( Valor de i: %i\n,i); exit(0); O processo de compilação Tipicamente há bibliotecas auxiliares das diversas linguagem que, através da sua inclusão num programa fonte, permitem e facilitam o acesso a constantes standard, estruturas, valores pré-definidos, etc. (por exemplo, em C, a instrução para incluir estas bibliotecas é #include <nome-da-biblioteca>). Esta facilidade implica que o compilador deverá, ao encontrar estas instruções especiais, substituí-las pelo conjunto de linhas que compõem cada uma das bibliotecas referidas. Além disso, e principalmente a nível de grandes projectos, há conjuntos de instruções que se repetem, sendo habitual a criação de macros que as contêm, e que serão também chamadas repetidamente no programa fonte. O processo completo de compilação é pois responsável pela tradução destas e doutras ocorrências, pelo que se encontra dividido por várias fases: o Pré-processador o Compilador propriamente dito o Assemblador o Linker Pré-processador O pré-processador tem por função: 1. Detectar qualquer instrução de inclusão e substituí-la pela correspondente biblioteca. 2. Detectar macros utilizadas e expandi-las para o conjunto de tarefas associadas. Podemos analisar esta função recorrendo à opção E do gcc (ver Opções do compilador para algumas das opções do compilador). Para analisarmos o resultado da aplicação do pré-processador no programa teste.c executamos o comando: $ gcc E teste.c o teste.cpp Podemos editar e analisar o conteúdo de teste.cpp, no qual veremos que a alteração consiste na substituição do parágrafo #include <stdio.h> pelo conteúdo desta biblioteca, além doutros caracteres específicos do pré-processador. Compilador propriamente dito O compilador propriamente dito tem por missão ler a saída do pré-processador e criar um programa em código assembler. Este código assembler gerado é o apropriado para o processador do equipamento utilizado. Podemos analisar esta função recorrendo à opção S do gcc (ver Opções do compilador para algumas das opções do compilador): $ gcc S teste.c o teste.asm Jorge Pinto Leite Página 2 de 8
Assemblador O assemblador tem por função traduzir o código assembler gerado pelo seu correspondente binário, não executável. Este código binário gerado é o apropriado para o processador do equipamento utilizado. Podemos analisar esta função recorrendo à opção c do gcc (ver Opções do compilador para algumas das opções do compilador): Linker $ gcc c teste.c o teste.bin O linker realiza a concatenação do código binário gerado com as bibliotecas específicas do sistema, a fim de criar um código já executável. Todos os passos anteriormente descriminados são executados sempre que compilamos um programa fonte, só que essa execução é efectuada sem que o utilizador se aperceba. Para compilar um programa e obter o respectivo executável, introduz-se uma linha de comando simples, tal como: $ gcc teste.c o teste.exe Convém ter em conta que o executável pode ser obtido a partir de um qualquer passo intermédio do processo de compilação, através de uma das opções existentes, a x. Esta opção permite indicar ao compilador que tipo de fonte vai encontrar. Como exemplo, poderíamos dar os seguintes comandos, todos com o mesmo resultado final: $ gcc x cpp-output teste.cpp o teste.exe $ gcc x assembler teste.asm o teste.exe Para o passo final, isto é, para efectuar a linkagem, não é necessário indicar ao compilador qual o tipo de ficheiro de input, já que ele o reconhece automaticamente: $ gcc teste.bin o teste.exe Em suma, o processo desencadeado pela linha de comando $ gcc teste.c o teste.exe pode ser efectuado passo a passo e a partir do output anterior através da sequência de comandos: $ gcc E teste.c o teste.cpp $ gcc S x cpp-output teste.cpp o teste.asm $ gcc c x assembler teste.asm o teste.bin $ gcc teste.bin o teste.exe Jorge Pinto Leite Página 3 de 8
Opções do compilador Sintaxe: gcc [opções] programa-fonte Opções: -include <ficheiro> Inclui o conteúdo de <ficheiro> antes de outros ficheiro. -imacros <ficheiro> Aceita a definição de macros de <ficheiro>. -iprefix <path> Especifica o caminho para as opções: -iwithprefix <dir> Adiciona <dir> ao final do caminho de inclusão de sistema. -iwithprefixbefore <dir> Adiciona <dir> ao início do caminho de inclusão. -isystem <dir> Adiciona <dir> ao início do caminho de inclusão de sistema. -idirafter <dir> Adiciona <dir> ao final do caminho de inclusão de sistema. -I <dir> Adiciona <dir> ao final do caminho de inclusão. -nostdinc Não procura no caminho de inclusão de sistema. -o Ficheiro de output. Se não for indicado assume a.out. -lang-c Assume que o ficheiro de input é em C. -w Inibe mensagens de warning. -Werror Trata as mensagens de warning como erros. -M Mostra as dependências de make. -MM Como M, mas ignora os system headers. -MD Como M, mas escreve o output num ficheiro.d. -v Mostra a versão. -H Mostra o nome dos header files à medida que são usados. -E Só executa o pré-processador. -S Executa pré-processador e compilador. -c Executa pré-processador, compilador e assembler. -x Altera tipo de ficheiro origem algumas das opções possíveis são cpp-output e assembler. --help Mostra o ficheiro de help. -g Compila para debbuging. Jorge Pinto Leite Página 4 de 8
Debugger O processo de debugging, permitindo a execução de um programa de forma controlada, é um processo muitas vezes fundamental para detecção e correcção de falhas de funcionamento. O debugger do GNU é o gdb. Para o poder utilizar é necessário instruir o sistema que pretendemos fazer o debug do programa através da instrução: $ gcc g teste.c o teste.exe que criará, como já se explicou, um ficheiro executável com o nome teste.exe, mas com informação extra através da opção g (função de debbuging). A sua execução é efectuada com o comando: $ gdb teste.exe que iniciará o interface para o utilizador como se observa na Figura 1. picasso.dei.isep.ipp.pt> gcc g o teste teste.c picasso.dei.isep.ipp.pt> gdb teste GNU gdb Rad Hat Linux (5.2-2) Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU Ge neral Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type show copying to see the conditions. There is absolutely no warranty for GDB. Type show warranty for details. This GDB was configured as i386-redhat-linux (gdb) Figura 1 A partir deste ponto há um conjunto de comandos passíveis de dar ao gdb que possibilitam a execução do modo pretendido. Alguns dos comandos são: run [argumentos] break ponto delete N info break help comando step next finish Inicia a execução como se tivesse passado os argumentos na linha de comando. Cria um breakpoint no ponto indicado. Ponto pode ser o nome de uma função ou um número de linha. Cada breakpoint é associado a um número identificativo. O comando break main interrompe no início da execução. Quando o programa está em execução e chega a um breakpoint dá-nos uma mensagem. Elimina o breakpoint com o número N. Se este número for omitido elimina todos os breakpoint activos. Mostra todos os breakpoint activos. Dá informação sobre o comando indicado. Executa uma linha do comando e pára na linha Seguinte. Se a linha contiver uma chamada de função, a linha seguinte é a primeira instrução dessa chamada. Igual ao step mas se a linha corrente contiver uma chamada de função executa-a sem mostrar cada uma das suas instruções. Equivalente a uma sucessão de next s até chegar ao final da Jorge Pinto Leite Página 5 de 8
continue where print variável display variável quit list função corrente. Executa o programa sem debbuging até ao próximo breakpoint. Faz uma lista das funções que trouxeram o programa até ao ponto em que está actualmente. Mostra o valor actual da variável, actualizando-a à medida que o programa vai decorrendo. Mostra o valor actual da variável, mas não o actualiza à medida que o programa vai decorrendo. Abandona o gdb. Mostra a rotina em execução. É necessário ter em mente que a utilização do debbuging só terá sentido se ao iniciarmos declararmos pelo menos um ponto de suspensão através da instrução break. Sem isto a sua execução, se bem que acompanhada pelo debbuger, será exactamente igual à obtida sem o recurso ao gdb, operação que se observa na Figura 2. (gdb) list 1 #include <stdio.h> 2 main(void) 3 { 4 int i; 5 for(i=1;i<10;i++) 7 exit(0); 8 } (gdb) break main Breakpoint 1 at 0x8048496: file teste.c, line5. <gdb> run Starting program: /users/2/jpl/so1/teste Breakpoint 1, main () at teste.c: 5 for(i=1;i<10;i++) (gdb) Figura 2 Também se deve ter em mente que a utilização do comando step deverá ser evitada, pois devido às bibliotecas de sistema que são implicitamente incluídas pelo linker poderemos ter um vasto conjunto de mensagens de erro se não tivermos acesso às mesmas. Por esse motivo a instrução preferível para analisar e acompanhar o desempenho da rotina é a instrução next, demonstrada na Figura 3. Na mesma figura chama-se ainda a atenção para a instrução display utilizada para acompanharmos a variação de uma variável, no caso, a variável i. Jorge Pinto Leite Página 6 de 8
3 { 4 int i; 5 for(i=1;i<10;i++) 7 exit(0); 8 } (gdb) break main Breakpoint 1 at 0x8048496: file teste.c, line5. <gdb> run Starting program: /users/2/jpl/so1/teste Breakpoint 1, main () at teste.c: 5 for(i=1;i<10;i++) (gdb) next (gdb) display i 1: i = 1 (gdb) next Valor de i: 1 5 for(i=1;i<10;i++) 1: i = 1 (gdb) n 1: i = 2 (gdb) Figura 3 Assumindo que o objectivo de execução com o debbuger já tinha sido obtido, poderíamos abandonar a sua execução ou instruir o debbuger para executar até ao fim, como se vê na Figura 4. 1: i = 6 (gdb) r The program beeing debugged has been started already. Start it from the beginning? (y or n) n Program not restarted (gdb) continue Continuing. Valor de i: 7 Valor de i: 8 Valor de i: 9 Program exited normally. (g db) Figura 4 Convém ainda ter em mente que em qualquer altura se pode instruir o debbuger para recomeçar a execução a partir do seu início, como se mostra na Figura 4. Um outro ponto que igualmente se observa nessa figura é que os comandos podem ser abreviados (note-se o comando r que é reconhecido como run). Jorge Pinto Leite Página 7 de 8
1: i = 6 (gdb) r The program beeing debugged has been started already. Start it from the beginning? (y or n) n Program not restarted (gdb) continue Continuing. Valor de i: 7 Valor de i: 8 Valor de i: 9 Program exited normally. (gdb) quit picasso.dei.isep.ipp.pt> Figura 5 Finalmente, e como visualizado na Figura 5, abandonaríamos o debbuger através do comando quit. Exercício proposto Escreva o programa seguinte: #include <stdio.h> main(void) { int i, j; for(i=1;i<=10;i++) for(j=i+1;j<=i+10;j++) printf( Valor de i: %i Valor de j: %i\n,i,j); } a) Compile o programa utilizando as várias opções de criação de código intermédio do compilador, e observe o output gerado. b) Compile o programa por forma a poder executá-lo com o debbuger c) Execute o programa com o debugger e analise a variação das variáveis i e j Jorge Pinto Leite Página 8 de 8