Processo de compilação C++ — De volta ao básico
Nesse artigo voltaremos ao básico do processo de compilação de executáveis e bibliotecas em C++.
Temos como intuito consolidar os conhecimentos básicos do processo de compilação, suas etapas e com isso auxiliar os desenvolvedores a encontrar com mais facilidade onde erros de compilação podem ocorrer.
Pre-processor
O processo de compilação em C++ envolve quatro etapas, sendo a primeira delas o Pre-Processor, ele é responsável por resolver as diretivas utilizadas em nossos programas, como #include, #if, #else, #endif, #define, entre outras, basicamente ele copia e cola código, convertendo a lógica no caso de estruturas condicionais em chamadas diretas no código.
Vamos a prática que fica mais simples de entender. Criaremos um simples executável e adicionaremos uma macro de pre-processor.
main.cpp
#define MY_DEFINE 10
auto main() -> int {
const int a { MY_DEFINE };
return MY_DEFINE;
}
Agora que temos esse exemplo mínimo, vamos passar pelo pre-processor e verificar o resultado. Para isso vamos executar o seguinte comando.
c++ -E main.cpp > main.i
A flag -E diz ao compilador a passar pelo pre-processor e então passamos o resultado desse processo para o arquivo main.i
Abrindo o arquivo podemos verificar que a diretiva #define MY_DEFINE 10 desapareceu e seu valor foi colado diretamente no código.
cat main.i
# 1 "main.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "main.cpp"
auto main() -> int {
const int a { 10 };
return 10;
}
Agora que temos uma ideia mais clara de como essa etapa funciona podemos fazer uma pequena mudança no nosso código e verificar novamente o resultado.
#include <iostream>
auto main() -> int {
std::cout << "Hello World\n";
return 0;
}
Vamos pré processar novamente e conferir o resultado.
c++ -E main.cpp > main.i
less main.i
Agora podemos notar uma grande quantidade de código adicionado ao nosso arquivo, esse código é o código de header da utilização da diretiva #include, o código referente a API da biblioteca iostream foi inserido naquele ponto do código o que será utilizado posteriormente para compilar a unidade de compilação (falaremos sobre isso mais para frente).
Compiler
A próxima etapa do processo é a Compilação propriamente dita, essa etapa irá utilizar o resultado da primeira etapa e gerar um código Assembly, para isso iremos executar o seguinte comando.
c++ -S main.i > main.s
O resultado desse processo é um código assembly no formato do hardware ao qual o compilador está configurado, no meu caso o toolchain tem como destino “target” o hardware do tipo x86 Intel/AMD little endian.
less main.s
.file "main.cpp"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $10, -4(%rbp)
movl $10, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
Assembler
Nessa terceira etapa do processo do que chamamos de compilação o assembler é responsável por ler o código assembly e converter o mesmo no que o hardware realmente entende que é código binário.
O resultado desse processo gera os conhecidos arquivos objeto, que são binários e não compreensíveis para leitura humana.
c++ -c main.s
Como saída teremos o arquivo main.o, o mesmo pode ser aberto no nosso editor, porém é incompreensivo para leitura.
less main.o
Podemos analisar o arquivo com outra ferramenta o hexdump, ele permite inspecionar arquivos binários e visualizar em formato hexadecimal, decimal, octogonal ou ASCII. Consulte o manual da ferramenta para mais detalhes.
hexdump -C main.o
Linker
A última etapa do processo é o linker, ele é o responsável por combinar o código escrito com as bibliotecas externas, é uma etapa muito importante e a causa da maioria dos problemas de compilação, pois precisa resolver caminhos e encontrar os símbolos dos objetos e funções da sua aplicação.
c++ main.o -o myApp
Se tudo ocorrer bem nessa etapa, teremos finalmente nosso executável com o nome myApp, então podemos executar e visualizar o resultado.
./myApp
O que fizemos foi passar por todos os passos de compilação manualmente, isso é interessante do ponto de vista de conhecimento, porém impraticável no dia a dia de desenvolvimento.
Todo esse processo é feito automaticamente pelo “Compilador” para isso poderíamos apenas utilizar o comando da seguinte forma.
c++ main.cpp -o myApp
Isso passará por todas as etapas anteriores e ao final criará o executável, bem melhor não é mesmo?
Bibliotecas
Header / Source
Um importante aspecto em C++ é a separação do código entre os arquivos de cabeçalho header e implementação source.
De forma simplificada, os arquivos de header *.h ou *.hpp, são utilizados para descrever a definição das funções e classes, passando a implementação para os arquivos de source, *.cpp ou *.cc.
Essa separação tem como objetivo ocultar os detalhes de implementação do usuário da biblioteca e prover uma promessa ao compilador de que a biblioteca que será linkada contenha uma função com a exata assinatura promovida pela interface utilizada no arquivo de header.
Vamos a prática!
Criaremos três arquivos para esse nosso novo exemplo, o já conhecido main.cpp, onde terá a função de mesmo nome que é a entrada do nosso programa e os arquivos que serão nossa biblioteca e que utilizaremos no main.
touch main.cpp tools.h tools.cpp
Com os arquivos criados, abra os mesmos no editor de sua preferência e vamos adicionar algumas linhas de código.
tools.h
#pragma once
void message();
tools.cpp
#include "tools.h"
#include <iostream>
void message() {
std::cout << "Hello from library!\n";
}
main.cpp
#include "tools.h"
auto main() -> int {
message();
return {};
}
Vamos compilar
c++ main.cpp -o myApp
Ops 😬, tivemos um erro!
Ao compilar dessa vez tivemos um erro, isso aconteceu porque incluímos no nosso main.cpp o conteúdo do header tools.h e com isso fizemos uma promessa ao compilador que utilizaremos uma função com aquela assinatura, “void message()”, porém na hora de compilar não indicamos onde está essa implementação, sendo assim, o compilador gerou um erro dizendo que message() não está declarado no escopo, ou seja, não foi possível encontrar o símbolo.
Vamos resolver esse problema, para isso primeiro teremos que compilar nossa biblioteca.
Aqui precisamos falar sobre os dois tipos de bibliotecas que podemos ter em C++. Elas podem ser do tipo STATIC ou DYNAMIC, nos sistemas UNIX/Linux, elas são diferenciadas por sua extensão, sendo *.a para static e *.so para dynamic, no Windows as bibliotecas dynamic utilizam a extensão *.dll.
As bibliotecas do tipo static são adicionadas ao binário do executável, isso elimina a necessidade do executável conhecer seu caminho durante a execução, além de ser mais eficiente em performance, já que o compilador pode fazer otimizações de seu uso, porém essas vantagens tem seu custo, nesse caso tornando os executáveis maiores, pois carregam toda a informação da biblioteca.
Já as bibliotecas do tipo dynamic, serão menos eficiente, porém trazem alguns benefícios, como a reutilização, sendo que mais de um executável pode consumi-la e também a facilidade de atualização, pois correções podem ser feitas apenas na biblioteca sem que haja a necessidade de gerar um novo executável, desde que não haja alterações na assinatura da API.
c++ -c tools.cpp
Ao compilarmos dessa forma passaremos pelas três primeiras etapas do processo de compilação e criaremos o nosso arquivo objeto, nesse caso tools.o.
ar rcs libtools.a tools.o
A ferramenta ar cria algo conceitualmente similar a um arquivo tipo zip, que é o que nossa bibliotecas vai se parecer, agrupando os binários com a implementação das funções.
No nosso caso temos apenas um arquivo, tools.o, porém poderiam ser quantos arquivos quanto necessários. Outro aspecto muito importante nessa etapa é que o nome da biblioteca inicie com a palavra “lib” isso facilita o trabalho do linker, pois ele irá buscar as mesmas com esse prefixo.
Vamos então compilar nosso main
c++ -c main.cpp
Agora temos nosso main.o. Vamos então juntar todas as peças desse quebra cabeça e gerarmos nosso executável que utiliza a biblioteca tools.
c++ main.o -L . -ltools -o myApp
Aqui a flag -L indica o caminho onde o linker deve procurar a nossa biblioteca e a flag -l informa o nome da mesma, note que não falamos o nome do arquivo, por padrão informamos apenas o nome e o linker se encarrega de procurar usando o prefixo “lib”
Pronto, podemos executar nossa aplicação.
./myApp
Teremos como resultado a mensagem gerada pela nossa biblioteca.
Hello from library
MetaBuild Systems
Tudo isso é muito legal para fins didáticos, porém e se o projeto for maior com dezenas ou centenas de arquivos, e se o mesmo utilizar bibliotecas externas que precisam ser passadas para o linker? Bom isso pode se tornar um problema bem rápido e ficar inviável de trabalhar.
Poderíamos escrever scripts em outras linguagens com em shell ou python porém não seriam tão práticos ainda assim.
Para resolver esse problema foram criados os build system, os mais conhecidos são o Make e o Ninja, eles automatizam o processo, mas suas linguagens ainda não são muito intuitivas e apesar de ajudarem seriam difíceis de escrever e manter.
Para solucionar esse problema foram desenvolvidos os Meta Build Systems, dentre os mais conhecidos o CMake e o Meson, sendo o CMake o mais popular entre os projetos Open Source.
Neles você escreve a intenção de forma de mais alto nível e ao executar ele cria o make, ninja ou outro build system pronto para ser executado.
Vamos atualizar nosso projeto, para isso criaremos mais um arquivo.
touch CMakeLists.txt
E adicionaremos ao seu conteúdo o seguinte código.
CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyProject
VERSION 1.0.0
DESCRIPTION "My project descriotion"
HOMEPAGE_URL "https://github.com/andreagen0r"
LANGUAGES CXX
)
add_library(tools tools.cpp)
add_executable(MyApp main.cpp)
target_link_libraries(MyApp tools)
Para compilar agora iremos primeiro criar um novo diretório e entrarmos nele, com isso isolaremos os arquivos gerados pelo CMake dos arquivos do nosso código.
mkdir build && cd build
cmake -S ..
Aqui iremos rodar o cmake informando que o arquivo de entrada dele está um diretório acima na estrutura. Podemos verificar os arquivos gerados e notaremos um arquivo Makefile, podemos executar nosso build system agora.
make
Se tudo correr bem, terá um output parecido com o abaixo.
[ 25%] Building CXX object CMakeFiles/tools.dir/tools.cpp.o
[ 50%] Linking CXX static library libtools.a
[ 50%] Built target tools
[ 75%] Building CXX object CMakeFiles/MyApp.dir/main.cpp.o
[100%] Linking CXX executable MyApp
[100%] Built target MyApp
Ao concluir o processo de compilação, teremos o nosso arquivo executável como antes, mas agora gerado de forma mais simples, sem tantas etapas manuais.
./MyApp
Com isso concluímos o processo de compilação em C++ e espero que tenha ficado claro todas as etapas que envolvem a criação de bibliotecas e executáveis, como os mesmos são relacionados e principalmente, como podemos usar esse conhecimento para identificarmos onde os problemas estão ocorrendo.