Compilación en C: desde un fuente hasta un binario ejecutable

Publicado por Diego Córdoba en

En este artículo vamos a introducir al detalle el proceso de compilación completo, desde que escribimos un archivo en código fuente, hasta que obtenemos un binario ejecutable, en lenguaje C. Los comandos y utilidades mostradas corresponden a sistemas GNU/Linux, pero el proceso es igual para cualquier sistema operativo y compilador.


¿Qué es la compilación en lenguaje C?

El proceso de compilación es, grosso modo, el de convertir uno o varios archivos de código fuente, en código binario ejecutable para una arquitectura de hardware/software determinada.

Y digo «grosso modo«, porque involucra varias etapas, particularmente en lenguaje C, que «convierten» un código fuente en uno ejecutable.

Primero y principal, definamos código fuente. El código fuente es el programa que escribimos nosotros como programadores, el texto plano que le «dice» a la computadora cómo hacer las cosas.

Por otro lado, el código binario ejecutable, en general para cualquier lenguaje compilado, y en particular para lenguaje C, es código binario (no texto) que a su vez puede ser ejecutado en la computadora. Aclaro esto, porque uno de los productos intermedios del proceso de compilación es el código objeto, que si bien es binario, no puede ser ejecutado y debe continuar su proceso de compilación a la siguiente etapa, el enlace, o link.

 lib libreria library biblioteca codigo code c gcc libc compilacion

Un ejemplo simple

Supongamos que tenemos el siguiente código fuente… el clásico «Hola Mundo»:

/*
* File: holamundo.c
* Mi primer "Hola Mundo" en Lenguaje C
* juncotic.com
*/

#include<stdio.h>

int main(int argc, const char *argv[]){
    printf("Hola mundo\n");
    return 0;
}

Una compilación simple sería, en sistemas GNU/Linux, la siguiente:

gcc holamundo.c -o holamundo

Que nos generará un archivo binario llamado holamundo, y cuya descripción será similar a la siguiente

diego@cryptos:/tmp$ file holamundo
holamundo: ELF 64-bit LSB pie executable x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=141789414df18ece4fa044cf91f5e776bf75f959, not stripped
diego@cryptos:/tmp$

Esto significa que el archivo holamundo de salida (output, por eso el «-o») es de tipo ELF, el formato ejecutable de GNU/Linux (similar al exe de windows).

Podremos ejecutarlo sin inconvenientes:

diego@cryptos:/tmp$ ./holamundo
Hola mundo

Compilando paso a paso

Analicemos ahora el proceso de compilación paso a paso, qué es lo que hace internamente un compilador de lenguaje C.

Preprocesamiento

Lo primero que hace el compilador es preprocesar el archivo fuente, esto es, interpretar todas las directivas de pre-procesamiento que hayamos utilizado, como #define, #include, #ifdef, etc… y además, eliminará todos los comentarios que hayamos escrito en el archivo.

En el caso particular de nuestro holamundo, incluirá el archivo stdio.h (cabecera de entrada/salida estándar), y eliminará los comentarios.

Preprocesemos nuestro ejemplo:

gcc -E holamundo.c -o holamundo.i

El modificador «-E » permite especificarle al compilador (gcc) que solo preprocese, y que la salida sea escrita en el archivo holamundo.i. La extensión .i es generalmente utilizada para archivos preprocesados.

Ahora, holamundo.i sigue siendo código fuente, pero si vemos su contenido encontraremos algo similar a esto:

[....]
extern int pclose (FILE *__stream);

extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__));
# 840 "/usr/include/stdio.h" 3 4
extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));

extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;

extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 868 "/usr/include/stdio.h" 3 4

# 8 "holamundo.c" 2

# 9 "holamundo.c"
int main(int argc, const char *argv[]){

printf("Hola mundo\n");

return 0;
}

En la parte superior tenemos más texto, producto de evaluar stdio.h, puesto que todo esto es generado al interpretar la directiva #include<stdio.h>. Si hubiéramos tenido más de un #include acá veríamos una combinación de muchas líneas de código.

Al final de este archivo está el código conocido por nosotros, nuestro «Hola Mundo«, por supuesto, ya sin comentarios.

Compilación

El siguiente paso es compilar nuestro código. el resultado de la compilación es un código binario NO ejecutable, llamado código objeto, cuya extensión característica es un archivo «.o».

Vamos a compilar:

gcc -c holamundo.i -o holamundo.o

Y si vemos el tipo de archivo, éste será un archivo binario ELF, pero no ejecutable, como sí lo era el anterior.

diego@cryptos:/tmp$ file holamundo.o
holamundo.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
diego@cryptos:/tmp$

Enlace

El siguiente paso para lograr que este código objeto se vuelva ejecutable, es el de enlazar, o «linkear» el objeto con las librerías del sistema, las librerías que utiliza.

En este caso todas las funciones incluidas en la cabecera stdio.h pertenecen a la librería estándar de C, por lo que solamente deberemos extraer de la misma las funciones que queremos enlazar con nuestro objeto, y luego enlazarlo.

Podemos crear nuestro archivo de librería de la siguiente forma:

ar -cvr libholamundo.a holamundo.o

El archivo libholamundo.a contendrá las funciones necesarias que debemos enlazar con nuestro holamundo.o para poder crear un ejecutable.

Ahora, solo bastará enlazar nuestro objeto con dicho archivo de librería. Por cierto, es un archivo «.a», que viene del inglés «Archive«, y es una librería de enlace estático, en contraposición con las librerías de enlace dinámico, que en sistemas GNU/Linux se llaman «.so» de «Shared Object«, y vienen a ser el equivalente a las «.DLL» de Windows.

gcc -Wall holamundo.o -L/tmp/ -lholamundo -o holamundo

Aquí hemos enlazado el archivo holamundo.o con la librería libholamundo.a y hemos generado el archivo holamundo. ·l modificador «-L » indica la ruta donde el compilador debe buscar las librerías, mientras que «-l » indica la librería particular que queremos enlazar al objeto, puesto que podemos tener varias.

Si ahora ejecutamos «file holamundo» veremos una salida similar a la primera, un ELF ejecutable.

Otro ejemplo

Veamos ahora un ejemplo adicional, donde podremos aprender qué es una librería creando nuestra propia librería de funciones.

Vamos a programar nuestra calculadora. Contendrá 4 archivos, uno llamado calculadora.c, con el código, otro, calculadora.h, con las funciones de suma y resta (bien básica la calculadora 😀 ), y por último, las dos archivos, suma.c y resta.c, que contendrán los códigos de implementación de dichas funciones.

La única diferencia con una calculadora simple escrita en lenguaje C, es que haremos uso de nuestra propia librería para el enlace durante la compilación.

Creando la librería

Lo primero que debemos hacer, es crear nuestra propia librería estática, con las funciones que utilizaremos, a saber, suma() y resta().

Para ello, crearemos los dos archivos en cuestión, supongamos, dentro de /tmp/libcalculadora:

suma.c

/*
* FILE: suma.c
* juncotic.com
*/

int suma(int n1, int n2){
    return n1+n2;
}

resta.c

/*
* FILE: resta.c
* juncotic.com
*/

int resta(int n1, int n2){
    return n1-n2;
}

Ahora, compilaremos los archivos suma.c y resta.c para obtener los objetos suma.o y resta.o

gcc -c suma.c
gcc -c resta.c

Una vez que tenemos los objetos de las funciones suma y resta, procedemos a crear la librería que las contenga, nuestro propio archivo «.a».

ar -cvr libcalculadora.a suma.o resta.o

Este comando creará una librería estática llamada «libcalculadora.a» que contendrá las implementaciones de las funciones suma() y resta().

Si listamos el contenido de las funciones almacenadas en la librería, podremos verlo:

diego@cryptos:/tmp/libcalculadora$ ar -t libcalculadora.a
suma.o
resta.o

Listo, ya tenemos nuestra librería estática!

 lib libreria library biblioteca codigo code c gcc libc compilacion

Creando la calculadora

Ahora vamos a crear nuestra calculadora! Vamos a hacerlo, por simplicidad, en otro directorio, por ejemplo, /tmp/calculadora.

Para ello vamos a necesitar 2 archivos, calculadora.c, que contendrá la función main(), y calculadora.h, que contendrá las cabeceras y definiciones de tipos de dato.

Los archivos bien podrían ser estos:

calculadora.h

Este archivo contiene las cabeceras necesarias para mostrar mensajes por pantalla, y por supuesto, los prototipos de las funciones suma y resta:

/*
* FILE: calculadora.h
* juncotic.com
*/

#ifndef CALCULADORA_H

#define CALCULADORA_H
#include<stdio.h>
#include<stdlib.h>
int suma(int,int);
int resta(int,int);

#endif

Y finalmente, el archivo calculadora.c, con el código de la función main(). Como puede verse, es muy simple, una de las muchas formas de hacerlo, y cabe aclarar, sin ningún tipo de control ni validación de entrada, solamente lo necesario para los fines de este ejemplo.

calculadora.c

/*
* FILE: calculadora.c
* juncotic.com
*/
#include "calculadora.h"

int main(int argc, const char *argv[]){
    int (*ptr_fc)(int,int);
    int n1 = atoi(*(argv+1));
    int n2 = atoi(*(argv+3));
    char op = *(*(argv+2));

    switch(op){
    case '+':
        ptr_fc = suma;
        break;
    case '-':
        ptr_fc = resta;
        break;
    default:
        printf("Opción incorrecta!!\n");
    }

    printf(">>> %d %c %d = %d\n", n1, op, n2, (*ptr_fc)(n1,n2));
    return 0;
}

Listo, este es el código de nuestra calculadora… y se preguntarán: ¿y las implementaciones de las funciones suma() y resta()? ¿solo es necesario el prototipo?

Pues si, solo los prototipos, y las implementaciones las tomaremos desde nuestra biblioteca estática previamente creada.

Si, por ejemplo, intentamos compilar el código directamente, obtendremos un error, puesto que las implementaciones de suma y resta no se encuentran a la hora de enlazar:

 lib libreria library biblioteca codigo code c gcc libc compilacionEsas implementaciones estaban en la librería previamente creada, por lo que vamos a compilar indicándole a gcc dónde buscar las librerías, y cuál librería utilizar:

gcc calculadora.c -L/tmp/libcalculadora -lcalculadora

En este caso, el archivo «.a» lo teníamos dentro de /tmp/libcalculadora/, y se llamaba «libcalculadora.a»… el nombre de la librería es entonces, «calculadora». La ruta se especifica con «-L «, mientras que la librería particular con «-l «.

Ahora sí tendremos la compilación correcta, y podremos ejecutar nuestro código!!

 lib libreria library biblioteca codigo code c gcc libc compilacion

Como se ve, hemos podido compilar nuestro código y luego enlazarlo con nuestra biblioteca estática previamente creada.

Video complementario

He publicado un video en el canal de youtube de @JuncoTIC para intentar aclarar estos temas… espero les resulte interesante, y se sumen!

Conclusiones

Antes que nada, te recuerdo que puedes descargar estos códigos desde nuestro repositorio GIT en Internet: https://gitlab.com/d1cor/juncotic

Si tuviéramos una aplicación grande, es buena idea crear librerías para grupos de funciones que pueden ser utilizadas en varios sistemas, y en el resto de los sistemas podríamos simplemente incluirlas y utilizarlas, sin necesidad de que sean parte del código de nuestra aplicación.

¿Qué ventajas tiene esto? Pues que podemos actualizar la librería individualmente, y los cambios se verán reflejados en el resto de las aplicaciones que la utilicen inmediatamente luego de la re-compilación de las mismas, sin necesidad de implementar los cambios en cada una de las aplicaciones.

En este código se puede ver también la diferencia entre un archivo de cabecera y una librería o biblioteca de código, una consulta que me hacen cada año en mis clases en la Universidad, y que generalmente viene acompañada de una gran confusión. Un archivo de cabecera, o header, es un archivo de texto con prototipos de funciones, definiciones de tipos de datos, macros #define, etc. Un archivo de librería o biblioteca es binario compilado, un archivo «.a» o «.so», que sirve para enlazar con una aplicación final, pero que NO NECESARIAMENTE hemos escrito nosotros.

Por ejemplo, cuando creamos un código C simple, y utilizamos funciones como printf(), estamos haciendo uso de la biblioteca estándar de C, una biblioteca de enlace dinámico que ya tenemos instalada en el sistema. Nosotros en nuestro código incluimos las cabeceras necesarias, como stdio.h, que tienen los prototipos y definiciones de tipos de datos de funciones como printf(), pero la implementación de dicha función NO ESTÁ AHÍ, está en la biblioteca estándar de C, por ejemplo, en /usr/lib/libc-2.27.so.

Entonces, es importante distinguir las dos cosas, ya que podemos escribir archivos de cabecera .h que eso sea escribir una librería, y por otro lado, como en el ejemplo visto, podemos escribir archivos de librería sin siquiera incluir un archivo de cabecera.

¡Espero que les haya sido de utilidad el contenido!


¿Preguntas? ¿Comentarios?

Si tenés dudas, o querés dejarnos tus comentarios y consultas, sumate al grupo de Telegram de la comunidad JuncoTIC!
¡Te esperamos!


Diego Córdoba

- Ingeniero en Informática - Mg. Teleinformática - Tesis pendiente - Docente universitario - Investigador