Programação com vim, gcc, gdb e make

Introdução

Nessa aula, você vai aprender para que servem e como usar algumas ferramentas que irá encontrar em qualquer projeto de software de porte razoável. Quanto mais complexo o projeto (em termos de quantidade de código e de componentes, número de pessoas envolvidas, dependências de bibliotecas externas, etc), mais essas ferramentas se tornam importantes para auxiliá-lo a se manter produtivo e colocar o computador para trabalhar a seu favor.

O conjunto das ferramentas usadas em um projeto de software é normalmente conhecido como toolchain. Dependendo da complexidade do projeto, a sua toolchain irá conter um número maior ou menor de ferramentas, e isso vale para o desenvolvimento de qualquer sistema e em qualquer linguagem de programação. Para deixar a discussão mais concreta, vamos falar sobre desenvolvimento em C, que é uma das linguagens mais usadas quando se trata de software básico.

Para escrever e executar um programa em C, o mínimo que você precisa é de:

  • Um editor de texto, para escrever o código-fonte do programa;
  • Um compilador, para converter o código-fonte em um arquivo executável escrito em linguagem de máquina (código binário). O executável resultante do processo de compilação será interpretado e executado pelo sistema operacional e pelo hardware.

A princípio, é só isso! Com essas duas ferramentas, você já pode escrever qualquer programa imaginável e executá-lo, mas existem muitas outras ferramentas que vão tornar a sua vida muito mais fácil se o seu programa for minimamente complexo.

Por exemplo, você roda o programa e descobre que ele não está se comportando exatamente como esperado... Às vezes ele produz o resultado certo, às vezes não. Relendo o código, você não encontra nada de errado. Como descobrir o que está acontecendo? Agora é um bom momento para introduzir mais uma ferramenta:

  • Um debugger, que permite que você execute o seu programa passo-a-passo, inspecionando como o estado do programa vai sendo alterado durante a execução—por exemplo, mudanças nos valores de variáveis. Com um debugger, você pode acompanhar a execução do programa desde o início ou interrromper a execução quando ele chegar em uma determinada linha de código.

Uma outra etapa do processo de desenvolvimento é que comumente automatizada é a etapa de build. Por exemplo, digamos que toda vez que você faz alguma alteração no software, precisa realizar os seguintes passos:

  • Recompilar o projeto (para projetos muito grandes e dependendo da capacidade do seu computador, a compilação de todos os arquivos pode demorar de minutos a algumas horas);
  • Executar testes automatizados com o executável—por exemplo, para garantir que a correção de um bug não introduziu novos bugs em outros componentes;
  • Mover o executável para o diretório onde o seu sistema operacional espera encontrar executáveis (que normalmente é qualquer diretório no seu $PATH).

Os passos acima podem ser automatizados de diversas formas. Uma delas é escrever um shell script, simplesmente. A outra, muito comum, é usar uma ferramenta de build, que permite que você declare receitas para obter determinados artefatos a partir de outros—por exemplo, como obter um arquivo executável a partir dos arquivos-fonte do seu projeto.

Para cada uma das ferramentas citadas acima, existem diversas opções de programas, acessíveis, inclusive, pela linha de comando. A tabela a seguir lista alguns exemplos.

Ferramenta Programas
Editor de texto nano, vi/vim, emacs
Compilador gcc, clang, icc, javac (Java)
Debugger gdb, lldb
Ferramenta de build make, rake (Ruby), ant (Java)

Note que o compilador e o debugger são ferramentas que dependem da linguagem de programação usada para desenvolvimento, enquanto o editor de texto e a ferramenta de build normalmente são independentes de uma linguagem.

No restante do tutorial, vamos falar sobre um programa para cada ferramenta: vim, gcc, gdb e make.

Editor de texto: vim

O vim é um editor de texto usado diretamente pela linha de comando (apesar de também poder ser usado com uma interface gráfica). Para editar um arquivo de texto com o vim, basta digitar:

vim <caminho_para_o_arquivo>

Por exemplo:

vim ~/.bashrc

O vim funciona de uma maneira um pouco diferente da que você provavelmente está acostumado. Ele possui dois modos de operação distintos: modo de comando (command mode) e modo de edição (insert mode).

O modo de comando, que é o modo em que o vim se encontra quando é iniciado, permite que você navegue pelo texto e faça alterações no arquivo usando... wait for it... comandos.

Por exemplo, se você abrir um arquivo no vim, levar o cursor até uma determinada letra e apertar a tecla x, o caractere em que o cursor está posicionado será excluído, ao invés da letra "x" ser adicionada ao texto. Ou seja: no modo de comando, as teclas do seu teclado servem para executar comandos, ao invés de inserir caracteres no texto.

O modo em que você usa o teclado para realmente digitar caracteres é o modo de edição. Para entrar no modo de edição, o comando é a tecla i. Se você estiver no modo de edição, o texto -- INSERT -- será mostrado no canto inferior esquerdo da tela.

Sobre o modo de edição não há muito o que falar porque, neste modo, o vim se comporta como qualquer editor comum.

Sobre o modo de comando, o que você precisa saber são os comandos. Vamos mostrar alguns aqui, mas existem muitos outros—este cheatsheet contém mais exemplos.

A tabela abaixo lista alguns dos mais comuns:

Comando Descrição
:q Sai do editor (quit)
:w Salva o arquivo (write)
:wq Salva o arquivo e sai (write and quit)
:q! Sai do editor (descartando alterações não salvas)
i Entra no modo de edição (insert mode)
Esc Entra no modo de comando (command mode)
j, k, l, h Move o cursor para baixo, cima, direita e esquerda, respectivamente
gg Move o cursor para o início do arquivo
Shift + g Move o cursor para o final do arquivo
$ Move o cursor para o final da linha
0 Move o cursor para o início da linha
:N Move o cursor para a linha N
dd Apaga a linha atual

Compilador: gcc

O gcc é um compilador: um programa que permite converter um ou mais arquivos-fonte escritos em C em um arquivo executável. Na verdade, falando mais rigorosamente, o gcc é um compiler driver, pois ele executa outros passos além da compilação, mas não vamos entrar nesses detalhes ainda.

O tópico de compilação de um programa (independente das linguagens de entrada e de saída) é uma das áreas mais complexas e interessantes da Computação. Um dos motivos para a complexidade é que existem muitas decisões que precisam ser tomadas ao longo do processo de compilação.

Por exemplo: qual a plataforma em que o programa será executado? Muitas vezes, você compila um programa em um computador, mas irá executá-lo em outro. Você gostaria de otimizar o código gerado? Se sim, gostaria de otimizar de acordo com qual parâmetro de desempenho: tamanho do executável, uso de memória (memory footprint), tempo de execução, ...? Onde o compilador deve procurar para encontrar bibliotecas externas que serão usadas pelo seu programa?

Dependendo da sua necessidade, o uso do gcc pode ficar bastante complexo. No entanto, para casos de uso simples, a linha de comando é igualmente simples:

gcc -o <arquivo_de_saida> <arquivo_de_entrada>

Por exemplo:

gcc -o foo foo.c

Depois de executar esse comando, se não houver nenhum erro, você poderá executar o programa pelo terminal:

./foo

Exercício 1.1: Utilize o template abaixo para criar o arquivo fatorial.c. Usando o vim, edite o arquivo de tal forma que o programa funcione corretamente (veja os comentários no código). Em seguida, compile e execute o programa usando o gcc.

Template:

#include <stdio.h>

int fatorial(int n);

/**
 * Ponto de entrada do programa (entry point).
 */
int main(int argc, char** argv) {
  printf("Digite um número: ");

  int n;
  scanf("%d", &n);

  printf("%d! = %d\n", n, fatorial(n));

  return 0;
}

/**
 * Calcula o fatorial do número recebido e retorna o resultado.
 */
int fatorial(int n) {
  // Por enquanto, essa função não faz nada de muito útil... Ela apenas
  // retorna o número recebido.
  // Altere esta implementação para retornar o resultado correto.
  return n;
}

Exemplo de execução:

$ ./fatorial
Digite um número: 4
4! = 24

Entrega: o arquivo fatorial.c 

Debugger: gdb

O gbd é um debugger: um programa que permite acompanhar a execução de um programa e também interferir nela. O gdb funciona com muitas linguagens, mas as mais comuns são C e C++.

O uso do gdb será demonstrado a partir deste exemplo, que mostra como descobrir a causa de um erro de segmentation fault em um programa. Esse exemplo é uma tradução/adaptação do original disponível aqui.

Debugger: make

A última ferramenta de que vamos falar aqui é o make, que permite a automatização de muitas operações, e é comumente utilizado para gerenciar o processo de build de um software.

O make é basicamente uma ferramenta que executa comandos do shell condicionalmente (normalmente, dependendo do estado de um ou mais arquivos). Um caso de uso muito comum para o make é o seguinte: imagine um software composto por centenas de arquivos em C. Compilar todos esses arquivos e construir os executáveis necessários leva, digamos, 40 minutos. Você estava corrigindo um bug no sistema e, para corrigir o bug, precisou alterar apenas 3 arquivos do projeto; por volta de 10 linhas foram modificadas ao todo.

Agora, você precisa obter novos executáveis, compilados a partir do código modificado. Não é possível recompilar apenas os 3 arquivos que foram alterados, porque existem outros arquivos que dependem destes 3, então eles precisarão ser recompilados também. E, então, os arquivos que dependem desses outros também precisarão ser recompilados... O que você acha de montar essa lista de quais arquivos precisarão ser recompilados manualmente, olhando arquivo por arquivo? Ou será que é melhor compilar tudo do zero e esperar os 40 minutos de uma vez?

Nenhuma dessas opções é ideal, mas a boa notícia é que o make ajuda a resolver exatamente esse tipo de problema: se você disser quais arquivos dependem de quais (e dessa vez é suficiente informar apenas o primeiro nível de dependência), o make utiliza a data de modificação dos arquivos para descobrir se um determinado executável precisa ser recompilado ou não, com base na data de modificação de suas dependências.

O arquivo que informa o make sobre as dependências de cada arquivo, contém instruções sobre como (re-)construir executáveis, executar testes, etc em um projeto é o Makefile.

Makefile

Um Makefile é um arquivo que contém regras (rules). Cada regra define como construir um ou mais targets a partir de dois itens: uma lista de dependências (prerequisites, que pode ser vazia) e uma receita (recipe), que são apenas comandos que serão executados em um shell.

A sintaxe básica de uma regra de um Makefile é:

# Sintaxe de uma regra.
# Linhas que começam com '#', como estas, são comentários.

targets : prerequisites
    recipe_line_1
    recipe_line_2
    ...
    recipe_line_n
Uma observação importante é que todas as linhas de uma receita devem começar com o caractere Tab (\t). Por isso, atenção para usar Tabs e não espaços. O seu editor de texto pode ser configurado (se já não estiver) para mostrar espaços em branco no texto, distinguindo tabs de espaços.

Exemplo de Makefile

Saindo um pouco do contexto de programação, o arquivo abaixo é um Makefile que define como construir um arquivo groceries.txt a partir de outros dois arquivos, fruits.txt e vegetables.txt. Quem nunca quis controlar a lista do supermercado usando Makefiles, certo?!

SHELL=/bin/bash

# Este Makefile contém 4 regras, descritas abaixo.

# Esta primeira regra contém apenas um target: o arquivo groceries.txt.
# Para que o make consiga construir o arquivo, outros dois arquivos estão
# listados como dependências: um com a lista de frutas e outro com a lista de
# legumes.
# Se as dependências forem satisfeitas, o make executa as linhas de receita,
# que apenas imprime o conteúdo dos dois arquivos em um arquivo final,
# acrescentando linhas de cabeçalho.
# Importante: cada linha é executada em uma nova instância nova de um shell
# (o que significa, por exemplo, que variáveis definidas em uma linha não
# estarão disponíveis nas linhas seguintes).
# -------------------------------------------------------------------------
groceries.txt : fruits.txt vegetables.txt
    echo -e "Fruits:\n" > groceries.txt
    cat fruits.txt >> groceries.txt
    echo -e "\nVegetables:\n" >> groceries.txt
    cat vegetables.txt >> groceries.txt

# Esta regra contém dois targets. Nenhum deles tem dependências, e a receita
# apenas imprime uma mensagem dizendo para o usuário criar o arquivo
# correspondente.
# -------------------------------------------------------------------------
fruits.txt vegetables.txt:
    echo "Please create the file $@"

# Esta é uma regra auxiliar. O target `clean` não é um arquivo que será
# criado; a regra é basicamente um atalho para executar uma linha de comando.
# Nesse caso, a receita apaga o arquivo groceries.txt.
# -------------------------------------------------------------------------
clean:
    rm -f groceries.txt

# Esta é uma outra regra auxiliar, que imprime o conteúdo do arquivo de saída
# (o que significa que faz sentido listá-lo como dependência).
# -------------------------------------------------------------------------
print : groceries.txt
    cat groceries.txt

Como usar o make na linha de comando

O uso básico do make é:

make <target>

Com este comando, o make irá procurar um Makefile (que é apenas um arquivo com este nome) no mesmo diretório em que o comando foi executado (a opção -f pode ser usada caso o Makefile esteja em outro diretório).

Em seguida, irá analisar o Makefile para encontrar uma regra que ensine como construir o target passado como parâmetro. Se o target tiver dependências pendentes, elas serão construídas primeiro, e assim por diante.

Por exemplo, para construir o arquivo groceries.txt usando o Makefile acima:

make groceries.txt

Exercício 1.2: criar um Makefile (usando o vim) com o conteúdo do exemplo acima. Em seguida, criar um shell script chamado makefile-test.sh que chama, um por um, todos os targets possíveis desse Makefile (dica: são 5).

Entrega: Um zip  lab2-atv2-XXXXXXX.zip) contendo o shell script ( makefile-test.sh )

Exercício 1.3: criar um Makefile (usando o vim) para o programa do fatorial. O Makefile deve definir as seguintes regras:

  1. Compilar o arquivo fatorial.c (com o métodomain() ) usando o gcc. O nome do target (e do executável produzido) deve ser fatorial.
  2. Executar o arquivo fatorial, caso ele exista. Esta regra não deve compilar o arquivo, apenas chamar o executável. Nome do target: run.
  3. Executar o arquivo no gdb. Na receita, você deve compilar o arquivo com a flag necessária para usar o gdb e, em seguida, chamar o executável. Nome do target: run_gdb.
  4. Remover o executável. Nome do target: clean.

Entrega: Um arquivo zip com nome lab2-atv3-XXXXXX.zip e com os seguintes arquivos:

lab2-atv3-XXXXXX.zip/
├── fatorial.c
└── Makefile
    



Última atualização: quarta-feira, 18 jan. 2017, 12:40