Sistemas Operativos 3º Ano MIEEC
Folha de Problemas nº 0: Geração de Programas em C

1  Introdução

Este trabalho prático tem por objectivo principal que aprenda a gerar programas executáveis a partir de ficheiros com código em C usando o compilador de C da GNU (gcc) em Linux. Pretende-se ainda apresentar algumas ferramentas que poderão ser úteis nesse processo.

Este guião foi concebido para que execute num terminal os comandos indicados no texto à medida que o vai lendo.

2  Ferramentas de Auxílio

Neste trabalho é-lhe sugerido que use vários comandos/programas para executar determinadas tarefas. Embora a sua funcionalidade seja explicada minimamente, muitos desses comandos são bastante complexos. Para obter mais informação sobre eles sugiro que use os utilitários: man e info. P.ex., os comandos:

man gcc
info gcc

permitem-lhe obter informação sobre o gcc.

O programa man é o utilitário básico, sendo de utilização muito fácil. A documentação da grande maioria dos programas em Linux está num formato que pode ser visualizado por man.

O programa info é um sistema de hipertexto, permitindo apresentar a documentação duma forma mais estruturada. Para alguns programas, a documentação acessível via info é mais completa do que a documentação acessível via man. Por outro lado, info é mais complexo porque a navegação é realizada usando essencialmente o teclado e não o rato. Em qualquer caso, há muitos programas cuja documentação só é acessível via man, pelo que é provável que tenha que usar ambos nesta disciplina.

Pode ainda usar o yelp, o qual é um documentation browser and viewer para o Gnome Desktop e que está instalado nos computadores do edifício B. Este programa suporta URIs do tipo:

man:gcc
info:gcc

apresentando a informação relativa ao comando especificado, gcc neste exemplo, acessível através do programa indicado. O yelp pode ser invocado directamente dum terminal, p. ex.

yelp info:gcc

Alternativamente, pode ser invocado através da interface gráfica do Gnome Desktop. Uma vez numa janela do yelp é possível visualizar a documentação inserindo o URI apropriado na área de pesquisa.

3  Linguagem C

3.1  Porquê C?

Porque nos próximos trabalhos, terá que desenvolver programas em C usando a Application Program Interface (API) do sistema operativo Linux.

3.2  Programa em C: Exemplo

C é uma linguagem baseada em funções. Do ponto de vista do programador, a execução dum programa em C começa na função main():

Todos os programas em C têm que ter uma e uma só função main.

O programa C mais conhecido é o famoso “Hello World!”:

#include <stdio.h>

int main() {
    printf("Hello World!\n");
}

o qual, quando executado, se limita a imprimir no ecrã a string Hello World!.

IMPORTANTE Note o alinhamento da instrução printf:

Quando escrever programas em C procure alinhar o código: facilita a sua leitura. Muitos editores de texto oferecem um modo de edição de programas C, reduzindo significativamente o esforço do programador. O alinhamento automático tem ainda a vantagem de permitir detectar facilmente erros no programa, p.ex. o esquecimento de uma chaveta, }, para fechar um bloco de instruções.

3.3  Execução dum Programa C

Como já deve ser do seu conhecimento, CPUs não “entendem” linguagens de alto nível, como a linguagem C.

Essencialmente, há 3 alternativas para executar um programa escrito numa linguagem de alto nível:

traduzindo-o:
i.e. usando um tradutor (compilador e/ou assembler), que o converte na linguagem máquina. Esta é a alternativa usada pela linguagens C e C++.
interpretando-o:
i.e. usando um programa, o interpretador, que lê as instruções na linguagem de alto nível e depois as executa. Esta é a alternativa usada por algumas linguagens de alto nível e pelas chamadas linguagens de scripting, p.ex. Perl e shell scripting languages;
traduzindo-o e interpretando-o
i.e. combinando as duas alternativas anteriores. Primeiro o programa na linguagem de alto nível é traduzido para um programa numa linguagem intermédia que é depois interpretada. Esta alternativa é usada pela linguagem Java, sendo a linguagem intermédia conhecida por bytecode e o interpretador por Java Virtual Machine (JVM).

4  Geração de Programas em C

Na verdade, o processo de tradução dum programa C inclui tipicamente vários passos. A figura seguinte ilustra os passos executados pelo compilador gcc (GNU C compiler):


Figure 1: Passos para geração do executável dum programa escrito na linguagem C.

Nas subsecções seguintes descrevo cada um destes passos em pormenor.

4.1  Pré-processamento

Neste passo realiza-se um processamento puramente ao nível do texto. Essencialmente é usado para:

Como indicado na Figura 5, este passo pode ser executado especificando a opção -E do gcc.

Usando um processador de texto, crie a seguinte variante do programa “Hello World!”:

#include <stdio.h>
#define HELLO_STRING "Hello World!\n"
int main() {
    printf(HELLO_STRING); /* This is a comment */
}

e guarde-o num ficheiro de nome hello.c.

Em seguida, pré-processe-o dando o comando:

gcc -E hello.c -o hello.cpp_out

Nota A opção -o do gcc permite-lhe especificar o nome do ficheiro de saída, i.e. do ficheiro com resultado do processamento do gcc.

Abra o ficheiro de saída (hello.cpp_out) numa janela do seu editor de texto. Quantas linhas tem este ficheiro? Procure identificar o processamento que o gcc realizou neste passo.

Nota Poderia determinar o número de linhas do ficheiro sem abrir o ficheiro no editor de texto usando o comando:

wc -l hello.cpp_out

4.2  Compilação

Neste passo o gcc tranforma o ficheiro com código em C gerado no passo anterior num ficheiro em código assembly.

Durante este passo o gcc pode aplicar várias técnicas para optimização do código gerado. Estas técnicas são especificadas via a opção -O# (“ó” maiúsculo), onde # é o nível de optimização. Para mais pormenores use info.

Execute este passo, invocando um dos 2 seguintes comandos:

gcc -x cpp-output -S hello.cpp_out
gcc -S hello.c 

Nota A opção -x do gcc permite-lhe especificar a linguagem do ficheiro de entrada.

O ficheiro de saída, hello.s, é um ficheiro ASCII e não um executável, como poderá verificar usando o comando:

file hello.s

Abra o ficheiro hello.s num editor de texto e tente analisá-lo. Que função é invocada por hello.s? É diferente da que estava à espera? Tente explicar a razão para este comportamento. (Dica: Use man 3 fun, onde fun é o nome da função.) Teste a sua explicação criando uma versão do programa hello.c e compilando-a..

4.3   Assembling

Neste passo o gcc converte o código em assembly em código máquina recolocável, i.e. de forma a que possa ser colocado em qualquer “região da memória”.

Note que o ficheiro de saída desta fase ainda não é executável:

Execute este passo dando um dos 2 seguintes comandos:

gcc -x assembler -c hello.s
gcc -c hello.c

O ficheiro de saída, hello.o, contém o código máquina do programa, o qual normalmente contém códigos que não podem ser visualizado com editores de texto habituais.

De facto, este ficheiro está em formato http://en.wikipedia.org/wiki/Executable_and_Linkable_Format ELF, como poderá confirmar executando o comando:

file hello.o

O comando readelf permite extrair informação variada sobre hello.o. Por exemplo, o header do ficheiro pode ser visualizado usando o comando:

readelf -h hello.o

Note que o campo Entry point address tem por valor 0x0, indicando que o compilador ainda não mapeou o programa na memória, i.e. não decidiu da sua localização na memória.

Outra opção interessante de readelf é a opção -s a qual permite visualizar a tabela de símbolos. Dê o comando:

readelf -s hello.o

e procure interpretar as 2 últimas linhas da tabela.

Dado um ficheiro ELF é possível visualizar o código assembly usando o utilitário objdump. Dê o comando:

objdump -d hello.o

e compare a saída deste comando com hello.s.

4.4  Linking

O código dum ficheiro não tem que incluir a definição de todas as funções invocadas nesse ficheiro (ou das estruturas de dados usadas). No caso de hello.c, a função printf() embora invocada por main(), não está definida. De facto, printf() é função que faz parte da biblioteca (library) standard da linguagem C. Esta biblioteca, libc, tal como todas as bibliotecas, é um ficheiro num formato especial que inclui o código máquina recolocável de funções frequentemente usadas por todos os programas da linguagem C, incluindo as funções que implementam a API do sistema operativo. Isto permite reutilizar código, mesmo sem dispôr dos ficheiros com o código fonte.

Outra razão para não incluir a definição de todas as funções dum programa C num único ficheiro é a organização do código. Colocando funções relacionadas entre si num mesmo ficheiro e funções sem qualquer relação em ficheiros diferentes, é possível isolar funcionalidades dum dado programa. Esta separação é particularmente importante para programas complexos com milhares de linhas de código C.

A separação em ficheiros diferentes tem a vantagem adicional de ser possível compilá-los separadamente. Uma vez mais, esta vantagem é particularmente importante para programas muito extensos: em vez de ter que compilar o programa inteiro basta compilar os ficheiros que são alterados.

A fragmentação do código máquina (ou objecto) por múltiplos ficheiros implica a necessidade dum passo adicional na geração de programas executáveis para ligar esses múltiplos ficheiros num único ficheiro executável. Este passo é conhecido por linking.

Para o executar pode dar qualquer um dos seguintes comandos:

gcc -static hello.o -o hello.st 
gcc -static hello.c -o hello.st 

Como poderá confirmar, usando p.ex. o comando file, o ficheiro de saída, hello.st é finalmente um ficheiro executável. Execute-o:

./hello.st

Este ficheiro usa também o formato ELF, pelo que pode usar o comando readelf -h para obter informação adicional. Compare esta informação com aquela obtida para hello.o. Pode ainda usar readelf -s para ver a tabela de símbolos. Compare-a com a de hello.o. (De facto, com alguma atenção poderá constatar que há ainda alguns símbolos por resolver, aqueles cuja 7ª coluna tem por valor UND, mas tal não impede o programa de executar, como deverá ter constatado.)

Se quiser ver o código assembly completo, mais de 100 mil linhas, pode usar objdump -d hello.st Para poder analisá-lo com um editor de texto, deverá redireccionar a saída para um ficheiro, como ilustrado (atenção que o resultado ocupa mais de 5,5 Mbytes):

objdump -d hello.st > /tmp/hello.st.asm

Nota Em Linux, o comando > permite redireccionar a saída standard. Neste exemplo, ela é redireccionada para o ficheiro /tmp/hello.st.asm. I.e. toda a informação que o programa objdump envia para o terminal é redireccionada para o ficheiro especificado.

Nota Neste exemplo, ao contrário dos restantes, usa-se um ficheiro no directório /tmp em vez dum ficheiro no directório corrente, para evitar ultrapassar a sua quota máxima de disco: o espaço usado em /tmp não é contabilizado para a quota, pois tipicamente é limpo sempre que o sistema operativo arranca.

5  Bibliotecas Partilhadas

A opção -static do gcc instrói o linker para fazer ligação estática, como confirmado pela expressão statically linked que aparece na saída de file. Quer isto dizer que o código necessário da biblioteca C é incluído no executável. Embora nem toda a biblioteca C seja ligada com hello.o para criar hello.st, como poderá constatar através da saída gerada pelo seguinte comando:

ls -l hello.st /usr/lib/libc.a

o tamanho dum programa tão simples com o hello.c com ligação estática deveria surpreendê-lo.

Determine o tamanho do programa mínimo:

void main(){
}

após compilação com ligação estática.

Estes dois exemplos mostram que a ligação estática exige muita memória, quer disco (para guardar os programas) quer principal.

Estime o espaço mínimo em disco necessário para guardar todos os programas existentes no directório /usr/bin. Para determinar o número de programas nesse directório pode executar o comando:

ls /usr/bin | wc -l

Nota O comando | designa-se por pipe e envia a saída do primeiro comando, ls neste exemplo, para a entrada do segundo, wc. Como o primeiro comando lista na saída o conteúdo do directório especificado, 1 ficheiro por linha, e o segundo determina o número de linhas na sua entrada, este comando composto permite determinar o número de ficheiros no directório /usr/bin, o qual é usado para guardar a maioria dos programas em Linux.

Compare este valor com o espaço efectivamente ocupado em disco por este directório, o qual pode ser obtido através do comando:

du -sh /usr/bin

Consegue explicar a diferença?

De facto, a principal razão para esta diferença é que os programas em /usr/bin usam ligação dinâmica e não ligação estática. Com ligação dinâmica, o gcc não inclui o código das bibliotecas no executável. A resolução das referências para símbolos das bibliotecas partilhadas é feita apenas quando o programa inicia a sua execução pelo dynamic linker/loader (/lib/ld-linux.so).

O uso de ligação dinâmica permite que uma biblioteca seja partilhada por vários programas quando executam simultaneamente, como ilustrado na Figura. Assim, em Linux as bibliotecas usadas para ligação dinâmica são conhecidas por bibliotecas partilhadas e, por convenção, os seus nomes têm por extensão so, as iniciais de shared object. Em Windows as bibliotecas partilhadas são conhecidas por dynamic linking libraries ou apenas dlls.


Figure 2: Partilha duma biblioteca partilhada por 2 processos.

Compile o programa hello usando ligação dinâmica, dando o comando:

gcc hello.c -o hello 

Note que por omissão gcc usa ligação dinâmica. Qual o tamanho do executável, hello?

O formato do ficheiro com o código ligado dinamicamente continua a ser o ELF, pelo que pode usar o comando readelf como anteriormente. Note ainda que como hello é um ficheiro ligado dinamicamente tem agora uma secção adicional (Dynamic section) que pode visualizar dando o comando readelf -d.

Compare a secção dinâmica dos ficheiros hello.st e hello.

Para determinar as bibliotecas partilhadas usadas por hello pode usar o comando:

ldd hello

Note que o próprio dynamic linker/loader se encontra numa biblioteca partilhada. Vê algum problema? Qual?

6  Outras Opções do gcc

Além das opções usadas acima o gcc oferece várias outras opções. Particularmente úteis para esta disciplina são as seguintes:

-Wall
esta opção faz com que o gcc gere avisos (warnings) sempre que detecte usos da linguagem C que, embora correctos, podem conduzir a erros. Como mencionado acima, a linguagem C dá muita liberdade ao programador. Ao permitir que os programadores façam certas coisas que noutras linguagens não são permitidas, C aumenta a possibilidade de erro dos programadores. Assim, recomendo que use sempre esta opção e corrija o seu código até que o gcc o compile sem qualquer aviso. (Maior liberdade requer maior responsabilidade!)
-g
esta opção faz com que o gcc acrescente código para permitir a depuração (debugging) do seu programa usando o gdb (o GNU debugger). Embora o uso dum debugger possa auxiliar bastante na localização de erros, ser descuidado na programação não compensa: o tempo adicional exigido por uma programação cuidada é normalmente muito menor do que o exigido para fazer o debugging que resulta duma atitude descuidada.

7  make

Como mencionado acima, uma das vantagens de poder gerar um executável a partir de vários ficheiros é reduzir o processamento necessário após alteração no código em C em alguns desses ficheiros: em vez de ter que compilar todo o código, basta compilar os ficheiros alterados e ligá-los com os restantes.

Para programas grandes, que resultam da ligação de dezenas (ou mais) de ficheiros, determinar quais os ficheiros que devem ser recompilados após alteração de outros ficheiros é uma tarefa dada a erros. O utilitário make é um programa que permite gerar duma forma automática novos executáveis por compilação apenas dos ficheiros que foram alterados (ou daqueles que deles dependem).

Para isso o make usa como entrada um ficheiro, cujo nome é tipicamente makefile ou Makefile, que contém um conjunto de regras especificando as dependências entre ficheiros e quais os comandos que é necessário executar para a sua geração. Na sua forma mais simples, estas regras têm o seguinte formato:

target: dep1 dep2 ... depn
      command

Essencialmente, esta regra indica que o ficheiro de nome target depende dos ficheiros dep1,dep2, ..., depn. command é o comando que deve ser executado para gerar target, e normalmente toma como argumentos todos os ficheiros dep1, dep2, ... depn. Note que command tem que ser precedido por um tab e não por uma sequência de espaços. Alguns editores substituem tabs por sequências de espaços, gerando makefiles com um formato incorrecto e dandi origem a erros.

Note que qualquer dos ficheiros à direita do : na regra acima, pode aparecer à esquerda do : noutra regra. Desta forma, a alteração dum único ficheiro pode conduzir à execução de vários comandos. A única restrição é que não se devem criar ciclos, doutra maneira o make poderá entrar num ciclo infinito e não mais terminar.

Teste se make entra de facto num ciclo infinito ou se consegue detectar essa situação e assinala um erro.

O exemplo seguinte é desnecessariamente complicado, mas ilustra uma makefile que poderia ser usada neste trabalho:

hello.cpp_out: hello.c
 gcc -E hello.c -o hello.cpp_out

hello.s: hello.cpp_out
 gcc -x cpp-output -S hello.cpp_out

hello.o: hello.s
 gcc -c hello.s

hello.st: hello.o
 gcc -static hello.o -o hello.st

hello: hello.o
 gcc hello.o -o hello

clean:
 rm -f hello.o hello.s hello.cpp_out

clean_all: clean
 rm -f hello hello.st 

As duas últimas regras permitem eliminar os ficheiros resultantes da compilação.

Execute o programa make dando o comando:

make target

onde target pode ser qualquer dos targets da makefile.

De facto, o formato duma makefile pode ser bem mais complicado do que o ilustrado acima. As funcionalidades adicionais são particularmente úteis para programas complexos. Nesta disciplina, desenvolverá apenas programas pequenos, pelo que nos limitaremos à sintaxe mais básica. Se estiver interessado pode ler a documentação acessível via info, ou um dos muitos tutoriais disponíveis na Web, p.ex. http://www.eng.hawaii.edu/Tutor/Make/index.html.


This document was translated from LATEX by HEVEA.