Arquitectura y
procesos
1
El diseño de cualquier sistema complejo como un sistema operativo tiene un componente muy
importante de arte e ingenio. No existe una metodología a seguir en la construcción de un sistema
operativo. La definición de una estructura por capas ayuda, pero no lo es todo. No existe, al menos hoy
día, una teoría establecida que nos asista en la tarea de crear un sistema operativo a partir de una
especificación de requisitos. En esta situación, llegar a un acuerdo ampliamente aceptado sobre cuáles
deben ser los contenidos de una asignatura como Diseño de Sistemas Operativos no resulta sencillo. Es
posible que la aproximación más adecuada sea estudiar un buen modelo, un caso de estudio. Para que
resulte útil en la docencia, este modelo de sistema operativo debe tener unos criterios de diseño bien
definidos y debe ser lo suficientemente sencillo para que permita ser comprendido en su totalidad por
una sola persona. Pero, ¿este sistema existe y es accesible? Afortunadamente sí. Es el sistema operativo
MINIX. Un sistema creado para ser enseñado. Del mismo modo que Niklaus Wirth diseñó el lenguaje
Pascal para mostrar lo en su opinión debía ser un lenguaje de programación, Andrew Tanenbaum ha
escrito MINIX para revelar sus ideas acerca de lo que debe ser el diseño y la implementación de un
sistema operativo. MINIX es el modelo sobre el que trabajaremos en la asignatura. Lo estudiaremos en
la clase de teoría y lo modificaremos en la clase de prácticas.
2
sistemas operativos. La versión 1.2 de MINIX fue publicada como un apéndice de la referencia [3]. La
versión 1.5 de MINIX, más completa, trata de ser, al menos en parte, conforme al estándar POSIX, lo
que provocó, entre otras cosas, un aumento en cinco llamadas al sistema, de cuarenta y cuatro a
cuarenta y nueve. Las nuevas llamadas son fcntl, mkdir, rmdir, ptrace y rename, y un
aumento de tamaño de 12000 a 20000 líneas. La versión 2.0.0 ([4]) es conforme POSIX 1003.1a. La
Fig. 1.1 muestra la evolución de MINIX a lo largo del tiempo:
La migración a otros procesadores de MINIX 1.5 se abandonó. La rama Linux fue tomada por
alguien que se cansó de las limitaciones de MINIX-386 y comenzó a construir su propio sistema
UNIX: Linux.
3
Fig. 1.2 Arquitectura plana de sistema operativo
4
procedimientos de servicio disponen de una amplia colección de procedimientos de utilidad que se
sitúan en el nivel más bajo de los tres.
El programa de usuario lleva a cabo las llamadas al sistema mediante interrupciones software o traps.
Una interrupción software especifica un vector de interrupción que, como su nombre indica, apunta a
una rutina de interrupción. ¿Por qué es necesaria una instrucción de interrupción software y no una
instrucción de salto? Debido a que esta instrucción de interrupción software conmuta el procesador
de modo usuario a modo supervisor, según ilustra la Fig. 1.5. Sólo en modo supervisor se permite al
procesador ejecutar instrucciones privilegiadas como los accesos a las posiciones de memoria
asignadas a los adaptadores de dispositivo o la copia de datos entre espacios de direccionamiento
diferentes. Uno de los parámetros del trap identifica la llamada al sistema solicitada y el resto
contienen los parámetros de la misma.
5
Fig. 1.6 Arquitectura microkernel de MINIX
Bajo la filosofía microkernel, un proceso de usuario que solicita un servicio del sistema se denomina
cliente. Un proceso de usuario que provee un servicio se denomina servidor. Entre estos últimos
figuran el sistema de ficheros y el gestor de memoria. Los manejadores de los dispositivos también
toman la forma de servidores; no obstante, se mantienen integrados en el núcleo para facilitar su
acceso a los dispositivos físicos. La labor del microkernel es soportar la comunicación entre clientes
y servidores, que se realiza mediante el envío de mensajes. Una de las ventajas de un sistema
microkernel es que las partes se dividen por fronteras bien definidas, por lo que resultan más
comprensibles y manejables. Una segunda ventaja es su idoneidad para construir sistemas
distribuidos, donde clientes y servidores ejecutan en máquinas diferentes y se intercambian los
mensajes a través de mecanismos de red.
6
Fig. 1.8 MINIX está estructurado en cuatro niveles de procesos
El nivel uno, el más profundo, que de aquí en adelante denominaremos "el núcleo" tiene dos
funciones fundamentales. La primera es proporcionar a los niveles superiores el concepto de proceso.
Se encarga de gestionar interrupciones y traps salvando el estado del proceso en ejecución en su
descriptor. La segunda es soportar el mecanismo de comunicación entre procesos a través de
mensajes. Sólo una mínima parte del nivel uno está escrita en ensamblador. El resto y los demás
niveles están escritos en lenguaje C. Los capítulos tres y cuatro están dedicados al estudio de este
nivel.
El nivel dos contiene los manejadores de los dispositivos de entrada-salida (teclado, disco, etc). Los
manejadores de dispositivos son conocidos como drivers pero, de aquí en adelante les llamaremos
tareas de entrada-salida o simplemente tareas. Existe una tarea para cada tipo de dispositivo. Si un
nuevo dispositivo se añade al sistema, es necesario incorporar una nueva tarea. El código del nivel
uno y las tareas se compilan conjuntamente en MINIX en un único espacio de direccionamiento. A
pesar de compartir un mismo espacio de direccionamiento, las tareas se comportan como procesos
independientes y se comunican entre sí mediante el mecanismo de paso mensajes del sistema, igual
que lo hacen los servidores y los procesos de usuario.
El nivel tres contiene tres procesos denominados servidores. Son el gestor de memoria, que sirve las
llamadas al sistema como fork, exec y brk, el sistema de ficheros, que sirve llamadas al sistema
como open, read, etc, y, finalmente el servidor de red, que implementa la pila TCP/IP. El sistema
de ficheros de MINIX ha sido diseñado como un servidor de ficheros, de manera que es sencillo
portarlo a una máquina diferente como servidor remoto. Lo mismo ocurre con el gestor de memoria.
En el nivel cuatro se sitúan los procesos de usuario, entre ellos init.
Cada tarea o proceso en un sistema MINIX tiene asociado un número de proceso. Cada proceso en el
sistema ocupa una entrada en la tabla de descriptores de proceso. El número de proceso es el índice del
proceso en la tabla (Fig. 1.9). Las primitivas de paso de mensajes send, receive y sendrec especifican
números de proceso en sus parámetros de dirección.
7
sistema, la tarea del terminal tiene siempre el número de proceso -9 y ocupa la primera entrada. Si se
introduce una nueva tarea, como un controlador de CD ROM, entonces esta pasa a tener número de
proceso -9 y el manejador del terminal número de proceso -10. Todos los demás procesos son
desplazados un lugar en la tabla, pero su número de proceso se mantiene.
La tarea -1 (HARDWARE) es una tarea ficticia. En MINIX, las rutinas de interrupción de los dispositivos
son cortas. Se limitan a construir un mensaje, que envían a la tarea que controla tal dispositivo,
indicándoles que hay trabajo que hacer. Estos mensajes se consideran procedentes de una tarea virtual,
la tarea -1. Lo importante de este truco es que consigue una abstracción del hardware como una tarea
que envía mensajes al resto de ellas, no recibe ninguno y no puede bloquearse.
El descriptor de proceso está partido en tres partes, de modo que realmente existen tres tablas de
descriptores. Una reside en el núcleo, otra en el gestor de memoria y otra en el sistema de ficheros.
Estas últimas no tienen entradas para las tareas, de modo que el primer descriptor corresponde al
gestor de memoria, que conserva el número de proceso 0.
Fig. 1.10 En MINIX un mensaje es una porción de memoria de tamaño fijo que se copia entre dos
espacios de direccionamiento
Observemos la Fig. 1.10. P1 y P2 son procesos. Como tales tienen espacios de direccionamiento
distintos. En un sistema UNIX, un intento de P1 de escribir en datos de P2 provoca que el proceso
muere y se reciba en pantalla un mensaje parecido a "segmentation fault". El paso de mensajes
resuelve el problema de la copia entre espacios de direccionamientos distintos. Para copiar el
mensaje msg1 en P2, P1 invoca el procedimiento send. Más que un procedimiento, send es una
llamada al sistema. El núcleo bloquea P1 hasta que realiza la copia. En P2 ocurren las cosas de forma
simétrica. P2 invoca la llamada al sistema receive para hacer saber al sistema que la copia ha de
realizarse en msg2. P2 queda entonces bloqueado hasta que la copia se produce.
El núcleo de MINIX realmente soporta únicamente tres llamadas al sistema, encargadas de llevar a cabo
el paso de mensajes. Son send, receive y sendrec. Los prototipos de estas funciones son:
int send (int dst, message *msg);
int receive(int src, message *msg);
int sendrec(int dst, message *msg);
Send envía el mensaje msg al proceso destinatario cuyo número de proceso es dst. Bloquea al
proceso invocante hasta que el receptor llaga a la cita. Receive bloquea al proceso invocante hasta
que en msg se recibe un mensaje procedente de el mensaje msg al proceso destinatario cuyo número
de proceso es src. Sendrec envía el mensaje msg al proceso destinatario cuyo número de proceso es
dst. Y bloquea al proceso invocante hasta que llega la réplica, que se almacena en msg,
sobrescribiendo su contenido. A los procesos de usuario sólo les está permitido invocar sendrec. El
autor de MINIX considera que el uso de send y receive en los programas de usuario les hace difícil
de entender y mantener, dando lugar a lo que denomina programación spaghetti. Considera tan nocivo
su uso en comunicación de procesos como la sentencia goto en programación estructurada.
Un proceso de usuario usa sendrec para solicitar los servicios del gestor de memoria o el sistema de
ficheros. No puede comunicarse directamente con las tareas de entrada-salida. Las funciones UNIX
read, fork, etc. están implementadas como funciones de biblioteca sobre sendrec, tal y como
veremos más adelante.
8
La Fig. 1.11 muestra un esquema simplificado de una operación hipotética de lectura por parte de un
proceso de usuario MINIX. En ella pueden apreciarse los actores implicados y los mensajes
intercambiados por estos.
El proceso de usuario realiza una llamada al sistema read. Su implementación invoca la llamada al
sistema sendrec, que envía un mensaje al sistema de ficheros y bloquea al proceso esperando la
réplica.
1. El sistema de ficheros comprueba que los parámetros del mensaje, los permisos, etc., son
correctos y envía (también vía sendrec) un mensaje al manejador de dispositivo pertinente, por
ejemplo el del terminal.
2. Si el manejador establece que los datos no están disponibles, envía un mensaje al sistema de
ficheros para hacérselo saber y desbloquearlo. El proceso de usuario continúa bloqueado en
sendrec.
3. El dispositivo proporciona los datos e interrumpe. La rutina de interrupción construye un
mensaje que envía a la tarea.
4. La tarea recoge los datos recién llegados y los copia al espacio de direccionamiento del programa
de usuario que espera por ellos.
5. La tarea envía un mensaje al sistema de ficheros comunicándole que puede despertar al proceso
de usuario.
6. El sistema de ficheros envía un mensaje al programa de usuario con el resultado de la operación
de lectura. El programa lo recibe y sale de sendrec.
Los manejadores de dispositivo son procesos de pleno derecho, con su contador de programa,
descriptor de proceso, pila, etc. Todos los manejadores en MINIX siguen el mismo patrón. El Fig. 1.12
muestra el esqueleto de una tarea de entrada-salida.
1 mensaje mens;
2 tarea()
3 {
4 int r, emisor;
5
6 inicializar();
7 while(TRUE)
8 {
9 receive(ANY, &mens);
10 emisor = mens.fuente;
11 switch(mens.tipo)
12 {
13 case READ: r = do_read(); break;
14 case WRITE: r = do_write(); break;
15 case INTERRUP: r = do_interrup(); break;
16 case OTRO: r = do_otro(); break;
17 default: r = ERROR;
18 }
19 mens.tipo = TASK_REPLAY;
9
20 mens.REP_STATUS = r;
21 send(emisor, &mens);
22 }
23 }
Como puede apreciarse, tras una inicialización de sus estructuras de datos, una tarea de entrada-
salida realiza un bucle infinito de espera por el mensaje, despacho del mismo y envío de la réplica. El
mensaje contiene un campo que especifica el procedimiento que lo sirve y otro campo que especifica
el remitente. Cuando se ha despachado el mensaje, la tarea replica al emisor con un código de retorno
en otro de los campos del mensaje.
1.5 El planificador
La función por excelencia del núcleo de un sistema operativo es la conmutación de procesos. En un
instante dado, un número determinado de procesos están dispuestos para ejecutar y el resto no lo está,
por estar suspendidos en el envío o recepción de un mensaje. En un sistema con un solo procesador, de
todos los procesos dispuestos para ejecutar sólo uno puede hacerlo. El planificador es un algoritmo que
decide cuál de ellos.
Una estructura de datos fundamental en el núcleo es la cola de los descriptores de procesos dispuestos.
En MINIX, esta cola está estructurada en tres niveles (Fig. 1.13) según su arquitectura. Una cola de
tareas de E/S, la más prioritaria, una cola de servidores y una cola de procesos de usuario, la menos
prioritaria. Es una variable global del núcleo definida en el fichero /usr/src/kernel/proc.h
según
struct proc *rdy_head[NQ]; /* pointers to ready list headers */
struct proc *rdy_tail[NQ]; /* pointers to ready list tails */
Rdy_head apunta al comienzo de las colas. Dentro de cada cola se utiliza el algoritmo Round Robin,
de modo que cuando un proceso se reanuda, pasa al final de la cola. También pasa al final de la cola un
proceso de usuario que ha completado su quantum, de modo que rdy_tail es útil para acelerar este
trabajo.
Conocida la estructura de datos sobre la que opera, el algoritmo de planificación de MINIX resulta
extremadamente sencillo: encontrar cuál es la cola más prioritaria que no está vacía y, una vez
identificada, escoger el proceso que está a la cabeza de la misma. Si todas las colas están vacías se
ejecuta la tarea IDLE. Admirablemente, toda la labor de planificación del procesador se realiza en
¡cuatro rutinas! que ocupan alrededor de ciento cincuenta líneas de código C. La mayoría de los
textos sobre sistemas operativos dedican un capítulo completo a la planificación. El lector puede
sacar de esto sus propias conclusiones. Estas rutinas son pick_proc, ready, unready y sched.
1 PRIVATE void pick_proc()
2 {
3 /* Decide who to run now. A new process is selected by setting 'proc_ptr'.
4 * When a fresh user (or idle) process is selected, record it in 'bill_ptr',
5 * so the clock task can tell who to bill for system time.
6 */
7
8 register struct proc *rp; /* process to run */
9
10
10 if ( (rp = rdy_head[TASK_Q]) != NIL_PROC) {
11 proc_ptr = rp;
12 return;
13 }
14 if ( (rp = rdy_head[SERVER_Q]) != NIL_PROC) {
15 proc_ptr = rp;
16 return;
17 }
18 if ( (rp = rdy_head[USER_Q]) != NIL_PROC) {
19 proc_ptr = rp;
20 bill_ptr = rp;
21 return;
22 }
23 /* No one is ready. Run the idle task. The idle task might be made an
24 * always-ready user task to avoid this special case.
25 */
26 bill_ptr = proc_ptr = proc_addr(IDLE);
27 }
Pick_proc examina las colas y decide quién toma el procesador. La rutina se autocomenta. Es un
ejemplo de lo sencillo y conciso que es el código cuando las estructuras de datos están bien definidas. Sí
cabe resaltar el papel de dos variables globales importantes, proc_ptr y bill_ptr. Pick_proc
examine las colas de descriptores, escoja un nuevo proceso dispuesto y hace que proc_ptr apunte a
este. Cuando el elegido es un proceso de usuario, su dirección también se hace apuntar por
bill_proc a fin de que la generar la contabilidad de uso de procesador que hace el proceso. Esta
información la necesita el planificador y el servicio de la llamada al sistema times.
La línea final es interesante. Cuando ningún proceso está dispuesto, es decir, no hay ningún proceso en
las colas, se elige la tarea ociosa, IDDLE. La tarea IDDLE se define en el fichero mpx88.x a
diferencia del resto de las tareas, que se definen en ficheros C.
1 idle_task: ! executed when there is no work
2 jmp _idle_task
También se diferencia del resto de tareas en que es una entrada adicional del núcleo, como puede ser
una interrupción o una llamada al sistema. Así, cuando el sistema está ocioso, el núcleo ejecuta este
bucle infinito en espera de una interrupción de dispositivo. No obstante, independientemente del
módulo dónde se encuentra su código fuente, IDLE se comporta como el resto de las tareas, ya que
tiene su propia pila y su propio código y dispone de su propio descriptor de tarea. IDLE no invoca a
ninguna rutina, de modo que su pila es más pequeña que el resto. La pila de IDLE tiene tan sólo 20
octetos, el tamaño necesario y suficiente para soportar la interrupción que reactive el sistema.
Cuando un proceso queda dispuesto, por ejemplo por haber recibido un mensaje que estaba esperando,
su descriptor ha de ser insertado en las colas de la Fig. 1.13 a fin de que el planificador pueda otorgarle
el procesador. El procedimiento ready (Código 1.3) añade un descriptor a la estructura de colas.
Ready está estructurada como pick_proc. Toma un parámetro que es un puntero al descriptor que
va a instalarse en las colas y comprueba si el descriptor es una tarea, un servidor o un proceso de
usuario. Una vez determinada la cola apropiada, añade a esta el descriptor.
1 PRIVATE void ready(rp)
2 register struct proc *rp; /* this process is now runnable */
3 {
4 /* Add 'rp' to the end of one of the queues of runnable processes. Three
5 * queues are maintained:
6 * TASK_Q - (highest priority) for runnable tasks
7 * SERVER_Q - (middle priority) for MM and FS only
8 * USER_Q - (lowest priority) for user processes
9 */
10
11 if (istaskp(rp)) {
12 if (rdy_head[TASK_Q] != NIL_PROC)
13 /* Add to tail of nonempty queue. */
11
14 rdy_tail[TASK_Q]->p_nextready = rp;
15 else {
16 proc_ptr = /* run fresh task next */
17 rdy_head[TASK_Q] = rp; /* add to empty queue */
18 }
19 rdy_tail[TASK_Q] = rp;
20 rp->p_nextready = NIL_PROC; /* new entry has no successor */
21 return;
22 }
23 if (!isuserp(rp)) { /* others are similar */
24 if (rdy_head[SERVER_Q] != NIL_PROC)
25 rdy_tail[SERVER_Q]->p_nextready = rp;
26 else
27 rdy_head[SERVER_Q] = rp;
28 rdy_tail[SERVER_Q] = rp;
29 rp->p_nextready = NIL_PROC;
30 return;
31 }
32 if (rdy_head[USER_Q] == NIL_PROC)
33 rdy_tail[USER_Q] = rp;
34 rp->p_nextready = rdy_head[USER_Q];
35 rdy_head[USER_Q] = rp;
36 }
Cuando una tarea de entrada-salida queda dispuesta, pasa al último lugar de la cola y ready retorna,
pero cuando la cola estaba vacía, además de la inserción en la cola, a la tarea se le asigna el procesador
(línea 17) sin tener en cuenta a pick_proc, que es quien normalmente toma esta decisión. La cola
más prioritaria es la de las tareas de dispositivos, de modo que con una única tarea, pick_proc
tomaría la decisión de ejecutarla. Si esta decisión ya ha sido tomada por ready, se produce el ahorro
de la llamada a pick_proc que, aunque sencilla, consume ciclos preciosos. La estrategia va en
menoscabo de la estructura del código pero aumenta la eficiencia de la conmutación de procesos. Hay
que tener en cuenta que el planificador se ejecuta cada vez que se produce una transición al núcleo, de
modo que consume una parte apreciable de los recursos del sistema. Un diseño eficiente del mismo
puede suponer algún sacrificio en su estructura.
Unready (Código 1.4) se invoca cuando un proceso cesa en su estado de dispuesto al quedar
bloqueado en una llamada al sistema y es preciso sacarle de la cola de procesos activos. También se
invoca para sacar de la cola un proceso de usuario a quien mata una señal. Como ready, unready
toma un único parámetro que es la dirección del descriptor del proceso implicado.
1 PRIVATE void unready(rp)
2 register struct proc *rp; /* this process is no longer runnable */
3 {
4 /* A process has blocked. */
5
6 register struct proc *xp;
7 register struct proc **qtail; /* TASK_Q, SERVER_Q, or USER_Q rdy_tail */
8
9 if (istaskp(rp)) {
10 if ( (xp = rdy_head[TASK_Q]) == NIL_PROC) return;
11 if (xp == rp) {
12 /* Remove head of queue */
13 rdy_head[TASK_Q] = xp->p_nextready;
14 if (rp == proc_ptr) pick_proc();
15 return;
16 }
17 qtail = &rdy_tail[TASK_Q];
18 }
19 else if (!isuserp(rp)) {
20 if ( (xp = rdy_head[SERVER_Q]) == NIL_PROC) return;
21 if (xp == rp) {
22 rdy_head[SERVER_Q] = xp->p_nextready;
23 pick_proc();
24 return;
25 }
26 qtail = &rdy_tail[SERVER_Q];
27 } else {
28 if ( (xp = rdy_head[USER_Q]) == NIL_PROC) return;
29 if (xp == rp) {
30 rdy_head[USER_Q] = xp->p_nextready;
12
31 pick_proc();
32 return;
33 }
34 qtail = &rdy_tail[USER_Q];
35 }
36
37 /* Search body of queue. A process can be made unready even if it is
38 * not running by being sent a signal that kills it. */
39 while (xp->p_nextready != rp)
40 if ( (xp = xp->p_nextready) == NIL_PROC) return;
41 xp->p_nextready = xp->p_nextready->p_nextready;
42 if (*qtail == rp) *qtail = xp;
43 }
Sched pasa el proceso activo al último lugar de la cola de dispuestos (Código 1.5). Es invocada en la
tarea del reloj cuando esta se apercibe que el proceso de usuario en curso ha completado su quantum de
tiempo. Por lo tanto, el algoritmo de planificación en la cola de procesos de usuario es Round-Robin.
Las tareas y los servidores nunca son puestas al final de sus colas respectivas, ya que, una vez que
reciben un mensaje lo despachan rápidamente y se bloquean. Se confía en que operan correctamente y
no acaparan el procesador.
1 PRIVATE void sched()
2 {
3 /* The current process has run too long. If another low priority (user)
4 * process is runnable, put the current process on the end of the user queue,
5 * possibly promoting another user to head of the queue.
6 */
7
8 if (rdy_head[USER_Q] == NIL_PROC) return;
9
10 /* One or more user processes queued. */
11 rdy_tail[USER_Q]->p_nextready = rdy_head[USER_Q];
12 rdy_tail[USER_Q] = rdy_head[USER_Q];
13 rdy_head[USER_Q] = rdy_head[USER_Q]->p_nextready;
14 rdy_tail[USER_Q]->p_nextready = NIL_PROC;
15 pick_proc();
16 }
1.6 Referencias
[1] Comer, D., "Operating Systems Design, The XINU approach", Prentice-Hall, 1984.
[2] Ritchie, D. M. and Thompson, K. "The UNIX time-shared system". Communications of the
ACM, vol. 17, número 7, pp. 365-75. 1974.
[3] Tanenbaum, A. S., "Operating Systems, Design and Implementation", Prentice-Hall, 1986.
[4] Tanenbaum, A. S., "Operating Systems, Design and Implementation", 2nd edition, Prentice-
Hall, 1997.
[5] Tanenbaum, A. S., "Operating Systems, Design and Implementation", 3nd edition, Prentice-
Hall, 2006.
[6] http://www.educ.umu.se/%7Ebjorn/mhonarc-files/obsolete/index.html
13