La shell como intérprete: qué pasa cuando presionas Enter

Cuando escribes ls -l /etc y pulsas Enter, tienes la sensación de que “la shell ejecuta el comando”. Eso es cierto, pero el camino entre tu tecla y el resultado en pantalla involucra siete u ocho pasos bien diferenciados. Entender esos pasos no es un ejercicio académico: es exactamente lo que necesitas cuando un script hace algo que no esperabas, o cuando un comando funciona en la terminal pero falla dentro de un cron o un servicio de systemd.

La shell —en Debian, /bin/bash por defecto para el usuario interactivo— es un intérprete de comandos. No ejecuta nada directamente: lee texto, lo transforma, llama al kernel para que ejecute un programa, y espera el resultado. Ese ciclo se llama REPL: Read → Evaluate → Print → Loop. Vamos paso a paso.


Lectura y expansiones

Lo primero que hace bash al recibir la línea es expandirla. Las expansiones ocurren en un orden fijo y predeterminado antes de que bash sepa siquiera qué programa vas a ejecutar:

  1. Expansión de variables: $HOME, $USER, $?, etc. se sustituyen por su valor.
  2. Sustitución de comandos: lo que está dentro de $(...) se ejecuta como subcomando y su salida reemplaza la expresión.
  3. Expansión aritmética: $((2 + 3)) se evalúa.
  4. Globbing (expansión de rutas): los patrones *, ?, [abc] se expanden a nombres de fichero reales que existen en el disco.

Esos pasos ocurren en ese orden, y el resultado es lo que bash realmente ve. Si escribes echo $HOME/*.conf, bash no pasa el asterisco a echo: lo expande antes, y echo recibe una lista de ficheros ya resuelta. Esto es fundamental para entender por qué citar las variables con comillas dobles importa tanto: "$HOME/*.conf" evita el globbing porque el asterisco queda dentro de las comillas.


Parseo: nombre del comando y argumentos

Una vez hechas las expansiones, bash tiene una cadena de tokens. El primer token es el nombre del comando; el resto son argumentos. En este punto bash todavía no sabe si ese nombre es una función definida en el propio script, un alias, un builtin de la shell (cd, echo, export…) o un ejecutable externo en disco. Lo determina en ese orden exacto: alias → función → builtin → ejecutable en PATH.

El PATH es una variable de entorno con una lista de directorios separados por :. Bash los recorre en orden y busca un fichero con el nombre del comando que sea ejecutable. Si no lo encuentra en ninguno, ves el error command not found. Cuando cron o un unit de systemd ejecutan un script y falla con ese error, el motivo casi siempre es que PATH en ese entorno tiene menos directorios que en tu sesión interactiva.


Fork y exec: cómo nace un proceso

Aquí entra el kernel. Para ejecutar un programa externo, bash hace dos llamadas al sistema en secuencia:

fork() duplica el proceso actual de bash. A partir de ese momento hay dos procesos idénticos: el padre (bash original) y el hijo (una copia exacta). El hijo hereda descriptores de fichero, variables de entorno, directorio de trabajo… todo.

exec() —en realidad execve() a nivel de llamada al sistema— reemplaza la memoria del proceso hijo con el código del ejecutable que encontró en el PATH. El PID del hijo no cambia, pero ya no es bash: es /bin/ls, o lo que fuera.

El proceso padre (bash) llama a wait() y se bloquea hasta que el hijo termina. Cuando el hijo termina, devuelve un exit code —un número entre 0 y 255— al padre. Exit code 0 significa éxito; cualquier otro número indica algún tipo de fallo, y el significado exacto depende del programa. Bash guarda ese valor en $?.

Si el exit code es diferente de cero y tienes set -e activo en un script, bash aborta ahí mismo. Por eso los scripts de producción casi siempre empiezan con set -euo pipefail: hace que los errores no pasen silenciosos.


Ejemplo: observar todo el ciclo en acción

#!/usr/bin/env bash
# Activa: salir en error, tratar variables no definidas como error,
# y propagar fallos dentro de pipelines.
set -euo pipefail

# --- 1. Expansión de variable y globbing ---
# $HOME se expande antes de que ls reciba nada.
# El * se expande a los ficheros reales que coincidan.
echo "Ficheros de configuración en home:"
ls -1 "$HOME"/.bashrc "$HOME"/.profile 2>/dev/null || echo "(no encontrados)"

# --- 2. Sustitución de comandos ---
# $(hostname) se ejecuta como subcomando; su stdout reemplaza la expresión.
# El resultado de hostname se asigna a MAQUINA antes de que echo vea nada.
MAQUINA=$(hostname)
echo "Ejecutando en: $MAQUINA"

# --- 3. Verificar qué tipo de entidad es un nombre ---
# 'type' le pregunta a bash dónde encontraría ese nombre.
type ls        # muestra si es alias, función, builtin o ejecutable
type cd        # 'cd' es un builtin; no existe en /usr/bin/
type foobar || echo "foobar no existe en PATH"  # evita abort con ||

# --- 4. Ver fork+exec en tiempo real con strace (solo para ilustración) ---
# strace muestra las llamadas al sistema. Filtramos execve y wait4.
# Necesitas: apt install strace
# No lo ejecutes en producción en procesos críticos; solo para aprender.
strace -e trace=execve,wait4 ls /tmp 2>&1 | head -6

# --- 5. Capturar el exit code explícitamente ---
# Desactivamos set -e localmente para poder inspeccionar el exit code
# de un comando que puede fallar.
set +e
ls /ruta/que/no/existe/en/este/sistema > /dev/null 2>&1
CODIGO=$?
set -e

if [ "$CODIGO" -ne 0 ]; then
  echo "El comando falló con exit code: $CODIGO"
fi

# --- 6. Ver el PATH que heredaría un proceso hijo ---
echo "PATH actual: $PATH"
# En cron o systemd el PATH suele ser solo /usr/bin:/bin
# Por eso los scripts robustos usan rutas absolutas para comandos críticos.

Qué está pasando exactamente

En el paso de las comillas dobles alrededor de "$HOME", no es un capricho de estilo. Si HOME tuviera un espacio en el valor (improbable, pero posible en configuraciones raras), sin comillas bash lo partiría en dos tokens y ls recibiría dos argumentos incorrectos. La regla práctica: siempre cita las variables a menos que necesites explícitamente la expansión de palabras o el globbing.

La línea type cd devuelve cd is a shell builtin. Eso explica por qué no puedes hacer strace cd /tmp: no hay execve que rastrear, porque cd nunca abandona el proceso de bash. El directorio de trabajo es un atributo del proceso, y solo el propio proceso puede cambiarlo.

El bloque set +e / set -e alrededor de ls /ruta/que/no/existe es necesario porque con set -e activo, un exit code distinto de cero abortaría el script inmediatamente. Guardas $? justo después del comando, antes de ejecutar cualquier otra cosa, porque $? se sobreescribe en cada instrucción.

La observación sobre strace -e trace=execve,wait4 te deja ver el ciclo fork/exec directamente: verás la llamada execve("/usr/bin/ls", ...) en el hijo y luego el wait4(PID, ...) en el padre. Es la manera más directa de comprobar que lo descrito no es metáfora sino llamadas reales al kernel de Linux.

La última línea, la que imprime $PATH, tiene una implicación concreta: cuando un script que funciona en tu terminal falla con command not found dentro de un timer de systemd, el motivo es que systemd arranca los procesos con un PATH mínimo (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin). La solución es o bien añadir Environment=PATH=... en el unit, o usar rutas absolutas como /usr/bin/python3 en lugar de solo python3.

9

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio