Lab2: Un Baash
Objetivos
Comprender y utilizar los mecanismos de concurrencia y comunicación
de gruesa granularidad que brinda UNIX. Proveer una implementación
simple de un intérprete de línea de comandos (shell) al estilo
del Bourne Shell y Bourne Again Shell.
Introducción
La interfaz más tradicional de un sistema operativo UNIX-like
(*NIX) es el intérprete de línea de comandos. Este
programa, que se ejecuta en modo usuario, funciona en cualquier *NIX que
soporte una interface de caracteres y tiene por misión aceptar comandos
ingresados por teclado, paresearlos cuando se ingresa Enter, ejecutar la
órden y mostrar el resultado en pantalla, para luego volver a repetir
el proceso.
Por defecto UNIX ejecuta un proceso shell cada vez que un usuario interactivo
ingresa al sistema. Aunque esto puede ser configurado de otra manera (ver
el último campo de cada línea del archivo /etc/passwd),
en la mayoría de los casos luego de ingresar nuestro nombre de usuario
y contraseña, el proceso que maneja el ingreso de usuarios genera
un proceso hijo que ejecuta un shell, con el uid/gid (identificador
de usuario y grupo) correspondiente al usuario. En este momento la pantalla
se suele presentar de la siguiente manera:
Después de este texto inicial llamado prompt, que contiene
información de entorno como por ejemplo el nombre del usuario, el
nombre del host y el último tramo del directorio corriente, el shell
espera datos a través de la stdin que normalmente se asocia al dispositivo
teclado. Podemos escribir el comando que deseamos que el shell ejecute,
e iniciar la ejecución ingresando el caracter NEWLINE ('\n') generalmente
asociado con la tecla Enter o Return.
[juan@hal juan]$ sleep 10
|
Hará que el shell ejecute un proceso con el programa binario
que se encuentra en /bin/sleep, pasándole el argumento
"10".
Operación Básica
La sintáxis básica del intérprete de comandos más
usual de *NIX, conocido como Bourne Shell (Bourne Again Shell - bash, en
Linux) es la siguiente,
comando argumento1 argumento2 ...
donde el comando y los argumentos son secuencias de caracteres separados
por uno o más espacios.
La semántica dice que al presionar Enter el shell buscará
el comando dentro de los comandos internos y si no lo encuentra tratará
de buscar un archivo ejecutable con ese nombre, siguiendo las reglas de
camino de *NIX o componiendo el comando a la secuencia de la variable
de entorno PATH, para luego crear un proceso hijo que
cargará y ejecutará el contenido de ese archivo con los argumentos
correspondientes.
Los comandos internos son manejados directamente por el shell, sin requerir
ningún archivo externo. Un ejemplo es el comando de cambio de directorio
cd,
el cual no se encuentra como archivo ejecutable en ningún lugar
del árbol de directorio (el comando find /bin /sbin /usr/bin
/usr/sbin -perm +400 -type f -name cd no devuelve nada).
Con man builtin obtenemos una lista de todos los comandos
internos implementados en bash.
Si un el comando no es un builtin, el shell deberá buscar
un archivo dentro de su sistema de archivos, cargarlo y ejecutarlo pasándole
los argumentos. El problema principal es dónde buscar este archivo.
Existen tres formas: camino absoluto, camino relativo y secuencia PATH.
Cuando el comando comienza con /, este se toma como un camino
absoluto dentro del árbol del filesystem, el shell cargará
en memoria y ejecutará el comando. En cambio si el comando comienza
con ./ o ../, se debe seguir las reglas usuales de camino
relativo de *NIX, cargar y ejecutar el archivo comando, relativo al
camino actual (ver comando pwd).
Podemos pensar las reglas de camino absoluto y relativo como una sola,
si el comando comienza con / , ./ o ../ entonces
representa el camino del archivo según la semántica *NIX
estandar de camino.
Otro mecanismo entra en juego cuando el comando no comienza con un delimitador
de camino absoluto o relativo. La variable de entorno PATH, que
puede ser leida con el comando env o con echo $PATH,
sirve de secuencia de caminos absolutos o relativos, separados por ':'
que serán prefijados a commando hasta encontrar un archivo que pueda
ser leido y ejecutado.
Usemos el archivo ejecutable /bin/date que nos proporciona
la fecha y hora del sistema para ejemplificar los mecanismos de camino
absoluto, relativo y secuencia PATH.
[juan@hal juan]$ /bin/date
Mon Aug 26 15:33:22 ART 2002
[juan@hal juan]$ cd /usr
[juan@hal juan]$ ../bin/date
Mon Aug 26 15:33:57 ART 2002
[juan@hal juan]$ date
Mon Aug 26 15:34:24 ART 2002
|
Todos los comandos ejecutados por bash son el mismo /bin/date.
Preguntas
-
¿Cómo llamamos a un archivo ejecutable que se llama exactamente
como un builtin?
-
¿Por qué las recomendaciones de seguridad indican que es
peligroso tener ./ en PATH al más puro estilo
DOS?
-
Supongamos que existen 2 comandos posibles dentro de la secuencia que contien
PATH,
donde el primero en la secuencia no está marcado como ejecutable
y el segundo si. ¿Qué hace el intérprete
bash,
ejecuta el segundo o informa que el primero no tiene permiso de ejecución?
(incorporte esta semántica a baash)
Procesos en Segundo Plano
En el paradigma básico para ejecutar un comando, el shell es el
proceso padre que crea a un hijo que cargará y ejecutará
el comando que se encuentra en el filesystem. Mientras tanto el padre quedará
esperando hasta que el proceso hijo termine.
Esta semántica puede ser cambiada para que el padre no espere
la terminación del hijo, si le agregamos al final el símbolo
'&'.
Este operador, que puede ser pensado como operador de composición
paralela, crea la posibilidad de ejecutar procesos de manera concurrente,
es decir, si el comando que ejecutamos termina con & y demora un tiempo
relativamente largo, todo lo que iniciemos desde el shell y el shell mismo
estarán ejecutándose al mismo tiempo (paralelamente, concurrentemente).
Esta característica resulta muy útil cuando nuestro kernel
esta corriendo sobre un motherboard SMP (symmetric multiprocessing), que
soporta varios microprocesadores compartiendo una única imagen de
memoria principal. Si tenemos un par de programas que tardan mucho en,
por ejemplo calcular decimales de pi o de e, podemos ponerlos
a correr de manera que cada uno ocupen un microprocesador, mientras utilizamos
un poco de los dos micros para jugar.
[juan@deepblue calculosLargos]$ ./decimalesPi salidaPi.txt &
[juan@deepblue calculosLargos]$ ./decimalesE salidaE.txt &
[juan@deepblue calculosLargos]$ freeciv
|
Existe un problema con los procesos hijos en background, la entrada
y salida estandard se comparten entre todos, con lo cual tendremos salidas
de pantalla entrelazadas y las entradas por teclado serán no-deterministicamente
asignadas a alguno de los procesos que están corriendo.
Este ejemplo nos muestra el lio que se puede armar.
[juan@hal juan]$ yes "HOLA" & yes "CHAU" &
... ud. acaba de perder el control de esa shell ...
|
Preguntas
-
¿Cuáles son los comandos internos para manejo de procesos
en background en bash?
-
En el ejemplo de arriba el operador '&' funciona como operador
de composición paralela. ¿Cuál es el operador
de composición secuencial en Bourne shell?
Redirección de la Entrada/Salida
Cuando se crea un proceso, se le asignan tres identificadores de archivos
(file descriptors): stdin, stdout y stderr.
Si se lee desde stdin, los datos que se reciben serán dirigidos
desde el teclado al file descriptor stdin. Similarmente, los datos
escritos en stdout, stderr serán mapeados a la
pantalla de la terminal.
El usuario puede redefinir el stdin desde la línea de
comandos del shell si se provee al final del comando un embudo de entrada
'<' y un nombre de archivo. Este archivo reemplazará
la corriente estandar de entrada, por lo que los valores que lea el comando
serán extraidos desde el archivo.
Similarmente podemos redefinir el file descriptor de stdout
con el embudo de salida '>' y un nombre de archivo al final del
comando y los argumentos, para indicar que toda la salida estandar del
comando se acumule en el archivo.
Como ejemplo podemos recolectar estadísticas sobre un header
del código fuente del kernel y ponerlo en un archivo de estadísticas.
[juan@hal juan]$ wc < kernel.h > kernel.h.stats
|
O si nuestros programas cpu-bound generan sus resultados en pantalla
podemos ejecutarlos concurrentemente y mantener sus salidas separadas,
mientras las monitoreamos con el comando tail.
[juan@deepblue juan]$ forest3d -n 0.2 > n0.2.out &
[juan@deepblue juan]$ forest3d -n 0.3 > n0.3.out &
[juan@deepblue juan]$ tail -f n0.2.out n0.3.out
|
Preguntas
-
Las corrientes estandar stdout y stderr están
dirigidas ambas a la consola. ¿Cómo podemos utilizar los
opeardores de redirección para separarlas?
Tuberías (o ... la plomería continua)
Las tuberías o pipes son el principal mecanismo de Comunicación
entre Procesos (IPC) para *NIX. Una tubería se compone de dos puntas,
una entrada y una salida, cada una conectada a un proceso. Las puntas de
una tubería son un par de filedescriptors, uno para escribir
y el otro para leer. Un pipe es básicamente un buffer FIFO, donde
tenemos un productor, sobre el write-end y un consumidor
sobre el read-end. Como sabemos que todas las colas que se encuentran
en el sistema operativo son acotadas (memoria finita), además de
tener un read bloqueante cuando no hay datos para consumir, tenemos un
send bloqueante cuando la capacidad del buffer está excedida.
El shell provee la funcionalidad de un pipe a través del operador
'|', denominado justamente pipe, el cual conecta la salida
estandar del proceso lanzado por el comando de la izquierda del pipe, con
la entrada estandar del proceso que se genera con el comando a la derecha
del símbolo de tubería.
Las tuberías se pueden componer secuencialmente para generar
una linea de producción de datos, el cual es un estilo arquitectural
denominado pipes&filters.
Estos ejemplos cuentan cuantas veces Juan ingresó al sistema,
los pids (process identifiers) de todos los netscapes, y el listado ordenado
de mayor a menor de todos los usuarios del sistema que tienen bash,
[juan@hal juan]$ last juan | wc -l
[juan@hal juan]$ ps aux | grep netscape
[juan@hal juan]$ grep bash /etc/passwd | cut -d ":" -f 1 | sort
-r
|
mientras que el siguiente es un poco más complicado y permite
reestablecer interactivamente un dump de una partición ext2,
que ha sido partido para sortear la limitación de 2GB de tamaño
del sistema de archivos ext2.
[juan@hal juan]$ cat backupL0.gza? | gzip -dc | /sbin/restore -i
-f -
|
Preguntas
-
¿Cúal es el mecanismo para que este estilo pipes&filters,
hasta ahora absolutamente lineal, permita bifurcaciones? Si tuvieramos
bifurcaciones (o "T's" para un plomero), podríamos capturar la salida
estandar en un archivo y al mismo tiempo visualizarla en consola.
-
Los pipes existen en el filesystem mientras dura el comando. ¿Dónde
se encuentran y que atributos tiene?
-
¿Cómo es el pipe de comandos para generar el dump
de nivel 0 que se reestablece interactivamente con el ejemplo anterior?
Tareas
El problema se divide en 3 partes, de manera que su concreción se
realice en forma progresiva. Es una guía de como llegar al resultado
final, nuestro baash, sin tener que sortear todos los problemas
de diseño y técnicos de una sola vez.
Parte A
Escriba un programa en "C" que actúe com una concha de intérprete
de línea de comandos para el sistema operativo Linux. Su programa
deberá utilizar la misma sintaxis de Bourne shell para correr programas.
comando argumento1 argumento2 ...
Efectuando la búsqueda del comando según se presente como
path absoluto, relativo o involucre una búsqueda en la secuencia
de la variable de entorno PATH.
El programa deberá generar un proceso hijo para correr el comando,
a fin de protegerse de comportamientos malos, y pasarle los argumentos
correspondientes.
Es preferible imprimir un prompt con información relevante a
la izquierda de donde se introduce el comando. El nombre del host, el nombre
del usuario y el camino corriente pueden ser algunos ejemplos.
El único comando interno que se implementa es exit,
el que termina con la ejecución del intérprete de comandos.
Luego de implementar esta parte su baash deberá ser
capaz de invocar desde simples peticiones como date,que involucran
búsquedas en PATH, hasta llamadas a complicados comandos de compilación
o linking utilizando path relativos y decenas de opciones.
Parte B
Agregue al shell de la parte anterior, la funcionalidad de correr programas
como procesos en background, o mejor dicho concurrentemente con el mismo
baash.
El usuario incluirá el operador '&' a la derecha del
último argumento para activar esta capacidad.
Pruebe la nueva versión de la siguiente manera
baash-0.01> ./ksamp -l 3 120 &
baash-0.01> ./ksamp -l 1 120 &
|
Debería obtener un entrelazado no determinístico (efectúe
varias corridas) de la información generada por el programa del
laboratorio 1.
Parte C
Incorpore las funcionalidades de redirección y tuberías a
baash,
de manera tal que se interpreten los opeardores < filename,
>
filename y | command2 argumento2_1 argumento2_2 ... al final
de los argumentos del comando.
Deberá soportar a lo más uno de los cuatro operadores
al mismo tiempo.
Utilize todo el poder de baash con los ejemplos dados en la
introducción que respeten la restricción de "a lo más
un operador".
Cómo atacar los problemas
Parte A
Se puede ir paso a paso, generando programas de prueba que realicen tareas
cada vez más complejas y cercanas al enunciado de la parte A. Si
usted se siente confiado respecto a poder solucionar el problema sin hacer
desarrollo incremental, puede saltar estos pasos.
-
Escriba un programa que inicialice las variables e ingrese en un lazo que
pida entrada por teclado y que termine sólo cuando se ingresa una
condición de EOF (end of file) como Ctrl-D o el
comando interno exit.
-
Refine el punto anterior de manera tal que haga parsing de la línea
de comandos y lo ponga en el array char *argv[]. También
compute argc. A manera de debug imprima, luego del parsing, el
contenido de argc y argv.
-
En el siguiente refinamiento, use argv[0] para encontrar el archivo ejecutable.
En esta versión solo imprima el fullpath del archivo.
-
Haga una versión simple que solo pueda encontrar archivos de comando
que están en el directorio corriente.
-
Mejore su programa para que acepte path absolutos y relativos.
-
Active en su prototipo la posibilidad de buscar en los directorios de la
lista PATH.
-
Ahora si cree el hijo y ejecute el comando con sus argumentos.
Esta parte debería ser un reflejo exacto del poder de las
llamadas a sistema para manejo de procesos fork, execv,
y wait. Todo lo que resta es el parsing de la línea de
comandos y la impresión del prompt.
Veamos un ejemplo de uso del fork y wait para crear
toda una parentela de procesos. Notar el interleaving que se produce en
las ejecuciones.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define N 8
int
main (int argc, char **argv)
{
unsigned
int i,j,h,s;
int status;
if (argc!=2)
{
fprintf(stderr,"Uso: %s cantidad_de_hijos\n",argv[0]);
exit(1);
}
h = atoi(argv[1]);
for (i=0;i<h;i++)
{
s = fork();
if (s<0) {
perror("Creando el hijo");
exit(1);
}
else if (s==0) {
for(j=0;j<N;j++)
printf("Hijo pid=%5d: iteración %3d\n",getpid(),j);
return 0; /*no quiero nietos!*/
}
else
printf("PADRE: hijo pid=%5d, creado\n",s);
}
for (i=0;i<h;i++)
{
s = wait(&status);
if (s<0) {
perror("Esperando el hijo");
exit(1);
}
printf("PADRE: hijo pid=%5d, terminado\n",s);
}
return
0;
}
|
El otro syscall a utilizar es execv, el cual reemplaza
la imagen del proceso por una nueva que se encuentra en un archivo. En
este pequeño ejemplo, se reemplaza el proceso actual por el que
ejecuta el programa
date.
#include <stdio.h>
#include <unistd.h>
int
main (int argc, char **argv)
{
execvp("date",argv);
printf("Llega hasta
aca?\n");
return 0;
}
|
Aunque el syscall execvp, parece que le soluciona todos
los problemas relativos a la búsqueda el fullpath del ejecutable,
no
se permite su utilización y deberá codificar o reutilizar
algoritmos y estructuras de datos apropiadas.
Parte B
El proceso que ejecuta el programa baash deberá correr concurrentemente
con el proceso hijo creado para ejecutar el comando, si es que hay un operador
'&' a la derecha de todos los argumentos. Simplemente no hay
que esperar!
Parte C
Cuando se crea un proceso, este hereda de los descriptores de archivos
abiertos de su padre. Además como el proceso 1, denominado init
y padre de los restantes procesos del sistema (ps aux | grep
init, o bien pstree para obtener el árbol genealógico),
tiene abierto los tres primeros identificadores de archivos stdin,
stdout
y stderr (0,1 y 2) a descriptores de archivos asociados a la terminal
de entrada y a la terminal de salida; todos los hijos heredan estos fileid's
asociados a los filedescriptors.
Para implementar los operadores de redirección bastaría
con cambiar los filedescriptors asociados a los fileid's de stdin,
stdout
y stderr. Esto se puede hacer con la syscall dup, que
duplica
el filedescriptor indicado en su argumento, en el menor fileid libre. Veamos
un ejemplo que redirige la salida a un archivo. Notar que las syscall
para manejo de archivos que no son las habituales de la libc (fopen,
fclose,
...).
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define STDOUT_FID 1
int
main (int argc, char **argv)
{
int fid;
int flags,perm;
flags =
O_WRONLY|O_CREAT|O_TRUNC;
perm =
S_IWUSR|S_IRUSR;
fid =
open("redireccion.out", flags, perm);
if (fid<0)
{ perror("open");exit(1); }
close(STDOUT_FID);
/* stdout def. en stdio.h es un FILE* */
if (dup(fid)<0)
{ perror("dup"); exit(1); }
printf("Hello
World!\n");
close(fid);
return
0;
}
|
Para implementar el operador de tubería '|', debemos
utilizar la syscall pipe, la cual crea en el espacio del filesystem
un nuevo nodo de este tipo y devuelve dos fileid's que serán la
salida y la entrada de la tubería.
Veamos un sencillo ejemplo de IPC.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int
main (int argc, char **argv)
{
int fork_id;
int srv_client_pipe[2];
int client_srv_pipe[2];
if (pipe(srv_client_pipe)<0)
{ perror("srv_client_pipe");exit(1); }
if (pipe(client_srv_pipe)<0)
{ perror("client_srv_pipe");exit(1); }
if ((fork_id
= fork())<0) { perror("fork");exit(1); };
if (fork_id!=0)
{ /* soy padre, soy cliente */
int status;
int x,fx;
x = 8;
printf("CLIENTE: envia %d\n",x);
write(client_srv_pipe[1], &x, sizeof(int));
read(srv_client_pipe[0], &fx, sizeof(int));
printf("CLIENTE: recibe %d\n",fx);
wait(&status);
close(srv_client_pipe[0]);
close(srv_client_pipe[1]);
close(client_srv_pipe[0]);
close(client_srv_pipe[1]);
}
else {
/* soy hijo, soy server */
int in,out;
read(client_srv_pipe[0], &in, sizeof(int));
printf("SERVIDOR: recibe %d\n",in);
out = in*in;
printf("SERVIDOR: envia %d\n",out);
write(srv_client_pipe[1], &out, sizeof(int));
}
return
0;
}
|
Notar que este programa aunque concurrente, presenta sólo un
interleaving en su salida. Se dice que el programa está fuertemente
sincronizado debido a las primitivas de comunicación.
Qué se debe Entregar
-
Código
-
Como en el laboratorio anterior se pide, código funcionando bajo
las especificaciones dadas y bajo cualquier caso de test de parámetros
de entrada, división en módulos, estilo de código,
uso de TAD, Makefile.
-
Todo el código debe estar bajo el directorio Lab2Gyy/,
con yy el número de grupo relleno con ceros a la izquierda
hasta completar los 2 dígitos.
-
Manejo de errores en todas las llamadas a sistema y uso de asserts.
-
Informe del desarrollo del proyecto
-
Como en el laboratorio anterior.
Cómo se debe Entregar
Idem al laboratorio anterior.
Tips
El problema de la búsqueda en la secuencia PATH es un simple problema
de búsqueda de secuencia, que con una buen ADT secuencia implementado
sobre listas se debería resolver de manera compacta.
Planteen el problema de parseo de forma general, ya saben todas las
posibilidades que hay para parsear, asi que no debería ser difícil
realizar una funcion parse_command que devuelva una estructura command
que contega toda la información necesaria para ejecutar el o los
comandos, y aplicar los operadores.
Si es lo suficientemente general, los puntos adicionales saldrán
sin esfuerzo adicional.
El shell refleja tan bien la estructura interna de los syscalls de *NIX
para creación de procesos, manejo de corrientes e IPC, como se refleja
en "The UNIX
Time-Sharing System" en su sección VI, que el programa principal
debería ser muy compacto. Si no logra esto reorganice sus
ideas intentando ser lo más económico posible.
Se recomienda fuertemente leer este artículo.
El libro "Advanced
Linux Programming", muestra le uso del assert en la página
30, y como manejar correctamente los errores que pueden devolver las llamadas
a sistema en la página 33. Estudie este material antes de comenzar
la codificación. Los ejemplos de código contienen un poco
de programación defensiva.
Deberá averiguar que funciones de la libc se pueden
utilizar para leer las variables de entorno.
Tareas Adicionales
Si les sobra tiempo pueden hacer las siguientes mejoras:
-
Posibilidad de mezclar los 4 operadores "&<>|", a lo más
una vez cada uno, en la misma línea de comandos.
-
Operador de composición secuencial ';', operador de composición
paralela general '&'.
-
Manejar bien los procesos lanzados en background para que no se generen zombies. Pueden consultar la sección 3.4.3 de "Advanced Linux Programming", que está en la página 57.
-
Prompt configurable desde la variable de entorno PS1.
Nicolás Wolovick, 4 de Septiembre de 2002