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.
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.
Porque nos próximos trabalhos, terá que desenvolver programas em C usando a Application Program Interface (API) do sistema operativo Linux.
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.
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:
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):
Nas subsecções seguintes descrevo cada um destes passos em pormenor.
Neste passo realiza-se um processamento puramente ao nível do texto. Essencialmente é usado para:
#include <stdio.h>
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
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..
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:
main()
,
não os endereços finais.
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
.
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.
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.
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?
Além das opções usadas acima o gcc
oferece várias outras
opções. Particularmente úteis para esta disciplina são as seguintes:
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!)
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.
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.