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
#includey#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.oy 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 buildllama agccpara 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 -xmuestra los comandos que ejecuta por dentro, ygo build -gcflags=-Smuestra 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 destrip.
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. staticdelante 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
- 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. - Distribuir librerías sin el código fuente: se entrega la cabecera más los
.oempaquetados (.ao.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 conobjdump --dwarf=info). Para distribuir, se compila sin-go se pasastripal resultado. - El código máquina: se desensambla con
objdump -dy 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 |
#includeno 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-Iy 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. -Irecibe una carpeta, no un fichero.- Cada
.cincluye 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
.cque incluyera la cabecera tendría su propia definición y el enlazador daríamultiple definition. - Las funciones
staticvan en el.c. Metidas en un.h, cada.orecibe 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
structdefinido 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,
statices privado al fichero. Para compartir algo entre dos.cde 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.dllo 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
Tpara unU:undefined reference to 'x'. - Dos
Tcon el mismo nombre:multiple definition of 'x'.
Para qué sirve nm en la práctica:
- Diagnosticar errores de enlazado: ver qué
.odefine 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 sont. - Comprobar que una
.ao.dllcontiene 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.exeocollect2, además de ficheros.oy nombres de símbolo. A esa etapa ya no llega el código fuente.
Warning frente a error
warning:es un aviso: el.ose 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:0es éxito y cualquier otro valor es fallo. makese 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.