OAK CODERS - Home Page
root@oakcoders:~# Dear Visitor, please leave a comment!
"When I read commentary about suggestions for where C should go, I often think back
and give thanks that it wasn't developed under the advice of a worldwide crowd." Dennis Ritchie.

Saturday, January 21, 2012

Mesclando teória e prática: Processo de Compilação - Linguagem C

Antes de realizar qualquer introdução, gostaria de agradecer o KOV(Gustavo Noronha Silva) pela base das informações contidas aqui.

Uma situação bem comum que vejo acontecer com os iniciantes no mundo computacional é confundir certos conceitos, tanto em teoria ou prática.
Hoje tentarei abordar como realmente funciona o processo de compilação em sistemas Unix-Like. Lógico que será resumidamente, pois ao contrário disso, estariamos diante de um artigo colossal.

Vamos parar com as brincadeiras e focar no assunto que realmente interessa. Um detalhe que vale a pena ser abordado, quais são os passos necessários que são realizados pelo compilador, iniciando a partir do código-fonte até chegar no executável ?

Lembrando aos leitores, tentarei mesclar a teória e a prática para tentar explicar a todos como realmente funcionam esses processos, portanto abram o editor de texto da sua preferência, porque iremos colocar a 'mão na massa'.

Pré-processador (cpp):
Breve descrição: é um processador de texto(nada além disso), cujo objetivo é passar pelo código-fonte buscando por diretivas '#', que são informações legíveis que ele entende. Ele realiza inserções (#include), faz alterações a partir de macros (#define), além de fornecer compilação condicional
(#if, #else, #endif,...).

* Mãos em obra, criem os seguintes arquivos com os respectivos conteúdos:

- test.c:
#include "test.h"
main() { 
   return 0; 
}
----------------
- test.h:
hello world
----------------

Agora iremos utilizar o cpp, para trabalhar em cima do nosso código-fonte 'test.c'. Em seu terminal, no diretório respectivo ao arquivo,
digite isso: cpp test.c

A saída que você recebeu no console, deverá ser similar a essa:
rsc@utroz:~$ cpp test.c
# 1 "test.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "test.c"
# 1 "test.h" 1
hello world
# 2 "test.c" 2
main()
{
    return 0;
}
O que nos acabamos de executar é a primeira etapa do processo de compilação, ou seja, o pré-processador.
Todas as linhas que começam com uma diretiva "#", são informações direcionadas à outras etapas do processo, isso é realmente necessário para que eventualmente mais tarde o compilador possa dar informações de erros que façam sentido para o programador.

Agora troquem #include "test.h" por #include <stdio.h> no arquivo test.c e executem cpp test.c novamente.

Não irei colar a saída aqui, pois como vocês podem perceber ela é realmente grande. O que vocês podem perceber em relação a ação do pré-processador em ambos exemplos, foi realizar a troca do #include pelo conteúdo do arquivo cabeçalho respectivo.
No exemplo atual ocorreu a substituição de #include<stdio.h> pelo conteúdo do arquivo "/usr/include/stdio.h".

Desconectem a idéia que arquivo cabeçalho é algo especial, pois o mesmo é apenas uma maneira simples de copiar/colar códigos.
Se o cabeçalho tiver implementações de funções ou definições, no final elas estarão no seu código-objeto, é como se a todo momento elas estivessem presentes.
A extensão '.h' não é nada de especial, apenas uma convenção, podemos dizer que isso ajuda na modularização do projeto, uma forma mais organizada de escrever códigos.

Vamos realizar outro exemplo, no arquivo 'test.c', substitua o atual include por: #include "non-exists" e execute 'cpp test.c' novamente.

O pré-processador terminou o processo sem éxito, certo? Isso tudo ocorreu porque você está tentando incluir um arquivo cabeçalho que não existe.
Agora se você digitar 'mv test.h non-exists' tudo irá voltar a funcionar corretamente, em certas partes.
Se você analisar o conteúdo de test.h: 'Hello World', lhe parece um código válido?
O único motivo que fez o cpp não reclamar, irá confirmar o que eu disse anteriormente: Pré-processador é apenas um processador de texto, ele não conhece a estrutura da Linguagem C, ele apenas tem um papel importante em trabalhar o código-fonte a partir de diretivas para as próximas etapas do processo de compilação.

Outro exemplo: Quando você chama uma função que recebe um integer com um unsigned integer, o compilador não reclama? Ele apenas consegue associar isso pelo fato do seu código possuir a declaração com os parâmetros da função, lembrando que esse processo de análise semântica, é realizado pelo compilador (gcc).

Um dos motivos da criação de header files foi pelo fato que você pode chamar funções como printf sem a necessidade de incluir a implementação da mesma no seu código, a qual são distribuídas junto a biblioteca compilada.

Terminamos essa parte, vamos ver o nosso progresso:
- o GCC chama o pré-processador para trabalhar no código-fonte em cima das diretivas.
- o GCC pega o código que o cpp criou e realiza a compilação para o código Assembly.
OBS: no código Assembly há várias referências a símbolos que apenas são declarados no código (ex: printf).
- o GCC chama o Assembler que gera o código-objeto(extensão .o).

Voltando a parte de símbolos, o arquivo .o contém referências a símbolos que não existe no mesmo, o GCC nem o Assembler reclamará disso, pois o arquivo objeto não é um binário, nem uma biblioteca, é apenas um código C transformado em código binário.

Exemplos com <math.h>:
Um exemplo bem comum é quando usamos a biblioteca de matemática(math.h), qual parâmetro passamos ao GCC? -lm, certo?
Esse -lm na verdade o compilador ignora, ele é direcionado ao dinamic linker, que posteriormente irá saber sua necessidade de achar uma biblioteca chamada 'libm.so'.
Vale a pena analisar, o 'l' é correspondente à lib e '.so' à shared object (biblioteca compartilhada).

Substitua o conteúdo do arquivo 'test.c', pelo seguinte e digite no console:
gcc test.c -o test:
main ()
{
    return isless(2, 4);
}
Resultado da saída:
rsc@utroz:~$ gcc test.c -o t   
/tmp/ccAoYXnK.o: In function `main':
test.c:(.text+0x19): undefined reference to `isless'
collect2: ld returned 1 exit status

Podemos vizualizar que temos um símbolo indefinido para a função isless, sendo .text uma sessão respectiva a dados do código Assembly.

Agora iremos dizer para o GCC parar depois que chamar o pré-processador e salvar o conteúdo em test.
Adicione no ínicio do seu código: "#include <math.h>" e novamente chame o GCC utilizando os seguintes parâmetros: : gcc -E test.c -o test

Veremos que o código estará imenso, pois o pré-processador incluiu todo o código de '/usr/include/math.h' no código-fonte.

É super interessante notar algo em nossa função main:
main()
{
    return __builtin_isless(2, 4);
}
Se você acessar o cabeçalho math.h, você verá que isless(x,y) está declarado da seguinte forma: #define isless(x, y)           __builtin_isless(x, y).
Viu como tudo está 'encaixando'? O cpp encontrou a definição da macro isless e substituiu pelo texto correspondente.

Gerando o código Assembly:
Utilizando o mesmo arquivo anterior('test.c'), digite no seu console:
gcc -S test.c -o test.S

Resultado da saída:
rsc@utroz:~$ gcc -S test.c -o test.S
test.c: In function 'main':
test.c:4:5: error: non-floating-point arguments in call to function '__builtin_isless'

O motivo do GCC ter reclamado foi pelo fato de existir uma declaração que espera float e nos demos int.
Substitua os parâmetros no arquivo 'test.c' por '2.0' e '4.0' respectivamente.
Chame o GCC novamente usando os parâmetros anteriores, lembrando que o parâmetro '-S', diz para o compilador parar depois que gerar o código Assembly.

Resultado da saída:
rsc@utroz:~$ gcc -S test.c -o test.S
rsc@utroz:~$ cat t.S
        .file   "t.c"
        .text
.globl main
        .type   main, @function
main:
        pushl   %ebp
        movl    %esp, %ebp
        movl    $1, %eax
        popl    %ebp
        ret
        .size   main, .-main
        .ident  "GCC: (GNU) 4.5.2"
        .section        .note.GNU-stack,"",@progbits

Então passamos por 3 etapas do processo:  
CPP -> Análise sintática do código C -> Gerar código Assembly.

Agora é a hora de 'Assemblar', hora da montagem ou hora do rush, brincadeirinha.
Chegou a hora de gerar o nosso famoso e tão sonhado código objeto, depois desse longo caminho que pode agora ser resolvido com um único simples comando:
as test.S -o test.o

Link-Edição:
Então chegamos ao final, que é o processo de link-edição, lembrando que por padrão o GCC sempre 'linka' à biblioteca C, ou libc, então não existe a necessidade de especificar isso ao compilador.

Vamos agora ao processo final, digite isso no seu console:
Debian(64 bits): ld -o test /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o test.o -lm -lc
Slackware: ld -o test /usr/lib/crt1.o /usr/lib/crti.o test.o -lm -lc
OBS: não sei como isso comportará em outras distribuições, faça a busca pelos arquivos 'crt1.o' e 'crti.o', usando 'whereis'.

Parâmetros passados ao ld (dynamic linker ou loader)
test.o - arquivo objeto.
-lm - requisição da biblioteca libm.so (-lm -> lib + m + .so = libm.so)
-lc - requisição da biblioteca libc.so (-lc -> lib + c + .so = libc.so)

'crt1.o'  e 'crti.o' são arquivos objeto que vem no GCC, necessariamente utilizadas pelo LD, onde contém informações do ELF(formato binário) para montar os executáveis finais, para ser mais específico são funções auxiliares de inicialização e etc.

Agora vamos conferir as bibliotecas 'linkadas' ao nosso arquivo executável, usando: ldd nome-executável.

Resultado de saída:
rsc@utroz:~$ ldd test
        linux-gate.so.1 =>  (0xffffe000)
        libm.so.6 => /lib/libm.so.6 (0xb7854000)
        libc.so.6 => /lib/libc.so.6 (0xb76f1000)
        /usr/lib/libc.so.1 => /lib/ld-linux.so.2 (0xb78a3000)

linux-gate e ld-linux são bibliotecas especiais que fazem parte do dynamic linker; Elas que iniciam todo processo do mapeamento de símbolos para os respectivos endereços de memória, lembrando que isso só ocorre quando o primeiro aplicativo faz requisição da mesma.
E as outras duas, são as que pedimos: libc e libm.


Existe uma sessão do ELF, a qual fala que o binário é associado com tal lib, os símbolos não irão apontar para nenhum local específico(area de memória), lembra-se: ainda.
Só na hora da execução a qual chamamos uma biblioteca, denomina-se resolução de símbolos.
o LD monta uma tabela das respectivas bibliotecas no ELF, quando o programa usa um símbolo ainda não resolvido, ele faz a busca em todas as bibliotecas associadas, começando a partir do próprio binário;
Ao encontrar, ele criar uma espécie de link para a área de memória onde o símbolo da biblioteca encontra-se mapeado.


Você pode perguntar-se, pelo fato da LIBC ser uma referência da linguagem, ela é carregada no processo de boot?
Ela é apenas carregada quando o primeiro processo associado a ela é executado, não existe nenhuma relação direta entre ela e o kernel.
Existe uma biblioteca chamada KLIBC que é uma implementação reduzida da LIBC que fornece recursos aos programas que participam do processo da inicialização do sistema.

Você novamente pode perguntar-se, a biblioteca fica em uma única área de memória, se dois programas em um exato instante executar o mesmo símbolo, não tem perigo de colisão?
Não, pois é apenas código. Cada processo tem o seu próprio espaço de memória(stack, heap), apenas o código da biblioteca é compartilhado.




Galera, finalmente chegamos ao FIM.
Espero que realmente tenham gostado e possam tirar proveitos das informações contidas aqui.


---------------------
Regards, Utroz.
E-mail Contact: utroz (at) oakcoders (dot) com

No comments:

Post a Comment