1. Una línea, todo el curso
En el tema 10 seguimos un comando a través de los componentes del sistema, en nueve pasos y sin entrar en ninguno. Prometimos reconstruir ese recorrido cuando tuviéramos con qué. El comando que seguiremos es de los más simples que existen:
$ cat /etc/hostname
equipo
$
Muestra el contenido de un archivo de siete bytes. Lo elegimos porque hace una vez cada cosa: lee de un dispositivo de almacenamiento, escribe en una terminal y termina. En el recorrido participan todos los componentes del sistema:
| Participante | Qué es | Qué hace aquí |
|---|---|---|
| El teclado y la pantalla | Dispositivos | Producen interrupciones; muestran píxeles. |
| El emulador de terminal | Un programa gráfico | Recibe las teclas y las entrega a la shell; dibuja lo que los programas escriben. |
La shell, bash | Un programa de usuario | Lee la línea, la interpreta, crea el proceso y espera. |
cat | Un archivo ejecutable, y luego un proceso | Abre, lee, escribe y termina. |
| El enlazador dinámico y la biblioteca de C | Código compartido | Preparan el programa antes de main; envuelven las llamadas al sistema. |
| El kernel | El programa que gobierna la máquina | Todo lo demás: atiende cada interrupción y cada pedido de los anteriores. |
Hay tres procesos de usuario —el emulador de terminal, la shell y cat— que no se llaman entre sí en ningún momento. Se comunican únicamente a través del kernel, mediante archivos, descriptores y señales. La figura muestra en qué momento trabaja cada uno; el resto del tema la recorre de izquierda a derecha.
2. El punto de partida: un sistema en reposo
Antes de la primera tecla, el sistema está quieto, y conviene ver esa quietud con los ojos del curso. La shell mostró su indicador y pidió leer de la terminal. No había nada que leer, de modo que el kernel la puso a dormir:
$ ps -o pid,stat,wchan:20,comm -p 4102
PID STAT WCHAN COMMAND
4102 Ss+ do_select bash
$ ls -l /proc/4102/fd | head -4
total 0
lrwx------ 1 diego diego 64 oct 12 14:40 0 -> /dev/pts/0
lrwx------ 1 diego diego 64 oct 12 14:40 1 -> /dev/pts/0
lrwx------ 1 diego diego 64 oct 12 14:40 2 -> /dev/pts/0
El estado es S, dormido, y la columna WCHAN indica dentro de qué función del kernel espera: una que aguarda actividad en un descriptor. Sus tres descriptores estándar llevan a /dev/pts/0, el extremo de programas de una pseudoterminal, según el tema 29.
El emulador de terminal está igual, dormido a la espera de un suceso. Casi todos los procesos del sistema lo están. Los procesadores, sin nada que ejecutar, se detuvieron con la instrucción hlt, y el kernel silenció su temporizador, como vimos en el tema 19. Nada ocurrirá hasta que llegue una interrupción.
3. Las teclas
La primera tecla, la c, pone todo en movimiento. Su camino hasta aparecer en pantalla es más largo de lo que parece:
- El controlador del teclado genera una interrupción. Un procesador despierta, guarda lo poco que estaba haciendo, pasa a modo kernel y salta, a través de la tabla de vectores, al manejador. interrupción
- El driver del teclado lee el código de la tecla y lo entrega al subsistema de entrada, que lo deja disponible en un archivo de
/dev/inputy despierta a quien lo esté esperando. modo kernel - El entorno gráfico recibe el suceso, determina qué ventana tiene el foco y se lo comunica al emulador de terminal. modo usuario
- El emulador escribe el carácter
cen su extremo de la pseudoterminal, con una llamadawrite. llamada al sistema - El kernel pasa el carácter al otro extremo,
/dev/pts/0, y despierta a la shell, que dormía esperando entrada. modo kernel - La shell lee el carácter, lo añade a la línea que está armando y lo escribe en su salida, para que se vea. llamadas al sistema
- El emulador lee ese eco de su extremo y dibuja la letra en la ventana. modo usuario
Una letra en pantalla supone una interrupción, varios cambios de contexto entre tres procesos y una decena de llamadas al sistema. Es el recorrido de la tecla del tema 17, con todos los intermediarios que entonces omitimos. Y ocurre dos veces por tecla, porque soltarla produce otra interrupción.
El paso 6 precisa algo que dijimos en el tema 29. Allí vimos que el kernel puede retener la línea y hacer él mismo el eco. Bash prefiere encargarse: mientras se escribe un comando, pone la terminal en un modo en que cada tecla le llega de inmediato y sin eco, y es él quien muestra los caracteres. Por eso funcionan las flechas, el historial y el completado con el tabulador, que el kernel no sabría hacer. Antes de ejecutar un comando, Bash devuelve la terminal al modo normal, de manera que el programa lanzado la encuentre como espera.
4. Enter: la shell interpreta la línea
Las dieciocho pulsaciones recorren el mismo camino. La última, Enter, le indica a la shell que la línea está completa. Lo que sigue ocurre por entero dentro de la shell, en modo usuario: es trabajo sobre texto.
| Paso | Qué hace la shell | En nuestra línea |
|---|---|---|
| Separar | Divide la línea en palabras y operadores. | Dos palabras: cat y /etc/hostname. |
| Expandir | Sustituye variables, comodines y otras construcciones por su valor. | Nada que expandir. |
| Reconocer redirecciones | Aparta los >, < y |, que no son argumentos. | No hay. |
| Clasificar el comando | Determina qué es la primera palabra: un alias, una función, un comando interno o un programa. | Un programa. |
| Localizarlo | Busca un archivo ejecutable con ese nombre en los directorios de la variable PATH. | /usr/bin/cat |
La expansión merece un ejemplo, porque corrige una idea equivocada muy extendida. Cuando se escribe ls *.c, el programa ls no recibe ningún asterisco:
$ strace -e trace=execve ls *.c 2>&1 | head -1
execve("/usr/bin/ls", ["ls", "abrir.c", "hijo.c", "lanzar.c", "mapa.c"], 0x7ffd… /* 36 vars */) = 0
La shell reemplazó el patrón por la lista de archivos que coinciden antes de crear ningún proceso. Los programas reciben siempre argumentos ya expandidos, y por eso todos interpretan los comodines del mismo modo: ninguno los interpreta.
La clasificación decide si hará falta un proceso nuevo. El comando type muestra el resultado:
$ type cat cd echo ll
cat is /usr/bin/cat
cd is a shell builtin
echo is a shell builtin
ll is aliased to `ls -alF'
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Los comandos internos los ejecuta la propia shell, sin crear ningún proceso. Algunos lo son por rapidez, como echo. Otros, porque no podrían ser otra cosa: cd debe cambiar el directorio de la shell, y un programa aparte cambiaría el suyo, como vimos en el tema 28.
cat es un programa. Para encontrarlo, la shell recorre en orden los directorios de PATH y pregunta al kernel, en cada uno, si existe allí un archivo ejecutable con ese nombre. Lo encuentra en el cuarto. La primera vez anota la ubicación, y las siguientes ya no busca.
5. Nace un proceso
Con la ruta del programa en la mano, la shell crea un proceso. Es el fork del tema 26, que la biblioteca traduce a la llamada clone:
- La shell llama a
fork. Entra al kernel. llamada al sistema - El kernel reserva una estructura de proceso y le asigna el siguiente PID libre. modo kernel
- Copia las credenciales, el directorio actual y la tabla de descriptores. El hijo queda con los mismos descriptores 0, 1 y 2, que llevan a la terminal. modo kernel
- Duplica el espacio de direcciones sin copiar memoria: crea tablas de páginas que apuntan a los mismos marcos, marcados como de solo lectura. modo kernel
- Pone al hijo en la cola de procesos listos y vuelve. A la shell le devuelve el PID del hijo; al hijo, cuando le toque ejecutarse, le devolverá cero. modo kernel
Durante un instante hay dos procesos bash idénticos. El padre no tiene más que hacer por ahora: llama a wait y queda dormido. El hijo, en cambio, todavía ejecutando código de la shell, hace unos preparativos antes de convertirse en otro programa:
- Se coloca en un grupo de procesos propio, y lo declara como el grupo que está en primer plano en la terminal. Lo hace con una operación
ioctlsobre la terminal. Es lo que determinará, si se pulsa Ctrl+C, que la señal vaya acaty no a la shell. - Restablece el tratamiento por defecto de las señales, que la shell había modificado para sí.
- Aplica las redirecciones, si las hay. En esta línea no hay ninguna.
Es el intervalo entre fork y exec cuya utilidad señalamos en los temas 26 y 28: ya es otro proceso, y aún puede prepararse el terreno.
6. El proceso cambia de programa
El hijo llama a execve("/usr/bin/cat", ["cat", "/etc/hostname"], entorno). Es la carga que describimos en el tema 9, y ahora podemos leerla completa:
- El kernel recorre la ruta hasta el inodo de
/usr/bin/cat, y comprueba que el proceso tenga permiso de ejecución sobre él. archivos y permisos - Lee los primeros bytes del archivo y reconoce el número mágico de ELF. formato
- Descarta el espacio de direcciones actual: la copia de la shell desaparece. memoria
- Crea uno nuevo, mapeando los segmentos del ejecutable con sus permisos: el código, de lectura y ejecución; los datos, de lectura y escritura. No lee el archivo: anota qué parte corresponde a cada rango. memoria
- Mapea también el enlazador dinámico, cuyo nombre figura en la cabecera del ejecutable. memoria
- Prepara una pila nueva y copia en ella los argumentos y las variables de entorno. memoria
- Fija el contador de programa en el punto de entrada del enlazador dinámico, y vuelve a modo usuario. cambio de modo
Las direcciones de todo lo mapeado se eligieron al azar, por el mecanismo del tema 27. Y hay cosas que execve no toca: el PID, el padre, las credenciales y la tabla de descriptores siguen siendo los mismos. El proceso es el que creó fork, con otro contenido.
La primera instrucción del programa nuevo no llega a ejecutarse. La página que la contiene no tiene todavía un marco asignado, y la CPU genera un fallo de página. El kernel lo atiende trayendo esa página del archivo —que con toda probabilidad está en la caché de disco, porque cat es un programa de uso frecuente— y la instrucción se repite. Es el primero de alrededor de un centenar de fallos de página que este proceso sufrirá en su milisegundo de vida, todos resueltos sin que lo note, como en el tema 18.
7. Antes de main
El proceso se ejecuta en modo usuario, pero todavía no es cat quien tiene el control. Es el enlazador dinámico, que debe completar el programa con las bibliotecas que necesita. Es la parte de la traza que identificamos en el tema 16 como los actos segundo y tercero:
- Consulta el índice de bibliotecas, localiza
libc.so.6, la abre y la mapea en el espacio de direcciones. Su código queda compartido con todos los demás procesos del sistema. - Resuelve las referencias pendientes: conecta cada llamada de
cata una función de la biblioteca con la dirección que esa función tiene en este proceso. - Protege contra escritura las zonas que ya no deben cambiar.
- Salta a
_start, el punto de entrada del programa, que inicializa la biblioteca de C y llama amain.
Unas veinticinco llamadas al sistema —openat, mmap, mprotect— antes de que se ejecute la primera línea escrita por los autores de cat. De las cuatro decenas y media de llamadas que hará este proceso, la mayoría pertenece a esta fase.
8. El programa hace su trabajo
Por fin, main. El trabajo de cat es el bucle de copiar.c, del tema 14, precedido de una apertura. En la traza del tema 16 eran cinco líneas:
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
read(3, "equipo\n", 131072) = 7
write(1, "equipo\n", 7) = 7
read(3, "", 131072) = 0
close(3) = 0
La apertura es el recorrido del tema 28. El kernel parte del inodo raíz, busca etc en su tabla, obtiene otro inodo, busca hostname en él. En cada directorio comprueba el permiso de atravesarlo, y en el archivo, el de lectura, contra las credenciales del proceso: las del usuario que escribió el comando, heredadas a través de la shell. Crea una entrada de archivo abierto con posición cero y devuelve el descriptor libre más bajo, el 3.
La lectura depende de dónde estén los datos:
| Si el bloque está en la caché | Si no está | |
|---|---|---|
| El kernel | Copia los bytes de la caché al búfer del programa | Pide el bloque al driver del disco y pone al proceso a dormir |
| El proceso | No deja la CPU | Pasa al estado bloqueado; otro proceso usa el procesador |
| Interviene el hardware | No | El controlador lee, copia a memoria por DMA y avisa con una interrupción |
| Duración | Menos de un microsegundo | Decenas de microsegundos, o milisegundos en un disco giratorio |
La segunda columna es la secuencia completa del tema 8, con la interrupción del tema 17 y el cambio de contexto voluntario del tema 19. En cualquiera de los dos casos, el programa no percibe diferencia: llamó a read y recibió 7.
Pidió 131 072 bytes y obtuvo siete. No es un error: es el límite del que hablamos en el tema 14. Por eso hará luego una segunda lectura, que devuelve cero, para saber que el archivo terminó.
9. La salida llega a la pantalla
cat llama a write(1, "equipo\n", 7). No sabe qué hay en el descriptor 1. Lo que hay es /dev/pts/0, y los siete bytes emprenden el camino inverso al de las teclas:
- El kernel busca el descriptor 1 en la tabla del proceso, llega a un archivo de dispositivo de caracteres y pasa el pedido a la función de escritura del driver de terminales. tabla de operaciones
- La disciplina de línea procesa la salida. Convierte el salto de línea en el par «retorno de carro y avance de línea» que una terminal necesita. modo kernel
- Los bytes quedan en el búfer del otro extremo de la pseudoterminal, y el kernel despierta al emulador de terminal, que dormía esperándolos.
writedevuelve 7. modo kernel - El emulador de terminal lee los bytes. Interpreta los caracteres de control, decide en qué fila y columna va cada letra y calcula su dibujo con el tipo de letra elegido. modo usuario
- Entrega la imagen de su ventana al entorno gráfico, que la combina con las demás ventanas. modo usuario
- El driver de video envía el resultado a la tarjeta gráfica, que lo muestra en el siguiente refresco de la pantalla. modo kernel
Hay una asimetría que conviene notar. Para cat, la escritura terminó en el paso 3: recibió su 7 y siguió adelante. Las letras aparecen en pantalla después, cuando el emulador recibe su turno de procesador, y es muy probable que para entonces cat ya no exista. «Escribir en la pantalla» significa, para un programa, dejar bytes en un búfer del kernel.
Como el descriptor 1 es una terminal, la biblioteca de C habría usado el búfer por líneas del tema 15. cat ni siquiera pasa por ella: llama a write directamente, con todo lo que leyó.
10. El final
La segunda lectura devuelve cero. cat cierra el descriptor 3 y retorna de main. El código de arranque llama a exit, que vacía los búferes de la biblioteca y hace la llamada al sistema exit_group:
- El kernel cierra los descriptores que quedaban abiertos. modo kernel
- Libera el espacio de direcciones. Los marcos que solo usaba este proceso vuelven a estar disponibles; los de la biblioteca siguen en uso por los demás, y los del archivo ejecutable permanecen en la caché de disco. modo kernel
- Guarda el código de salida, 0, en la estructura del proceso, que pasa al estado zombi. modo kernel
- Envía
SIGCHLDal padre y lo despierta de su espera. modo kernel - Llama al planificador, porque el proceso que estaba en la CPU dejó de existir. modo kernel
La shell recibe el procesador y su llamada wait retorna, con el PID del hijo y su código de salida. En ese momento el kernel elimina la estructura del proceso: el zombi duró unos microsegundos. La shell entonces:
- Guarda el código en la variable
$?. - Vuelve a declararse como el grupo en primer plano de la terminal.
- Pone la terminal otra vez en el modo de edición de línea.
- Escribe el indicador.
- Pide leer de la terminal, y queda dormida.
El sistema volvió al estado de la sección 2. Los procesadores no tienen nada que hacer y se detienen. Entre la pulsación de Enter y este instante pasaron entre uno y dos milisegundos.
11. Verlo ocurrir
El recorrido puede observarse casi completo. La parte de la shell se ve conectando strace a ella desde otra terminal, antes de escribir el comando:
$ sudo strace -f -e trace=clone,execve,wait4,exit_group -p 4102
strace: Process 4102 attached
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f3c1a2b4a10) = 9301
strace: Process 9301 attached
[pid 4102] wait4(-1, <unfinished ...>
[pid 9301] execve("/usr/bin/cat", ["cat", "/etc/hostname"], 0x5c7e0d4f1b20 /* 36 vars */) = 0
[pid 9301] exit_group(0) = ?
[pid 9301] +++ exited with 0 +++
<... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WSTOPPED|WCONTINUED, NULL) = 9301
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=9301, si_uid=1000, si_status=0, …} ---
Son las secciones 5, 6 y 10 en cinco líneas: crear, esperar, reemplazar, terminar, despertar. Quitando el filtro se ve además la lectura de cada tecla y la búsqueda en PATH. La parte de cat es la traza del tema 16. Con las dos, y algunas cuentas, se puede dimensionar lo que cuesta un comando:
$ time cat /etc/hostname
equipo
real 0m0.002s
user 0m0.000s
sys 0m0.002s
$ strace -c cat /etc/hostname 2>&1 | tail -1
100.00 0.000000 0 47 1 total
| Para mostrar siete bytes… | Cantidad aproximada |
|---|---|
| Interrupciones del teclado | 36: dos por cada una de las dieciocho pulsaciones |
| Procesos creados | 1 |
Llamadas al sistema de cat | Unas 50, de las cuales 5 son su trabajo propio |
Archivos abiertos por cat | 4: el índice de bibliotecas, la biblioteca de C, la configuración regional y el que pedimos |
| Fallos de página | Alrededor de 100 |
| Procesos de usuario que intervienen | 3, además del entorno gráfico |
| Tiempo, desde Enter | De 1 a 2 milisegundos |
Las cifras varían de un equipo a otro, pero el orden de magnitud es ese. La desproporción entre el resultado y el esfuerzo es la medida de lo que el sistema operativo hace por cada programa. El autor de cat escribió un bucle con read y write; no escribió nada sobre teclados, discos, pantallas, memoria ni otros programas.
12. Variaciones
Con pequeños cambios en la línea, el recorrido se modifica en puntos concretos. Cada variación corresponde a algo que ya estudiamos:
| Se escribe | Qué cambia | Tema |
|---|---|---|
cd /tmp | Es un comando interno. No hay fork ni exec: la shell llama a chdir en su propio proceso. | 28 |
cat /etc/hostname > copia | Entre fork y exec, el hijo abre copia y lo coloca en el descriptor 1 con dup2. cat escribe en un archivo, y la sección 9 no ocurre. | 28 |
cat /etc/hostname | wc -c | La shell crea una tubería y dos procesos. La salida de uno es la entrada del otro, y los dos se ejecutan a la vez. | 28 |
sleep 100 & | La shell no llama a wait: muestra el indicador enseguida. Recogerá al hijo cuando termine. | 26 |
| Ctrl+C durante la ejecución | La disciplina de línea envía SIGINT al grupo que está en primer plano: el del comando, no el de la shell. | 29 |
noexiste | La búsqueda en PATH falla. La shell informa command not found y deja 127 en $?, sin crear ningún proceso. | 26 |
./notas.txt | execve falla con EACCES, porque el archivo no tiene permiso de ejecución. El código es 126. | 30 |
./script.sh | El kernel encuentra #! en lugar del número mágico de ELF, y ejecuta el intérprete indicado en esa línea, pasándole el archivo. | 9 |
./segfault | Una excepción termina con el proceso. wait informa a la shell que murió por una señal, y la shell escribe el mensaje. | 18 |
sudo cat /etc/shadow | El programa ejecutado es sudo, que tiene el bit setuid. Comprueba la autorización y ejecuta cat con otra identidad. | 30 |
El caso de la tubería deja ver cómo se combinan las piezas. Para cat /etc/hostname | wc -c, la shell pide al kernel una tubería y recibe dos descriptores. Crea dos hijos: en el primero pone el extremo de escritura en el descriptor 1; en el segundo, el de lectura en el 0. Cada uno ejecuta su programa, y ninguno sabe que el otro existe. cat cree escribir en su salida; wc cree leer de su entrada. Cuando cat termina, su extremo se cierra, y la lectura de wc devuelve cero: es el fin de los datos.
13. Una shell propia
En el tema 3 anotamos, entre las decisiones de diseño de Unix, que la shell es un programa común, que cualquiera puede reemplazar. Tenemos todo para comprobarlo. El siguiente programa es una shell: muestra un indicador, lee una línea, la divide en palabras y ejecuta el comando.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define MAX_ARGS 32
int main(void)
{
char linea[1024];
char *args[MAX_ARGS];
for (;;) {
printf("mini$ ");
fflush(stdout); /* el indicador no termina en salto de línea */
if (fgets(linea, sizeof(linea), stdin) == NULL)
break; /* fin de la entrada: Ctrl+D */
/* 1. separar la línea en palabras */
int n = 0;
for (char *p = strtok(linea, " \t\n"); p != NULL && n < MAX_ARGS - 1; p = strtok(NULL, " \t\n"))
args[n++] = p;
args[n] = NULL;
if (n == 0)
continue; /* línea vacía */
/* 2. comandos internos: se ejecutan en este mismo proceso */
if (strcmp(args[0], "exit") == 0)
break;
if (strcmp(args[0], "cd") == 0) {
if (chdir(n > 1 ? args[1] : getenv("HOME")) == -1)
perror("cd");
continue;
}
/* 3. programas: crear, reemplazar, esperar */
pid_t pid = fork();
if (pid == -1) {
perror("fork");
continue;
}
if (pid == 0) {
execvp(args[0], args); /* busca en PATH y ejecuta */
perror(args[0]);
_exit(127);
}
int estado;
waitpid(pid, &estado, 0);
if (WIFSIGNALED(estado))
printf("[finalizado por la senal %d]\n", WTERMSIG(estado));
else if (WEXITSTATUS(estado) != 0)
printf("[codigo de salida %d]\n", WEXITSTATUS(estado));
}
return 0;
}
$ gcc -Wall -Wextra -g minishell.c -o minishell
$ ./minishell
mini$ cat /etc/hostname
equipo
mini$ pwd
/home/diego/so
mini$ cd /tmp
mini$ pwd
/tmp
mini$ ls /noexiste
ls: cannot access '/noexiste': No such file or directory
[codigo de salida 2]
mini$ /home/diego/so/segfault
Antes de escribir
[finalizado por la senal 11]
mini$ noexiste
noexiste: No such file or directory
[codigo de salida 127]
mini$ exit
$
Cincuenta líneas, y ejecuta cualquier programa del sistema. Cada parte del código tiene su tema:
| En el código | Corresponde a | Tema |
|---|---|---|
fflush(stdout) después del indicador | El búfer por líneas: sin esa llamada, el texto sin salto de línea no aparecería. | 15 |
fgets devuelve NULL | Ctrl+D: la disciplina de línea hace que read devuelva cero. | 14, 29 |
cd tratado aparte | El directorio actual es de cada proceso. | 28 |
fork, execvp, waitpid | Crear, reemplazar, esperar. | 26 |
_exit(127) y no return | Si exec falla, el hijo es una copia de la shell: no debe vaciar búferes que no son suyos. | 15, 26 |
WIFSIGNALED | Distinguir un final voluntario de uno por señal. | 18 |
Lo que le falta respecto de Bash es, casi todo, el trabajo sobre texto de la sección 4. No expande comodines ni variables, de modo que ls *.c le pasa a ls un asterisco literal. No entiende comillas, redirecciones ni tuberías. Y no hace los preparativos de la sección 5: no crea grupos de procesos ni maneja señales, por lo que Ctrl+C la termina a ella junto con el comando en curso. Los ejercicios proponen añadir algunas de estas cosas. Ninguna requiere nada que no hayamos visto.
La parte que habla con el kernel, en cambio, está completa, y cabe en diez líneas. La interfaz que Unix definió en los años setenta para ejecutar programas es así de pequeña.
14. El mapa completo
Para cerrar, el recorrido entero en una tabla, con el tema donde se estudió cada paso. Es también un índice del curso, ordenado no por materia sino por el orden en que las cosas ocurren:
| Momento | Qué ocurre | Temas |
|---|---|---|
| En reposo | La shell duerme en una lectura; los procesadores están detenidos. | 13, 19, 26 |
| Una tecla | Interrupción, driver, subsistema de entrada. | 8, 17, 29 |
| El eco | Emulador, pseudoterminal, shell; cambios de contexto entre los tres. | 19, 29 |
| Enter | La shell separa, expande y busca en PATH. | 10, 28 |
| Crear | fork: estructura de proceso, copia al escribir, descriptores heredados. | 26, 27, 28 |
| Reemplazar | execve: permisos, formato ELF, espacio de direcciones nuevo. | 9, 27, 30 |
| Cargar a pedido | Fallos de página resueltos desde la caché de disco. | 7, 18 |
| Enlazar | El enlazador dinámico mapea la biblioteca de C. | 9, 15, 16 |
| Abrir | Recorrido de la ruta, permisos, descriptor. | 28, 30 |
| Leer | Llamada al sistema, cambio de modo, caché o disco. | 12, 13, 14, 7, 8 |
| Escribir | Driver de terminal, disciplina de línea, emulador, video. | 14, 29 |
| Terminar | exit, liberación de recursos, zombi, SIGCHLD. | 15, 26 |
| Recoger | wait retorna; la shell muestra el indicador y vuelve a dormir. | 26 |
| Durante todo el recorrido | El temporizador interrumpe; el planificador reparte los procesadores; el kernel contabiliza. | 19, 31 |
| Antes de todo | El firmware, el cargador y el sistema de inicio dejaron el kernel en marcha y la sesión abierta. | 20, 21, 22 |
En el tema 1 hicimos este mismo ejercicio con un programa de una línea, y lo resolvimos en seis pasos, casi todos con palabras que no podíamos explicar. La distancia entre aquella lista y esta tabla es el contenido del curso.
Queda aplicarlo. En el tema 33 construimos un programa completo, un inspector del sistema, que usa las llamadas al sistema y los archivos de /proc para informar qué está haciendo la máquina.
15. Resumen
- Ejecutar un comando involucra tres procesos de usuario —el emulador de terminal, la shell y el programa— que solo se comunican a través del kernel.
- En reposo, la shell está dormida en una lectura de la terminal, y los procesadores, detenidos.
- Cada tecla produce una interrupción y recorre el driver, el entorno gráfico, el emulador y la pseudoterminal hasta la shell, que hace el eco.
- Al recibir Enter, la shell separa la línea en palabras, expande variables y comodines, y clasifica el comando. Los programas reciben los argumentos ya expandidos.
- Los comandos internos se ejecutan en el proceso de la shell. Los programas se buscan en los directorios de
PATH. forkcrea una copia de la shell, que hereda sus descriptores. Antes deexec, el hijo se pone en primer plano y aplica las redirecciones.execvecomprueba los permisos, reconoce el formato, reemplaza el espacio de direcciones y conserva el PID y los descriptores.- El enlazador dinámico mapea las bibliotecas antes de que empiece
main. La mayor parte de las llamadas al sistema de un programa pequeño son de esa fase. - La lectura se resuelve desde la caché de disco o, si hace falta, durmiendo al proceso hasta la interrupción del dispositivo.
- Para el programa, escribir en pantalla es dejar bytes en un búfer del kernel. El emulador de terminal los dibuja después.
- Al terminar, el proceso queda como zombi hasta que la shell lo recoge con
wait, guarda su código y vuelve a mostrar el indicador. - Redirecciones, tuberías, segundo plano y errores son variaciones en puntos precisos de ese recorrido.
- La parte de una shell que habla con el kernel cabe en diez líneas de C.
16. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué tres procesos de usuario intervienen al ejecutar cat /etc/hostname, y cómo se comunican?
El emulador de terminal, la shell y cat. No se llaman entre sí: se comunican a través del kernel, mediante la pseudoterminal, los descriptores heredados y la notificación de la terminación del hijo.
2. ¿En qué estado está la shell mientras espera que se escriba un comando?
Dormida, bloqueada en una llamada al sistema que espera entrada de la terminal. No consume procesador.
3. ¿Quién muestra en pantalla las letras de un comando mientras se lo escribe en Bash?
Bash. Durante la edición pone la terminal en un modo sin eco y recibe cada tecla de inmediato; él mismo escribe los caracteres, y el emulador de terminal los dibuja. Así puede ofrecer historial y completado.
4. En ls *.c, ¿quién reemplaza el asterisco por los nombres de archivo?
La shell, antes de crear el proceso. ls recibe como argumentos los nombres ya expandidos.
5. ¿Por qué cd no puede ser un programa?
Porque debe cambiar el directorio actual de la shell, que es un dato de ese proceso. Un programa aparte cambiaría el suyo y terminaría, sin ningún efecto.
6. ¿Cómo encuentra la shell el archivo ejecutable de cat?
Recorre en orden los directorios de la variable PATH y comprueba en cada uno si existe un archivo ejecutable con ese nombre. Usa el primero que encuentra, y recuerda la ubicación.
7. ¿Qué hace el proceso hijo entre fork y exec?
Se coloca en un grupo de procesos propio y lo declara en primer plano en la terminal, restablece el tratamiento de las señales y aplica las redirecciones que tenga la línea.
8. ¿Qué conserva un proceso después de execve, y qué pierde?
Conserva el PID, el padre, las credenciales, el directorio actual y los descriptores abiertos. Pierde todo su espacio de direcciones: código, datos, heap y pila se reemplazan por los del programa nuevo.
9. ¿Por qué la primera instrucción del programa nuevo provoca un fallo de página?
Porque execve no lee el ejecutable: solo anota qué parte del archivo corresponde a cada rango de direcciones. La página se trae, normalmente de la caché de disco, en el primer acceso.
10. ¿Quién ejecuta las primeras llamadas al sistema del proceso, antes de main?
El enlazador dinámico, que localiza y mapea las bibliotecas compartidas, y luego el código de inicialización de la biblioteca de C.
11. Cuando write devuelve 7 a cat, ¿ya está el texto en la pantalla?
No necesariamente. Los bytes están en el búfer de la pseudoterminal. El emulador de terminal los leerá y dibujará cuando reciba su turno de procesador.
12. ¿Por qué cat hace una segunda lectura, si ya obtuvo los siete bytes del archivo?
Porque recibir menos bytes de los pedidos no indica el fin del archivo. Solo una lectura que devuelve cero lo confirma.
13. ¿Cómo se entera la shell de que el comando terminó?
Estaba bloqueada en wait. Al terminar el hijo, el kernel le envía SIGCHLD y la despierta; la llamada retorna con el PID del hijo y la forma en que terminó.
14. ¿Qué cambia en el recorrido si se añade > copia al comando?
Entre fork y exec, el hijo abre el archivo y lo coloca en el descriptor 1. El programa escribe en el archivo sin saberlo, y nada llega a la terminal.
15. En minishell.c, ¿por qué el hijo termina con _exit si execvp falla?
Porque en ese punto el hijo es todavía una copia de la shell, con una copia de sus búferes de stdio. exit los vaciaría, y lo que estuviera pendiente saldría dos veces. _exit termina sin tocarlos.
Para practicar
- Reproduce el experimento de la sección 11 con dos terminales. Repítelo con
cd /tmp, conecho holay conls | wc -l, y explica qué llamadas aparecen en cada caso. - Conecta
stracea tu shell sin filtros y pulsa una sola tecla. ¿Qué llamadas hace la shell? Identifica la lectura de la tecla y la escritura del eco. - Ejecuta
strace -f -e trace=newfstatat,access,execve bash -c 'noexiste'y observa la búsqueda en los directorios dePATH. - Clasifica con
typeestos comandos:pwd,kill,ls,exit,test,time. ¿Cuáles existen además como programas? Compruébalo contype -a. - Mide con
strace -clas llamadas al sistema decat /etc/hostnamey cuenta cuántas ocurren antes delopenatdel archivo. - Añade a
minishell.cel comando internopwd, usando la funcióngetcwd. Luego, que muestre el directorio actual en el indicador. - Añade a
minishell.cla redirección de salida: si la penúltima palabra es>, la última es el archivo. Usa lo que haceredirigir.c, del tema 28. - Haz que
minishellejecute en segundo plano los comandos que terminan en&. ¿Qué pasa con esos hijos cuando terminan, si nadie los espera? Compruébalo conps. - Ejecuta
sleep 100enminishelly pulsa Ctrl+C. ¿Qué proceso termina? Haz que la shell ignoreSIGINTconsignal(SIGINT, SIG_IGN)y que el hijo restablezca el tratamiento por defecto antes deexecvp. - Elige otro comando simple, como
dateols, y escribe su recorrido completo siguiendo las secciones 3 a 10, señalando en qué difiere del decat.