Files
printer-driver/docs/01-compilation-and-linking.md
T
2026-09-30 10:22:42 +02:00

13 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.

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.
  • 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.
  • 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.

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.

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 o collect2, además de ficheros .o y nombres de símbolo. A esa etapa ya no llega el código fuente.

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.

Varios

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