paint-brush
Construyamos un shell de Linux [Parte I]por@MIMA
21,509 lecturas
21,509 lecturas

Construyamos un shell de Linux [Parte I]

por Mohammed Isam12m2020/06/08
Read on Terminal Reader
Read this story w/o Javascript

Demasiado Largo; Para Leer

El primer shell de Unix (el shell de Thompson) tenía funciones muy limitadas, principalmente redirección de E/S y canalizaciones de comandos. Veremos cómo un shell de Linux logra ejecutar comandos, bucles y expresiones condicionales. Al final de este tutorial, tendremos un shell básico de Linux que no servirá de mucho por ahora, pero que ampliaremos y mejoraremos en las próximas partes. La parte central de cualquier shell de Linux es el intérprete de línea de comandos o CLI. Esta parte tiene dos propósitos: lee y analiza los comandos del usuario y luego ejecuta los comandos analizados.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Construyamos un shell de Linux [Parte I]
Mohammed Isam HackerNoon profile picture

Desde los primeros días de Unix, el shell ha sido parte de la interfaz del usuario con el sistema operativo. El primer shell de Unix (el shell de Thompson ) tenía características muy limitadas, principalmente redirección de E/S y canalizaciones de comandos. Los shells posteriores ampliaron ese shell inicial y agregaron más y más capacidades, lo que nos brindó características poderosas que incluyen expansión de palabras, sustitución de historial, bucles y expresiones condicionales, entre muchas otras.

Por qué este tutorial

Durante los últimos 20 años, he estado usando GNU/Linux como mi sistema operativo principal. He usado muchos shells de GNU/Linux, incluidos, entre otros, bash , ksh y zsh . Sin embargo, siempre me ha molestado esta pregunta: ¿qué hace que el shell funcione? Como por ejemplo:

  • ¿Cómo analiza el shell mis comandos, los convierte en instrucciones ejecutables y luego ejecuta estos comandos?
  • ¿Cómo realiza el shell los diferentes procedimientos de expansión de palabras, como la expansión de parámetros, la sustitución de comandos y la expansión aritmética?
  • ¿Cómo implementa el shell la redirección de E/S?
  • ... y así.

Como la mayoría de los shells de GNU/Linux son de código abierto, si desea aprender el funcionamiento interno del shell, puede buscar en línea el código fuente y comenzar a profundizar (eso es lo que realmente hice). Pero este consejo es más fácil decirlo que hacerlo. Por ejemplo, ¿desde dónde debería empezar a leer el código exactamente? ¿Qué archivos fuente contienen el código que implementa la redirección de E/S? ¿Dónde puedo encontrar el código que analiza los comandos del usuario? Supongo que entendiste el punto.

Es por eso que he decidido escribir este tutorial, para ayudar a los usuarios y programadores de Linux a comprender mejor sus shells. Juntos, vamos a implementar un shell de Linux completamente funcional, desde cero. En el camino, veremos cómo un shell de Linux logra analizar y ejecutar comandos, bucles y expresiones condicionales al escribir el código C que realiza las tareas anteriores. Hablaremos sobre expansiones de palabras y redirección de E/S, y veremos el código que realiza funciones.

Al final de este tutorial, tendremos un shell básico de Linux, que no servirá de mucho por ahora, pero que ampliaremos y mejoraremos en las próximas partes. Al final de esta serie, tendremos un shell de Linux completamente funcional que puede analizar y ejecutar un conjunto bastante complejo de comandos, bucles y expresiones.

Que necesitarás

Para seguir este tutorial, necesitará lo siguiente:

  • Un sistema GNU/Linux que funcione (yo personalmente uso Ubuntu y Fedora , pero puedes usar tu distribución de Linux favorita).
  • GCC (GNU Compiler Collection) para compilar el código.
  • Un editor de texto para escribir el código (yo personalmente uso GEdit , pero también puedes usar Vim , Emacs o cualquier otro editor).
  • como programar en c

No voy a profundizar en los detalles de la instalación del software requerido aquí. Si no está seguro de cómo hacer que su sistema ejecute cualquiera de los paquetes de software anteriores, consulte la documentación de su distribución de Linux y asegúrese de tener todo configurado antes de continuar.

Ahora vayamos al grano. Comenzaremos por tener una vista panorámica de lo que constituye un shell de Linux.

Componentes de un shell de Linux

El shell es una pieza compleja de software que contiene muchas partes diferentes.

La parte central de cualquier shell de Linux es el intérprete de línea de comandos o CLI . Esta parte tiene dos propósitos: lee y analiza los comandos del usuario, luego ejecuta los comandos analizados. Puede pensar que la CLI en sí misma tiene dos partes: un analizador (o front-end) y un ejecutor (o back-end).

El analizador analiza la entrada y la divide en tokens. Un token consta de uno o más caracteres (letras, dígitos, símbolos) y representa una sola unidad de entrada. Por ejemplo, un token puede ser un nombre de variable, una palabra clave, un número o un operador aritmético.

El analizador toma estos tokens, los agrupa y crea una estructura especial que llamamos Árbol de sintaxis abstracta , o AST . Puede pensar en el AST como una representación de alto nivel de la línea de comando que le dio al shell. El analizador toma el AST y lo pasa al ejecutor , que lee el AST y ejecuta el comando analizado.

Otra parte del shell es la interfaz de usuario, que normalmente funciona cuando el shell está en modo interactivo , por ejemplo, cuando ingresa comandos en el indicador del shell. Aquí, el shell se ejecuta en un bucle, que conocemos como Read-Eval-Print-Loop o REPL.

Como indica el nombre del ciclo, el shell lee la entrada, la analiza y la ejecuta, luego realiza un ciclo para leer el siguiente comando, y así sucesivamente hasta que ingrese un comando como

 exit
,
 shutdown
, o
 reboot
.

La mayoría de los shells implementan una estructura conocida como tabla de símbolos , que el shell utiliza para almacenar información sobre las variables, junto con sus valores y atributos. Implementaremos la tabla de símbolos en la parte II de este tutorial.

Los shells de Linux también tienen una función de historial, que permite al usuario acceder a los comandos ingresados más recientemente, luego editar y volver a ejecutar los comandos sin escribir mucho. Un shell también puede contener utilidades integradas , que son un conjunto especial de comandos que se implementan como parte del propio programa shell.

Las utilidades integradas incluyen comandos de uso común, como

 cd
,
 fg
, y
 bg
. Implementaremos muchas de las utilidades integradas a medida que avanzamos en este tutorial.

Ahora que conocemos los componentes básicos de un shell típico de Linux, comencemos a construir nuestro propio shell.

Nuestra primera concha

Nuestra primera versión del caparazón no hará nada elegante; simplemente imprimirá una cadena de solicitud, leerá una línea de entrada y luego repetirá la entrada en la pantalla. En partes posteriores de este tutorial, agregaremos la capacidad de analizar y ejecutar comandos, bucles, expresiones condicionales y mucho más.

Comencemos por crear un directorio para este proyecto. yo suelo usar

 ~/projects/
para mis nuevos proyectos, pero siéntete libre de usar cualquier camino con el que te sientas cómodo.

Lo primero que haremos será escribir nuestro bucle REPL básico. Crear un archivo llamado

 main.c
(usando
 touch main.c
), luego ábralo usando su editor de texto favorito. Ingrese el siguiente código en su
 main.c
expediente:

 # include <stdio.h> # include <stdlib.h> # include <errno.h> # include <string.h> # include "shell.h" int main ( int argc, char **argv) { char *cmd; do { print_prompt1(); cmd = read_cmd(); if (!cmd) { exit (EXIT_SUCCESS); } if (cmd[ 0 ] == '\0' || strcmp (cmd, "\n" ) == 0 ) { free (cmd); continue ; } if ( strcmp (cmd, "exit\n" ) == 0 ) { free (cmd); break ; } printf ( "%s\n" , cmd); free (cmd); } while ( 1 ); exit (EXIT_SUCCESS); }

Nuestro

 main()
La función es bastante simple, ya que solo necesita implementar el ciclo REPL. Primero imprimimos el indicador del shell, luego leemos un comando (por ahora, definamos un comando como una línea de entrada que termina con
 \n
). Si hay un error al leer el comando, salimos del shell. Si el comando está vacío (es decir, el usuario presionó
 ENTER
sin escribir nada), salteamos esta entrada y continuamos con el bucle.

Si el comando es

 exit
, salimos del shell. De lo contrario, hacemos eco del comando, liberamos la memoria que usamos para almacenar el comando y continuamos con el bucle. Bastante simple, ¿no?

Nuestro

 main()
función llama a dos funciones personalizadas,
 print_prompt1()
y
 read_cmd()
. La primera función imprime la cadena de solicitud y la segunda lee la siguiente línea de entrada. Echemos un vistazo más de cerca a esas dos funciones.

Impresión de cadenas de solicitud

Dijimos que el shell imprime una cadena de solicitud antes de leer cada comando. De hecho, hay cinco tipos diferentes de cadena de solicitud: PS0 , PS1 , PS2 , PS3 y PS4 . La cadena cero, PS0 , solo la usa bash , por lo que no la consideraremos aquí. Las otras cuatro cadenas se imprimen en ciertos momentos, cuando el shell quiere transmitir ciertos mensajes al usuario.

En esta sección, hablaremos de PS1 y PS2 . El resto vendrá más adelante cuando discutamos temas de shell más avanzados.

Ahora crea el archivo fuente

 prompt.c
e ingrese el siguiente código:

 # include <stdio.h> # include "shell.h" void print_prompt1 ( void ) { fprintf ( stderr , "$ " ); } void print_prompt2 ( void ) { fprintf ( stderr , "> " ); }

La primera función imprime la primera cadena de solicitud, o PS1 , que generalmente ve cuando el shell está esperando que ingrese un comando. La segunda función imprime la segunda cadena de solicitud, o PS2 , que imprime el shell cuando ingresa un comando de varias líneas (más sobre esto a continuación).

A continuación, leamos algunas entradas de los usuarios.

Lectura de la entrada del usuario

Abre el archivo

 main.c
e ingrese el siguiente código al final, justo después del
 main()
función:

 char * read_cmd ( void ) { char buf[ 1024 ]; char *ptr = NULL ; char ptrlen = 0 ; while (fgets(buf, 1024 , stdin )) { int buflen = strlen (buf); if (!ptr) { ptr = malloc (buflen+ 1 ); } else { char *ptr2 = realloc (ptr, ptrlen+buflen+ 1 ); if (ptr2) { ptr = ptr2; } else { free (ptr); ptr = NULL ; } } if (!ptr) { fprintf ( stderr , "error: failed to alloc buffer: %s\n" , strerror(errno)); return NULL ; } strcpy (ptr+ptrlen, buf); if (buf[buflen -1 ] == '\n' ) { if (buflen == 1 || buf[buflen -2 ] != '\\' ) { return ptr; } ptr[ptrlen+buflen -2 ] = '\0' ; buflen -= 2 ; print_prompt2(); } ptrlen += buflen; } return ptr; }

Aquí leemos la entrada de stdin en fragmentos de 1024 bytes y almacenamos la entrada en un búfer. La primera vez que leemos la entrada (el primer fragmento del comando actual), creamos nuestro búfer usando

 malloc()
. Para fragmentos posteriores, ampliamos el búfer usando
 realloc()
. No deberíamos encontrar ningún problema de memoria aquí, pero si sucede algo incorrecto, imprimimos un mensaje de error y devolvemos
 NULL
. Si todo va bien, copiamos el fragmento de entrada que acabamos de leer del usuario a nuestro búfer y ajustamos nuestros punteros en consecuencia.

El último bloque de código es interesante. Para entender por qué necesitamos este bloque de código, consideremos el siguiente ejemplo. Digamos que desea ingresar una línea de entrada muy, muy larga:

 echo "This is a very long line of input, one that needs to span two, three, or perhaps even more lines of input, so that we can feed it to the shell"

Este es un ejemplo tonto, pero demuestra perfectamente de lo que estamos hablando. Para ingresar un comando tan largo, podemos escribir todo en una línea (como hicimos aquí), lo cual es un proceso engorroso y feo. O podemos cortar la línea en pedazos más pequeños y alimentar esos pedazos al caparazón, una pieza a la vez:

 echo "This is a very long line of input, \ one that needs to span two, three, \ or perhaps even more lines of input, \ so that we can feed it to the shell"

Después de escribir la primera línea, y para que el shell sepa que no terminamos nuestra entrada, terminamos cada línea con un carácter de barra invertida.

 \\
, seguido de nueva línea (también puse sangría en las líneas para que fueran más legibles). A esto lo llamamos escapar del carácter de nueva línea. Cuando el shell ve la nueva línea escapada, sabe que necesita descartar los dos caracteres y continuar leyendo la entrada.

Ahora volvamos a nuestro

 read_cmd()
función. Estábamos discutiendo el último bloque de código, el que dice:

 if (buf[buflen -1 ] == '\n' ) { if (buflen == 1 || buf[buflen -2 ] != '\\' ) { return ptr; } ptr[ptrlen+buflen -2 ] = '\0' ; buflen -= 2 ; print_prompt2(); }

Aquí, verificamos si la entrada que tenemos en el búfer termina con

 \n
y, de ser así, si el
 \n
es escapado por un carácter de barra invertida
 \\
. si el ultimo
 \n
no se escapa, la línea de entrada está completa y la devolvemos a la
 main()
función. De lo contrario, eliminamos los dos caracteres (
 \\
y
 \n
), imprima PS2 y continúe leyendo la entrada.

Compilando el Shell

Con el código anterior, nuestro shell de nicho está casi listo para compilarse. Solo agregaremos un archivo de encabezado con nuestros prototipos de funciones, antes de proceder a compilar el shell. Este paso es opcional, pero mejora en gran medida la legibilidad de nuestro código y evita algunas advertencias del compilador.

Crear el archivo fuente

 shell.h
, e ingrese el siguiente código:

 # ifndef SHELL_H # define SHELL_H void print_prompt1 ( void ) ; void print_prompt2 ( void ) ; char * read_cmd ( void ) ; # endif

Ahora vamos a compilar el shell. Abra su emulador de terminal favorito (yo pruebo mis proyectos de línea de comandos usando GNOME Terminal y Konsole , pero también puede usar XTerm , otros emuladores de terminal o una de las consolas virtuales de Linux). Navegue a su directorio de origen y asegúrese de tener 3 archivos allí:

Ahora compile el shell usando el siguiente comando:

 gcc -o shell main.c prompt.c

Si todo va bien,

 gcc
no debería generar nada, y debería haber un archivo ejecutable llamado
 shell
en el directorio actual:

Ahora invoque el shell ejecutando

 ./shell
e intente ingresar algunos comandos:

En el primer caso, el shell imprime PS1 , que por defecto es

 $
y un espacio Entramos en nuestro comando,
 echo Hello World
, que el shell nos devuelve (extenderemos nuestro shell en la parte II para permitirle analizar y ejecutar este y otros comandos simples).

En el segundo caso, el shell vuelve a hacer eco de nuestro comando (ligeramente largo). En el tercer caso, dividimos el comando largo en 4 líneas. Observe cómo cada vez que escribimos una barra invertida seguida de

 ENTER
, el shell imprime PS2 y continúa leyendo la entrada. Después de ingresar la última línea, el shell fusiona todas las líneas, elimina todos los caracteres de nueva línea escapados y nos devuelve el comando.

Para salir del shell, escriba

 exit
, seguido por
 ENTER
:

¡Y eso es! Acabamos de terminar de escribir nuestro primer shell de Linux. ¡Hurra!

Que sigue

Aunque nuestro shell actualmente funciona, no hace nada útil. En la siguiente parte, arreglaremos nuestro shell para que sea capaz de analizar y ejecutar simples
comandos

¡Manténganse al tanto!