sábado, 22 de septiembre de 2012

Compilación y bibliotecas (parte 3): ese oscuro y mágico proceso

Centrándonos ahora en el proceso de compilación de un programa C++, hay que aclarar que es un proceso de varias etapas, y que no se realiza por un solo programa, sino por un conjunto. El compilador es en realidad un grupo de programas. En el proceso de convertir el código C++ en código objeto hay que preprocesar los fuentes, analizar la sintaxis y traducir a lenguaje ensamblador, convertir el código ensamblador en código de máquina dentro de un archivo llamado "objeto", aplicando optimizaciones, agregando información para el depurador, etc, y finalmente juntar todos los archivos objetos y, si las piezas encajan, empaquetar todo en un archivo ejecutable final. La parte del preproceso en C/C++ es la que analiza las lineas que empiezan con # (como los #define, #ifdefs, #includes) y otras tareas similares (quitar comentarios y expandir macros por ejemplo). Luego viene la etapa más importante, que sería la verdadera compilación, que es donde se traduce el código fuente a código de máquina. Pero en un proyecto real, el código fuente no es un sólo gran archivo, sino que está distribuido en muchas partes, y se debe traducir cada uno de ellas. Luego, juntando todas esas traducciones, se confecciona el ejecutable. Este último paso se conoce como enlazado. En general, a fines prácticos, al programador sólo le interesa diferenciar entre la etapa de compilación (incluyendo aquí en un solo bloque preproceso, compilación y ensamblado), y la etapa de enlazado.

Cada compilación de un archivo .cpp es un paso independiente, que de resultar exitoso genera un archivo objeto .o. Estos son los que se unen para formar el ejecutable final. Cuando un archivo .cpp se modifica, sólo hay que recompilar ese archivo y volver a enlazar para obtener el nuevo ejecutable. Si el archivo modificado era un .h, habrá que recompilar todos los cpp que incluyan (#include) directa o indirectamente a ese .h. En general, el IDE (o make/cmake/scons/ants/algo de eso) se encarga de determinar qué hay que recompilar en cada cambio, comparando las fechas de los fuentes y los objetos, de forma que mientras cambiamos las cosas no tengamos que reconstruir de cero todo el proyecto.

Y llegamos finalmente a las bibliotecas. Empecemos por decir que una biblioteca es un conjunto de clases, macros, funciones, datos, algo de código que pensamos reutilizar. Un programador aplicado, cuando escribe alguna clase o función por ejemplo, intenta escribirla de forma que sea más o menos genérica y pueda reutilizarse en situaciones parecidas, para que la próxima vez que necesite resolver el mismo problema pueda buscar ese código y reusarlo. Si analizamos el proceso de compilación descripto antes, el exe surge de la suma de los archivos objeto. Por esto, si uno dispone del archivo objeto de la biblioteca, puede utilizare eso como entrada para el enlazador, y entonces no necesita el código fuente. Pero esto no es tan así. Supongamos que mi biblioteca tiene una función que busca el mayor dato de una lista. Supongamos que mi programa (llamado cliente) quiere utilizar la biblioteca para buscar la mejor nota de un curso. Para generar el ejecutable necesito las versiones compiladas (objetos) de la biblioteca y del programa cliente. Pero para que el compilador pueda generar el objeto del programa cliente, tiene que asegurarse de que el código fuente de dicho programa sea válido, y para eso necesita, por ejemplo, verificar que la cantidad y los tipos de los argumentos con los que el programa llama a la función de la biblioteca sean correctos. Por esto, el fuente del programa cliente debe incluir los archivos de cabecera de la biblioteca (los .h, los que dicen qué tiene la biblioteca, pero no cómo hace lo que hace). Entonces, para usar la biblioteca necesitamos las cabeceras (.h) y los objetos. Solo si no tenemos los objetos, necesitaremos los fuentes para generarlos.


Pero todavía falta algo más. Los archivos objeto de la biblioteca pueden funcionar de dos formas: el enlazado con el programa cliente puede realizarse una sola vez al compilar, de forma de obtener realmente un ejecutable con todo lo necesario para correr; o bien puede realizarlo el sistema operativo cada vez que queremos correr el programa. El primer caso se conoce como enlazado estático, mientras que el segundo como enlazado dinámico. Un ejecutable enlazado estáticamente tiene dentro todo lo necesario para ejecutarse; mientras que uno enlazado dinámicamente, en realidad lo que hace es ir a buscar las partes que le faltan a otros archivos cada vez que se lo intenta ejecutar. Esas partes son los famosos archivos .dll en Windows, o los .so en GNU/Linux. La ventaja del enlazado dinámico es que el ejecutable es más pequeño (obviamente porque le falta algo todavía), y que si muchos programas utilizan una misma biblioteca (código común), la versión compilada de esta biblioteca puede estar solo una vez en el sistema para que todos los ejecutables que la requieran la compartan (y por ejemplo, si actualizo la biblioteca, cambiando un archivo afecto a todos los ejecutables). Cuando las bibliotecas se utilizan de forma dinámica, en el proceso de enlazado de la compilación lo que se hace es colocar en el ejecutable el código y la información necesaria para que el programa busque (mediante la ayuda del sistema operativo) las partes que le faltan. En Windows, por ejemplo, los archivos .dll se buscan en la propia carpeta del ejecutable y donde indique la variable PATH, que suele incluir la carpeta windows/system32 por ejemplo. En GNU/Linux, lo determina la variable LD_LIBRARY_PATH primero y algunas prefijadas luego (como /lib, /usr/lib y /usr/local/lib, o sus variantes lib64).

(click en la imágen para ampliarla)

Entonces, para compilar un programa que utilice una cierta biblioteca, se necesitan los archivos de cabecera y los archivos objeto de la misma. Pero para correr un programa compilado con esa biblioteca puede que no se necesite nada (enlazado estático), o que se necesiten solo los binarios de la biblioteca (enlazado dinámico). Por eso, en muchas distribuciones de GNU/Linux cada biblioteca aparece en el gestor de paquetes con una versión común y una versión de desarrollo (dev/devel). La primera alcanza para ejecutar programas que la utilizan dinámicamente, mientras que la segunda incluye además los .h para compilar un nuevo programa cliente.

De lo expuesto se desprende que a la hora de compilar un programa para utilizar una biblioteca hay que decirle muchas cosas al compilador. Hay que decirle dónde están los archivos .h, cuáles son, dónde están los objetos, cuáles son, en qué orden debe enlazarlos, si elegimos enlazar dinámica o estáticamente, cómo compilar las llamadas para que las piezas encajen, etc. En el próximo post de la serie voy a explicar cómo se dice todo esto en un proyecto de ZinjaI, con algunos ejemplos, y los posibles errores típicos asociados a cada paso.

Este post es continuación de Compilación y bibliotecas (parte 2): Intérpretes vs Compiladores y sigue en Compilación y bibliotecas (parte 4): utilizar bibliotecas desde Zinja

1 comentario: