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 ) 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. shell de Thompson 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, , y . Sin embargo, siempre me ha molestado esta pregunta: Como por ejemplo: bash ksh zsh ¿qué hace que el shell funcione? ¿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, 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. desde cero. 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 y , pero puedes usar tu distribución de Linux favorita). Ubuntu Fedora (GNU Compiler Collection) para compilar el código. GCC Un editor de texto para escribir el código (yo personalmente uso , pero también puedes usar , o cualquier otro editor). GEdit Vim Emacs 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 o . 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 (o front-end) y un (o back-end). intérprete de línea de comandos CLI analizador ejecutor El analiza la entrada y la divide en tokens. Un 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. analizador token El toma estos tokens, los agrupa y crea una estructura especial que llamamos , o . 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 , que lee el AST y ejecuta el comando analizado. analizador Árbol de sintaxis abstracta AST ejecutor Otra parte del shell es la interfaz de usuario, que normalmente funciona cuando el shell está en , por ejemplo, cuando ingresa comandos en el indicador del shell. Aquí, el shell se ejecuta en un bucle, que conocemos como o modo interactivo Read-Eval-Print-Loop 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 , , o . exit shutdown reboot La mayoría de los shells implementan una estructura conocida como , 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. tabla de símbolos 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 , que son un conjunto especial de comandos que se implementan como parte del propio programa shell. utilidades integradas Las utilidades integradas incluyen comandos de uso común, como , , y . Implementaremos muchas de las utilidades integradas a medida que avanzamos en este tutorial. cd fg bg 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 para mis nuevos proyectos, pero siéntete libre de usar cualquier camino con el que te sientas cómodo. ~/projects/ Lo primero que haremos será escribir nuestro bucle REPL básico. Crear un archivo llamado (usando ), luego ábralo usando su editor de texto favorito. Ingrese el siguiente código en su expediente: main.c touch main.c main.c { *cmd; { print_prompt1(); cmd = read_cmd(); (!cmd) { (EXIT_SUCCESS); } (cmd[ ] == || (cmd, ) == ) { (cmd); ; } ( (cmd, ) == ) { (cmd); ; } ( , cmd); (cmd); } ( ); (EXIT_SUCCESS); } # include <stdio.h> # include <stdlib.h> # include <errno.h> # include <string.h> # include "shell.h" int main ( argc, **argv) int char char do if exit if 0 '\0' strcmp "\n" 0 free continue if strcmp "exit\n" 0 free break printf "%s\n" free while 1 exit Nuestro 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 ). Si hay un error al leer el comando, salimos del shell. Si el comando está vacío (es decir, el usuario presionó sin escribir nada), salteamos esta entrada y continuamos con el bucle. main() \n ENTER Si el comando es , 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? exit Nuestro función llama a dos funciones personalizadas, y . 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. main() print_prompt1() read_cmd() 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: , , , y . La cadena cero, , solo la usa , 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. PS0 PS1 PS2 PS3 PS4 PS0 bash En esta sección, hablaremos de y . El resto vendrá más adelante cuando discutamos temas de shell más avanzados. PS1 PS2 Ahora crea el archivo fuente e ingrese el siguiente código: prompt.c { ( , ); } { ( , ); } # include <stdio.h> # include "shell.h" void print_prompt1 ( ) void fprintf stderr "$ " void print_prompt2 ( ) void fprintf stderr "> " La primera función imprime la o , que generalmente ve cuando el shell está esperando que ingrese un comando. La segunda función imprime la o , que imprime el shell cuando ingresa un comando de varias líneas (más sobre esto a continuación). primera cadena de solicitud, PS1 segunda cadena de solicitud, PS2 A continuación, leamos algunas entradas de los usuarios. Lectura de la entrada del usuario Abre el archivo e ingrese el siguiente código al final, justo después del función: main.c main() { buf[ ]; *ptr = ; ptrlen = ; (fgets(buf, , )) { buflen = (buf); (!ptr) { ptr = (buflen+ ); } { *ptr2 = (ptr, ptrlen+buflen+ ); (ptr2) { ptr = ptr2; } { (ptr); ptr = ; } } (!ptr) { ( , , strerror(errno)); ; } (ptr+ptrlen, buf); (buf[buflen ] == ) { (buflen == || buf[buflen ] != ) { ptr; } ptr[ptrlen+buflen ] = ; buflen -= ; print_prompt2(); } ptrlen += buflen; } ptr; } * char read_cmd ( ) void char 1024 char NULL char 0 while 1024 stdin int strlen if malloc 1 else char realloc 1 if else free NULL if fprintf stderr "error: failed to alloc buffer: %s\n" return NULL strcpy if -1 '\n' if 1 -2 '\\' return -2 '\0' 2 return Aquí leemos la entrada de 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 . Para fragmentos posteriores, ampliamos el búfer usando . No deberíamos encontrar ningún problema de memoria aquí, pero si sucede algo incorrecto, imprimimos un mensaje de error y devolvemos . 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. stdin malloc() realloc() NULL 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, larga: muy 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 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. \\ escapar Ahora volvamos a nuestro función. Estábamos discutiendo el último bloque de código, el que dice: read_cmd() (buf[buflen ] == ) { (buflen == || buf[buflen ] != ) { ptr; } ptr[ptrlen+buflen ] = ; buflen -= ; print_prompt2(); } if -1 '\n' if 1 -2 '\\' return -2 '\0' 2 Aquí, verificamos si la entrada que tenemos en el búfer termina con y, de ser así, si el es por un carácter de barra invertida . si el ultimo no se escapa, la línea de entrada está completa y la devolvemos a la función. De lo contrario, eliminamos los dos caracteres ( y ), imprima y continúe leyendo la entrada. \n \n escapado \\ \n main() \\ \n PS2 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 , e ingrese el siguiente código: shell.h ; ; ; # SHELL_H ifndef # SHELL_H define 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 y , pero también puede usar , otros emuladores de terminal o una de las de Linux). Navegue a su directorio de origen y asegúrese de tener 3 archivos allí: GNOME Terminal Konsole XTerm consolas virtuales Ahora compile el shell usando el siguiente comando: gcc -o shell main.c prompt.c Si todo va bien, no debería generar nada, y debería haber un archivo ejecutable llamado en el directorio actual: gcc shell Ahora invoque el shell ejecutando e intente ingresar algunos comandos: ./shell En el primer caso, el shell imprime , que por defecto es y un espacio Entramos en nuestro comando, , que el shell nos devuelve (extenderemos nuestro shell en la parte II para permitirle analizar y ejecutar este y otros comandos simples). PS1 $ echo Hello World 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 , el shell imprime 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. ENTER PS2 Para salir del shell, escriba , seguido por : exit 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!