Como funciona o processo de compilation / vinculação?

Como funciona o processo de compilation e vinculação?

(Nota: Esta é uma input para o C ++ FAQ do Stack Overflow . Se você quiser criticar a idéia de fornecer um FAQ neste formulário, então o post no meta que iniciou tudo isso seria o lugar para fazer isso. essa questão é monitorada na sala de chat do C ++ , onde a ideia do FAQ começou em primeiro lugar, então é muito provável que sua resposta seja lida por aqueles que surgiram com a ideia.)

A compilation de um programa C ++ envolve três etapas:

  1. Pré-processamento: o pré-processador usa um arquivo de código-fonte C ++ e lida com as diretivas #include s, #define s e outras do pré-processador. A saída desta etapa é um arquivo C ++ “puro” sem diretivas de pré-processador.

  2. Compilação: o compilador pega a saída do pré-processador e produz um arquivo de object a partir dele.

  3. Vinculação: o vinculador obtém os arquivos de object produzidos pelo compilador e produz uma biblioteca ou um arquivo executável.

Pré-processando

O pré-processador manipula as diretivas do préprocessador , como #include e #define . É agnóstico da syntax do C ++, e é por isso que deve ser usado com cuidado.

Ele funciona em um arquivo de origem C ++ de cada vez, substituindo diretivas #include pelo conteúdo dos respectivos arquivos (que normalmente são apenas declarações), fazendo a substituição de macros ( #define ) e selecionando diferentes partes do texto dependendo do #if , Diretivas #ifdef e #ifndef .

O pré-processador funciona em um stream de tokens de pré-processamento. A substituição de macro é definida como a substituição de tokens por outros tokens (o operador ## permite mesclar dois tokens quando faz sentido).

Depois de tudo isso, o pré-processador produz uma saída única que é um stream de tokens resultante das transformações descritas acima. Ele também adiciona alguns marcadores especiais que informam ao compilador de onde cada linha veio, para que possa usá-los para produzir mensagens de erro sensatas.

Alguns erros podem ser produzidos neste estágio com o uso inteligente das diretivas #if e #error .

Compilação

A etapa de compilation é executada em cada saída do pré-processador. O compilador analisa o código-fonte C ++ puro (agora sem quaisquer diretivas de pré-processador) e o converte em código assembly. Em seguida, invoca o back-end subjacente (assembler no toolchain) que monta esse código em código de máquina produzindo arquivo binário real em algum formato (ELF, COFF, a.out, …). Este arquivo object contém o código compilado (em forma binária) dos símbolos definidos na input. Símbolos em arquivos de objects são referidos pelo nome.

Arquivos de object podem se referir a símbolos que não estão definidos. Esse é o caso quando você usa uma declaração e não fornece uma definição para ela. O compilador não se importa com isso, e terá prazer em produzir o arquivo de object, desde que o código fonte esteja bem formado.

Compiladores geralmente permitem que você pare a compilation neste momento. Isto é muito útil porque com ele você pode compilar cada arquivo de código fonte separadamente. A vantagem disso é que você não precisa recompilar tudo se apenas alterar um único arquivo.

Os arquivos de objects produzidos podem ser colocados em arquivos especiais chamados de bibliotecas estáticas, para facilitar a reutilização posterior.

É nesse estágio que são relatados erros de compilador “regulares”, como erros de syntax ou erros de resolução de sobrecarga com falha.

Vinculando

O vinculador é o que produz a saída final de compilation dos arquivos de object que o compilador produziu. Essa saída pode ser uma biblioteca compartilhada (ou dinâmica) (e, embora o nome seja semelhante, eles não têm muito em comum com as bibliotecas estáticas mencionadas anteriormente) ou um executável.

Ele vincula todos os arquivos de object, substituindo as referências a símbolos indefinidos pelos endereços corretos. Cada um desses símbolos pode ser definido em outros arquivos de objects ou em bibliotecas. Se eles forem definidos em bibliotecas diferentes da biblioteca padrão, você precisará informar ao vinculador sobre eles.

Neste estágio, os erros mais comuns estão faltando definições ou definições duplicadas. O primeiro significa que as definições não existem (isto é, elas não são escritas) ou que os arquivos ou bibliotecas de objects onde eles residem não foram dados ao vinculador. O último é óbvio: o mesmo símbolo foi definido em dois arquivos ou bibliotecas de objects diferentes.

Este tópico é discutido em CProgramming.com:
https://www.cprogramming.com/compilingandlinking.html

Aqui está o que o autor escreveu:

Compilar não é o mesmo que criar um arquivo executável! Em vez disso, criar um executável é um processo de múltiplos estágios dividido em dois componentes: compilation e vinculação. Na realidade, mesmo que um programa “compile bem”, ele pode não funcionar devido a erros durante a fase de vinculação. O processo total de ir dos arquivos de código-fonte para um executável pode ser melhor chamado de build.

Compilação

Compilação refere-se ao processamento de arquivos de código-fonte (.c, .cc ou .cpp) e à criação de um arquivo ‘object’. Esta etapa não cria nada que o usuário possa executar. Em vez disso, o compilador produz apenas as instruções de linguagem de máquina que correspondem ao arquivo de código-fonte que foi compilado. Por exemplo, se você compilar (mas não vincular) três arquivos separados, você terá três arquivos de object criados como saída, cada um com o nome .o ou .obj (a extensão dependerá do seu compilador). Cada um desses arquivos contém uma tradução do seu arquivo de código-fonte em um arquivo de idioma de máquina – mas você não pode executá-los ainda! Você precisa transformá-los em executáveis ​​que seu sistema operacional possa usar. É aí que o vinculador entra.

Vinculando

Vinculação refere-se à criação de um único arquivo executável a partir de vários arquivos de object. Nesta etapa, é comum que o linker se queixe de funções indefinidas (geralmente main). Durante a compilation, se o compilador não puder encontrar a definição para uma function específica, apenas assumiria que a function foi definida em outro arquivo. Se este não for o caso, não há como o compilador saber – ele não examina o conteúdo de mais de um arquivo de cada vez. O vinculador, por outro lado, pode examinar vários arquivos e tentar encontrar referências para as funções que não foram mencionadas.

Você pode perguntar por que existem etapas de compilation e vinculação separadas. Primeiro, é provavelmente mais fácil implementar as coisas dessa maneira. O compilador faz a sua coisa, e o linker faz a sua coisa – mantendo as funções separadas, a complexidade do programa é reduzida. Outra vantagem (mais óbvia) é que isso permite a criação de programas grandes sem ter que refazer a etapa de compilation toda vez que um arquivo é alterado. Em vez disso, usando a chamada “compilation condicional”, é necessário compilar apenas os arquivos de origem que foram alterados; para o resto, os arquivos object são uma input suficiente para o vinculador. Por fim, isso simplifica a implementação de bibliotecas de código pré-compilado: basta criar arquivos de objects e vinculá-los como qualquer outro arquivo de object. (O fato de que cada arquivo é compilado separadamente das informações contidas em outros arquivos, aliás, é chamado de “modelo de compilation separado”.)

Para obter todos os benefícios da compilation de condições, provavelmente é mais fácil obter um programa para ajudá-lo do que tentar lembrar quais arquivos foram alterados desde a última compilation. (Você poderia, é claro, apenas recompilar todos os arquivos com um registro de data e hora maior que o registro de data e hora do arquivo de object correspondente.) Se você estiver trabalhando com um ambiente de desenvolvimento integrado (IDE), ele já poderá cuidar disso para você. Se você estiver usando ferramentas de linha de comando, existe um utilitário bacana chamado make que vem com a maioria das distribuições * nix. Juntamente com a compilation condicional, ela possui vários outros resources interessantes para programação, como permitir diferentes compilações de seu programa – por exemplo, se você tiver uma versão produzindo uma saída detalhada para debugging.

Saber a diferença entre a fase de compilation e a fase de link pode facilitar a busca por bugs. Os erros do compilador geralmente são de natureza sintática – um ponto e vírgula ausente, um parêntese extra. Os erros de link geralmente têm a ver com definições ausentes ou múltiplas. Se você receber um erro de que uma function ou variável é definida várias vezes a partir do vinculador, isso é uma boa indicação de que o erro é que dois dos seus arquivos de código-fonte têm a mesma function ou variável.

Na frente padrão:

  • uma unidade de tradução é a combinação de arquivos de origem, headers incluídos e arquivos de origem, menos as linhas de origem ignoradas pela diretiva de pré-processador de inclusão condicional.

  • o padrão define 9 fases na tradução. Os quatro primeiros correspondem ao pré-processamento, os próximos três são a compilation, o próximo é a instanciação de modelos (produzindo unidades de instanciação ) e o último é a vinculação.

Na prática, a oitava fase (a instanciação de templates) é freqüentemente feita durante o processo de compilation, mas alguns compiladores atrasam a fase de vinculação e alguns espalham nos dois.

O mais magro é que uma CPU carrega dados de endereços de memory, armazena dados em endereços de memory e executa instruções sequencialmente de endereços de memory, com alguns saltos condicionais na sequência de instruções processadas. Cada uma dessas três categorias de instruções envolve o cálculo de um endereço para uma célula de memory a ser usada na instrução da máquina. Como as instruções de máquina são de comprimento variável, dependendo da instrução específica envolvida, e porque encadeamos um comprimento variável delas juntas à medida que construímos nosso código de máquina, há um processo de duas etapas envolvidas no cálculo e na construção de qualquer endereço.

Primeiro, estabelecemos a alocação de memory da melhor maneira possível, antes que possamos saber exatamente o que acontece em cada célula. Nós descobrimos os bytes, ou palavras, ou o que quer que seja, que formem as instruções, literais e quaisquer dados. Acabamos de começar a alocar memory e construir os valores que criarão o programa enquanto vamos, e anote em qualquer lugar que precisamos voltar e consertar um endereço. Naquele lugar colocamos um boneco para apenas preencher o local para que possamos continuar a calcular o tamanho da memory. Por exemplo, nosso primeiro código de máquina pode levar uma célula. O próximo código de máquina pode levar 3 células, envolvendo uma célula de código de máquina e duas células de endereço. Agora, nosso ponteiro de endereço é 4. Sabemos o que vai na célula da máquina, que é o código operacional, mas temos que esperar para calcular o que vai nas células de endereço até sabermos onde esses dados serão localizados, ou seja, qual será o endereço da máquina desses dados.

Se houvesse apenas um arquivo de origem, um compilador poderia, teoricamente, produzir código de máquina totalmente executável sem um vinculador. Em um processo de duas passagens, ele poderia calcular todos os endereços reais para todas as células de dados referenciadas por qualquer carga de máquina ou instruções de armazenamento. E poderia calcular todos os endereços absolutos referenciados por qualquer instrução de salto absoluto. É assim que compiladores mais simples, como o de Forth trabalham, sem linker.

Um linker é algo que permite que blocos de código sejam compilados separadamente. Isso pode acelerar o processo geral de criação de código e permite alguma flexibilidade com a forma como os blocos são usados ​​posteriormente, em outras palavras, eles podem ser realocados na memory, por exemplo, adicionando 1000 a cada endereço para aumentar o bloco em 1000 células de endereço.

Então, o que o compilador produz é um código de máquina que ainda não está totalmente construído, mas é definido de modo que sabemos o tamanho de tudo, em outras palavras, para que possamos começar a calcular onde todos os endereços absolutos serão localizados. o compilador também gera uma lista de símbolos que são pares de nome / endereço. Os símbolos relacionam um deslocamento de memory no código da máquina no módulo com um nome. O deslocamento é a distância absoluta até o local da memory do símbolo no módulo.

É aí que chegamos ao linker. O vinculador primeiro coloca todos esses blocos de código de máquina juntos de ponta a ponta e anota onde cada um começa. Em seguida, calcula os endereços a serem corrigidos, sumndo o deslocamento relativo em um módulo e a posição absoluta do módulo no layout maior.

Obviamente eu simplifiquei isso para que você possa tentar entendê-lo, e eu deliberadamente não usei o jargão de arquivos de objects, tabelas de símbolos, etc., o que para mim faz parte da confusão.

Veja o URL: http://faculty.cs.niu.edu/~mcmahon/CS241/Notes/compile.html
O processo de complemento completo do C ++ é apresentado claramente nesta URL.

O GCC compila um programa C / C ++ em executável em 4 etapas.

Por exemplo, um ” gcc -o hello.exe hello.c ” é realizado da seguinte maneira:

1. Pré-processamento

Pré-processamento através do pré-processador GNU C (cpp.exe), que inclui os headers (#include) e expande as macros (#define).

cpp hello.c> hello.i

O arquivo intermediário resultante “hello.i” contém o código-fonte expandido.

2. Compilação

O compilador compila o código-fonte pré-processado em código assembly para um processador específico.

gcc -S hello.i

A opção -S especifica para produzir o código de assembly, em vez do código de object. O arquivo de assembly resultante é “hello.s”.

3. Montagem

O assembler (as.exe) converte o código assembly em código de máquina no arquivo de object “hello.o”.

como -o hello.o hello.s

4. Linker

Finalmente, o linker (ld.exe) vincula o código do object ao código da biblioteca para produzir um arquivo executável “hello.exe”.

ld -o hello.exe hello.o … bibliotecas …