Cuando escribes código en C++, lo que ves en tu editor es solo una representación textual que el procesador no entiende. Para convertir ese texto en un programa ejecutable, el código debe atravesar un proceso de transformación dividido en cuatro etapas: el preprocesado, la compilación, el ensamblado y el enlazado. Este flujo existe para separar las tareas: un programa que procesa texto no es lo mismo que un programa que analiza la lógica de tipos, ni lo mismo que un generador de código de máquina.
El preprocesador es el primer paso y trabaja puramente con texto. Su trabajo es “limpiar” el código antes de que el compilador lo vea, expandiendo las directivas que empiezan por #. Si usas #include, el preprocesador copia el contenido de ese archivo directamente en tu archivo; si usas #define, sustituye cada ocurrencia de la macro por su valor. El resultado es un archivo de código fuente “expandido” (típicamente con extensión .i).
Después, entra en juego el compilador. Este componente es el cerebro semántico: lee el código expandido, verifica que la sintaxis sea correcta y que los tipos de datos sean coherentes (por ejemplo, que no intentes sumar un std::string con un int sin una sobrecarga definida). Su función principal no es generar código de máquina directamente, sino traducir el C++ a lenguaje ensamblador (un lenguaje de bajo nivel, pero todavía legible para humanos), generando un archivo .s.
El ensamblador toma ese ensamblador y lo traduce a código binario puro, creando lo que llamamos un archivo objeto (.o). En este punto, el archivo ya es binario, pero es “incompleto”: si tu función llama a otra función que está en otro archivo, el archivo objeto solo deja una “nota” (una referencia) diciendo que esa función existe pero que no sabe dónde está su dirección de memoria.
Finalmente, el enlazador (linker) actúa como el pegamento. Toma todos los archivos .o y las bibliotecas del sistema, resuelve todas esas “notas” de funciones externas y une las piezas en un único archivo ejecutable. Si en el proceso no encuentra la definición de una función que habías prometido que existiría, el proceso fallará.
Para entender este ciclo, fíjate en cómo se puede ver cada etapa usando el compilador g++:
* g++ -E main.cpp: Muestra el resultado del preprocesador (el código con todos los #include expandidos).
* g++ -S main.cpp: Genera el archivo ensamblador .s.
* g++ -c main.cpp: Crea el archivo objeto .o.
// ejemplo.cpp
// Compilable con: g++ -std=c++20 -Wall -Wextra -pedantic -o ejemplo ejemplo.cpp
#include <iostream>
// Definición de una macro (el preprocesador la sustituirá por el valor real)
#define VERSION_APP "v1.0.0"
// Declaración de una función: le decimos al compilador que 'saludar' existe,
// pero no le decimos cómo funciona. Esto es una "promesa".
void saludar();
// Variable global inicializada: irá a la sección .data del archivo objeto.
int contador_visitas = 10;
// Variable global no inicializada: irá a la sección .bss.
int registros_error;
int main() {
// El código de ejecución residirá en la sección .text.
std::cout << "Iniciando App " << VERSION_APP << std::endl;
contador_visitas++;
registros_error = 0;
saludar(); // Llamada a una función declarada pero no definida aquí.
return 0;
}
// Definición de la función: aquí es donde se cumple la "promesa".
// El cuerpo de esta función irá a la sección .text.
void saludar() {
std::cout << "¡Hola desde el sistema!" << std::endl;
}
Desglose del proceso
Al compilar este ejemplo, el compilador realiza un trabajo minucioso en cada capa:
- Preprocesado: La palabra
VERSION_APPdesaparecerá y será sustituida por"v1.0.0"en todas las líneas donde se use. El contenido de<iostream>se habrá pegado al principio de nuestro código. - Compilación: El compilador analizará la llamada a
saludar()enmain(). Como solo hemos declarado la función (le dijimos que existía, pero no cómo era su cuerpo), el compilador simplemente anota: “Aquí debe ir un salto a la dirección de ‘saludar’, pero aún no sé cuál es”. Esto se guarda en la tabla de símbolos. - Ensamblado: Se genera el archivo
.o. El código demainysaludarse coloca en la sección.text. La variablecontador_visitasse guarda en la sección.dataporque tiene un valor inicial. La variableregistros_errorse guarda en.bsspara que el sistema operativo la inicialice a cero al arrancar el programa, ahorrando espacio en el archivo binario. El archivo contiene una tabla de reubicación, que es una lista de “tareas pendientes” para el enlazador. - Enlazado: El enlazador ve que
mainnecesitasaludar. Busca en el archivo objeto si la definición desaludarestá disponible. Al encontrarla, “parchea” la dirección de memoria correcta en la instrucción de salto. Finalmente, une nuestro código con la implementación destd::coutque reside en las bibliotecas estándar.
El error frecuente
Un error clásico que ocurre en la fase de enlazado es el error de “definición duplicada” o “referencia no definida”.
Si intentas definir una variable global en un archivo de cabecera (.h) sin usar la palabra clave inline (introducida para este propósito en [C++17]) o extern, y luego incluyes ese .h en dos archivos .cpp distintos, el compilador generará dos archivos objeto, cada uno con su propia copia de la variable. Cuando el enlazador intente unirlos, se encontrará con dos entidades con el mismo nombre en la tabla de símbolos y lanzará un error de “multiple definition”.
Si, por el contrario, declaras una función en un .h pero olvidas escribir su cuerpo en algún .cpp, el compilador no se quejará (porque la declaración es válida), pero el enlazador fallará con un error tipo undefined reference to 'nombre_de_funcion', ya que no encontró la “realidad” de la función, solo la “promesa”. Este error suele detectarse con ld (el enlazador) si usas g++ o ld, y es muy visible al usar -Wl,--print-errors-all en GCC.
N° 4