← Automatización con Scripts en Bash Bloque 9 · Automatización en la práctica · Tema 36 de 42

Tema 36

Automatizar una compilación

Con este tema empieza el último bloque del curso, en el que todo lo aprendido se aplica a las tareas que más se automatizan en la práctica. La primera es la compilación: convertir el código fuente de un proyecto en un programa probado y listo para entregar. Hacerlo a mano significa recordar una serie de comandos, en un orden preciso, con las opciones correctas, cada vez. Este tema construye, paso a paso, un script que hace todo eso con una sola orden, que solo recompila lo que cambió, que se detiene ante la primera prueba que falla, y que produce un paquete idéntico cada vez que se lo pide.

1. Qué es automatizar una compilación

Una compilación o build es el proceso que lleva del código fuente de un proyecto a un resultado que se puede usar o entregar. Automatizarla es reunir todos sus pasos en una sola orden, que cualquiera puede ejecutar y que produce siempre el mismo resultado a partir del mismo código.

El nombre de las etapas varía de un proyecto a otro, pero casi siempre son las mismas cuatro:

Las etapas de una compilación: compilar convierte src/*.c en build/bin/mayus; probar ejecuta las pruebas sobre el programa; empaquetar produce dist/mayus-1.4.0.tar.gz. Cada etapa necesita la anterior. Limpiar, aparte, borra build y dist compilar src/ → build/bin/ probar tests/casos/ empaquetar dist/*.tar.gz antes antes limpiar: borra build/ y dist/
Cada etapa necesita la anterior: no tiene sentido probar un programa sin compilar, ni empaquetar uno que no pasó las pruebas.

Un buen script de compilación tiene las cualidades que vimos a lo largo del curso, aplicadas a este caso: hace todo con una orden; se detiene ante el primer error, en lugar de empaquetar un programa roto; es incremental, es decir, rehace solo lo que cambió desde la última vez; es reproducible, de modo que el mismo código produce el mismo paquete en cualquier máquina; y no depende del directorio desde el que se lo llama ni de la configuración de quien lo ejecuta. Es, además, el mismo script que ejecutará un sistema de integración continua cada vez que alguien suba un cambio.

2. El proyecto de ejemplo

Para no perder de vista la automatización, el proyecto es mínimo: un programa en C llamado mayus, que lee texto de la entrada estándar y lo escribe en mayúsculas. Tiene dos archivos de código, un encabezado, un archivo con la versión y una carpeta de pruebas:

la estructura del proyecto
mayus/
├── build.sh               # el script de este tema
├── VERSION                # una línea: 1.4.0
├── README.md
├── LICENSE
├── src/
│   ├── main.c             # lee la entrada y llama a a_mayusculas
│   ├── texto.c            # define a_mayusculas
│   └── texto.h            # la declara; lo incluyen los dos .c
└── tests/casos/
    ├── simple.entrada     simple.esperado
    ├── vacio.entrada      vacio.esperado
    └── varias-lineas.entrada  varias-lineas.esperado
src/main.c
#include <stdio.h>
#include <string.h>
#include "texto.h"

int main(int argc, char *argv[])
{
    char linea[4096];

    if (argc > 1 && strcmp(argv[1], "--version") == 0) {
        printf("mayus %s\n", VERSION);
        return 0;
    }
    while (fgets(linea, sizeof linea, stdin)) {
        a_mayusculas(linea);
        fputs(linea, stdout);
    }
    return 0;
}

No hace falta saber C para seguir el tema: lo que importa es que hay varios archivos fuente, que cada uno se compila por separado en un archivo objeto (.o), y que luego los objetos se enlazan en un ejecutable. VERSION no está definido en el código: lo pondrá el script, con una opción del compilador, a partir del archivo VERSION. Todo lo que el script genera va a dos directorios, build/ y dist/, que se pueden borrar sin perder nada.

3. Un primer script

La forma más simple de automatizar es escribir en un archivo los comandos que uno tecleaba:

build.sh, primera versión
#!/usr/bin/env bash
set -euo pipefail

mkdir -p build
cc -O2 -Wall -c src/main.c  -o build/main.o
cc -O2 -Wall -c src/texto.c -o build/texto.o
cc build/main.o build/texto.o -o build/mayus

Ya es una mejora real: los comandos quedan escritos, no se olvida ninguno y, gracias al modo estricto del tema 23, un error de compilación detiene todo. Pero tiene varios problemas, que el resto del tema resuelve uno por uno: recompila todo cada vez, aunque no haya cambiado nada; no prueba ni empaqueta; hay que agregar a mano cada archivo nuevo; solo funciona si se ejecuta desde la raíz del proyecto; y el compilador y sus opciones están fijos.

4. Las etapas como funciones

El primer paso es darle estructura: una función por etapa, como en las bibliotecas del tema 22, y que los argumentos del script digan cuáles ejecutar. ./build.sh compila; ./build.sh probar compila y prueba; ./build.sh limpiar empaquetar borra todo y hace el paquete desde cero.

Como cada etapa necesita la anterior, cada función llama primero a la que la precede. Eso plantea un problema: ./build.sh probar empaquetar ejecutaría probar dos veces, una pedida y otra porque empaquetar la necesita. La solución es una función que recuerde qué etapas ya se hicieron, con un array asociativo del tema 8:

cada etapa, una sola vez
declare -A hecho=()

una_vez() {                            # ejecuta la etapa $1, si no se ejecutó ya
  [[ -n "${hecho[$1]:-}" ]] && return 0
  hecho[$1]=1
  "$1"
}

probar() {
  una_vez compilar
  …
}

empaquetar() {
  una_vez probar                       # que a su vez compila, si hace falta
  …
}

El nombre de la etapa es también el de la función, de modo que "$1" la ejecuta. Antes de eso, el script comprueba cada argumento contra la lista de etapas válidas con un case, como en el tema 13, para que ./build.sh rm no ejecute rm.

5. Rehacer solo lo que cambió

En un proyecto real, con cientos de archivos, recompilar todo puede llevar minutos. Pero si solo cambió un archivo, solo hace falta recompilarlo a él y volver a enlazar. La regla es la misma que usa make desde hace cincuenta años: un archivo generado hay que rehacerlo si no existe, o si alguno de los archivos de los que depende es más nuevo que él.

Bash tiene la comparación necesaria desde el tema 11: a -nt b es cierto si a se modificó después que b, o si b no existe. Con ella, una función responde la pregunta para cualquier archivo:

¿hay que rehacerlo?
necesita() {                           # ¿hay que rehacer $1? Sí, si falta o algo es más nuevo
  local destino="$1" dep
  shift
  [[ -e "$destino" ]] || return 0
  for dep in "$@"; do
    [[ "$dep" -nt "$destino" ]] && return 0
  done
  return 1
}

# Un objeto depende de su fuente; el ejecutable, de todos los objetos
if necesita build/obj/main.o src/main.c; then
  cc -O2 -Wall -c src/main.c -o build/obj/main.o
fi
if necesita build/bin/mayus build/obj/main.o build/obj/texto.o; then
  cc build/obj/main.o build/obj/texto.o -o build/bin/mayus
fi

La función devuelve 0, «sí, hay que rehacerlo», o 1, y se usa directamente como condición, como las funciones que responden sí o no del tema 19. Recorriendo src/*.c en lugar de nombrar cada archivo, un archivo nuevo se compila sin tocar el script.

6. Las dependencias de los encabezados

La regla anterior tiene un agujero. main.c incluye texto.h: si cambia el encabezado, main.o queda desactualizado, aunque main.c no haya cambiado. Mantener a mano la lista de qué incluye cada archivo es tedioso y propenso a errores. Pero el compilador ya lo sabe, porque lee los #include, y puede escribirlo: con -MMD, al compilar main.c, deja junto al objeto un archivo main.d con sus dependencias:

build/obj/main.d, escrito por el compilador
build/obj/main.o: src/main.c src/texto.h
src/texto.h:

El formato es el de una regla de make: el objeto, dos puntos y los archivos de los que depende, que pueden continuar en varias líneas terminadas en barra invertida. La opción -MP agrega las líneas siguientes, que solo le sirven a make. Para el script basta con la primera regla, y awk, como en el tema 29, la extrae:

leer las dependencias de un objeto
dependencias() {                       # los archivos de los que depende el objeto $1, según su .d
  local d="${1%.o}.d"
  [[ -f "$d" ]] || return 0
  awk 'NR == 1 { sub(/^[^:]*:/, "") } { fin = !sub(/\\$/, ""); printf "%s ", $0 } fin { exit }' "$d"
}

read -ra deps <<< "$(dependencias "$objeto")"
if necesita "$objeto" "$fuente" "${deps[@]}"; then …

El programa de awk quita, en la primera línea, todo hasta los dos puntos; en cada línea, quita la barra final si la hay, y si no la había, es la última de la regla y termina. Hay una sutileza que hace funcionar todo: la primera vez que se compila un archivo no hay .d, pero tampoco hay objeto, así que se compila igual, y en esa compilación nace el .d. El método supone, eso sí, que las rutas del proyecto no tienen espacios.

7. Cuando cambian las opciones

Hay otro cambio que deja los objetos desactualizados sin tocar ningún archivo: compilar con otras opciones. Si ayer se compiló con -O2 y hoy se pide -O0 -g para depurar, los objetos de ayer parecen al día, porque son más nuevos que sus fuentes, pero no son lo que se pidió. Lo mismo pasa al cambiar de compilador o de versión.

La solución es convertir la configuración en un archivo: el script escribe en build/config el compilador, las opciones y la versión, solo si cambiaron, y agrega ese archivo como dependencia de todos los objetos:

la configuración como dependencia
local config="$CC ${opciones_cc[*]} -DVERSION=$version"
if [[ ! -f build/config || "$(< build/config)" != "$config" ]]; then
  printf '%s\n' "$config" > build/config
fi
…
if necesita "$objeto" "$fuente" "${deps[@]}" build/config; then …

Si la configuración no cambió, el archivo no se toca y su fecha no cambia. Si cambió, se reescribe, queda más nuevo que todos los objetos, y necesita los manda a recompilar. Es un truco sencillo que make, sorprendentemente, no trae de fábrica.

8. Las pruebas

Un programa que compila no es un programa que funciona. La etapa de pruebas ejecuta el programa sobre casos conocidos y compara lo que produce con lo esperado. La forma más simple, y suficiente para muchos programas de línea de comandos, es una carpeta con pares de archivos: NOMBRE.entrada, lo que recibe el programa, y NOMBRE.esperado, lo que debe producir.

ejecutar cada caso
for caso in tests/casos/*.entrada; do
  nombre=$(basename "$caso" .entrada)
  esperado="${caso%.entrada}.esperado"
  if "$bin/$programa" < "$caso" > "$obtenido" 2>&1 && cmp -s -- "$esperado" "$obtenido"; then
    echo "    ok     $nombre"
  else
    echo "    FALLA  $nombre"
    diff -u --label esperado --label obtenido -- "$esperado" "$obtenido" | sed 's/^/           /' || true
    fallidas=$(( fallidas + 1 ))
  fi
done

Un caso pasa si el programa termina con éxito y su salida es idéntica byte a byte a la esperada, lo que comprueba cmp -s sin mostrar nada. Si falla, diff -u muestra exactamente la diferencia, que es lo primero que se quiere ver. El || true es necesario porque diff termina con 1 cuando los archivos difieren, y con pipefail eso detendría el script antes de contar el fallo. Al terminar, si alguna prueba falló, la etapa termina con error, y el script no llega a empaquetar.

Agregar una prueba es agregar dos archivos. Una buena costumbre: cuando aparece un error, antes de corregirlo, escribir el caso que lo reproduce. Así se comprueba que la corrección funciona, y el error no puede volver sin que alguien se entere.

9. Un paquete reproducible

La última etapa reúne lo que se entrega, el ejecutable y sus documentos, en un directorio con el nombre y la versión, y lo comprime. Pero un tar czf común produce un archivo distinto cada vez, aunque el contenido sea el mismo, porque guarda las fechas de modificación, el dueño de cada archivo y el orden en que el sistema de archivos los devolvió, y gzip agrega su propia fecha. Un paquete reproducible elimina esas diferencias:

OpciónElimina
--sort=nameEl orden arbitrario del sistema de archivos.
--owner=0 --group=0 --numeric-ownerEl usuario y el grupo de quien compiló.
--mtime="@SEGUNDOS"Las fechas de modificación: todas pasan a ser una fecha fija.
gzip -nEl nombre y la fecha que gzip guarda en su encabezado.
empaquetar
local fecha="@${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2> /dev/null || date +%s)}"

tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="$fecha" \
    -C build/paquete -cf - "$nombre" | gzip -n > "dist/$nombre.tar.gz"
(cd dist && sha256sum -- "$nombre.tar.gz" > "$nombre.tar.gz.sha256")

La fecha fija sale de la variable SOURCE_DATE_EPOCH, una convención que respetan muchas herramientas de compilación; si no está definida, de la fecha del último cambio en git, que es la misma en cualquier copia del repositorio; y como último recurso, de la hora actual. El @ delante indica a tar que es un número de segundos desde 1970. Con esas opciones, dos compilaciones del mismo código producen un paquete idéntico, con la misma suma de verificación, lo que permite comprobar que un paquete descargado salió realmente de ese código. El ejecutable en sí puede no ser idéntico entre compiladores distintos, pero el paquete ya no agrega diferencias propias.

La última línea guarda la suma SHA-256 del paquete junto a él. Quien lo reciba puede verificarlo con sha256sum -c, como veremos en el tema 38. Se calcula desde dentro de dist para que el archivo de sumas contenga solo el nombre, y no la ruta, y funcione en cualquier directorio.

10. Un script que funciona en cualquier lugar

Un script de compilación lo ejecutan personas distintas, en máquinas distintas, y también un sistema de integración continua. Unas pocas líneas al comienzo lo independizan de todos ellos:

el comienzo de build.sh
cd "$(dirname -- "$(readlink -f -- "$0")")"    # siempre desde la raíz del proyecto
export LC_ALL=C

: "${CC:=cc}"
: "${CFLAGS:=-O2 -Wall -Wextra}"
read -ra opciones_cc <<< "$CFLAGS"

11. ¿Y make?

Lo que hacen las secciones 5 a 7 es, en esencia, lo que hace make: una herramienta dedicada a rehacer archivos a partir de sus dependencias. Para comparar, este es el equivalente de la etapa de compilación en un Makefile:

Makefile
CC      ?= cc
CFLAGS  ?= -O2 -Wall -Wextra
VERSION := $(shell cat VERSION)
OBJ     := $(patsubst src/%.c,build/obj/%.o,$(wildcard src/*.c))

build/bin/mayus: $(OBJ)
	@mkdir -p $(@D)
	$(CC) $(CFLAGS) $^ -o $@

build/obj/%.o: src/%.c
	@mkdir -p $(@D)
	$(CC) $(CFLAGS) -DVERSION='"$(VERSION)"' -MMD -MP -c $< -o $@

-include $(OBJ:.o=.d)

Es más corto, lee los archivos .d por sí mismo, y con make -j"$(nproc)" compila en paralelo. Para un proyecto con muchos archivos fuente, make, o la herramienta propia del lenguaje, como cargo, Maven o CMake, es la mejor opción para compilar, como adelantaba el tema 1. Pero su lenguaje es incómodo para todo lo demás: decidir etapas, recorrer casos de prueba, calcular versiones, armar un paquete o mostrar un resumen.

Por eso en la práctica conviven: el script de Bash es la puerta de entrada única, ./build.sh empaquetar, y delega la compilación propiamente dicha en make. Pasar de una a otra es reemplazar el cuerpo de la función compilar por make -j"$(nproc)" CC="$CC" CFLAGS="$CFLAGS"; las demás etapas no cambian.

12. Un script completo

Este es build.sh con todas las piezas del tema reunidas:

build.sh
#!/usr/bin/env bash
#
# build.sh — compila, prueba y empaqueta el proyecto mayus,
# recompilando solo lo que cambió.
#
# Uso: ./build.sh [-v] [ETAPA...]
#   ETAPA  compilar (por omisión), probar, empaquetar o limpiar
#   -v     muestra cada comando que ejecuta
#
# Variables: CC, el compilador (cc por omisión), y CFLAGS, sus opciones.
#
set -euo pipefail
shopt -s nullglob

cd "$(dirname -- "$(readlink -f -- "$0")")"    # siempre desde la raíz del proyecto
export LC_ALL=C

: "${CC:=cc}"
: "${CFLAGS:=-O2 -Wall -Wextra}"
read -ra opciones_cc <<< "$CFLAGS"

programa=mayus
version=$(< VERSION)
obj=build/obj
bin=build/bin
detallado=0
declare -A hecho=()

uso() { echo "Uso: ${0##*/} [-v] [compilar|probar|empaquetar|limpiar]..." >&2; exit 2; }
error() { echo "${0##*/}: $*" >&2; exit 1; }
etapa() { printf '==> %s\n' "$*"; }

ejecutar() {                           # ejecuta un comando, mostrándolo con -v
  if (( detallado )); then printf '    %s\n' "$*"; fi
  "$@"
}

una_vez() {                            # ejecuta la etapa $1, si no se ejecutó ya
  [[ -n "${hecho[$1]:-}" ]] && return 0
  hecho[$1]=1
  "$1"
}

necesita() {                           # ¿hay que rehacer $1? Sí, si falta o algo es más nuevo
  local destino="$1" dep
  shift
  [[ -e "$destino" ]] || return 0
  for dep in "$@"; do
    [[ "$dep" -nt "$destino" ]] && return 0
  done
  return 1
}

dependencias() {                       # los archivos de los que depende el objeto $1, según su .d
  local d="${1%.o}.d"
  [[ -f "$d" ]] || return 0
  awk 'NR == 1 { sub(/^[^:]*:/, "") } { fin = !sub(/\\$/, ""); printf "%s ", $0 } fin { exit }' "$d"
}

compilar() {
  etapa "Compilando $programa $version"
  command -v "$CC" > /dev/null || error "no se encuentra el compilador: $CC"
  mkdir -p "$obj" "$bin"

  # Si cambian el compilador, sus opciones o la versión, hay que recompilar todo
  local config="$CC ${opciones_cc[*]} -DVERSION=$version"
  if [[ ! -f build/config || "$(< build/config)" != "$config" ]]; then
    printf '%s\n' "$config" > build/config
  fi

  local fuentes=(src/*.c) fuente objeto deps objetos=() recompilados=0
  (( ${#fuentes[@]} )) || error "no hay archivos .c en src/"
  for fuente in "${fuentes[@]}"; do
    objeto="$obj/$(basename "$fuente" .c).o"
    objetos+=("$objeto")
    read -ra deps <<< "$(dependencias "$objeto")"
    if necesita "$objeto" "$fuente" "${deps[@]}" build/config; then
      ejecutar "$CC" "${opciones_cc[@]}" -DVERSION="\"$version\"" -MMD -MP -c "$fuente" -o "$objeto"
      recompilados=$(( recompilados + 1 ))
    fi
  done

  if necesita "$bin/$programa" "${objetos[@]}"; then
    ejecutar "$CC" "${opciones_cc[@]}" "${objetos[@]}" -o "$bin/$programa"
    echo "    $bin/$programa enlazado; recompilados $recompilados de ${#objetos[@]} archivos"
  else
    echo "    $bin/$programa está al día"
  fi
}

probar() {
  una_vez compilar
  etapa "Probando"
  local caso nombre esperado fallidas=0 total=0
  obtenido=$(mktemp)
  trap 'rm -f -- "$obtenido"' EXIT

  for caso in tests/casos/*.entrada; do
    nombre=$(basename "$caso" .entrada)
    esperado="${caso%.entrada}.esperado"
    total=$(( total + 1 ))
    if "$bin/$programa" < "$caso" > "$obtenido" 2>&1 && cmp -s -- "$esperado" "$obtenido"; then
      echo "    ok     $nombre"
    else
      echo "    FALLA  $nombre"
      diff -u --label esperado --label obtenido -- "$esperado" "$obtenido" | sed 's/^/           /' || true
      fallidas=$(( fallidas + 1 ))
    fi
  done

  (( total )) || error "no hay pruebas en tests/casos/"
  echo "    $(( total - fallidas )) de $total pruebas correctas"
  if (( fallidas )); then
    error "pruebas fallidas: $fallidas"
  fi
}

empaquetar() {
  una_vez probar
  etapa "Empaquetando"
  local nombre="$programa-$version"
  local raiz="build/paquete/$nombre"
  local fecha="@${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2> /dev/null || date +%s)}"

  rm -rf -- build/paquete
  mkdir -p "$raiz/bin" dist
  cp -- "$bin/$programa" "$raiz/bin/"
  cp -- README.md LICENSE "$raiz/"

  tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="$fecha" \
      -C build/paquete -cf - "$nombre" | gzip -n > "dist/$nombre.tar.gz"
  (cd dist && sha256sum -- "$nombre.tar.gz" > "$nombre.tar.gz.sha256")
  echo "    dist/$nombre.tar.gz y su suma de verificación"
}

limpiar() {
  etapa "Limpiando"
  rm -rf -- build dist
}

while getopts ":v" opcion; do
  case "$opcion" in
    v) detallado=1 ;;
    *) uso ;;
  esac
done
shift $(( OPTIND - 1 ))
(( $# )) || set -- compilar

for e in "$@"; do
  case "$e" in
    compilar|probar|empaquetar|limpiar) ;;
    *) uso ;;
  esac
done

inicio=$SECONDS
for e in "$@"; do
  una_vez "$e"
done
etapa "Listo en $(( SECONDS - inicio )) s"
terminal
$ ./build.sh
==> Compilando mayus 1.4.0
    build/bin/mayus enlazado; recompilados 2 de 2 archivos
==> Listo en 1 s
$ ./build.sh
==> Compilando mayus 1.4.0
    build/bin/mayus está al día
==> Listo en 0 s
$ touch src/main.c; ./build.sh
==> Compilando mayus 1.4.0
    build/bin/mayus enlazado; recompilados 1 de 2 archivos
==> Listo en 0 s
$ touch src/texto.h; ./build.sh -v
==> Compilando mayus 1.4.0
    cc -O2 -Wall -Wextra -DVERSION="1.4.0" -MMD -MP -c src/main.c -o build/obj/main.o
    cc -O2 -Wall -Wextra -DVERSION="1.4.0" -MMD -MP -c src/texto.c -o build/obj/texto.o
    cc -O2 -Wall -Wextra build/obj/main.o build/obj/texto.o -o build/bin/mayus
    build/bin/mayus enlazado; recompilados 2 de 2 archivos
==> Listo en 1 s
$ ./build.sh empaquetar
==> Compilando mayus 1.4.0
    build/bin/mayus está al día
==> Probando
    ok     simple
    ok     vacio
    ok     varias-lineas
    3 de 3 pruebas correctas
==> Empaquetando
    dist/mayus-1.4.0.tar.gz y su suma de verificación
==> Listo en 0 s
$ tar tvzf dist/mayus-1.4.0.tar.gz
drwxr-xr-x 0/0               0 2026-10-14 18:20 mayus-1.4.0/
-rw-r--r-- 0/0            1071 2026-10-14 18:20 mayus-1.4.0/LICENSE
-rw-r--r-- 0/0             412 2026-10-14 18:20 mayus-1.4.0/README.md
drwxr-xr-x 0/0               0 2026-10-14 18:20 mayus-1.4.0/bin/
-rwxr-xr-x 0/0           16184 2026-10-14 18:20 mayus-1.4.0/bin/mayus

Las dos primeras ejecuciones muestran la compilación incremental: la segunda no hace nada. Tocar main.c recompila solo ese archivo; tocar texto.h, que incluyen los dos, recompila ambos, gracias a los archivos .d. En el paquete, todos los archivos tienen dueño 0/0 y la fecha del último cambio en git. Y cuando una prueba falla, el script lo muestra y se detiene antes de empaquetar:

terminal · una prueba que falla
$ ./build.sh empaquetar
==> Compilando mayus 1.4.0
    build/bin/mayus está al día
==> Probando
    FALLA  simple
           --- esperado
           +++ obtenido
           @@ -1 +1 @@
           -HOLA MUNDO
           +HOLA MUNDO!
    ok     vacio
    ok     varias-lineas
    2 de 3 pruebas correctas
build.sh: pruebas fallidas: 1
$ echo $?
1

13. Errores frecuentes

EscribisteQué pasaCorrección
Un script sin set -e que compila y empaquetaUn error de compilación no lo detiene: se empaqueta un programa viejo o roto.Modo estricto, y pruebas antes de empaquetar.
Comparar solo la fuente con el objetoUn cambio en un encabezado no recompila los archivos que lo incluyen.-MMD -MP y leer los .d.
Cambiar CFLAGS sin limpiarLos objetos viejos parecen al día y se mezclan opciones.La configuración como dependencia, o ./build.sh limpiar compilar.
$CC $CFLAGS -c … sin comillasFunciona hasta que un valor tiene un comodín o un espacio raro.read -ra a un array y "${opciones_cc[@]}".
CC=gcc fijo en el scriptNo se puede cambiar el compilador sin editarlo.: "${CC:=cc}"
Rutas relativas sin cd al comienzoDesde otro directorio, el script no encuentra src/.cd "$(dirname -- "$(readlink -f -- "$0")")"
diff esperado obtenido con pipefail en una tuberíaLa diferencia esperada detiene el script antes de contar el fallo.… || true
tar czf común para el paqueteCada compilación da un archivo distinto, con la misma fuente.--sort=name --owner=0 --group=0 --mtime y gzip -n.
sha256sum dist/x.tar.gz > dist/x.sha256La suma guarda la ruta dist/, y sha256sum -c falla en otro lugar.Calcularla desde dentro de dist.
for e in "$@"; do "$e"; done sin validar./build.sh rm ejecuta cualquier comando.Validar contra la lista de etapas.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué significa que una compilación sea incremental?

Que solo rehace los archivos afectados por los cambios desde la última compilación, en lugar de recompilar todo cada vez.

2. ¿Cuándo hay que rehacer un archivo generado?

Cuando no existe, o cuando alguno de los archivos de los que depende se modificó después que él. En Bash se comprueba con [[ dependencia -nt archivo ]].

3. ¿Por qué comparar main.c con main.o no alcanza?

Porque main.o depende también de los encabezados que incluye main.c. Si cambia texto.h, main.o queda desactualizado aunque main.c no haya cambiado.

4. ¿Qué hace la opción -MMD del compilador?

Al compilar, escribe junto al objeto un archivo .d con la lista de archivos de los que depende: la fuente y los encabezados del proyecto que incluye. El script lo lee para saber cuándo recompilar.

5. ¿Para qué sirve el archivo build/config?

Guarda el compilador, las opciones y la versión con que se compiló. Solo se reescribe si cambiaron, y como es dependencia de todos los objetos, un cambio de configuración los hace recompilar.

6. ¿Qué evita la función una_vez?

Que una etapa se ejecute más de una vez en la misma ejecución, cuando la piden el usuario y otra etapa que depende de ella, como en ./build.sh probar empaquetar.

7. ¿Cuándo pasa un caso de prueba en build.sh?

Cuando el programa termina con código 0 y su salida es idéntica byte a byte al archivo .esperado, lo que comprueba cmp -s.

8. ¿Por qué dos tar czf del mismo directorio pueden dar archivos distintos?

Porque guardan las fechas de modificación, el dueño de los archivos y el orden en que los devuelve el sistema de archivos, y gzip agrega su propia fecha. Las opciones --sort=name, --owner=0 --group=0, --mtime y gzip -n eliminan esas diferencias.

9. ¿Qué hace : "${CC:=cc}"?

Si CC no está definida o está vacía, le asigna cc. Así, el usuario puede elegir otro compilador con CC=clang ./build.sh sin editar el script. Los dos puntos son un comando que no hace nada: solo sirve para que la expansión se evalúe.

10. Si make ya hace la compilación incremental, ¿para qué un script de Bash?

Para todo lo que rodea a la compilación y que en el lenguaje de make es incómodo: elegir etapas, ejecutar las pruebas, calcular la versión, armar el paquete y mostrar un resumen. Lo habitual es que el script sea la puerta de entrada y llame a make para compilar.

Para practicar

  1. Crea el proyecto mayus con los archivos de la sección 2 y el script completo. Comprueba que una segunda ejecución no recompila nada, y que tocar texto.h recompila los dos archivos.
  2. Agrega un archivo src/contar.c con su encabezado y comprueba que el script lo compila sin cambios.
  3. Compila con CFLAGS="-O0 -g" ./build.sh y luego sin la variable. ¿Cuántos archivos se recompilan cada vez, y por qué?
  4. Agrega un caso de prueba con un texto que tenga acentos y observa qué hace el programa con ellos. ¿Pasa la prueba si esperabas «ÁRBOL»?
  5. Empaqueta dos veces con el mismo SOURCE_DATE_EPOCH y compara las sumas. Luego quita la opción -n de gzip y repite.
  6. Escribe el Makefile de la sección 11 y cambia la función compilar para que lo use, con make -j"$(nproc)". Comprueba que las demás etapas siguen funcionando.