1.4.4 Enlazado
"This is the concept behind all programming: that a suitably general computing machine can emulate any specific ones. A computer program is nothing more than a means of turning a general-purpose computing engine into a special-purpose one". David Chisnall: "A Brief History of Programming".
§1 Sinopsis
El proceso de crear un ejecutable comienza por la compilación de varias unidades de compilación ( 1.4.2) independientes. Los ficheros objeto resultantes, junto con librerías ya existentes y algún trozo de código añadido, son después agrupados de forma ordenada en un solo fichero que constituye el ejecutable.
Este proceso de agrupación y ordenación es realizado por un programa especial, el enlazador ("Linker"), cuyo nombre evoca una de sus principales funciones, el enlazado ("Binding"). Este proceso es responsable de que, en el ejecutable, cada instancia de un identificador sea asociada correctamente con una función u objeto particular. A efectos prácticos esto significa que la mayoría de los identificadores utilizados por el programador desaparecen y son sustituidos por direcciones concretas de memoria donde se encuentran los objetos referenciados.
Destacar que el enlazado es un proceso independiente del lenguaje de programación utilizado. Es decir, no es específico de C++. Para obtener un ejecutable, el linker puede partir de una serie de objetos que se han obtenido de la compilación de distintos fuentes, incluso escritos originalmente en lenguajes distintos. La única condición exigida es que tales ficheros-objeto, producidos por un compilador o por un macro-ensamblador, sean del formato adecuado.
§1.1 De forma sinóptica el proceso responde al siguiente esquema:
En el código fuente:
...
func1(); // invocación de una función
...
El compilador traslada la sentencia anterior a una instrucción del siguiente aspecto:
call func1
El enlazador sustituye el nemónico func1 por una dirección concreta, donde comienza el código correspondiente a la función. Algo parecido a:
call 0x4000000
Sin embargo, aunque la función primordial del enlazador es resolver todas las referencias que puedan existir en el
programa y agrupar todos los módulos en un solo fichero, que pueda ser cargado y ejecutado por el Sistema Operativo, realiza
además otras funciones. Por ejemplo, insertar en el fichero resultante el código del módulo inicial, que es el encargado de
iniciar la ejecución (
1.5).
Nota: en el caso concreto de Borland C++ el "Linker" es el programa ILINK32.EXE. Pero es bastante raro que sea ejecutado explícitamente durante el proceso de construcción de un ejecutable. Lo normal es que sea invocado indirectamente, a través del programa supervisor ( 1.4.0). En concreto, el compilador BCC32.EXE invoca al enlazador incluso cuando no hay nada que compilar. Por ejemplo, el comando:
BCC32 mainfile.obj sub1.obj mylib.lib
conduce al enlazado de los ficheros MAINFILE.OBJ, SUB1.OBJ y MYLIB.LIB para producir el ejecutable MAINFILE.EXE. Para ello, no solo invoca al enlazador, con los argumentos adecuados, también incluye por su cuenta el módulo de inicio C0W32.OBJ; la librería CW32.LIB y la librería de importación IMPORT32.LIB. Además, le pasa la opción /c (distinguir mayúsculas y minúsculas).
Por su parte, el enlazador GNU es el programa ld.exe (suponemos la versión MinGW para Windows) y para la construcción de proyectos C++, generalmente suele ser invocado por el compilador (g++.exe). Por ejemplo, para enlazar los ficheros del ejemplo anterior en un solo ejecutable main.exe, se utilizaría el siguiente comando:
g++ mainfile.o sub1.o mylib.lib -o "main.exe"
§2 Propiedades del enlazado
Respecto al enlazado existen dos aspectos a tener en cuenta: uno de ellos tiene que ver con una especie de "ámbito" de nombres dentro del ejecutable. El otro se refiere a "cómo" se enlaza un identificador con el objeto al que representa.
§2.1 Enlazado estático
Empezaremos por la segunda de las consideraciones: Hemos dicho que durante el enlazado, el "Linker" asocia cada identificador con el objeto correspondiente. Lo que equivale a decir que asocia el identificador con la dirección del objeto. Esto puede efectuarse en tiempo de compilación o en runtime. Cuando esto puede quedar completamente definido en tiempo de compilación, se dice que se trata de un enlazado previo o estático ("Early binding"). Así pues, enlazado estático significa que cuando, por ejemplo, el compilador genera una llamada a una función determinada, el enlazador puede resolverla mediante la dirección absoluta del código que debe ejecutarse. En el caso del esquema anterior , el enlazador pudo determinar que func1 comienza en la dirección 0x4000000. Puede ser el caso de una función sobrecargada en la que el compilador puede saber que instancia corresponde a una invocación por el análisis de los argumentos utilizados.
Para la resolución antes descrita, cada vez que el enlazador encuentra el
identificador de una variable o de una función, debe buscar en la unidad de
compilación que esté utilizando y en caso de no encontrarlo en ella, en el
resto de módulos .obj; en los ficheros de recursos .res, y en las
librerías .lib que hemos ordenado enlazar juntos. Si
finalmente la variable, función o recurso no aparece, el enlazador lanza un mensaje de
error: unresolved external reference...
A efectos prácticos, lo anterior supone que código responsable de la funcionalidad del programa, se encuentran en el propio ejecutable, lo que presenta la ventaja de que la aplicación no depende de la existencia de módulos exteriores. Sin embargo, esto es rarísimo en la programación actual. Sobre todo porque en los entorno multiusuario, las aplicaciones no pueden controlar directamente los dispositivos del Sistema, sino que debe hacerlo mediante llamadas a determinados servicios situados fuera del ejecutable (lo contrario, además de ser imposible, exigiría que cada ejecutable englobara gran parte del propio SO).
§2.2 Enlazado dinámico
En ocasiones las cosas no suceden como se han descrito en el párrafo anterior. Hay veces en que hasta el momento de la ejecución, el programa no puede (o no quiere) determinar la dirección de la función que se invoca. Esta situación se presenta típicamente cuando se usan las denominadas librerías dinámicas ( 1.4.4b) y en la POO, cuando se programan operaciones genéricas con objetos sin saber que objeto concreto (instancia de la clase) la utilizará en su momento. Es decir, se utilizan clases polimórficas ( 4.11.8).
Para comprender el proceso, supongamos una variación del esquema anterior , en el que la función invocada no se encuentra en un módulo objeto, sino en una librería dinámica (DLL) que solo será cargada en el momento de la ejecución del programa o incluso más tarde; no cuando este arranca, sino cuando se realiza la invocación a la función. En estas condiciones, el enlazador no puede conocer la dirección de func1. La solución adoptada es precisamente el enlazado o dinámico ("Late binding").
En este tipo de enlazado, parte del código necesitado por la aplicación se encuentra en ficheros distintos del propio ejecutable. Por ejemplo, en las conocidas librerías .DLL. El inconveniente es que la ejecución exige la presencia de todos los módulos externos (que pueden estar efectivamente presentes o no). La ventaja es que determinadas habilidades, sobre todo las del propio Sistema Operativo, y otras muy comunes, no necesitan estar duplicadas en cada ejecutable, con la consiguiente economía de espacio. Los ejecutables resultan así más pequeños. Además, múltiples programas pueden compartir los mismos módulos.
Como ejemplo ilustrativo de lo anterior, podemos citar la utilidad Linux que permite encadenar (link) ficheros y directorios. Esta utilidad está disponible en dos versiones: La primera sln, utiliza enlazado estático; la segunda ln, enlazado dinámico. En mi sistema (SuSE 9.0), el tamaño de ambos ejecutables es el siguiente:
/sbin/sln 429308 Bytes
/bin/ln 22864 Bytes
Linux dispone incluso de la utilidad ldd que permite conocer el tipo de enlazado que tiene un
ejecutable y, en su caso, de qué módulos depende. En mi
sistema obtengo el siguiente resultado para el encadenador dinámico [3]:
# ldd -v /bin/ln
libc.so.6 => /lib/i686/libc.so.6 (0x40028000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
Version information:
/bin/ln:
libc.so.6 (GLIBC_2.3) => /lib/i686/libc.so.6
libc.so.6 (GLIBC_2.1.3) => /lib/i686/libc.so.6
libc.so.6 (GLIBC_2.1) => /lib/i686/libc.so.6
libc.so.6 (GLIBC_2.0) => /lib/i686/libc.so.6
libc.so.6 (GLIBC_2.2) => /lib/i686/libc.so.6
/lib/i686/libc.so.6:
ld-linux.so.2 (GLIBC_2.1) => /lib/ld-linux.so.2
ld-linux.so.2 (GLIBC_2.0) => /lib/ld-linux.so.2
ld-linux.so.2 (GLIBC_PRIVATE) =>
/lib/ld-linux.so.2
§2.2.1 Librerías de enlazado dinámico
Cuando los recursos a utilizar se encuentran en librerías de enlazado dinámico y en consecuencia, su ubicación exacta no puede ser establecida en tiempo de enlazado, se recurre a un artificio algo más tortuoso que en el caso de enlazado estático. En principio, la información sobre la localización de las funciones externas, están contenidas en las denominadas librerías de importación ( 1.4.4b2c), que tienen la misma terminación .lib/.a que las librerías normales, aunque en realidad, no contienen código, sino registros con el nombre del módulo que contiene la función (fichero .dll), así como el nombre o número del punto de entrada a la función dentro del fichero. El enlazador busca en estas librerías de importación de la misma forma que lo hace con el resto de ficheros .obj, .res y .lib que se compilan. Cuando el enlazador encuentra esta información la anota en la forma que se describe a continuación y da el asunto por resuelto sin original ningún error de enlazado.
Nota: las librerías de importación pueden contener datos sobre una o varias DLLs. En el caso de la programación C++ para Windows, esta situación es frecuentísima, ya que la mayor parte de propio Sistema Operativo está repartido en multitud de DLLs. En dicho Sistema, las librerías de importación más importantes estén en los ficheros kernel32.lib; user32.lib y gdi32.lib. Recordar también que entre las herramientas que acompañan al compilador C++ de Borland, está la utilidad implib.exe, que puede crear una librería de importación a partir de una DLL. El resto de compiladores suelen disponer de herramientas análogas.
Los compiladores C++ construyen en cada ejecutable una tabla de entradas que contiene la dirección de todas las funciones de dirección conocida, y además, para poder realizar el enlazado dinámico, con la información obtenida de las librerías de importación construyen otra tabla denominada Tabla de Direcciones Importadas o Itable ("Import Address Table"). Cada función cuya dirección no pueda ser conocida en tiempo de compilación, tiene un sitio en la tabla, en el que se inserta un pequeño trozo de código ("thunk") que más tarde, en tiempo de ejecución, podrá establecer la verdadera dirección.
La invocación de una función como func1 que esté situada en una librería de enlazado dinámico, da lugar a un "thunk" del siguiente aspecto:
jmp DWORD PTR __imp_func1
En este caso __imp_func1
es una dirección conocida: la del "slot" de func1
en la tabla de direcciones importadas del ejecutable. Para que todo funcione correctamente solo es preciso que el
cargador de la DLL, que utiliza la información obtenida de la librería de
importación, actualice dicha tabla en el momento de la carga.
§2.2.2 Funciones virtuales
También existen situaciones en las que es preciso utilizar enlazado dinámico aunque el ejecutable no utilice DLLs. En la POO se presentan situaciones en las que se programan operaciones (mediante funciones virtuales) cuya definición es distinta para distintos objetos. En estos casos, cuando se necesita que un objeto ejecute uno de sus métodos, el código que deberá llamarse no está determinado hasta el momento de la ejecución, porque depende del objeto que realiza la invocación.
Los compiladores pueden utilizar cualquier mecanismo para resolver el problema, pero el más común consiste en la utilización de tres elementos:
-
Para cada clase en la que existan métodos susceptibles de este comportamiento (métodos virtuales), se crea una tabla denominada vtable [2], de punteros con las direcciones de dichas funciones. La vtable realiza en este caso la misma función que la Itable cuando el programa utiliza enlazado dinámico debido a la existencia de librerías DLL.
-
En cada objeto que se instancia de estas clases, se incluye automáticamente un puntero oculto vptr a la vtable.
-
En cada invocación a un método de un objeto, se añade un pequeño trozo de código ("thunk"), que permite calcular la dirección de la función que debe utilizarse. Este código suele ser pequeño, contiene solo unas pocas instrucciones ensamblador, y utiliza la vtable y el vptr para seleccionar en tiempo de ejecución la función correspondiente. Cada objeto puede comportarse de forma distinta según este código especial. Así que cuando se le envía un mensaje, es este código el que calcula y decide que hacer con el mensaje (el proceso se describe con más detalle al tratar de las funciones virtuales 4.11.8a). Naturalmente esta versatilidad tiene su coste de eficacia, de forma que las funciones con enlazado dinámico son menos eficientes que las de enlazado estático.
En C++ el programador puede decidir en que funciones desea esta capacidad de enlazado
retrasado utilizando la palabra-clave virtual. Cada vez que en
una clase C se declara una función virtual, la case deviene en polimórfica
( 4.11.8)
o abstracta (
4.11.8c), lo que obliga al compilador a incluir en
la estructura de la clase un puntero vptr que señala a una estructura de
tipo matriz, la vtable, que contiene un "slot" o espacio
para un puntero-a-función por cada función virtual que se haya declarado. Si
C es a su vez derivada de una clase-base B, la vtable incluye espacio para las direcciones de cualquier función virtual que pudiera
existir en B y en cualquier clase antecesora de esta. A su vez,
obliga también a incluir una vtable en cualquier clase derivada de C.
Existe una sola vtable para cada clase y los datos (punteros-a-función)
contenidos en ella son estáticos, en el sentido de que no dependen de ninguna
instancia u objeto particular de la clase.
Como ejemplo, consideremos un caso de utilización de funciones virtuales en un supuesto de herencia simple (los casos de herencia múltiple requieren esquemas más complicados):
Fig. 1 |
class A {
int a:
virtual void foo1(int);
virtual void foo2(int);
virtual void foo3(int);
};
class B : public A {
int b;
void foo2(int);
};
class C : public B {
int c;
void foo3(int);
};
La figura adjunta muestra un esquema de lo que puede ser la zona contigua de memoria que contiene una instancia de C. Junto con el resto de miembros, privativos y heredados, se incluye el puntero oculto vptr que señala a la vtable. Observe que en realidad las funciones-miembro no forman parte del objeto-instancia, sino del objeto-clase ( 4.11.5).
No es necesario entender los mecanismos subyacentes para utilizarla, pero sin ella no es posible la programación orientada a objetos en C++ (recuérdese que en este sentido, C++ es un lenguaje mixto). Es preciso recordarlo porque por defecto las funciones miembro no tienen este tipo de enlazado dinámico. Son precisamente las funciones virtuales ( E4.11.8a) las que hacen posible diferentes comportamientos entre distintas clases de la misma familia; diferencias que a fin de cuentas definen el comportamiento polimórfico. Seguramente esta es la razón por la que mucha gente considera la palabra-clave virtual como la más importante y característica del lenguaje C++ frente al C.
Nota: en relación con este tema existe varios artículos: "ATL Under the Hood" de Zeeshan Amjad, publicados en The Code Project, que recomiendo vivamente si desea una visión más cercana de cómo está constituida la vtable. Aunque dedicados a la Active Template Library (ATL), los dos primeros artículos de la serie muestra de forma muy didáctica las interioridades de la tabla de funciones virtuales y cómo puede ser accedida.
§3 Atributos de enlazado
Todos los identificadores tienen alguno de los tres atributos de enlazado (estrechamente relacionados con su ámbito): externo, interno, o sin enlazado. Estos atributos son propios para cada identificador en cada unidad de compilación, y vienen determinados por la situación y el formato de la declaración en que se introdujo cada identificador ( 4.1.2) , así como del uso explícito o implícito (por defecto) de los especificadores de tipo de almacenamiento static ( 4.1.8c) y extern ( 4.1.8d).
El tipo de enlazado define una especie de ámbito, pues indica si el mismo nombre en otro ámbito se refiere al mismo
objeto (variable o función) o a otro distinto.
-
Cada instancia de un identificador con enlazado externo representa el mismo objeto o función a través del total de ficheros y librerías que componen el programa. Es el tipo de enlazado a utilizar con objetos cuyo identificador puede ser utilizado en unidades de compilación distinta de aquella en la que se ha definido. Por esta razón se dice que las etiquetas con enlazado externo son "globales" para el programa.
Recuerde: enlazado externo ↔ visibilidad global. -
Cada instancia de un identificador con enlazado interno representa el mismo objeto o función solo dentro del mismo fichero. Los objetos con el mismo nombre en otros ficheros son objetos distintos. Este tipo de objetos solo pueden utilizarse en la unidad de compilación en que se han definido, por lo que suele decirse que las etiquetas con enlazado interno son "locales" a sus unidades de compilación.
Recuerde: enlazado interno ↔ visibilidad de fichero. -
Las unidades sin enlazado representan entidades únicas. Por ejemplo, las variables declaradas dentro de un bloque, que no contengan el modificador extern, representan entidades únicas dentro del bloque, sin relación con nada en el exterior del mismo. Los objetos con el mismo nombre en otros bloques son objetos distintos. No obstante, es posible asignar punteros a este tipo de objetos sin enlazado, de forma que puedan ser accedidos desde cualquier punto del programa, incluso desde otras unidades de compilación.
Recuerde: sin enlazado ↔ visibilidad de bloque.
Como puede verse, el hecho que un identificador utilizado en diversas unidades
de compilación señale potencialmente a la misma entidad en todos los
módulos, depende exclusivamente del tipo de enlazado que tenga en cada uno de ellos.
§4 Reglas de enlazado
§4.1 En un fichero, cualquier objeto o identificador que tenga ámbito global deberá tener enlazado interno si su declaración contiene el especificador static.
§4.2 Si el mismo identificador aparece con ambos enlazados externo e interno, dentro del mismo fichero, tendrá enlazado externo.
§4.3 Si en la declaración de un objeto o función aparece el especificador de tipo de almacenamiento extern, el identificador tiene el mismo enlazado que cualquier declaración visible del identificador con ámbito global. Si no existiera tal declaración visible, el identificador tiene enlazado externo.
§4.4 Si una función es declarada sin especificador de tipo de almacenamiento, su enlazado es el que correspondería si se hubiese utilizado extern ( es decir, extern se supone por defecto en los prototipos de funciones).
§4.5 Si un objeto (que no sea una función) de ámbito global a un fichero es declarado sin especificar un tipo de almacenamiento, dicho identificador tendrá enlazado externo (ámbito de todo el programa). Como excepción, los objetos declarados const que no hayan sido declarados explícitamente extern tienen enlazado interno.
§4.6 Los identificadores que respondan a alguna de las condiciones que siguen tienen un atributo sin enlazado:
a.- Cualquier identificador distinto de un objeto o una función (por ejemplo, un identificador typedef ).
b.- Parámetros de funciones.
c.- Identificadores para objetos de ámbito de bloque, entre corchetes { } , que sean declarados sin el especificador de clase extern.
Ejemplo:
int x;
static st = 0;
void func(int);
int main() {
for (x = 0; x < 10; x++) func(x);
}
void func(int j) {
st += j;
cout << st << endl;
}
Comentario:
Las etiqueta x tiene enlazado externo debido a su situación en el código, fuera de cualquier bloque o función. Podría ser utilizada desde cualquier otro módulo que la declarase a su vez como extern (regla §4.5 ).
Las etiqueta func tiene igualmente enlazado externo debido a su situación en el código (regla §4.4 ).
La variable j, pertenece exclusivamente al ámbito de la función func, por lo que es sin enlazado (regla §4.6b ).
La variable st tiene enlazado interno. Debido al especificador static. solo es accesible desde dentro de su propio módulo ( 4.1.8c) (regla §4.1 ).
§5 extern
Es relativamente frecuente que determinados ficheros objeto C++ ( 1.4.2) sean incluidos como rutinas en otros lenguajes. También a la inversa (aunque menos frecuente), que un programa C++ pueda aceptar rutinas escritas en otros lenguajes o compiladas con otro compilador C++.
Ocurre que cada lenguaje, e incluso cada variedad de compilador dentro del mismo, utiliza sus propias convenciones de compilación. Por ejemplo, la forma en que son tratados y almacenados los tipos básicos ( 2.2.1); las alineaciones internas ( 4.5.9a); la forma en que se utilizan los registros del procesador para almacenar los datos; la disposición de los elementos situados en la pila (stack 1.3.2); las convenciones de llamada y paso de argumentos a funciones ( 4.4.6a) y muchos otros detalles. Para facilitar la interoperatividad, C++ dispone de una palabra clave específica, extern, aunque esta palabra tiene distintos significados. En unos casos es un especificador de tipo de almacenamiento ( 4.1.8d); en el uso que aquí nos ocupa es una directiva para el enlazador.
En concreto, existe una convención bien conocida, denominada "C", que no se refiere al lenguaje (que también la usa), sino a una forma concreta de compilación que puede ser utilizada por otros lenguajes como Fortran [1] e incluso por rutinas escritas en ensamblador. Por ejemplo:
extern int strcmp(const char* s1, const char*
s2); // L.1:
extern "C" int strcmp(const char* s1, const char* s2); // L.2:
En L.1 extern es un especificador de tipo de almacenamiento y su uso es redundante, pues hemos visto que se supone por defecto en todos los prototipos de funciones.
En L.2 extern "C" es una directiva de enlazado que indica al "linker" que utilice esta convención para el enlazado de la función. Por ejemplo, porque no se desea que los identificadores sean "planchados" ( 1.4.2).
La diretiva extern "C" puede también aplicarse a bloques completos (bloque de enlazado):
extern "C" {
void func1( int );
void func2( int );
void func3( int );
};
También puede usarse un bloque de enlazado para aplicar la directiva a todas las funciones incluidas en un fichero de cabecera:
extern "C" {
#include "locallib.h"
};
Nota: extern "C" no puede utilizarse con identificadores de clases ( 4.11.2a).
[1] Uno de los primeros lenguajes de programación de alto nivel y el primero de los que podrían denominarse "Procedurales". Su nombre es acrónimo de "Formula Translator", en atención que permite compilar fórmulas que siguen un álgebra matemática.
[2] vtable significa "Virtual table". Precisamente porque las funciones que pueden exhibir este comportamiento se denominan virtuales.
[3] Los indicativos .so. significan "Shared Object" (librerías dinámicas). Pero el fichero ld-linux.so.2 es un caso especial. Es un ejecutable estático (no depende de nadie más) cuya misión es precisamente la carga de las librerías dinámicas necesarias para ejecutar un programa. Este módulo lee determinada información de la cabecera del ejecutable donde se encuentra esta información. A continuación carga las librerías dinámicas necesarias, y realiza un mini-enlazado que establece todas las direcciones del ejecutable que se relacionan con las librerías cargadas, de forma que el ejecutable puede funcionar.