Lección 5 de 8: redirecciones, tuberías y procesos en Bash. La potencia de la terminal está en conectar órdenes: guardar su salida, separar los errores, pasar el resultado de una a otra y ejecutar varias a la vez. Esta lección cubre los tres canales de todo proceso, >, >>, 2>&1 y /dev/null, los here-docs, las tuberías con tee y xargs, el código de salida de una tubería, las subshells, el segundo plano y las señales.
Tres canales: entrada, salida y errores
Cada orden que lanzas, y cada script, arranca con tres canales abiertos, numerados: la entrada estándar (0, stdin), de donde lee; la salida estándar (1, stdout), donde escribe los resultados, y la salida de errores (2, stderr), donde escribe los avisos y los fallos. En una terminal, la entrada es el teclado y las dos salidas van a la pantalla, por eso parecen lo mismo. No lo son, y separarlas es la base de todo lo que sigue.
Para verlo, le pedimos a ls un archivo que existe y otro que no, y redirigimos cada canal a un sitio distinto. Esto es una sesión de terminal: cada línea con $ es una orden tecleada, seguida de lo que escribió:
terminal$ ls notas.txt no-existe.txt ls: cannot access 'no-existe.txt': No such file or directory notas.txt $ ls notas.txt no-existe.txt > salida.txt ls: cannot access 'no-existe.txt': No such file or directory $ cat salida.txt notas.txt $ ls notas.txt no-existe.txt 2> errores.txt notas.txt $ cat errores.txt ls: cannot access 'no-existe.txt': No such file or directory $ ls notas.txt no-existe.txt > todo.txt 2>&1 $ cat todo.txt ls: cannot access 'no-existe.txt': No such file or directory notas.txt $ ls notas.txt no-existe.txt 2> /dev/null notas.txt $ echo "revisar el correo" >> notas.txt $ cat notas.txt comprar pan llamar a Luis revisar el correo
Al redirigir la salida con >, el resultado fue a salida.txt y en la pantalla solo quedó el error; con 2>, al revés. > todo.txt 2>&1 manda las dos cosas al mismo archivo: se lee «la salida, a todo.txt; y los errores (2), a donde vaya la salida (&1)». /dev/null es un archivo especial que se traga todo lo que le llega: sirve para descartar lo que no te interesa. Y >> añade al final en lugar de sobrescribir.
- RedirecciónQué hace
orden > fLa salida, af(lo vacía antes)orden >> fLa salida, al final deforden 2> fLos errores, aforden > f 2>&1Salida y errores, af(el orden importa)orden &> fLo mismo, abreviado (solo Bash)orden 2> /dev/nullDescarta los erroresorden < fLa entrada sale defen lugar del tecladoecho "aviso" >&2Escribe en el canal de errores
El error que te ahorra: 2>&1 en el orden equivocado
#!/usr/bin/env bash ls notas.txt no-existe.txt 2>&1 > todo.txt echo "--- contenido de todo.txt:" cat todo.txt
salidals: cannot access 'no-existe.txt': No such file or directory --- contenido de todo.txt: notas.txt
El error no ha ido al archivo. Las redirecciones se aplican de izquierda a derecha: 2>&1 copió el destino que tenía la salida en ese momento (la pantalla) y después > todo.txt cambió solo la salida. Por eso 2>&1 va siempre al final. ShellCheck lo sabe:
shellcheckIn orden.sh line 3:
ls notas.txt no-existe.txt 2>&1 > todo.txt
^--^ SC2069 (warning): To redirect stdout+stderr, 2>&1 must be last (or use '{ cmd > file; } 2>&1' to clarify).
For more information:
https://www.shellcheck.net/wiki/SC2069 -- To redirect stdout+stderr, 2>&1 m...Escribir avisos en stderr
Tus scripts deberían seguir la misma regla que las órdenes del sistema: los resultados, a la salida; los mensajes de error y de progreso, a stderr con >&2. Así, quien redirija tu script a un archivo obtiene solo los datos, y los avisos siguen apareciendo en su pantalla:
#!/usr/bin/env bash
aviso() {
echo "[aviso] $*" >&2
}
aviso "Empiezo a contar líneas"
wc -l < notas.txt
aviso "Terminado"salida[aviso] Empiezo a contar líneas 2 [aviso] Terminado
Aquí ves las tres líneas porque el ejecutor junta los dos canales, como una terminal; con ./avisos.sh > total.txt, en total.txt solo estaría el 2. Fíjate también en wc -l < notas.txt: al leer de la entrada estándar en lugar de recibir el nombre del archivo, wc escribe solo el número, sin el nombre detrás.
Here-docs y here-strings: texto en línea
Un here-doc mete un bloque de texto como entrada de una orden, sin crear un archivo. Empieza con <<PALABRA y termina en la línea que solo contiene esa palabra. Las variables se expanden dentro, salvo que la palabra vaya entre comillas. Es la forma cómoda de generar archivos de configuración, correos o SQL desde un script:
#!/usr/bin/env bash usuario="ana" puerto=8080 cat <<EOF # Configuración generada para $usuario servidor.puerto = $puerto servidor.raiz = /home/$usuario/web EOF echo "---" cat <<'EOF' Con la palabra entre comillas no se expande nada: $usuario, $(date), $((1 + 1)) EOF echo "---" # here-string: una sola cadena como entrada wc -w <<< "cuenta las palabras de esta frase" read -r nombre apellido <<< "Luis Pérez" echo "Nombre: $nombre / Apellido: $apellido"
salida# Configuración generada para ana servidor.puerto = 8080 servidor.raiz = /home/ana/web --- Con la palabra entre comillas no se expande nada: $usuario, $(date), $((1 + 1)) --- 6 Nombre: Luis / Apellido: Pérez
La palabra de cierre (EOF es una costumbre, puede ser cualquiera) tiene que estar sola en su línea, sin espacios delante. El here-string, <<<, es la versión de una sola línea: pasa una cadena como entrada sin necesidad de echo … |, y combinado con read sirve para repartir una frase en variables.
Tuberías: encadenar órdenes
La barra vertical | conecta la salida de una orden con la entrada de la siguiente. Es la idea central de Unix: herramientas pequeñas que hacen una sola cosa bien y se combinan. Con un registro de accesos de un servidor web (direcciones de ejemplo), estas cinco órdenes sacan las tres IP que más peticiones han hecho:
#!/usr/bin/env bash echo "Las 3 IP con más peticiones:" cut -d' ' -f1 accesos.log | sort | uniq -c | sort -rn | head -3 echo "Peticiones que acabaron en 404:" grep -c '" 404 ' accesos.log
salidaLas 3 IP con más peticiones:
4 192.0.2.10
3 203.0.113.42
2 198.51.100.7
Peticiones que acabaron en 404:
3uniq -c solo junta líneas repetidas consecutivas, por eso hace falta el primer sort. En la lección 6 verás grep, cut, sort y awk a fondo; aquí lo importante es el mecanismo: ninguna de estas órdenes sabe nada de las demás, solo leen y escriben texto.
tee: ver y guardar a la vez
tee copia lo que recibe en un archivo y además lo deja pasar, como una T de fontanería. Sirve para guardar un resultado intermedio sin cortar la tubería:
#!/usr/bin/env bash grep '" 404 ' accesos.log | tee errores-404.log | wc -l echo "Guardadas en errores-404.log:" cut -d'"' -f2 errores-404.log
salida3 Guardadas en errores-404.log: GET /wp-login.php HTTP/1.1 GET /.env HTTP/1.1 GET /xmlrpc.php HTTP/1.1
xargs: de líneas a argumentos
Algunas órdenes no leen de la entrada estándar sino de sus argumentos, como rm, mkdir o wc con varios archivos. xargs hace de puente: lee líneas y las pasa como argumentos. Como por omisión parte por espacios, con nombres de archivo hay que decirle que separe solo por saltos de línea (-d '\n'), o mejor por el carácter nulo (-0), que nunca aparece en un nombre:
#!/usr/bin/env bash echo "Con xargs a secas (mal):" printf '%s\n' "informe final.txt" resumen.txt | xargs wc -l echo "Con -d, separando solo por saltos de línea (bien):" printf '%s\n' "informe final.txt" resumen.txt | xargs -d '\n' wc -l echo "Con -0, separando por el carácter nulo (lo más seguro):" printf '%s\0' "informe final.txt" resumen.txt | xargs -0 wc -l
salidaCon xargs a secas (mal): wc: informe: No such file or directory wc: final.txt: No such file or directory 1 resumen.txt 1 total Con -d, separando solo por saltos de línea (bien): 3 informe final.txt 1 resumen.txt 4 total Con -0, separando por el carácter nulo (lo más seguro): 3 informe final.txt 1 resumen.txt 4 total
El primer intento partió «informe final.txt» en dos nombres que no existen, así que wc solo pudo contar resumen.txt. Con -d '\n', cada línea es un argumento entero; con -0, junto con printf '%s\0' o con find … -print0, funciona incluso con nombres que contengan un salto de línea, que en Linux son raros pero posibles.
El código de salida de una tubería
Una tubería devuelve el código de salida de su última orden. Si falla una de en medio, por omisión nadie se entera. PIPESTATUS guarda el de cada una, y la opción pipefail hace que la tubería falle si falla cualquiera de ellas:
#!/usr/bin/env bash
grep "algo" no-existe.log | sort
echo "Código de la tubería: $?"
grep "algo" no-existe.log | sort
echo "Códigos de cada orden: ${PIPESTATUS[*]}"
set -o pipefail
grep "algo" no-existe.log | sort
echo "Con pipefail: $?"salidagrep: no-existe.log: No such file or directory Código de la tubería: 0 grep: no-existe.log: No such file or directory Códigos de cada orden: 2 0 grep: no-existe.log: No such file or directory Con pipefail: 2
grep devuelve 2 cuando no puede leer el archivo, pero sort, que recibió una entrada vacía, acaba bien, y la tubería entera dice 0. Con pipefail, el fallo se propaga. Hay que leer PIPESTATUS justo después de la tubería, porque la siguiente orden lo sobrescribe. En la lección 7 verás por qué casi todos los scripts serios empiezan con set -o pipefail.
Sustitución de procesos: una orden como si fuera un archivo
Algunas órdenes necesitan nombres de archivo, no una tubería: diff compara dos archivos, por ejemplo. <( orden ) ejecuta la orden y la presenta como un archivo temporal que se puede leer. Así se comparan dos listas ya ordenadas sin crear archivos intermedios:
#!/usr/bin/env bash echo "Diferencias entre las asistencias del lunes y del martes:" diff <(sort lunes.txt) <(sort martes.txt) echo "Vinieron los dos días:" comm -12 <(sort lunes.txt) <(sort martes.txt) echo "Qué es <( ) en realidad:" <(true)
salidaDiferencias entre las asistencias del lunes y del martes: 2d1 < Luis 4a4 > Sofía Vinieron los dos días: Ana Marta Pedro Qué es <( ) en realidad: /dev/fd/63
La última línea lo desvela: <( … ) se convierte en una ruta como /dev/fd/63, un descriptor abierto que la orden lee como un archivo cualquiera. Es propio de Bash (y de zsh y ksh); en sh no existe.
Subshells y llaves: ( ) frente a { }
Los paréntesis ejecutan las órdenes en una subshell, una copia del proceso: lo que cambien (variables, carpeta actual, opciones) desaparece al terminar. Las llaves agrupan órdenes en la misma shell, y los cambios se quedan. Las dos sirven para redirigir de una vez la salida de varias órdenes:
#!/usr/bin/env bash
cd /home/ana/proyecto || exit 1
contador=1
( cd /tmp || exit; contador=99; echo "Dentro de ( ): $PWD, contador=$contador" )
echo "Después de ( ): $PWD, contador=$contador"
{ cd /tmp || exit; contador=99; echo "Dentro de { }: $PWD, contador=$contador"; }
echo "Después de { }: $PWD, contador=$contador"
# Agrupar para redirigir todo junto
{
echo "Informe del día"
date -d '2026-10-30 09:00' '+%F %H:%M'
wc -l < /home/ana/proyecto/accesos.log
} > /tmp/informe.txt
cat /tmp/informe.txtsalidaDentro de ( ): /tmp, contador=99
Después de ( ): /home/ana/proyecto, contador=1
Dentro de { }: /tmp, contador=99
Después de { }: /tmp, contador=99
Informe del día
2026-10-30 09:00
10Las llaves tienen una sintaxis exigente: necesitan un espacio después de { y un punto y coma (o un salto de línea) antes de }. Las subshells aparecen también sin pedirlas: $( ) y cada parte de una tubería se ejecutan en una. Por eso una variable que se cambia dentro de un while alimentado por una tubería se pierde al salir del bucle, una sorpresa clásica que explicamos en las rarezas. ShellCheck, de hecho, avisa de que el cambio de contador dentro de los paréntesis se perderá:
shellcheckIn subshell.sh line 6:
( cd /tmp || exit; contador=99; echo "Dentro de ( ): $PWD, contador=$contador" )
^------^ SC2030 (info): Modification of contador is local (to subshell caused by (..) group).
In subshell.sh line 7:
echo "Después de ( ): $PWD, contador=$contador"
^-------^ SC2031 (info): contador was modified in a subshell. That change might be lost.
For more information:
https://www.shellcheck.net/wiki/SC2030 -- Modification of contador is local...
https://www.shellcheck.net/wiki/SC2031 -- contador was modified in a subshe...Procesos en segundo plano: & y wait
Un & al final de una orden la lanza en segundo plano: el script sigue sin esperarla. $! guarda el número de proceso (PID) de la última orden lanzada así, y wait espera a que termine y recoge su código de salida. Con eso se pueden hacer varias cosas a la vez:
#!/usr/bin/env bash
trabajo() {
local nombre=$1 segundos=$2 codigo=$3
sleep "$segundos"
echo "Trabajo $nombre terminado"
return "$codigo"
}
trabajo A 0.6 0 &
pid_a=$!
trabajo B 0.2 3 &
pid_b=$!
echo "Lanzados dos trabajos; el script sigue"
wait "$pid_b"
echo "B acabó con código $?"
wait "$pid_a"
echo "A acabó con código $?"salidaLanzados dos trabajos; el script sigue Trabajo B terminado B acabó con código 3 Trabajo A terminado A acabó con código 0
B tarda menos y termina antes, aunque se lanzó después. wait sin argumentos espera a todos los trabajos pendientes, pero entonces no sabrás cuál falló; con el PID de cada uno, sí. En una terminal interactiva, jobs lista los trabajos en segundo plano, fg trae uno al primer plano y Ctrl+Z seguido de bg manda al fondo el que está en marcha. En el ejemplo Tareas en paralelo verás cómo limitar cuántos van a la vez.
Señales: kill y trap
Las señales son avisos que el sistema envía a un proceso: INT cuando pulsas Ctrl+C, TERM cuando se le pide que termine (lo que hace kill por omisión), KILL para matarlo sin contemplaciones. Un script puede capturarlas con trap para limpiar antes de salir:
#!/usr/bin/env bash
sleep 30 &
pid=$!
kill "$pid"
wait "$pid"
echo "sleep terminó con el código $? (128 + 15, la señal TERM)"
limpiar() {
echo "Recibida la señal TERM: borro los temporales y salgo"
exit 1
}
trap limpiar TERM
echo "Me envío TERM a mí mismo"
kill -TERM $$
echo "Esta línea no llega a ejecutarse"salida con errorsleep terminó con el código 143 (128 + 15, la señal TERM) Me envío TERM a mí mismo Recibida la señal TERM: borro los temporales y salgo código de salida: 1
Cuando una señal termina un proceso, su código de salida es 128 más el número de la señal: TERM es la 15, y de ahí el 143. $$ es el PID del propio script. KILL (la 9) no se puede capturar, por eso conviene usarla solo cuando TERM no basta: el proceso no tendrá ocasión de limpiar nada. En la lección 7 usarás trap … EXIT para borrar archivos temporales pase lo que pase.
Ojo con la seguridad: > sobrescribe sin preguntar, y /tmp es de todos
Una redirección con > vacía el archivo de destino sin pedir confirmación, y si ese destino está en /tmp con un nombre fijo como /tmp/informe.txt, cualquier otro usuario del sistema puede haberlo creado antes, o haber dejado ahí un enlace simbólico que apunte a un archivo tuyo. Dos defensas: la opción noclobber, que impide sobrescribir con > (para hacerlo a propósito, >|), y mktemp, que crea un archivo con nombre aleatorio que solo puede leer su dueño:
#!/usr/bin/env bash echo "datos importantes" > resultado.txt set -o noclobber echo "otra cosa" > resultado.txt echo "Contenido: $(cat resultado.txt)" echo "a propósito" >| resultado.txt echo "Contenido: $(cat resultado.txt)" tmp=$(mktemp) [[ $tmp == /tmp/tmp.* ]] && echo "mktemp creó un archivo en /tmp con nombre aleatorio" echo "Permisos: $(stat -c '%a (%A)' "$tmp")" rm -f "$tmp"
salida./noclobber.sh: line 5: resultado.txt: cannot overwrite existing file Contenido: datos importantes Contenido: a propósito mktemp creó un archivo en /tmp con nombre aleatorio Permisos: 600 (-rw-------)
Con noclobber, el segundo > falla con un error y el archivo se conserva. Y el temporal de mktemp nace con permisos 600: solo tú puedes leerlo y escribirlo.
Más en Los fallos de seguridad típicos de los scripts de Bash.
Retos
Reto 1. Ejecuta ls sobre notas.txt, falta1.txt y falta2.txt de forma que lo que encuentra vaya a ok.txt y los errores a fallos.txt, y luego escribe cuántas líneas tiene cada archivo.
Ver la solución
#!/usr/bin/env bash ls notas.txt falta1.txt falta2.txt > ok.txt 2> fallos.txt echo "ok.txt: $(wc -l < ok.txt) línea(s)" echo "fallos.txt: $(wc -l < fallos.txt) línea(s)"
salidaok.txt: 1 línea(s) fallos.txt: 2 línea(s)
Cada canal a su archivo, con > y 2> en la misma orden. Y wc -l < archivo da el número a secas.
Reto 2. Con el registro accesos.log de esta lección, escribe en una sola tubería cuántas IP distintas hay.
Ver la solución
#!/usr/bin/env bash cut -d' ' -f1 accesos.log | sort -u | wc -l
salida4
sort -u ordena y quita los duplicados de una vez, sin necesidad de uniq.
Reto 3. ¿Qué escribe esto? Piénsalo antes de mirar: { echo hola; echo adiós >&2; } 2> /dev/null | tr '[:lower:]' '[:upper:]'.
Ver la solución
#!/usr/bin/env bash
{ echo hola; echo adiós >&2; } 2> /dev/null | tr '[:lower:]' '[:upper:]'salidaHOLA
Solo «HOLA». «adiós» va por stderr, que se descarta antes de llegar a la tubería: | solo conecta la salida estándar. Si quisieras pasar las dos, sería 2>&1 | (o |& en Bash).
Todos los scripts de esta lección se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y se revisaron con ShellCheck 0.11 antes de publicarla; las salidas están copiadas de esa ejecución. ¿Algo se comporta distinto? Mira tu versión con bash --version: macOS trae de serie Bash 3.2, y en Windows hay que usar WSL o Git Bash.
