5. Componentes do Compilador
5.1. Pré-processador
Como dito no capítulo inicial, o pré-processador é o componente do GCC responsável por processar e manipular o código-fonte antes da compilação começar. Ele lê o código-fonte e realiza um conjunto de operações, incluindo substituição de macros, inclusão de arquivos e compilação condicional. Essas operações são definidas por um conjunto de diretivas, que são comandos especiais iniciados pelo símbolo #
.
5.1.1. Inclusão de Arquivos
A diretiva de pré-processamento #include
é utilizada para incluir no código o conteúdo de um arquivo especificado. Existem duas notações:
- #include<arquivo>
: o pré-processador buscará o arquivo em algum diretório do sistema (geralmente em /usr/include
, em sistemas Unix);
- #include"arquivo"
: o arquivo será buscado no diretório onde o código está localizado.
Geralmente, os arquivos incluídos possuem a extensão .h
(de header), que são arquivos cabeçalhos contendo definições de macros e declarações de protótipos de funções padrão do C. Porém, é possível criar arquivos de cabeçalho personalizados e adicionar outros programas em C contendo outros #include
, que serão tratados de maneira recursiva pelo pré-processador. Veja o exemplo:
main.c
#include "preproc.c"
int main(){
say_hello();
puts("Digite um número para obter sua raiz quadrada:");
scanf("%lf", &y);
raiz = raiz_quadrada(y);
printf("O valor da raiz quadrada desse número é: %lf\n", raiz);
return 0;
}
preproc.c
#pragma once
#include <stdio.h>
#include <math.h>
double y, raiz;
void say_hello(){
puts("Hello!");
}
double raiz_quadrada(double x){
return sqrt(x);
}
Ao compilar o programa main.c, o pré-processador acoplará o código de preproc.c. Note que foi necessário utilizar apenas o #include "preproc.c"
no arquivo principal, pois esse arquivo já contém as diretivas necessárias para a execução do código. Caso ocorram múltiplos #include
acidentais, a diretiva #pragma once
garante que o respectivo arquivo seja lido apenas uma vez durante o processo de compilação. Para gerar um arquivo com o código pré-processado, utilize:
No final do arquivo gerado, estará o código dos dois arquivos (preproc.c e main.c).
5.1.2. Definição de Macros
Uma macro é um pedaço de código ao qual se atribui um nome, definida pela diretiva #define
. Essa diretiva permite que você atribua um nome a um valor, uma expressão ou até mesmo a um bloco de código, facilitando a reutilização sem precisar repetir o código. Veja o exemplo:
main.c
#include <stdio.h>
#define SQUARE(x) ((x) * (x))
#define NUM 10
int main()
{
printf("The value of NUM is: %d\n", NUM);
printf("The value of SQUARE(NUM) is: %d\n", SQUARE(NUM));
return 0;
}
O pré-processador substitui as ocorrências das macros pelo seu conteúdo correspondente. Note que, como as macros não possuem escopo ou tipo explícito, elas realizam substituições simples. No exemplo, a macro NUM
é interpretada como um inteiro (10) em tempo de compilação e SQUARE(NUM)
executa a multiplicação. Ao compilar e executar, a saída será:
5.1.3. Compilação Condicional
A compilação condicional permite que se execute uma parte do programa somente se uma certa macro estiver definida. Para isso, utilizamos as diretivas #ifdef
e #endif
. Por exemplo:
main.c
#include <stdio.h>
int main()
{
#ifdef NUM
printf("The value of NUM is: %d\n", NUM);
#else
#error "A macro NUM não foi definida!";
#endif
return 0;
}
Neste caso, se a macro NUM
não estiver definida, a mensagem de erro "A macro NUM não foi definida!"
será exibida e a compilação será interrompida. Entretanto, podemos definir a macro ao compilar o programa com a opção -D
. Por exemplo:
As definições de macros passadas na linha de comando possuem maior precedência, ou seja, são consideradas antes das definições presentes no código fonte. Um código similar pode ser feito usando #ifndef
e #undef
, como no exemplo a seguir:
main.c
#include <stdio.h>
#undef NUM
int main(){
#ifndef NUM
#error "A macro NUM não foi definida!";
#else
printf("The value of NUM is: %d\n", NUM);
#endif
return 0;
}
Também é possível utilizar a diretiva #elif
em conjunto com #ifdef
para simplificar o código. Por exemplo, se somente a macro Z
estiver definida:
main.c
#include <stdio.h>
//#define X 'x'
//#define Y 'y'
#define Z 'z'
int main(){
#ifdef X
printf("The value of X is: %c\n", X);
#elif defined(Y)
printf("The value of Y is: %c\n", Y);
#elif defined(Z)
printf("The value of Z is: %c\n", Z);
#endif
return 0;
}
5.1.4. Fornecendo Instruções
A diretiva #pragma
é utilizada para fornecer instruções ao compilador. Essa diretiva não faz parte do padrão C e pode variar de acordo com o compilador e a plataforma. Ela é utilizada, por exemplo, para forçar que uma função seja inline:
Exemplo
Nesse caso, a função add
será substituída diretamente em sua chamada, evitando uma chamada de rotina. Também é possível controlar quais trechos do código serão otimizados:
Exemplo
#pragma optimize("", off) // Desliga a otimização
int add(int a, int b){
return a + b;
}
#pragma optimize("", on) // Liga a otimização novamente
Além disso, #pragma
pode ser utilizada para determinar o alinhamento de estruturas de dados em memória. No exemplo abaixo, forçamos o alinhamento mínimo para a estrutura s2
:
main.c
#include <stdio.h>
struct{
char c; // 1 byte
int i; // 4 bytes
double d; // 8 bytes
}s1;
#pragma pack(push, 1) // Força o alinhamento mínimo
struct{
char c; // 1 byte
int i; // 4 bytes
double d; // 8 bytes
}s2;
#pragma pack(pop) // Restaura o alinhamento padrão
int main(){
printf("The size of struct 1 is: %ld\n", sizeof(s1));
printf("The size of struct 2 is: %ld\n", sizeof(s2));
return 0;
}
Ao compilar e executar o código:
É possível ver que a primeira estrutura consome 3 bytes a mais, pois há preenchimento entre as variáveis char
e int
para garantir o alinhamento adequado (por exemplo, para que o int
esteja alinhado a um endereço múltiplo de 4 e o double
a um múltiplo de 8).
5.2. Compilador
Falar sobre Árvore de Derivação, resumir o processo de compilação e técnicas, comentar sobre otimização (otimização já foi falada no capítulo seguinte, então é só comentar informações relevantes)...
5.3. Montador
O montador é o componente responsável por traduzir um código de montagem em código de máquina, conforme mencionado na introdução. Além disso, ele lida com símbolos, diretivas de montagem e dados, organizando-os de forma executável. Como o GCC é compatível com diversas arquiteturas, ele suporta diretivas específicas que tratam de alinhamento dos dados, definição de constantes e strings, determinação de seções de código, dados e endereços de memória onde as instruções e/ou os dados serão posicionados, entre outras funções. Durante a montagem, o GCC resolve endereços, offsets (deslocamentos), alinha dados, manipula macros e gera a tabela de símbolos e de realocação. Para ilustrar, considere o seguinte exemplo de código em C:
main.c
Ao compilar esse programa com o GCC, o compilador gera um código intermediário que é convertido em código de montagem. Esse arquivo de montagem pode ser gerado explicitamente com:
Isso criará um arquivo chamado hello_world.s. Para compilar esse arquivo de montagem e gerar um objeto, utilize:
Continuar...
5.4. Ligador
O ligador é o componente que atua na fase final da compilação. Ele combina os arquivos-objeto gerados, resolve símbolos definidos em outros arquivos-fonte e incorpora bibliotecas ao código para gerar o arquivo executável final. Enquanto na fase anterior os endereços dos dados e códigos são tratados de forma relativa, nesta etapa esses endereços são ajustados para refletir suas posições reais na memória.
Continuar...