Al cerrar 05-04 quedó planteado el problema: informe-diario.sh ya valida y extrae texto con precisión, pero no sabe dirigir su propia salida. Imprime con printf sueltos, y para guardar el informe en un fichero hay que redirigir el script entero desde fuera —lo que se lleva por delante también los mensajes de error—. No puede escribir a pantalla y a fichero a la vez, ni mantener abierto un registro mientras trabaja, ni componer una plantilla de veinte líneas sin veinte printf. En 02-04 viste >, >>, 2>&1 y las tuberías como recetas que funcionan. Esta lección te enseña la maquinaria que hay debajo, y con ella la redirección deja de ser un conjunto de trucos memorizados para convertirse en algo que puedes diseñar.

Contenido

  1. Los descriptores son números
  2. Abrir, leer y cerrar descriptores propios
  3. Leer dos ficheros a la vez
  4. Duplicar descriptores y el orden de la redirección
  5. Guardar y restaurar la salida estándar
  6. Redirigir bloques, funciones y bucles
  7. Here-documents
  8. Here-strings
  9. Sustitución de procesos
  10. tee con varios destinos
  11. Los ficheros especiales de /dev
  12. El informe con plantilla

  1. Los descriptores son números

Un descriptor de fichero es un número entero con el que un proceso se refiere a un canal abierto. No hay nada mágico: es un índice en una tabla que el núcleo mantiene por proceso. Todo proceso arranca con tres: el 0 (stdin, el teclado), el 1 (stdout, la pantalla) y el 2 (stderr, también la pantalla).

Lo que no se suele contar es que del 3 al 9 están libres para que tú los uses, y que toda la sintaxis de redirección que ya conoces son operaciones sobre esa tabla:

comando > fichero        # equivale a  comando 1> fichero
comando < entrada.txt    # equivale a  comando 0< entrada.txt

El número va pegado al operador: en comando 2> errores.log el 2 pegado al > significa "el descriptor 2 va a este fichero", mientras que comando 2 > f (con espacio) pasa 2 como argumento y redirige stdout, un error silencioso. Para verlo con tus propios ojos, ls -l /proc/$$/fd lista los descriptores abiertos de tu shell ahora mismo, los tres apuntando a tu terminal (/dev/pts/3 o similar).

  1. Abrir, leer y cerrar descriptores propios

exec, sin un comando detrás, aplica las redirecciones al shell actual en lugar de a un comando concreto. Ese es el mecanismo para abrir canales duraderos:

exec 3< /srv/veloz/datos/envios.csv     # LECTURA
exec 4> ~/veloz-ops/logs/informe.txt    # ESCRITURA (trunca); 5>> para AÑADIR
exec 6<> /tmp/canal                     # lectura Y escritura

Se lee con read -u N y se escribe redirigiendo a >&N:

exec 3< /srv/veloz/datos/envios.csv
read -r -u 3 cabecera                    # consume solo la primera línea
while IFS=, read -r -u 3 id fecha ciudad resto; do
    printf 'Envío %s en %s\n' "$id" "$ciudad"
done
exec 3<&-                                # cerrar: el &- significa "cierra este"

El cierre (exec 3<&- para lectura, exec 4>&- para escritura) no es opcional en un script largo: cada proceso tiene un límite de descriptores abiertos, y uno que quede abierto para escritura puede retener datos en el búfer.

¿Por qué molestarse, si done < fichero ya funciona? Por dos motivos. Primero, eficiencia: abrir un fichero tiene coste, y si en cada iteración de un bucle externo vuelves a abrir el mismo fichero, lo pagas cada vez. Con exec 3< lo abres una vez y el descriptor conserva la posición de lectura entre lecturas —por eso el read de la cabecera y el bucle posterior no leen la misma línea—. Segundo, y más importante: permite leer dos ficheros a la vez.

  1. Leer dos ficheros a la vez

Un bucle while read normal consume stdin. Si necesitas recorrer dos ficheros en paralelo —comparar el informe de hoy con el de ayer, línea a línea—, con un solo canal es imposible. Con descriptores propios es directo:

exec 3< ~/veloz-ops/logs/informe-2026-08-02.txt 4< ~/veloz-ops/logs/informe-2026-08-03.txt
while read -r ayer <&3 && read -r hoy <&4; do
    [[ "$ayer" != "$hoy" ]] && printf -- '- %s\n+ %s\n' "$ayer" "$hoy"
done
exec 3<&- 4<&-       # varias redirecciones caben en un solo exec

read -r ayer <&3 redirige la entrada de ese read concreto al descriptor 3; es la forma alternativa a read -u 3, y ambas son equivalentes. El && hace que el bucle termine cuando cualquiera de los dos ficheros se acabe.

  1. Duplicar descriptores y el orden de la redirección

N>&M significa "haz que el descriptor N apunte a donde apunta ahora M". Es una copia del destino, no un enlace permanente, y esa distinción explica el clásico que todo el mundo memoriza sin entender:

comando > salida.log 2>&1     # AMBOS al fichero
comando 2>&1 > salida.log     # stderr a la PANTALLA, stdout al fichero

Las redirecciones se procesan de izquierda a derecha, y cada una copia el destino que el otro descriptor tiene en ese instante:

Orden Paso 1 Paso 2 Resultado
> f 2>&1 1 → fichero 2 → copia de 1 (= fichero) Ambos al fichero
2>&1 > f 2 → copia de 1 (= pantalla) 1 → fichero 2 a pantalla, 1 al fichero

En el segundo caso, cuando 2>&1 se ejecuta, el descriptor 1 todavía apunta a la pantalla; que 1 cambie después no arrastra a 2. La regla mnemotécnica: 2>&1 debe ir siempre después de la redirección de stdout. Y el atajo &> fichero de 02-04 es exactamente > fichero 2>&1, escrito de forma que no admite este error. La duplicación en el otro sentido es igual de útil: printf 'Aviso\n' >&2 escribe a stderr, y exec 3>&1 guarda dónde apunta stdout ahora.

  1. Guardar y restaurar la salida estándar

Combinando lo anterior sale un patrón muy potente: redirigir todo lo que el script imprima a partir de cierto punto, y luego volver atrás.

exec 3>&1                                  # 3 recuerda la pantalla
exec 1> ~/veloz-ops/logs/informe.txt       # a partir de aquí, todo va al fichero
tabla_ciudades                             # sus printf acaban en el informe
exec 1>&3 3>&-                             # restaurar stdout y cerrar el guardado
printf 'Informe escrito en informe.txt\n'  # esto ya se ve en pantalla

Sin el exec 3>&1 previo, no habría forma de volver: la referencia original a la pantalla se habría perdido. Este patrón es el que permite que una función escriba a fichero sin que quien la escribió tenga que saberlo, y es la base de los sistemas de registro que verás en 07-04.

  1. Redirigir bloques, funciones y bucles

No hace falta llegar al exec global. Cualquier agrupación acepta una redirección al final, que se aplica a todo su contenido:

{                                           # bloque: se ejecuta en ESTE shell
    printf 'INFORME VELOZ ENVÍOS\n'
    tabla_ciudades
} > ~/veloz-ops/logs/informe.txt

( cd /srv/veloz/datos && ls -l ) > listado.txt   # subshell: el cd no te afecta
resumen_ciudad Valencia > val.txt 2> val.err     # una función, como un comando
while IFS=, read -r id fecha ciudad resto; do    # y un bucle, al final del done
    printf '%s\n' "$ciudad"
done < /srv/veloz/datos/envios.csv > ciudades.txt

La diferencia entre { } y ( ) es la del Módulo 1: las llaves ejecutan en el shell actual (las variables que asignes persisten) y los paréntesis crean un subshell (no persisten, pero el cd no te afecta). Sintaxis: { necesita espacio después y ; o salto antes de }.

  1. Here-documents

Un here-document alimenta la entrada estándar de un comando con un bloque de texto escrito en el propio script:

cat << EOF
Informe de Veloz Envíos — Fecha: $(date +%F)
Total de envíos: $total
EOF

El delimitador (EOF por convención, puede ser cualquier palabra) marca el final, y debe aparecer solo en su línea, sin espacios delante. Ese es el fallo número uno con here-documents: un delimitador indentado no cierra el bloque y el script muere con unexpected end of file.

Hay tres variantes y la diferencia es importante:

Sintaxis Expande $var y $(...) Uso
<< EOF / <<- EOF (el - quita tabuladores iniciales) Plantillas con datos; bloques indentados
<< 'EOF' No (literal) Ayuda, código, texto con $
uso() {
	cat <<- 'FIN'
		Uso: informe-diario.sh [opciones] <subcomando>
		Subcomandos: resumen | detalle | ciudades
		  --fecha AAAA-MM-DD   Día a analizar (por defecto, hoy)
	FIN
}

Dos decisiones en ese ejemplo. El delimitador entre comillas simples hace que el texto sea literal: sin ellas, un $1 o un $HOME en la ayuda se expandirían. Y <<- permite indentar el bloque al nivel del código, quitando los tabuladores iniciales al imprimir —solo tabuladores, no espacios, y por eso el ejemplo está escrito con tabuladores reales—.

Un here-document funciona con cualquier comando que lea de stdin, no solo con cat: bc -l << EOF seguido de scale=2 y la expresión también es un uso perfectamente normal.

  1. Here-strings

Cuando el texto cabe en una línea, <<< evita el bloque entero. Ya lo has usado en 04-06 con bc:

read -r ciudad envios <<< "Valencia 128"
grep -c ERROR <<< "$contenido"
bc -l <<< "scale=2; $imp / 100"

Un here-string convierte una variable en la entrada estándar de un comando, y eso resuelve el problema del subshell de la tubería que viste en el Módulo 4: echo "$x" | read var no funciona porque el read corre en un subshell, mientras que read var <<< "$x" sí asigna, porque no hay tubería y por tanto no hay subshell.

Ojo: <<< añade siempre un salto de línea final, algo que se nota al comparar longitudes (wc -c <<< "abc" da 4).

  1. Sustitución de procesos

<(comando) y >(comando) son la construcción más potente de la lección: convierten la salida (o la entrada) de un comando en un nombre de fichero. El ejemplo canónico es diff <(sort ayer.txt) <(sort hoy.txt).

Esto no se puede hacer con tuberías, y el motivo es estructural: diff necesita dos ficheros, y una tubería solo alimenta un stdin. Bash lo resuelve creando un fichero especial por cada sustitución y pasando su ruta al comando —echo <(echo hola) imprime /dev/fd/63, que es literalmente lo que el comando recibe—.

El uso más frecuente en scripts es el que ya apareció en el Módulo 4, y ahora entiendes por qué funciona:

tail -n +2 "$CSV" | while IFS=, read -r ...; do (( total++ )); done
printf 'Total: %d\n' "$total"        # 0  ← el bucle corrió en un SUBSHELL

while IFS=, read -r ...; do (( total++ )); done < <(tail -n +2 "$CSV")
printf 'Total: %d\n' "$total"        # 450 ← correcto

En el primer caso, la tubería mete el bucle en un subshell y sus variables mueren con él. En el segundo, < <(...) es una redirección desde un fichero que resulta ser la salida de un comando: el bucle corre en el shell principal y sus variables sobreviven. El espacio entre < y <( es obligatorio; sin él, Bash lee << y espera un here-document.

>(comando) funciona al revés, alimentando la entrada de un comando: tar -czf - /srv/veloz/datos | tee >(sha256sum > respaldo.sha256) > respaldo.tar.gz archiva y calcula la suma de verificación en una sola pasada.

  1. tee con varios destinos

tee (02-04) duplica la entrada estándar hacia stdout y hacia los ficheros que le indiques. Con sustitución de procesos, esos destinos pueden ser comandos:

informe-diario.sh ciudades | tee ~/veloz-ops/logs/informe-$(date +%F).txt \
  | tee >(grep -c incidencia > /tmp/n-incidencias) | column -t

El informe se guarda en un fichero, se cuenta en paralelo y se muestra formateado en pantalla, todo en una pasada. Y si necesitas que un bloque entero se vea y se guarde, basta canalizarlo: { printf 'INFORME %s\n' "$(date +%F)"; tabla_ciudades; } | tee ~/veloz-ops/logs/informe.txt.

Con tee -a se añade en lugar de truncar, e importante para el modo estricto de 05-03: bajo pipefail, el código de salida de esa tubería es el peor de los tres eslabones, así que un fallo al escribir el fichero sí se detecta.

  1. Los ficheros especiales de /dev

Fichero Qué es
/dev/null El agujero negro: lo escrito se descarta, leerlo da fin de fichero
/dev/stdin, /dev/stdout, /dev/stderr, /dev/fd/N Nombres de fichero para 0, 1, 2 y para el descriptor N (lo que usa <(...))
/dev/tty El terminal real, aunque todo esté redirigido
/dev/tcp/host/puerto Pseudo-fichero de Bash: abre una conexión TCP

/dev/stdin sirve para comandos que exigen un nombre de fichero: awk -f programa.awk /dev/stdin. Y /dev/tty resuelve un caso real: preguntar al usuario aunque la salida del script esté redirigida a un fichero, escribiendo con printf '¿Continuar? [s/N] ' > /dev/tty y leyendo con read -r respuesta < /dev/tty, que toma del teclado aunque stdin venga de una tubería.

/dev/tcp/host/puerto no existe en el disco: es una invención de Bash que permite hablar TCP sin nc ni curl. Lo verás a fondo en 06-04; por ahora quédate con que (echo > /dev/tcp/localhost/8080) 2>/dev/null es una comprobación de puerto en una línea.

  1. El informe con plantilla

Todo junto, aplicado al script vertebrador:

# generar_informe — Compone el informe del día. Uso: generar_informe <fecha> <destino>
generar_informe() {
    local fecha="${1:?}" destino="${2:?}" incid_pct
    incid_pct=$(porcentaje "$total_inc" "$total_env")
    {
        cat << FIN
  VELOZ ENVÍOS — Informe de reparto del $fecha
  Generado por $(basename "$0") en $(hostname) a las $(date +%T)
FIN
        tabla_ciudades
        cat << FIN
Resumen: $total_env envíos, $total_inc incidencias ($incid_pct %)
FIN
    } | tee "$destino"
}

La plantilla vive en un here-document con expansión, así que el texto del informe se lee tal cual va a salir, sin la niebla de veinte printf. El bloque { } agrupa la cabecera, la tabla y el pie en un único flujo, y tee lo entrega simultáneamente al fichero y a la pantalla —lo que el script no sabía hacer al empezar la lección—. Los mensajes de log_info/log_error siguen yendo a stderr y no contaminan el informe.

Errores Comunes y Consejos

  • 2>&1 antes de > fichero. stderr se queda en la pantalla. Siempre después, o usa &>.
  • Delimitador del here-document indentado. Debe ir pegado al margen, salvo con <<- y tabuladores.
  • Usar << EOF para un texto de ayuda con $. Se expande y sale vacío. Usa << 'EOF'.
  • Escribir <<(comando) sin espacio. Bash lo lee como here-document. Es < <(comando).
  • Olvidar cerrar descriptores, o usar echo "$x" | read var: el subshell se lleva la variable. Usa read var <<< "$x".
  • Redirigir a un fichero que también se lee en la misma línea. sort f > f vacía el fichero: el shell lo trunca antes de que sort lo abra. Usa un temporal de mktemp (05-01) o sort -o f f.
  • Consejo: exec 3>&1 antes de cualquier redirección global. Es el único modo de volver, y cuesta una línea.

Ejercicios

Ejercicio 1. Escribe una función comparar_informes() que reciba dos ficheros y muestre las líneas que difieren, indicando el número de línea, leyendo ambos a la vez con descriptores propios y sin usar diff.

Ejercicio 2. Escribe cabecera_informe() que genere con un here-document una cabecera con fecha, servidor y usuario, y una función uso() con un here-document literal indentado que no expanda nada.

Ejercicio 3. Haz que informe-diario.sh escriba su informe simultáneamente en pantalla y en ~/veloz-ops/logs/informe-AAAA-MM-DD.txt, guarde aparte solo las líneas con incidencias en incidencias-AAAA-MM-DD.txt y mantenga los mensajes de registro fuera de ambos ficheros.

Soluciones

Solución 1.

comparar_informes() {                 # Uso: comparar_informes <fichero1> <fichero2>
    local a="${1:?}" b="${2:?}" l1 l2 n=0 dif=0
    [[ -r "$a" && -r "$b" ]] || { printf 'Ficheros ilegibles\n' >&2; return 66; }
    exec 3< "$a" 4< "$b"
    while read -r l1 <&3 && read -r l2 <&4; do
        (( ++n )); [[ "$l1" == "$l2" ]] && continue
        printf '%4d | -%s\n%4d | +%s\n' "$n" "$l1" "$n" "$l2"; (( ++dif ))
    done
    exec 3<&- 4<&-
    printf '%d líneas comparadas, %d diferencias\n' "$n" "$dif"
    (( dif == 0 ))                     # código 0 si son iguales, 1 si no
}

Un solo bucle recorre dos ficheros porque cada read tiene su propio canal. La última línea aprovecha que (( )) fija $? (04-06) para que la función devuelva un código útil sin un if explícito.

Solución 2.

cabecera_informe() {
    cat << FIN
 VELOZ ENVÍOS · Informe del $(date +%F)
 Servidor: $(hostname)   Usuario: $USER
FIN
}
uso() {
	cat <<- 'FIN'
		Uso: informe-diario.sh [--fecha AAAA-MM-DD] [--debug] <subcomando>
		Variables: VELOZ_CSV, VELOZ_LOG_NIVEL
	FIN
}

La primera usa << FIN sin comillas porque necesita que $(date), $(hostname) y $USER se expandan. La segunda usa <<- 'FIN' porque quiere el texto literal —si VELOZ_CSV no llevara comillas simples, la ayuda mostraría su valor en lugar de su nombre— y el guion permite indentar el bloque con tabuladores al nivel del código.

Solución 3.

main() {
    local fecha="${1:-$(date +%F)}"
    local inf=~/veloz-ops/logs/informe-$fecha.txt
    local inc=~/veloz-ops/logs/incidencias-$fecha.txt
    {
        cabecera_informe
        tabla_ciudades
    } | tee "$inf" | tee >(grep -i 'incidencia' > "$inc")
    log_info "Informe en $inf; incidencias en $inc"
}

El bloque { } produce un único flujo; el primer tee lo guarda completo y lo sigue pasando; el segundo lo deriva a un grep mediante sustitución de procesos, que filtra hacia el segundo fichero mientras el flujo principal continúa hacia la pantalla. Como log_info escribe a stderr (decisión que tomamos en 04-02), sus mensajes no entran en la tubería y por tanto no aparecen en ninguno de los dos ficheros: ese es el motivo real de aquella convención.

Conclusión

Los descriptores son números en la tabla de canales abiertos de un proceso: 0, 1 y 2 vienen dados y del 3 al 9 son tuyos. exec 3< f, exec 4> f y exec 6<> f los abren, read -u 3 o read <&3 leen de ellos, exec 3<&- los cierra, y su gran ventaja es abrir una vez lo que se lee muchas —y poder recorrer dos ficheros a la vez—. N>&M duplica un destino en el instante en que se ejecuta, y de ahí sale la explicación definitiva de que > f 2>&1 mande todo al fichero mientras 2>&1 > f deja stderr en la pantalla. Guardar stdout en el 3 y restaurarlo después permite redirigir un tramo entero; { ...; } > f y done < f redirigen bloques, funciones y bucles sin tocar el shell global. Los here-documents dan plantillas legibles con expansión (<<EOF), texto literal (<<'EOF') e indentación con tabuladores (<<-EOF); los here-strings <<< meten una variable en stdin sin subshell. Y la sustitución de procesos convierte comandos en ficheros: diff <(a) <(b) no tiene equivalente con tuberías, y done < <(comando) es la forma correcta de que un bucle conserve sus variables.

Con esto, informe-diario.sh es un programa completo: robusto, verboso cuando se le pide, preciso al validar y capaz de dirigir su salida con exactitud. Y es también un fichero de varios cientos de líneas donde conviven log_info, morir, validar_entorno, porcentaje y media docena más de funciones que nada tienen que ver con los informes y que cualquier otro script del toolkit necesitaría. Copiarlas y pegarlas en el siguiente script sería el principio del fin. En 05-06, el hito del módulo, extraemos esas funciones a ~/veloz-ops/lib/comun.sh: source frente a ejecutar, cómo localizar la librería sin que las rutas relativas te traicionen, guardas de inclusión, convenios de nombres, ficheros de configuración con su orden de precedencia y la estructura definitiva del toolkit.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados