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
- Los descriptores son números
- Abrir, leer y cerrar descriptores propios
- Leer dos ficheros a la vez
- Duplicar descriptores y el orden de la redirección
- Guardar y restaurar la salida estándar
- Redirigir bloques, funciones y bucles
- Here-documents
- Here-strings
- Sustitución de procesos
teecon varios destinos- Los ficheros especiales de
/dev - El informe con plantilla
- 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.txtEl 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).
- 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 escrituraSe 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.
- 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 execread -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.
- 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 ficheroLas 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.
- 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 pantallaSin 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.
- 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.txtLa 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 }.
- Here-documents
Un here-document alimenta la entrada estándar de un comando con un bloque de texto escrito en el propio script:
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 |
Sí (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.
- 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).
- 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 ← correctoEn 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.
tee con varios destinos
tee con varios destinostee (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 -tEl 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.
- Los ficheros especiales de
/dev
/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.
- 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>&1antes 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
<< EOFpara 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. Usaread var <<< "$x". - Redirigir a un fichero que también se lee en la misma línea.
sort f > fvacía el fichero: el shell lo trunca antes de quesortlo abra. Usa un temporal demktemp(05-01) osort -o f f. - Consejo:
exec 3>&1antes 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
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
