Files
printer-driver/docs/01-compilation-and-linking.md
T
2026-09-30 11:49:39 +02:00

19 KiB

Compilación y enlazado

Las etapas de gcc

gcc no es un único programa: es un driver que llama a otros por turnos.

         -E para aquí     -S para aquí      -c para aquí
              │                │                 │
.c ── cpp ──▶ .i ── cc1 ──▶ .s ── as ──▶ .o ── ld ──▶ .exe
     preprocesador  compilador  ensamblador  enlazador
  • Preprocesador: procesa #include y #define. Solo manipula texto.
  • Compilador: traduce C a ensamblador y comprueba tipos.
  • Ensamblador: traduce el ensamblador a código máquina y produce un .o, llamado fichero objeto.
  • Enlazador (ld): junta los .o y las librerías en un ejecutable.

Sin flags de parada, gcc hace la cadena completa. Si no le das -o, el ejecutable se llama a.exe en Windows y a.out en Linux.

Si juntas dos flags de parada, gana la que para antes: gcc -E -S se comporta como -E. Además, -o no convierte nada: gcc -E x.c -o x.o guarda texto C preprocesado en un fichero que se llama .o, aunque no sea un fichero objeto.

Empezar por cualquier etapa

gcc decide por la extensión del fichero de entrada en qué etapa empieza:

Entrada Empieza en
.c preprocesador
.i compilador (ya está preprocesado; sobra -I)
.s ensamblador
.o enlazador

Por ejemplo, gcc -c build/escpos.i -o build/escpos.o compila un fichero ya preprocesado.

Lo normal es partir del .c: gcc hace la cadena completa y crea el .i y el .s como ficheros temporales que borra al acabar. Parar en cada etapa solo sirve para aprender o para depurar. -save-temps hace la cadena completa y conserva los intermedios en la carpeta actual.

En Go: go build también genera intermedios, pero en una carpeta temporal que no ves (go build -work imprime cuál es y no la borra).

Qué hay dentro de un .i

Es el .c después del preprocesador: C puro, sin #include, #define ni #ifndef. Es exactamente lo que lee el compilador.

  • Cada #include ha desaparecido y en su lugar está el texto del .h pegado. Esto va en cadena: stdio.h incluye otras cabeceras, que a su vez incluyen otras. Un hello.c de 10 líneas se convierte en un .i de unas 800.
  • Los include guards (#ifndef/#define/#endif) ya se han ejecutado y no aparecen. Solo queda lo que había dentro.
  • Las líneas como # 1 "include/escpos.h" 1 no son código. Son marcas de línea: le indican al compilador de qué fichero y de qué línea viene cada trozo, para que un error diga escpos.h:4 en vez de "línea 11 del .i".
  • En el .i no hay código de otros .c. De escpos.h solo llega la declaración const char *escpos_version(void);, sin cuerpo. Con printf pasa igual: el .i trae extern int printf(...); y su código está en la librería de C del sistema (libc.so en Linux), ya compilado.

En Go: no hay preprocesador. import no pega texto, y además de darte las firmas mete el código del paquete en el binario. En C son dos cosas separadas: las firmas llegan con #include al preprocesar, y el código llega al enlazar.

En Go: go build hace lo mismo por dentro (go tool compile genera un .o por paquete y go tool link los enlaza), pero no lo ves.

Cómo compila Go

Go no pasa por C. Su compilador traduce Go a una representación intermedia (SSA) y de ahí directamente a código máquina, con su propio ensamblador y su propio enlazador. No usa gcc ni ld.

  • El compilador de Go estuvo escrito en C hasta Go 1.5 (2015). Después se tradujo a Go y ahora se compila a sí mismo.
  • Excepción, cgo: si un fichero tiene import "C", go build llama a gcc para compilar la parte de C, y el resultado se enlaza con el de Go. Así funcionarán los bindings de la fase 12.
  • go build -x muestra los comandos que ejecuta por dentro, y go build -gcflags=-S muestra el ensamblador generado.
  • Un "hola mundo" en Go ocupa unos 2 MB porque incluye el runtime (recolector de basura, planificador de goroutines). El de C es mucho más pequeño porque usa la librería de C del sistema (en Windows, la UCRT, una DLL).

Enlazado estático frente a dinámico

  • Estático: el código de las librerías se copia dentro del ejecutable. Funciona en cualquier máquina sin instalar nada, pero ocupa más. Go lo hace por defecto, y por eso es tan fácil de desplegar.
  • Dinámico: el ejecutable guarda solo una referencia a la DLL (o .so), y el sistema operativo la carga al arrancar el programa. Ocupa menos, pero la DLL tiene que estar instalada en la máquina y en una versión compatible. Es lo que hace gcc por defecto con la librería de C.

Comandos para medirlo en MSYS2:

  • objdump -p hello.exe | grep "DLL Name": lista las DLL de las que depende.
  • strip hello.exe: quita símbolos e información de depuración.
  • gcc -static ...: enlaza estáticamente también la librería de C.
  • En Go, go build -ldflags="-s -w" es el equivalente de strip.

En MSYS2 UCRT64, -static no mete la librería de C: la UCRT es un componente de Windows y solo existe como DLL. -static afecta a las demás librerías (libgcc, winpthread...). Por eso un "hola mundo" ocupa lo mismo con y sin -static.

Tres formas de distribuir software

Modelo Ejemplos Ventajas Inconvenientes
Todo dentro del ejecutable (estático) Go, herramientas "portables" Un único fichero, no depende de nada Ocupa más. Si una librería tiene un fallo de seguridad, cada programa que la lleva dentro hay que recompilarlo y redistribuirlo
Ejecutable con sus DLL al lado Programas típicos de Windows en Program Files El fabricante controla las versiones; puede actualizar una DLL por separado; varios ejecutables del mismo producto la comparten Cada programa lleva su copia. Riesgo de DLL hijacking: Windows busca primero en la carpeta del ejecutable
Librerías compartidas del sistema Linux con dnf/apt; en Windows, el "Visual C++ Redistributable" o el runtime de .NET Una sola copia para todo el sistema: un parche de seguridad arregla todos los programas a la vez Hay que instalar las dependencias en la versión correcta (dependency hell)

Cuando compilas desde el código fuente en Linux, además de la librería (libfoo) hay que instalar su paquete -dev o -devel, que trae la cabecera y el fichero para el enlazador: las dos mitades del "import" de C.

Las licencias también cuentan. Una librería LGPL enlazada estáticamente obliga a permitir que el usuario la vuelva a enlazar con otra versión, y por eso se suele distribuir como DLL.

Unidades de traducción

Cada .c se compila por separado y a ciegas: cuando gcc compila hello.c no sabe nada de escpos.c. Cada .c, junto con todo lo que incluye, forma una unidad de traducción.

  • Declaración: const char *escpos_version(void);. Anuncia que la función existe y con qué firma. Puede repetirse las veces que quieras.
  • Definición: la función con su cuerpo { ... }. Debe aparecer una sola vez en todo el programa. Es la regla de una definición.

Para compilar una llamada basta con la declaración. Al compilar hello.c, gcc no tiene el código de escpos_version, y no le hace falta. Con saber que existe, qué recibe y qué devuelve, comprueba que la llamada es correcta y genera la instrucción de llamada con la dirección en blanco. Ese hueco lo rellena después el enlazador. Por eso gcc -c examples/hello.c funciona sin escpos.c, y nm build/hello.o muestra U escpos_version. Es como programar en Go contra una interfaz: el compilador comprueba las firmas sin conocer la implementación.

  • static delante de una función la hace privada a su .c. Es lo más parecido a la minúscula inicial de Go.

Por qué compilar a .o y enlazar después

  1. Compilación incremental: si solo cambia un .c, recompilas ese y vuelves a enlazar. Go lo hace solo con su caché (go env GOCACHE); en C se encarga el Makefile.
  2. Distribuir librerías sin el código fuente: se entrega la cabecera más los .o empaquetados (.a o .dll). Así será la librería que use cgo.

Qué ve quien recibe los binarios

No recibe el .c, pero no todo queda oculto:

  • Nombres de los símbolos exportados (T): el enlazador los necesita para emparejarlos.
  • Cadenas literales: salen en claro con strings fichero.o.
  • Con -g: nombres de variables y tipos, rutas de los ficheros fuente y la línea de código de cada instrucción (se ven con objdump --dwarf=info). Para distribuir, se compila sin -g o se pasa strip al resultado.
  • El código máquina: se desensambla con objdump -d y se puede descompilar a un C aproximado con herramientas como Ghidra.

Entregar solo binarios dificulta leer el código, pero no lo protege. La cabecera, en cambio, es texto que ve todo el mundo: es el contrato público.

Un .o solo sirve para la plataforma en la que se compiló (arquitectura, sistema operativo, ABI). Un .o de Windows x86-64 no sirve en Android ARM: hay que generar uno por cada plataforma.

En Go: las librerías se distribuyen casi siempre como código fuente. Los paquetes solo binarios se eliminaron en Go 1.13. Un binario de Go se desensambla igual que uno de C.

Cabeceras e #include

En Go, import hace dos cosas a la vez. En C son dos pasos separados, y los hacen dos programas distintos:

Para qué Quién la usa Cómo se indica
Cabecera (.h) Conocer las firmas y comprobar tipos Compilador #include + -I carpeta
Código (.o, .a, .dll) El cuerpo de las funciones Enlazador Pasarle el fichero en la línea de comandos
  • #include no es un import: el preprocesador copia y pega el texto del .h, sin más. No trae código compilado.
  • #include <x.h> busca en las carpetas de -I y luego en las del sistema, nunca en la carpeta del fichero que incluye. Se usa para cabeceras del sistema y de librerías externas.
  • #include "x.h" busca primero en la carpeta del fichero que incluye y después igual que < >. Se usa para las cabeceras del proyecto.
  • No pongas rutas en el #include ("include/escpos.h"). Escribe "escpos.h" y di dónde buscarlo con -I include.
  • -I recibe una carpeta, no un fichero.
  • Cada .c incluye su propia cabecera. Así el compilador comprueba que la declaración y la definición coinciden. Si no la incluye y difieren, nadie lo detecta, y el resultado es comportamiento indefinido.
  • En las cabeceras van declaraciones, tipos y macros. Nunca cuerpos de funciones: cada .c que incluyera la cabecera tendría su propia definición y el enlazador daría multiple definition. Comprobado: con el cuerpo en escpos.h, nm muestra T escpos_version en hello.o y en escpos.o, y el enlazado falla.
  • Si cambias un .h, hay que recompilar todos los .c que lo incluyen. Si no lo haces, enlazas .o compilados con la versión vieja de la cabecera, y ni el compilador ni el enlazador avisan. Nos pasó: después de mover el cuerpo a escpos.h solo se recompiló escpos.o. El hello.o viejo seguía teniendo U escpos_version, así que todo enlazaba y parecía que no pasaba nada. Hay que comparar las fechas (ls -l) o, mejor, dejar que un Makefile lleve la cuenta. En Go esto no puede pasar: go build sabe qué depende de qué y recompila lo necesario.
  • Las funciones static van en el .c. Metidas en un .h, cada .o recibe una copia privada: el código se duplica y aparecen avisos de "no usada".

Encapsulación con cabeceras

La cabecera oculta funciones (lo que no declara y es static no existe para el cliente), pero tiene límites:

  • Un struct definido en la cabecera no tiene campos privados. Todos son visibles y modificables. Además, su tamaño y el orden de sus campos pasan a formar parte del contrato: si cambian, los programas que usan la librería tienen que recompilarse.
  • No existe el nivel "paquete". En Go, lo que no se exporta se comparte entre todos los ficheros del paquete. En C, static es privado al fichero. Para compartir algo entre dos .c de la librería tiene que ser no-static, y entonces es visible para todo el mundo. Se resuelve con cabeceras internas que no se entregan y controlando qué exporta la .dll o la .so.
  • No hay protección en tiempo de ejecución. Todo es memoria compartida: con un puntero se puede escribir cualquier byte. La encapsulación la impone el compilador, no el programa en marcha. En Go (unsafe, reflect) y en Java (reflexión) pasa lo mismo.

La encapsulación de verdad se consigue con el tipo opaco (el handle opaco de la API): la cabecera declara que el struct existe pero no lo define, y la definición va en el .c. El cliente solo puede tener punteros a él y llamar a funciones de la librería; ni siquiera conoce su tamaño ni sus campos. Precio: hay que crearlo en memoria dinámica y cada acceso es una llamada a función.

Include guard

#ifndef ESCPOS_H
#define ESCPOS_H

/* todo el contenido de la cabecera */

#endif

Es un if del preprocesador: la segunda vez que la cabecera se incluye en la misma unidad de traducción, ESCPOS_H ya está definido y se salta el bloque entero. El #endif tiene que ser la última línea; si no, lo que queda fuera se copia siempre.

Una declaración de función repetida no da error, pero la definición de un struct repetida sí (redefinition of 'struct ...'), y la librería tendrá structs en la cabecera.

f() frente a f(void)

En C17, int f(); significa "parámetros sin especificar", así que f(1, 2, 3) compila sin avisar. Para decir "sin parámetros" hay que escribir int f(void);. En C23 ya significan lo mismo, pero este proyecto usa C17.

Flags de gcc

Flag Etapa Qué hace
-std=c17 compilación Versión del lenguaje. Sin ella, gcc usa su dialecto con extensiones.
-Wall -Wextra compilación Activan avisos. -Wall no activa todos, pese al nombre.
-Wpedantic compilación Avisa de extensiones de gcc que no son C estándar.
-g compilación Añade información de depuración (secciones .debug_*) para gdb.
-I carpeta preprocesador Añade una carpeta donde buscar los #include.
-E / -S / -c control Paran tras preprocesar, compilar o ensamblar.
-o nombre control Nombre del fichero de salida.

Los flags que llevan un valor admiten el valor pegado o separado: -Iinclude y -I include son lo mismo, igual que -obuild/x.o y -o build/x.o. Por costumbre, -I, -L, -l y -D se escriben pegados (-lws2_32, -DDEBUG), y -o separado. Los flags largos como -std=c17 llevan siempre =. En Go pasa algo parecido con el paquete flag: vale -o x y -o=x.

Todo menos -o se usa al compilar. Al enlazar solo se pasan los .o, -o y, más adelante, las librerías (por ejemplo, -lws2_32 para Winsock).

En Go: los avisos importantes son errores obligatorios (un import o una variable sin usar no compilan), la versión va en go.mod y la información de depuración se incluye siempre.

nm: los símbolos de un .o

nm fichero.o lista los símbolos de un fichero objeto, es decir, los nombres de sus funciones y variables globales. Las líneas que empiezan por . son secciones internas.

Letra Significado
T Definido aquí y visible para otros .o
t Definido aquí pero privado (static)
U Usado aquí pero definido en otro sitio: un hueco que rellenará el enlazador

El trabajo del enlazador es emparejar cada U con exactamente una T:

  • Ninguna T para un U: undefined reference to 'x'.
  • Dos T con el mismo nombre: multiple definition of 'x'.

Para qué sirve nm en la práctica:

  • Diagnosticar errores de enlazado: ver qué .o define un símbolo y cuál lo necesita.
  • Comprobar que la librería solo exporta (T) lo que está en la API pública y que las funciones internas son t.
  • Comprobar que una .a o .dll contiene lo que esperas antes de enlazarla desde cgo.

Una declaración que no usas no crea ningún U: no genera hueco.

Pueden aparecer símbolos que no escribiste tú. Por ejemplo, puts: gcc cambia printf("%s\n", s) por puts(s). Y __main, que es el código de arranque de MinGW.

Ese U puts no da undefined reference aunque no le pases nada al enlazador para resolverlo, porque gcc añade siempre la librería de C (libc) al enlazar. Las demás librerías hay que pedirlas explícitamente con -l.

Errores: compilador frente a enlazador

  • Compilador: el mensaje empieza por fichero.c:línea:columna:, porque está leyendo código fuente.
  • Enlazador: el mensaje menciona ld.exe (Windows), /usr/bin/ld (Linux) o collect2, además de ficheros .o y nombres de símbolo. A esa etapa ya no llega el código fuente. collect2 es el programa de gcc que lanza ld.

Ejemplo real, enlazando hello.o sin escpos.o:

/usr/bin/ld: build/hello.o: in function `main':
.../examples/hello.c:6:(.text+0x9): undefined reference to `escpos_version'
collect2: error: ld returned 1 exit status

Sale hello.c:6 aunque el enlazador no lee código fuente. Ese dato viene de la información de depuración que -g guardó en el .o. Sin -g solo verías (.text+0x9), que es la posición dentro del código máquina.

Warning frente a error

  • warning: es un aviso: el .o se genera igualmente.
  • error: significa que no se genera nada.

Cuando gcc falla, no borra el .o ni el .exe anteriores. Puedes acabar ejecutando un binario viejo creyendo que es el nuevo. Para evitarlo:

  • Busca error: en la salida.
  • Comprueba echo $? justo después del comando: 0 es éxito y cualquier otro valor es fallo.
  • make se detiene en cuanto un comando falla.

Salto de línea al final del fichero

C17 exige que todo fichero fuente no vacío termine en \n. Si no, el comportamiento es indefinido. El riesgo está en #include, que pega el texto: la última línea del .h (#endif) podría quedar unida a la siguiente línea del fichero que lo incluye. Los preprocesadores actuales lo arreglan por dentro, pero sigue sin ser C correcto.

  • Clang avisa con -Wpedantic: warning: no newline at end of file [-Wnewline-eof]. gcc ya no avisa.
  • Para verlo: tail -c 5 fichero.c | xxd. El último byte tiene que ser 0a.
  • Solución: configurar el editor ("files.insertFinalNewline": true en VS Code).

Compilar con gcc y con clang da dos juegos de avisos distintos, sin coste: make clean && make CC=clang.

En Go: gofmt añade el salto final automáticamente.

Varios

  • En bash, un programa de la carpeta actual se ejecuta con ./hello.exe. Bash no busca en la carpeta actual por seguridad.