Aprende Programación de Sistemas programando tu propia shell. Ejecutando Procesos
PROGRAMACIÓN
Aprende Programación de Sistemas programando tu propia shell. Ejecutando Procesos
2026-02-11
Por
Richi C. Poweri

La shell es algo tan cotidiano que no le prestamos mucha atención. Damos por hecho una serie de funcionalidades que han estado ahí siempre. Pero si nos paramos a pensar como programar muchas de ellas veremos que no se trata de una tarea nada fácil. En esta nueva serie vamos a desarrollar una pequeña shell y aprender como se implementan muchas de esas cosas que, como veremos, no son ni mucho menos triviales. En esta primera entrega escribiremos una shell mínima simplemente capaz de ejecutar un único comando.

En esta primera entrega vamos a escribir el esqueleto sobre el que iremos construyendo más funcionalidades e introduciendo algunas de las características más alucinantes de las shells. Vamos, que no esperéis ver nada alucinante por el momento, aunque diría que si bastante guay ;)

Bucle principal

El bucle principal de nuestra shell de ejemplo simplemente lee los comandos del usuario y los ejecuta. Podríamos escribir nuestro propio código para leer el teclado y hacer todas las maravillas que hacen las shells permitiéndonos editar nuestra línea de comandos, pero en su lugar vamos a utilizar la librería readline que con un par de líneas nos va a permitir hacer que nuestra pequeña shell se parezca a bash.

Así que el programa principal, incluyendo todos los included que necesitaremos en nuestro programa los podéis ver a continuación:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <readline/readline.h>
#include <readline/history.h>

#include <unistd.h>
#include <sys/wait.h>

static char ps1[1024] = "mesh $ ";

int
main () {
  char   *buffer;
  
  printf ("MESH is a Educational Shell\n");
  
  while (1) {
    buffer = readline (ps1);
    add_history (buffer);
    run_cmd (buffer);
  }
  return 0;
}

Si, hemos llamado a nuestra shell mesh que es un nombre bastante guay, además es probable que represente muy bien el código final, ya que lo vamos a ir construyendo sobre la marcha. Por último es un acrónimo recursivo que es una de las cosas que más molan en el mundo del software libre.

El programa, como veis no hace mucho. Se trata de un bucle infinito, en el que se leen los comandos del usuario usando readline, la cual, automáticamente nos permite editar la línea de comandos como lo hacemos normalmente. A continuación añade el comando recién ingresado a la historia, de forma que podamos usar los cursores arriba y abajo o CTRL+R para buscar comandos anteriores.

Finalmente la función run_cmd es la que va a parsear la entrada del usuario y ejecutarla. Como dijimos al principio, esta primera versión solo permitirá ejecutar comandos simples, así que hemos puesto todo el código junto por el momento para que sea más fácil de leer.

Ejecutando comandos

La forma de ejecutar un comando en un sistema GNU/Linux (desde un programa claro) es utilizando la llamada al sistema execve. La librería C estándar nos ofrece varios wrappers a esta llamada al sistema, los cuales podéis ver usando el comando man exec. Fundamentalmente no ofrecen formas más convenientes de pasar los parámetros y el entorno al programa que queremos ejecutar.

El prototipo de la llamada al sistema execve es el siguiente:

    int execve(const char *pathname, char *const _Nullable argv[],
               char *const _Nullable envp[]);

El primer parámetro es el path al programa que queremos ejecutar, y luego siguen dos arrays uno conteniendo los parámetros que le queremos pasar al programa y otro las variables de entorno. Ambos arrays se deben terminar con el valor NULL y para el caso de argv, el primero debe ser el nombre del programa. Bueno, realmente es argv[0] que es utilizado por ejemplo por programas como ps para mostrar el nombre de los procesos. Podemos poner lo que queramos ahí, pero como estamos escribiendo una shell nos interesa que argv[0] represente el programa real que estamos ejecutando, así que pathname y argv[0] normalmente tendrán el mismo valor.

Ahora que sabemos esto, debemos parsear la entrada del usuario y poner cada uno de los parámetros en una de las entradas del array argv.

Parseando la entrada de usuario

Para parsear la entrada de usuario vamos a usar la función strtok que nos permite usar varios delimitadores y además hará el trimming (eliminar espacios al principio y final de una cadena) de los argumentos automáticamente.

Este sería un posible código para hacer esto:

int
run_cmd (char *buffer) {
  char   *token;
  char   *delim = "\t\n ";
  char   *arg[10];
  int    i, cnt = 0; 

  for (i = 0; i < 10; i++) arg[i] = NULL;

  token = strtok (buffer, delim);
  if (token == NULL) return -1;
  arg[cnt++] = token;

  while (1) {
    if ((token = strtok (NULL, delim)) == NULL) break;
    arg[cnt++] = token;
  }
  arg[cnt] = NULL; // Último parámetro debe ser NULL
  (...)

Como podéis ver este es el principio de la función run_cmd que llamamos desde el programa principal. Hemos incluido todas las variables que utilizaremos en ella, incluyendo las necesarias para ejecutar programas, cosa que haremos inmediatamente después de procesar los argumentos.

Lo primero que hacemos es inicializar la lista de parámetros. En este caso estamos usando una lista fija de un máximo de 10 parámetros para no complicar el código reservando memoria dinámicamente, pero podéis modificar el programa muy fácilmente para que trabaje con cualquier número de argumentos, solamente debéis utilizar realloc para añadir memoria para los argumentos nuevos.

Tras ello, hacemos la primera llamada a strtok, la cual recibe como primer parámetro la cadena a parsear y la lista de delimitadores. En este caso hemos seleccionado como delimitadores el espacio, el tabulador y el retorno de carro.

El primer resultado de strtok es el nombre del programa (al menos para esta primera shell mínima). Si el usuario ha introducido una línea vacía (lo que incluye un montón de espacios y tabuladores), simplemente retornamos sin hacer nada. En caso contrario añadimos al array par el resto de argumentos. Observad como las siguientes llamadas a strtok reciben como primer parámetro NULL en lugar de la cadena a parsear.

Al final, cuando hemos procesado todos los parámetros añadimos una entrada nula al final del array como requiere execve. Ahora ya estamos en condiciones de ejecutar el programa indicado por el usuario

Ejecutando un programa

La forma de ejecutar un programa en un sistema UNIX requiere de dos pasos. El primer paso consiste en crear un proceso, algo que podemos hacer con la llamada al sistema fork y, una vez que el nuevo proceso ha sido creado, indicamos que código queremos que ejecute (y con que argumentos), usando la llamada al sistema execve.

La llamada al sistema fork crea un nuevo proceso idéntico al proceso que invocó la llamada, y cuya ejecución continua en el mismo punto, justo después de la llamada a fork. Es literalmente hacer una copia del proceso actual, tal y como se encuentra en ese momento. El programa puede saber si se trata del proceso original (o proceso padre) o el nuevo proceso (o proceso hijo), comprobando el resultado de fork. Si el valor es 0, se trata del proceso hijo, mientras que si es un número positivo, se trata del proceso padre, y de hecho, ese número es el identificador de proceso (o PID) del proceso recién creado. Algo que necesitaremos en un rato.

El código que hace todo esto se muestra a continuación:

  pid_t pid;
  int   r, status;
  if ((pid = fork ()) < 0) perror ("fork:");
  else if (pid == 0) { // Proceso Hijo
    if ((r = execvp (par[0], arg)) < 0)  {
        perror ("exec:");
        exit (r);
    }
  } else  {
    waitpid (pid, &status, 0);
    fprintf (stderr, "\n[DEBUG:Process finished with status: %d]\n", WEXITSTATUS(status));
  }
  return 0;
}

Observad que estamos usando la versión execvp del execve con la que no necesitamos pasar las variables de entorno y además comprobando su resultado para poder mostrar errores en caso de que el programa que queremos ejecutar no exista, no tenga permisos de ejecución o cualquier otro error que se pueda producir. En ese caso además terminamos el proceso hijo retornando como código de error el valor de la variable errno, que nos indica que tipo de error se ha producido. perror muestra una representación en lenguaje natural del valor de la variable errno

Y eso concluye nuestra shell mínima. Ahora podemos compilarla y probarla:

$ gcc -Wall -o mesh01 mesh01.c -lreadline
$ ./mesh01
MESH is a Educational Shell
mesh $ pwd
/home/occam

[DEBUG:Process finished with status: 0]
mesh $ programa_que_no_existe
exec:: No such file or directory

[DEBUG:Process finished with status: 2]
mesh $ errno 2
ENOENT 2 No such file or directory

[DEBUG:Process finished with status: 0]
mesh $

Como podéis ver podemos ejecutar cualquier comando, con menos de 10 parámetros ;), y mostrar el código de error que retorna.

ANTES DE TERMINAR. COMANDOS INTERNOS

Antes de terminar vamos a introducir el concepto de comandos internos. Hay ciertos comandos en una shell que no son programas externos como pwd, ls o errno, sino que se deben ser implementados por la propia shell. Para que esta primera shell sea completa funcionalmente (aunque su funcionalidad sea limitada), debemos añadir dos comandos internos.

Los comandos internos los podemos ejecutar directamente, no es necesario crear un nuevo proceso para ello, así que los implementaremos justo entre el parseo de la entrada de usuario y la ejecución del nuevo proceso, y vamos a implementar dos:

  • El primero será exit que nos permite salir de la shell. Este comando terminal el programa actual que es la shell, algo que no podemos hacer con un programa externo. Bueno, podríamos llamar a kill con nuestro PID pero eso sería un poco enrevesado.
  • El otro comando será cd. Si cd (cambiar directorio) es un comando interno en todas las shells. La razón es que la llamada al sistema que nos permite cambiar el directorio actual chdir solo lo realizar en el proceso actual. Si implementamos un programa que utiliza chdir, solo podrá cambiar el directorio actual para si mismo, pero no para el programa que lo invoca (la shell en este caso).

Estos dos comandos los podemos implmentar de forma muy sencilla con solo dos líneas de código:

  if (!strcmp (par[0], "exit")) exit (0);
  if (!strcmp (par[0], "cd"))   return chdir (par[1]);

Observad que el comando cd que ofrece, por ejemplo, bash, hace muchas más cosas que esto, pero por el memento esto nos permite cambiar de directorio en nuestra shell. Esas funciones más avanzadas las veremos cuando introduzcamos nuevos conceptos como las variables de entorno.

CONCLUSIÓN

Como podéis ver implementar una shell básica no es tan complicado, apenas 70 líneas de código. Al utilizar readline hemos incluido muy fácilmente capacidad para editar los comandos, añadir historia y auto completado de nombres de fichero con solo dos líneas. Hemos visto como una shell ejecuta programas y tenemos un punto de partida para explorar las distintas maravillas que ocultan estos interesantes programas. No dudéis en enviarnos vuestras mejoraras como construir la lista de argumentos dinámicamente o añadir otros comando internos. Hasta el próximo número.


SOBRE Richi C. Poweri
Cuando se trata de programación, Richi es tu chica. Tiene un don especial para los lenguajes de programación, y habla con el sistema operativo de tu a tu. En su tienpo libre Richi ayuda a su familia, en los temas familiares

 
Tu publicidad aquí :)