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
#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.
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
#includeha desaparecido y en su lugar está el texto del.hpegado. Esto va en cadena:stdio.hincluye otras cabeceras, que a su vez incluyen otras. Unhello.cde 10 líneas se convierte en un.ide 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" 1no 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 digaescpos.h:4en vez de "línea 11 del.i". - En el
.ino hay código de otros.c. Deescpos.hsolo llega la declaraciónconst char *escpos_version(void);, sin cuerpo. Conprintfpasa igual: el.itraeextern int printf(...);y su código está en la librería de C del sistema (libc.soen 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 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.
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.
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. Comprobado: con el cuerpo enescpos.h,nmmuestraT escpos_versionenhello.oy enescpos.o, y el enlazado falla. - Si cambias un
.h, hay que recompilar todos los.cque lo incluyen. Si no lo haces, enlazas.ocompilados con la versión vieja de la cabecera, y ni el compilador ni el enlazador avisan. Nos pasó: después de mover el cuerpo aescpos.hsolo se recompilóescpos.o. Elhello.oviejo seguía teniendoU 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 buildsabe qué depende de qué y recompila lo necesario. - 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. |
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
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.
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) ocollect2, además de ficheros.oy nombres de símbolo. A esa etapa ya no llega el código fuente.collect2es el programa de gcc que lanzald.
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.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.
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 ser0a. - Solución: configurar el editor (
"files.insertFinalNewline": trueen 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.