Cómo hacer una compilación y módulos en C y C++
El objetivo de este artículo es presentar los fundamentos de la compilación en C, C++ y la programación modular. Esto nos permitirá comprender mejor los mensajes de error del compilador. Las nociones abordadas aquí son independientes del sistema operativo.
¿Qué es un lenguaje compilado?
El objetivo de un compilador es el de convertir un archivo texto con código fuente en un archivo binario (por ejemplo un ejecutable). Una vez creado el ejecutable, este es utilizado como cualquier programa. Para ello no es necesario que se disponga del código fuente.
Un lenguaje compilado (como C o C++) es diferente a un lenguaje interpretado (por ejemplo, un script de shell) o un seudo-interpretado (por ejemplo python).
En C, la compilación transformará el código C de un programa en código nativo, es decir una serie de instrucciones binarias que pueden ser directamente comprendidas por el procesador.
¿Cómo instalar un compilador C y C++?
Linux
En general se utiliza gcc y g++. Para instalarlo se utiliza el gestor de paquetes habitual. Por ejemplo, bajo Debian o en cualquier distribución basada en debian basta con escribir como root o con un sudo:
aptitude update aptitude safe-upgrade aptitude install gcc g++
Del mismo modo podemos instalar un entorno de desarrollo, como por ejemplo kdevelop (bajo KDE) o anjuta (bajo gnome).
Bajo Windows
Podemos utilizar dev-cpp o code::blocks: dos entornos de desarrollo libres que se basan en gcc y g++.
¿Cuáles son las fases de compilación?
La primera fase consiste en escribir el código fuente en lenguaje C o C++ (archivos con extensión .c y .h en C y .cpp y .hpp en C++). A continuación se efectúa la compilación. Por ejemplo con gcc (en C) o g++ (en C++). La compilación se desarrolla en tres fases:
El preprocesado
El compilador comienza por aplicar cada instrucción pasada al preprocesador (todas las líneas que comienzan con #, entre estas #define). Estas instrucciones son en realidad muy simples ya que únicamente copian o eliminan secciones de código sin compilarlas.
Es en esta fase que #define, localizada en un archivo fuente (.c o .cpp) o en un header (.h o .hpp), es reemplazada por código C/C+.
La compilación
El compilador compila cada archivo fuente (.c y .cpp), es decir crea un archivo binario (.o) para cada archivo fuente, excepto para el archivo que contiene la función main. Esta fase constituye la compilación propiamente dicha.
Estas dos primeras etapas son realizadas por cc cuando se utiliza gcc/g++.
El enlazado
Finalmente, el compilador une cada archivo .o con los archivos binarios de las librerías que son utilizadas (archivos .a y .so bajo Linux, archivos .dll bajo Windows).
Especialmente, verifica que cada función llamada en el programa no esté únicamente declarada si no que también implementada. También verifica que una función no esté implementada en varios archivos .o.
Esta fase constituye la fase final para obtener un ejecutable (.exe bajo Windows, generalmente sin extensión bajo Linux).
Esta fase es realizada por ld cuando se utiliza gcc/g++.
¿Cómo detectar warning y errores?
En un entorno de desarrollo, basta con hacer clic sobre “build” y estas tres fases se llevan a cabo automáticamente. No obstante es importante conocerlas para interpretar correctamente los mensajes de error o warning de un compilador.
Un warning significa que el código es ambiguo y que puede ser interpretado de manera diferente de un compilador a otro, pero el ejecutable puede ser creado igualmente.
En cambio, un error significa que el código no ha podido ser compilado completamente y que el ejecutable no puede ser creado.
¿Cuáles son las grandes etapas para escribir un programa en C o C++?
Escribir el código fuente
Un simple bloc de notas puede ser suficiente. Por ejemplo, podemos escribir en el fichero plop.c:
#include <stdio.h> int main(){ printf("plop !\n"); return 0; }
Compilar
En Linux llamamos directamente a gcc (-W y –Wall permiten mostrar mensajes para verificar si el código es “limpio”, -o plop.exe indica que el ejecutable que será creado deberá llamarse plop.exe):
gcc -W -Wall -o plop.exe plop.c
Implícitamente, el compilador hace las tres etapas descritas anteriormente.
- Preprocesado:
/* Todo lo que es definido por <stdio.h>, incluyendo printf() */ int main(){ printf("plop !\n"); return 0; }
- Compilación (encuentra sin problemas printf ya que éste es declarado en <stdio.h>)
- Enlazado (encuentra sin problemas printf en el binario de la lib c). También lo podemos verificar en Linux con ldd:
ldd plop.exe
Lo que da:
linux-gate.so.1 => (0xb7f2b000) libc.so.6 => /lib/i686/cmov/libc.so.6 (0xb7dbb000) /lib/ld-linux.so.2 (0xb7f2c000)
En la segunda línea podemos ver que utiliza la lib c y después crea plop.exe. Por otra parte comprobamos que no hay error ni warning.
Ejecución
Tan solo queda ejecutarlo:
./plop.exe
Lo que da como esperado:
plop!
Si algún error se produce aquí (error de segmentación, falta de memoria, etc.), por lo general habrá que recurrir a un depurador (por ejemplo gdb o ddd), revisar el código fuente, etc. En todos los casos, no será un problema de compilación.
Atención: En Windows existen dos métodos para ejecutar un programa:
Método 1: podemos ejecutar un programa a través de los comandos ms-dos (haciendo clic en Inicio / Ejecutar y escribir “cmd”). Con el comando cd nos colocamos en el directorio y ejecutamos el programa. En este caso todo irá bien.
Método 2: Si ejecutamos el programa desde el explorador, no podremos ver el programa a menos que pongamos una pausa justo antes del final del programa.
#include <stdio.h> int main(){ printf("plop !\n"); getchar(); /* el programa se detiene a menos que presionemos una tecla */ return 0; }
¿Cuáles son los errores clásicos?
Error de compilación
Supongamos que olvidamos incluir en nuestro código fuente el archivo <stdio.h> (en el que está declarada la función printf), o un ";". Tendremos entonces un mensaje de error de compilación, que es el más típico.
Error de enlazado
Estos errores son más sutiles, ya que no tienen que ver con la sintaxis, sino con la manera en que está estructurado y compilado el programa. Son fáciles de reconocer cuando se utiliza gcc o g++, pues los mensajes de error correspondientes mencionan al ld (el linker).
Un error de enlazado puede ocurrir cuando se escribe el código de un programa utilizando varios ficheros. A continuación veremos este tipo de error:
Supongamos que nuestro programa está escrito utilizando 3 archivos: a.h, a.c, y main.c. El encabezado a.h está incluido en los dos archivos fuente main.c y a.c. el archivo main.c contiene la función main().
Si compilamos únicamente a.c (el archivo que no contiene la función main) es necesario indicarle al compilador (opción –c en gcc). De lo contrario, no sabrá cómo crear un ejecutable, ya que no hay punto de partida. Es por ello que el archivo que contiene la función main (sin opción –c) y los otros archivos fuente se compilan de manera diferente. En este caso:
gcc -W -Wall -c a.c gcc -W -Wall -o plop.exe main.c a.o
-
Las opciones –W y –Wall permiten mostrar mas mensajes de warning.
-
El primer comando construye a.o a partir de a.c.
-
El segundo genera el binario asociado a main.c, lo une con a.o, y produce así un ejecutable (plop.exe).
Podemos observar que si el programa contiene un error en a.c, el compilador producirá un error al momento de compilar a.c. y esto provocará errores en cascada en todos los archivos. Por ello, cuando un programa no compila se comienza por los primeros mensajes de error, los solucionamos, lo volvemos a compilar, etc. Y así hasta que todos los errores sean solucionados.
Recordemos que normalmente declaramos la función en el encabezado (por ejemplo, a.h):
void plop();
y que lo implementamos en el archivo fuente (por ejemplo, a.c):
#include "a.h" #include <stdio.h> void plop(){ printf("plop !\n"); }
Ahora supongamos que implementamos la función plop() en a.h, es decir, que la función no está declarada en a.h. En otras palabras, el archivo a.h contiene:
#include <stdio.h> void plop(){ printf("plop !\n"); }
y el fichero a.c contiene por ejemplo:
#include "a.h" void f(){ plop(); }
El fichero a.h es incluido por main.c y a.c. De este modo, el contenido de a.h es copiado en a.c y en main.c. Así, cada uno de estos dos archivos fuentes dispondrán de una implementación de la función plop(), pero el compilador no sabrá cuál tomar y generará un error de multi definición en el momento del enlazado:
(mando@aldur) (~) $ gcc -W -Wall main.c a.o a.o: In function `plop': a.c:(.text+0x0): multiple definition of `plop' /tmp/ccmRKAvQ.o:main.c:(.text+0x0): first defined here collect2: ld returned 1 exit status
Esto muestra por qué en general la implementación de una función se debe hacer en un archivo fuente (.c o .cpp) y no en un encabezado (.h y .hpp). Solo hay dos excepciones a esta regla en C+: las funciones inline y las funciones template (o los métodos de una clase template).
Programación modular: multi-definición, bucles de inclusión, etc
Supongamos que tenemos 3 archivos: main.c y los archivos a.h y b.h y supongamos que a.h y b.h se incluyen mutuamente. Esto es indefinido y aquí el preprocesador necesita nuestra ayuda, pues vamos a poder evitar que los #include no se hagan indefinidamente poniendo a.h:
#ifndef A_H #define A_H #include "b.h" /* El contenido del encabezado a.h */ #endif
Y en b.h:
#ifndef B_H #define B_H #include "a.h" /* El contenido del header b.h */ #endif
¿Qué es lo que pasará? El compilador comenzará con un archivo (por ejemplo, a.h). Como en ese momento A_H no está definido, éste avanza, luego define a.h y continua leyendo el encabezado a.h e incluyendo b.h. En ese momento B_H no está definido. Por lo tanto, de la misma manera entramos en el encabezado b.h y activamos A_H.
Ahora leemos el contenido de b.h(que pretende incluir a.h). Entramos nuevamente en a.h, pero esta vez A_H está definido, por lo que ignoramos el contenido del encabezado. Terminamos de leer el encabezado b.h, lo que resuelve el #include "b.h" que estábamos tratando en a.h. Después terminamos el encabezado b.h.
Este caso puede parecer extraño, pero hay que tener en cuenta que cuando un encabezado está incluido varias veces, pueden aparecer multi definiciones (en particular si son declaradas estructuras en el encabezado). Por ello debemos poner sistemáticamente este mecanismo de cerrojo en todos los encabezados.
Error: Función declarada pero no encontrada
Si una función es declarada, utilizada, pero no implementada, también se producirá un error de enlazado. Esto ocurre típicamente en dos casos:
-
Se ha declarado una función pero aún no se la ha implementado.
-
Se desea utilizar una función de una librería, se han incluido los encabezados correspondientes, pero se han olvidado pasar como parámetro del compilador dichas librerías.
¿Cuál es la compilación makefile?
Cuando no se dispone de un entorno de desarrollo, y a fin de evitar teclear manualmente un “gcc” por fichero fuente, hacemos un fichero “makefile” que se ocupa de describir cómo construir el ejecutable.
En la práctica es buena idea escribir un archivo además de nuestros ficheros C/C+ para que el código sea fácil de compilar. Si este fichero está escrito correctamente, tan solo hay que ejecutar makefile para construir íntegramente el programa. En Linux escribimos simplemente el comando:
make