El kernel por dentro: arquitecturas, llamadas al sistema, modulos y planificacion

Por: Artiko
sistemas-operativosadaelixirconcurrenciakernelsyscallsplanificadormicrokernelmoduloslinux

El kernel por dentro: arquitecturas, llamadas al sistema, modulos y planificacion

En el capitulo 4 desarmamos la maquina: el ciclo de busqueda y ejecucion, los registros, la jerarquia de memoria, las interrupciones y el bus de entrada/salida. Vimos que el procesador, por si solo, no sabe nada de archivos, usuarios ni programas: solo sabe leer una instruccion, ejecutarla y avanzar el contador de programa. Toda esa maquinaria bruta necesita una capa que la administre, la reparta y la proteja. Esa capa es el kernel.

Este capitulo es la bisagra del curso. Hasta aqui hablamos de hardware; desde aqui hablamos de software privilegiado. Vamos a responder cuatro preguntas concretas y las vamos a responder hasta el fondo:

  1. Que significa exactamente que exista un “espacio de kernel” separado de un “espacio de usuario”, y que hardware lo hace cumplir.
  2. Como un programa comun le pide algo al kernel, instruccion por instruccion, y que ocurre entre que lo pide y lo recibe.
  3. Por que existen kernels monoliticos, microkernels e hibridos, y que se gana y se pierde en cada diseno.
  4. Como el kernel decide, miles de veces por segundo, cual de todos los hilos listos ocupa la CPU.

Al final tendras codigo ejecutable en C, Ada y Elixir, un modulo de kernel compilable, y un modelo mental que te va a servir para todo lo que viene: procesos, concurrencia, semaforos, monitores y sistemas de archivos.


Que es exactamente el kernel

El kernel es el programa que se ejecuta con los privilegios maximos del procesador y que media entre el hardware y todo lo demas. No es “el sistema operativo” completo: un sistema operativo tipico incluye ademas bibliotecas, interprete de comandos, servicios de arranque, gestores de paquetes y utilidades. El kernel es solo el nucleo, pero es el unico componente que no puede ser reemplazado en caliente por un programa comun, porque es quien define que puede hacer un programa comun.

Sus responsabilidades minimas, en cualquier sistema operativo de proposito general, son cinco:

ResponsabilidadQue administraAbstraccion que ofrece
Gestion de procesosTiempo de CPU, contextos de ejecucionProceso, hilo
Gestion de memoriaRAM fisica, tablas de paginasEspacio de direcciones virtual
Gestion de entrada/salidaControladores, interrupciones, DMADescriptor de archivo, dispositivo
Sistemas de archivosBloques en disco, cache de paginasArchivo, directorio, ruta
Comunicacion y proteccionPermisos, aislamiento, IPCUsuario, tuberia, socket, senal

Fijate en la columna de la derecha. El kernel no solo administra recursos: inventa abstracciones. Un archivo no existe en el disco, existen bloques magneticos o celdas de memoria flash. Un proceso no existe en el procesador, existe un conjunto de registros. El kernel construye ficciones utiles y despues las defiende con hardware para que nadie las rompa.

Esa ultima parte, “las defiende con hardware”, es la clave de todo el capitulo.


Espacio de usuario y espacio de kernel

El modo dual del procesador

Los procesadores de proposito general implementan al menos dos modos de operacion. En la arquitectura x86 se llaman niveles de privilegio o rings, numerados de 0 a 3:

  • Ring 0 (modo kernel o modo supervisor): puede ejecutar cualquier instruccion, acceder a cualquier direccion fisica, reprogramar la tabla de interrupciones, cambiar las tablas de paginas y hablar directamente con los puertos de entrada/salida.
  • Ring 3 (modo usuario): puede ejecutar solo instrucciones aritmeticas, de control de flujo y de acceso a memoria previamente autorizada. Cualquier intento de ejecutar una instruccion privilegiada provoca una excepcion.

En la practica, Linux, Windows, macOS y los BSD usan solo el ring 0 y el ring 3; los rings 1 y 2 quedaron sin uso porque no se traducen bien a otras arquitecturas. En ARM64 el equivalente son los niveles de excepcion: EL0 para aplicaciones, EL1 para el kernel, EL2 para el hipervisor y EL3 para el firmware seguro. En RISC-V son los modos U (user), S (supervisor) y M (machine).

El punto conceptual es identico en todas: existe un bit (o un campo) en un registro de estado del procesador que dice “estoy en modo privilegiado” o “no lo estoy”, y el hardware consulta ese bit en cada instruccion. No es una convencion de software que se pueda ignorar; es circuiteria.

Este es el modo de operacion dual que menciona el temario clasico, y de el se derivan tres reglas que un programa de usuario no puede violar:

  1. No puede ejecutar instrucciones privilegiadas.
  2. No puede leer ni escribir memoria que su tabla de paginas no le haya mapeado.
  3. No puede acceder directamente a los dispositivos.

El mapa de memoria dividido

Cada proceso tiene un espacio de direcciones virtual. En x86-64 con direcciones virtuales de 48 bits, ese espacio se parte en dos mitades bien separadas:

  • La mitad baja, desde 0x0000000000000000 hasta 0x00007fffffffffff, pertenece al proceso: su codigo, sus datos estaticos, su heap, sus bibliotecas compartidas y su pila.
  • La mitad alta, desde 0xffff800000000000 hacia arriba, pertenece al kernel: su codigo, sus estructuras de datos, el mapeo directo de la memoria fisica y las pilas de kernel.

Las direcciones del medio no son validas y se conocen como el “agujero no canonico”. Lo interesante es que el kernel esta mapeado en todos los espacios de direcciones, siempre en las mismas direcciones altas, pero sus paginas estan marcadas como accesibles solo en modo supervisor. Un puntero de usuario a 0xffff888000001000 no falla porque la pagina no exista: falla porque el bit de usuario/supervisor de esa entrada de la tabla de paginas dice que solo el ring 0 puede tocarla.

Esta decision de diseno no es capricho, es rendimiento: si el kernel viviera en su propio espacio de direcciones, cada llamada al sistema exigiria cambiar la tabla de paginas y vaciar la TLB, que como vimos en el capitulo anterior es una de las operaciones mas caras que existen.

flowchart TB
    subgraph VA["Espacio de direcciones virtual de un proceso en x86-64"]
        direction TB
        K["Mitad alta: 0xffff8000... en adelante<br/>Codigo del kernel, estructuras internas,<br/>mapeo directo de RAM fisica<br/>Bit U/S = supervisor"]
        HOLE["Agujero no canonico<br/>direcciones invalidas"]
        U["Mitad baja: 0x0000... hasta 0x00007fff...<br/>Pila, bibliotecas compartidas, heap,<br/>datos estaticos, codigo del programa<br/>Bit U/S = usuario"]
    end

    CPU{{"Nivel de privilegio actual del procesador"}}
    CPU -->|"ring 3"| U
    CPU -->|"ring 3 hacia la mitad alta"| FALLO["Fallo de proteccion<br/>page fault, senal SIGSEGV"]
    CPU -->|"ring 0"| K
    CPU -->|"ring 0"| U

    K -.- HOLE
    HOLE -.- U

El costo real de la frontera y por que se volvio mas cara

Cruzar de ring 3 a ring 0 no es gratis. El procesador debe guardar el estado de retorno, cambiar de pila, invalidar predicciones especulativas y ajustar registros de control. En hardware moderno, una llamada al sistema simple cuesta del orden de cientos de nanosegundos a unos pocos microsegundos, dependiendo de las mitigaciones activas.

Ese “dependiendo de las mitigaciones” tiene historia. Las vulnerabilidades Meltdown y Spectre, publicadas en 2018, demostraron que la ejecucion especulativa del procesador permitia a un programa de usuario inferir el contenido de memoria del kernel sin tener permiso para leerla. La respuesta de Linux fue KPTI (Kernel Page Table Isolation): mantener dos tablas de paginas por proceso, una para modo usuario que casi no mapea el kernel y otra para modo kernel que lo mapea completo. Eso reintrodujo exactamente el costo que el diseno original evitaba, y encarecio las llamadas al sistema de forma medible en cargas con mucha entrada/salida.

Puedes ver que mitigaciones tiene activas tu maquina:

# Lista todas las mitigaciones de vulnerabilidades de CPU y su estado
grep -r . /sys/devices/system/cpu/vulnerabilities/

# Ver si KPTI esta activo (busca "pti" en las flags del procesador)
grep -o ' pti ' /proc/cpuinfo | head -1

# Contar cuantos cambios de contexto lleva el sistema desde el arranque
grep ctxt /proc/stat

La conclusion practica es que el numero de llamadas al sistema importa. Un programa que hace un millon de write() de un byte cada uno es dramaticamente mas lento que uno que hace mil write() de mil bytes, aunque muevan la misma cantidad de datos. Esa es la razon por la que existen los buffers de la biblioteca estandar, writev(), sendfile(), io_uring y las colas de entrada/salida asincrona.


Llamadas al sistema

Que es y que no es una llamada al sistema

Una llamada al sistema (system call o syscall) es la unica puerta legitima para que un programa de usuario pida un servicio al kernel. No es una funcion normal: no hay un call a una direccion del kernel, porque el ring 3 no puede saltar a codigo del ring 0. Lo que hay es una trampa controlada: el programa ejecuta una instruccion especial que le pide al procesador que cambie de privilegio y salte a una direccion que el kernel definio de antemano.

La distincion mas importante para no confundirse:

  • printf() no es una llamada al sistema. Es una funcion de la biblioteca C que formatea texto en un buffer en memoria de usuario y, cuando el buffer se llena o encuentra un salto de linea, invoca write().
  • write() si llega a una llamada al sistema, pero el write() que llamas tampoco es la syscall: es un envoltorio de glibc que coloca los argumentos donde corresponde, ejecuta la instruccion de trampa y traduce el resultado.
  • malloc() normalmente no hace ninguna llamada al sistema, porque reparte memoria que ya pidio antes con brk() o mmap().

La instruccion que cruza la frontera

Cada arquitectura tiene su instruccion de trampa y su convencion de argumentos:

ArquitecturaInstruccionNumero de syscallArgumentosRetorno
x86-64syscallraxrdi, rsi, rdx, r10, r8, r9rax
x86 (32 bits, clasico)int 0x80eaxebx, ecx, edx, esi, edi, ebpeax
ARM64svc #0x8x0 a x5x0
RISC-Vecalla7a0 a a5a0

Nota un detalle de x86-64: el cuarto argumento va en r10 y no en rcx, como si fuera una funcion C normal. La razon es que la instruccion syscall usa rcx para guardar la direccion de retorno y r11 para guardar el registro de flags, asi que esos dos registros quedan reservados por el hardware.

Anatomia completa de un write()

Este es el recorrido real, sin simplificaciones, de un write(1, "hola\n", 5) en Linux sobre x86-64:

sequenceDiagram
    autonumber
    participant App as Programa en C (ring 3)
    participant Libc as glibc (ring 3)
    participant CPU as Procesador
    participant Entry as entry_SYSCALL_64 (ring 0)
    participant Tabla as Tabla de syscalls
    participant VFS as Capa VFS
    participant Drv as Controlador tty

    App->>Libc: write(1, buf, 5)
    Libc->>Libc: rax=1, rdi=1, rsi=buf, rdx=5
    Libc->>CPU: instruccion syscall
    CPU->>CPU: guarda RIP en rcx y RFLAGS en r11
    CPU->>CPU: cambia a ring 0 y carga RIP desde el MSR LSTAR
    CPU->>Entry: salta al punto de entrada del kernel
    Entry->>Entry: cambia a la pila de kernel del hilo
    Entry->>Entry: guarda los registros de usuario en pt_regs
    Entry->>Tabla: consulta sys_call_table[rax]
    Tabla->>VFS: invoca ksys_write con los argumentos
    VFS->>VFS: valida el descriptor 1 y sus permisos
    VFS->>VFS: copy_from_user copia los 5 bytes al kernel
    VFS->>Drv: llama a la operacion write del dispositivo
    Drv-->>VFS: 5 bytes escritos
    VFS-->>Entry: devuelve 5 en rax
    Entry->>Entry: revisa si hay senales o si toca replanificar
    Entry->>CPU: instruccion sysret
    CPU->>CPU: restaura RIP desde rcx y RFLAGS desde r11
    CPU->>Libc: vuelve a ring 3 con rax = 5
    Libc-->>App: devuelve 5

Tres pasos de ese diagrama merecen atencion especial:

Paso 9 (copy_from_user): el kernel nunca desreferencia un puntero de usuario directamente. Usa funciones especiales que validan que la direccion pertenezca a la mitad baja del espacio de direcciones y que manejan con gracia un fallo de pagina. Si un programa pasa un puntero invalido, la syscall devuelve -EFAULT en lugar de tumbar el kernel. Este es el punto donde historicamente aparecen las vulnerabilidades mas graves.

Paso 15 (revision de senales y replanificacion): la salida de una llamada al sistema es uno de los momentos naturales donde el kernel comprueba si hay que entregar una senal pendiente o si el planificador quiere darle la CPU a otro hilo. Volveremos a esto en la seccion del planificador.

Paso 3 (la instruccion syscall): no es una interrupcion. Antiguamente se usaba int 0x80, que si es una interrupcion software y es mas lenta porque pasa por la tabla de descriptores de interrupcion. syscall y sysret son instrucciones dedicadas que leen la direccion de destino de un registro especial del procesador llamado MSR LSTAR, escrito por el kernel durante el arranque.

Tabla de llamadas al sistema frecuentes

Linux tiene mas de 400 llamadas al sistema. Estas son las que aparecen en casi cualquier programa, con su numero en x86-64:

NumeroNombreQue hace
0readLee bytes desde un descriptor de archivo
1writeEscribe bytes hacia un descriptor de archivo
2openAbre un archivo y devuelve un descriptor
3closeCierra un descriptor
9mmapMapea memoria o un archivo en el espacio de direcciones
16ioctlOperacion especifica de dispositivo
22pipeCrea una tuberia anonima
39getpidDevuelve el identificador del proceso
56cloneCrea un proceso o hilo nuevo
57forkCrea un proceso hijo duplicando el actual
59execveReemplaza la imagen del proceso por otro programa
60exitTermina el hilo actual
61wait4Espera a que un hijo cambie de estado
62killEnvia una senal a un proceso
202futexPrimitiva de espera y despertar para sincronizacion
231exit_groupTermina todos los hilos del proceso
257openatAbre un archivo relativo a un directorio

Los numeros son estables dentro de una arquitectura: una vez asignado, un numero de syscall nunca cambia de significado, porque romperia todos los binarios existentes. Esa es una de las reglas mas estrictas del desarrollo de Linux: “no rompemos el espacio de usuario”.

Puedes consultar la tabla completa de tu sistema:

# Tabla de numeros de syscall segun la arquitectura
cat /usr/include/asm/unistd_64.h | head -40

# Documentacion de una llamada concreta
man 2 write
man 2 syscall

Ejemplo 1: la misma escritura por tres caminos en C

Este programa hace exactamente lo mismo tres veces, subiendo de nivel de abstraccion en cada intento. Compila y ejecuta tal cual.

/* tres_caminos.c
 * Compilar: gcc -O2 -Wall -o tres_caminos tres_caminos.c
 * Ejecutar : ./tres_caminos
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/syscall.h>

/* Camino 3: instruccion syscall escrita a mano en ensamblador en linea.
 * Solo se compila en x86-64; en otra arquitectura se omite.
 */
#if defined(__x86_64__)
static long write_crudo(int fd, const void *buf, size_t n)
{
    long resultado;
    __asm__ volatile (
        "syscall"
        : "=a" (resultado)                       /* salida: rax */
        : "a" (1L),                              /* rax = 1, numero de write */
          "D" ((long) fd),                       /* rdi = primer argumento  */
          "S" (buf),                             /* rsi = segundo argumento */
          "d" ((long) n)                         /* rdx = tercer argumento  */
        : "rcx", "r11", "memory"                 /* el hardware destruye rcx y r11 */
    );
    return resultado;
}
#endif

int main(void)
{
    const char *mensaje = "Camino 1: printf de la biblioteca estandar\n";

    /* Camino 1: funcion de alto nivel, con buffer propio en espacio de usuario. */
    printf("%s", mensaje);
    fflush(stdout);   /* forzamos el vaciado del buffer para ver el orden real */

    /* Camino 2: envoltorio POSIX de glibc, sin buffer intermedio. */
    const char *m2 = "Camino 2: write() de POSIX, envoltorio de glibc\n";
    ssize_t escritos = write(STDOUT_FILENO, m2, strlen(m2));
    if (escritos < 0) {
        perror("write");
        return 1;
    }

    /* Camino 2b: syscall() generico, indicando el numero a mano. */
    const char *m2b = "Camino 2b: syscall(SYS_write, ...) por numero\n";
    if (syscall(SYS_write, STDOUT_FILENO, m2b, strlen(m2b)) < 0) {
        perror("syscall");
        return 1;
    }

#if defined(__x86_64__)
    /* Camino 3: la instruccion desnuda, sin ninguna biblioteca de por medio. */
    const char *m3 = "Camino 3: instruccion syscall en ensamblador en linea\n";
    long r = write_crudo(STDOUT_FILENO, m3, strlen(m3));
    if (r < 0) {
        /* El kernel devuelve el error como un valor negativo en rax:
         * no toca errno, eso lo hace el envoltorio de la biblioteca. */
        fprintf(stderr, "write crudo fallo, codigo %ld\n", -r);
        return 1;
    }
    printf("El kernel devolvio %ld bytes escritos en el camino 3\n", r);
#else
    printf("Camino 3 omitido: esta maquina no es x86-64\n");
#endif

    return 0;
}

El detalle mas instructivo esta en el manejo de errores del camino 3. El kernel no conoce errno. Cuando una llamada falla, devuelve el codigo de error negado: -1 es EPERM, -2 es ENOENT, -14 es EFAULT. Es el envoltorio de la biblioteca C quien detecta que el valor de retorno esta entre -1 y -4095, guarda el valor absoluto en la variable errno de ese hilo y devuelve -1. Si invocas la instruccion a mano, ese trabajo es tuyo.

Ejemplo 2: llamar a write(2) desde Ada

Ada no oculta el sistema operativo; permite enlazar directamente con las funciones de C mediante Interfaces.C y el aspecto Import. Este programa completo escribe en la salida estandar sin pasar por Ada.Text_IO:

--  escritura_directa.adb
--  Compilar: gnatmake escritura_directa.adb
--  Ejecutar : ./escritura_directa

with Interfaces.C;
with Interfaces.C.Strings;
with Ada.Text_IO;

procedure Escritura_Directa is

   use Interfaces.C;
   use Interfaces.C.Strings;

   --  Enlazamos con el envoltorio write(2) que expone la biblioteca C.
   --  El aspecto Import evita que Ada genere un cuerpo para esta funcion.
   function C_Write (Fd    : int;
                     Buf   : chars_ptr;
                     Count : size_t) return long
     with Import        => True,
          Convention    => C,
          External_Name => "write";

   Salida_Estandar : constant int := 1;

   Mensaje  : constant String := "Ada invocando write(2) sin Text_IO" & ASCII.LF;
   Buffer   : chars_ptr        := New_String (Mensaje);
   Escritos : long;

begin
   Escritos := C_Write (Salida_Estandar, Buffer, size_t (Mensaje'Length));
   Free (Buffer);

   if Escritos < 0 then
      Ada.Text_IO.Put_Line ("La llamada al sistema fallo");
   else
      Ada.Text_IO.Put_Line
        ("Bytes entregados al kernel:" & long'Image (Escritos));
   end if;
end Escritura_Directa;

Dos cosas que este ejemplo deja claras. Primero, Ada.Text_IO.Put_Line termina exactamente en la misma llamada al sistema: la diferencia es cuantas capas de buffer hay en el medio. Segundo, New_String reserva memoria del heap y Free la devuelve; olvidar ese Free es una fuga, porque chars_ptr es un puntero crudo al estilo C y el recolector de Ada no lo administra.

Observar las llamadas al sistema con strace

La forma mas rapida de entender un programa ajeno es mirar que le pide al kernel. strace usa la infraestructura de trazado del propio kernel para interceptar cada entrada y salida de syscall:

# Todas las llamadas de un comando simple
strace ./tres_caminos

# Solo las llamadas de escritura, con la marca de tiempo relativa
strace -e trace=write -r ./tres_caminos

# Resumen estadistico: cuantas veces se llamo a cada syscall y cuanto tiempo tomo
strace -c ls -l /usr

# Seguir tambien los procesos hijos que se creen
strace -f -e trace=clone,execve bash -c 'echo hola'

# Adjuntarse a un proceso que ya esta corriendo (requiere permisos)
strace -p 1234

La salida de strace -c es una herramienta de diagnostico de rendimiento por si sola: si un programa lento pasa el 90% de su tiempo en read con tamanos de 1 byte, ya sabes que arreglar sin abrir el codigo.

El vDSO: la llamada que no cruza la frontera

Algunas llamadas son tan frecuentes y tan inofensivas que cruzar a ring 0 seria un desperdicio. Consultar la hora es el ejemplo canonico: un servidor puede pedirla millones de veces por segundo, y la respuesta no requiere ningun privilegio, solo leer un contador que el kernel ya mantiene actualizado.

Para eso existe el vDSO (virtual dynamic shared object): una pequena biblioteca compartida que el kernel mapea en el espacio de direcciones de cada proceso al arrancar. Contiene implementaciones de unas pocas funciones que leen datos que el kernel deja en una pagina de solo lectura compartida.

# Ver el vDSO mapeado en el espacio de direcciones de un proceso
grep vdso /proc/self/maps

# Comparar: clock_gettime casi no aparece en strace porque se resuelve en el vDSO
strace -c -e trace=clock_gettime date

En Linux/x86-64 el vDSO resuelve en espacio de usuario, entre otras, clock_gettime, gettimeofday, time y getcpu. Es una excepcion deliberada al modelo de frontera estricta, justificada por medicion.

Restringir las llamadas permitidas con seccomp

Si el conjunto de llamadas al sistema es la superficie de ataque de un programa contra el kernel, reducirla es una defensa directa. Linux ofrece seccomp (secure computing mode): un filtro que se instala una sola vez, es irreversible para ese proceso y se hereda a los hijos.

/* jaula.c  -- ejemplo minimo de seccomp en modo estricto
 * Compilar: gcc -O2 -Wall -o jaula jaula.c
 * Ejecutar : ./jaula
 * El proceso muere con SIGKILL en cuanto intenta una syscall no permitida.
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>

int main(void)
{
    const char *antes = "Antes del filtro puedo hacer lo que quiera\n";
    write(STDOUT_FILENO, antes, strlen(antes));

    /* SECCOMP_MODE_STRICT solo permite read, write, _exit y sigreturn. */
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT) != 0) {
        perror("prctl");
        return 1;
    }

    const char *dentro = "write sigue permitido dentro del modo estricto\n";
    write(STDOUT_FILENO, dentro, strlen(dentro));

    /* getpid NO esta en la lista blanca del modo estricto:
     * el kernel mata el proceso con SIGKILL en este punto. */
    pid_t p = getpid();

    /* Esta linea no se alcanza nunca. */
    printf("Nunca veras esto, pid=%d\n", (int) p);
    return 0;
}

El modo estricto es didactico pero rigido. En produccion se usa seccomp-bpf, que permite escribir un programa BPF que decide, syscall por syscall y argumento por argumento, si permitir, denegar con un error o matar el proceso. Es la base del aislamiento de contenedores, de los sandboxes de los navegadores y de systemd con sus directivas de restriccion de llamadas.


Arquitecturas de kernel

Ahora que sabemos que es la frontera y como se cruza, la pregunta de diseno se vuelve concreta: cuanto codigo debe vivir del lado privilegiado. Las tres respuestas historicas son monolitico, microkernel e hibrido.

Kernel monolitico

Todo el sistema operativo (planificador, memoria, sistemas de archivos, pila de red, controladores de dispositivo) se compila en un solo binario que se ejecuta completo en ring 0, en un unico espacio de direcciones.

La consecuencia inmediata es el rendimiento: cuando el sistema de archivos necesita leer del disco, llama a una funcion del controlador. Es una llamada de funcion ordinaria, de unos pocos nanosegundos. No hay cambio de contexto, no hay copia de datos, no hay mensajes.

La consecuencia opuesta es la fragilidad: un error de puntero en un controlador de una tarjeta de red exotica puede corromper las estructuras del sistema de archivos, porque comparten espacio de direcciones y privilegio. Un fallo en cualquier parte tumba la maquina entera.

Ejemplos: Linux, FreeBSD, OpenBSD, NetBSD, Solaris, AIX, y los Unix historicos.

Microkernel

La idea opuesta: en ring 0 se deja solo lo estrictamente indispensable, y todo lo demas se mueve a procesos de usuario llamados servidores. El nucleo minimo suele contener nada mas que tres cosas: gestion de espacios de direcciones, planificacion de hilos y paso de mensajes (IPC).

El sistema de archivos pasa a ser un proceso. El controlador de disco, otro proceso. La pila de red, otro. Cuando una aplicacion quiere leer un archivo, no llama a una funcion: envia un mensaje al servidor de archivos, que envia otro mensaje al controlador de disco, que responde, y asi de vuelta.

sequenceDiagram
    autonumber
    participant App as Aplicacion
    participant MK as Microkernel (ring 0)
    participant FS as Servidor de archivos (ring 3)
    participant Drv as Controlador de disco (ring 3)
    participant HW as Disco

    App->>MK: enviar mensaje "leer /datos.txt"
    MK->>FS: entrega el mensaje y le cede la CPU
    FS->>FS: resuelve la ruta y calcula el bloque fisico
    FS->>MK: enviar mensaje "leer bloque 4712"
    MK->>Drv: entrega el mensaje
    Drv->>HW: programa la transferencia por DMA
    HW-->>Drv: interrupcion de fin de transferencia
    Drv->>MK: responder con los datos del bloque
    MK->>FS: entrega la respuesta
    FS->>MK: responder con el contenido del archivo
    MK->>App: entrega la respuesta y le devuelve la CPU

Cuenta los cruces de frontera de ese diagrama: son ocho, contra dos de un kernel monolitico. Ese es el costo del microkernel, y es la razon por la que los primeros microkernels de los anos ochenta y noventa (Mach 3.0, por ejemplo) tuvieron fama de lentos.

Lo que se gana es sustancial:

  • Aislamiento de fallos: si el controlador de disco se cae, es un proceso de usuario que se cae. MINIX 3 incluye un reincarnation server cuyo unico trabajo es detectar servidores muertos y reiniciarlos, a veces sin que la aplicacion note nada.
  • Superficie de confianza minima: seL4 tiene alrededor de diez mil lineas de codigo en ring 0, y existe una prueba matematica formal de que su implementacion cumple su especificacion. Verificar formalmente los treinta millones de lineas de Linux no es una tarea que alguien haya intentado.
  • Extensibilidad en caliente: reemplazar un sistema de archivos es reiniciar un proceso.

Ejemplos: seL4, QNX Neutrino (usado en automocion y en dispositivos medicos), MINIX 3, GNU Hurd, L4 y sus derivados. Un dato poco conocido: el procesador de banda base de muchisimos telefonos moviles ejecuta un microkernel de la familia L4.

Kernel hibrido

En la practica, la mayoria de los sistemas comerciales tomaron un camino intermedio: adoptan la estructura conceptual de un microkernel (subsistemas separados, comunicacion por mensajes en las interfaces) pero ejecutan la mayoria de esos subsistemas dentro de ring 0 para no pagar los cruces de frontera.

Windows NT es el caso mas citado: su diseno original de 1993 separaba subsistemas de entorno en procesos de usuario, pero el gestor de ventanas y el subsistema grafico se movieron dentro del kernel en NT 4.0 por rendimiento, y ahi siguen.

XNU, el kernel de macOS e iOS, combina el microkernel Mach (para gestion de memoria, IPC y planificacion) con codigo de FreeBSD (para el modelo POSIX, sistemas de archivos y red) y el entorno de controladores IOKit escrito en un subconjunto de C++. Todo eso corre en el mismo espacio de direcciones privilegiado, asi que en terminos de aislamiento se comporta como un monolitico.

La etiqueta “hibrido” genera discusion: hay quienes sostienen que es simplemente un monolitico con modularidad interna. Para efectos practicos lo que importa es la pregunta operativa: si un controlador falla, se cae el sistema? Si la respuesta es si, el aislamiento es el de un monolitico, sin importar como se llame la arquitectura.

Exokernels y unikernels

Dos disenos menos comunes que vale la pena conocer porque aparecen en infraestructura moderna:

  • Exokernel: el kernel no abstrae nada, solo multiplexa el hardware de forma segura y deja que cada aplicacion implemente sus propias abstracciones en bibliotecas. La idea nacio en el MIT en los anos noventa; su influencia sobrevive en tecnologias de acceso directo al hardware desde espacio de usuario.
  • Unikernel: la aplicacion y el sistema operativo se compilan juntos en una unica imagen que corre sin separacion de privilegios, normalmente sobre un hipervisor que provee el aislamiento. Ejemplos: MirageOS, IncludeOS, Unikraft. El resultado son imagenes de pocos megabytes que arrancan en milisegundos, a costa de perder toda la flexibilidad de un sistema de proposito general.

Comparacion lado a lado

flowchart TB
    subgraph MONO["Monolitico (Linux, FreeBSD)"]
        direction TB
        MU["Aplicaciones - ring 3"]
        MK["Ring 0: planificador + memoria + VFS +<br/>sistemas de archivos + red + controladores"]
        MH["Hardware"]
        MU -->|"syscall"| MK --> MH
    end

    subgraph MICRO["Microkernel (seL4, QNX, MINIX 3)"]
        direction TB
        CU["Aplicaciones - ring 3"]
        CS["Servidores en ring 3:<br/>archivos, red, controladores"]
        CK["Ring 0: IPC + planificacion +<br/>espacios de direcciones"]
        CH["Hardware"]
        CU -->|"mensaje"| CK
        CK -->|"mensaje"| CS
        CS -->|"mensaje"| CK
        CK --> CH
    end

    subgraph HIB["Hibrido (Windows NT, XNU)"]
        direction TB
        HU["Aplicaciones - ring 3"]
        HS["Subsistemas de entorno - ring 3"]
        HK["Ring 0: nucleo + ejecutivo +<br/>grafico + controladores"]
        HH["Hardware"]
        HU --> HS --> HK --> HH
        HU -->|"syscall directa"| HK
    end
CriterioMonoliticoMicrokernelHibrido
Codigo en ring 0Millones de lineasDecenas de milesMillones de lineas
Comunicacion internaLlamada de funcionPaso de mensajes con cruce de fronteraLlamada de funcion, interfaces por mensajes
Costo de una operacion de E/SMinimoVarios cruces de contextoCercano al monolitico
Fallo de un controladorCae el sistema completoCae un proceso, puede reiniciarseCae el sistema completo
Verificacion formalImpracticableLograda en seL4Impracticable
Extensibilidad en calienteModulos cargablesReiniciar un servidorModulos y controladores firmados
Complejidad de depuracionAlta, requiere depurador de kernelMedia, herramientas de usuarioAlta
Casos de uso tipicosServidores, escritorio, movilesSistemas criticos, automocion, aviacionEscritorio y moviles comerciales
EjemplosLinux, FreeBSD, SolarisseL4, QNX, MINIX 3, HurdWindows NT, XNU

El debate historico entre Andrew Tanenbaum y Linus Torvalds en 1992, sobre si Linux era un diseno obsoleto por ser monolitico, sigue siendo lectura util: los dos tenian razon en su propio eje. Tanenbaum sobre robustez y portabilidad, Torvalds sobre pragmatismo y velocidad de desarrollo. Treinta anos despues, Linux domina servidores y moviles, y los microkernels dominan los sistemas donde un fallo cuesta vidas.


Modulos del kernel

El problema que resuelven

Un kernel monolitico que incluyera de fabrica el codigo de cada dispositivo existente seria enorme, y cargaria en memoria controladores para hardware que la maquina no tiene. La solucion de Linux, adoptada tambien por Solaris, FreeBSD y otros, son los modulos cargables (loadable kernel modules): fragmentos de codigo objeto que se enlazan con el kernel en ejecucion.

Un modulo cargado no es un proceso ni corre en un espacio protegido. Se convierte, para todo efecto practico, en parte del kernel: mismo espacio de direcciones, mismo privilegio, mismos riesgos. Un puntero nulo desreferenciado en un modulo produce un kernel oops, y si ocurre en un contexto critico, un kernel panic.

Ciclo de vida de un modulo

stateDiagram-v2
    [*] --> Fuente: escribes hola.c
    Fuente --> Objeto: make contra los headers del kernel<br/>produce hola.ko
    Objeto --> Verificado: insmod o modprobe<br/>syscall finit_module
    Verificado --> Rechazado: firma invalida, vermagic distinto<br/>o simbolo no resuelto
    Rechazado --> [*]
    Verificado --> Inicializando: el kernel resuelve simbolos<br/>y reserva memoria
    Inicializando --> Activo: la funcion module_init<br/>devuelve 0
    Inicializando --> Rechazado: module_init<br/>devuelve un error
    Activo --> Activo: atiende llamadas, registra<br/>dispositivos, responde interrupciones
    Activo --> Descargando: rmmod, solo si el<br/>contador de uso es cero
    Activo --> Activo: rmmod rechazado si<br/>hay usuarios activos
    Descargando --> [*]: module_exit libera<br/>todo lo reservado

El detalle del contador de uso es central: el kernel lleva la cuenta de cuantos usuarios tiene cada modulo. Si un sistema de archivos montado depende de un modulo, ese modulo no se puede descargar. lsmod muestra ese contador en su tercera columna.

El detalle del vermagic explica la frustracion mas comun con modulos: un .ko compilado para el kernel 6.8.0 no carga en el 6.9.0. Linux no garantiza una interfaz binaria estable dentro del kernel, asi que cada modulo lleva grabada la version exacta y las opciones de compilacion con las que se construyo, y el cargador las compara.

Un modulo completo, compilable

Este modulo se carga, imprime un mensaje configurable en el registro del kernel, expone su parametro en /sys y limpia al descargarse.

/* hola.c -- modulo minimo pero completo para Linux */
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/moduleparam.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Artiko");
MODULE_DESCRIPTION("Modulo de ejemplo para el curso de sistemas operativos");
MODULE_VERSION("1.0");

/* Parametro configurable al cargar el modulo.
 * El tercer argumento es el modo de los permisos del archivo en /sys/module.
 * 0444 = lectura para todos, sin escritura.
 */
static char *saludo = "mundo";
module_param(saludo, charp, 0444);
MODULE_PARM_DESC(saludo, "Texto que se imprime al cargar el modulo");

static int veces = 1;
module_param(veces, int, 0444);
MODULE_PARM_DESC(veces, "Cuantas veces repetir el mensaje");

/* __init marca esta funcion para que su memoria se libere despues de correr. */
static int __init hola_init(void)
{
    int i;

    if (veces < 1 || veces > 10) {
        pr_err("hola: el parametro veces debe estar entre 1 y 10\n");
        return -EINVAL;   /* un valor negativo aborta la carga del modulo */
    }

    for (i = 0; i < veces; i++)
        pr_info("hola: hola, %s (%d de %d)\n", saludo, i + 1, veces);

    pr_info("hola: modulo cargado correctamente\n");
    return 0;   /* devolver 0 significa exito */
}

/* __exit marca esta funcion para omitirla si el modulo se compila estatico. */
static void __exit hola_exit(void)
{
    pr_info("hola: modulo descargado, adios %s\n", saludo);
}

module_init(hola_init);
module_exit(hola_exit);

El Makefile correspondiente usa el sistema de construccion del propio kernel, no compila de forma independiente:

# Makefile para el modulo hola
obj-m += hola.o

KDIR ?= /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

Sesion completa de uso:

# 1. Requisitos: headers del kernel en ejecucion
#    Debian/Ubuntu: sudo apt install linux-headers-$(uname -r) build-essential
#    Arch:          sudo pacman -S linux-headers base-devel
#    Fedora:        sudo dnf install kernel-devel gcc make

# 2. Compilar
make
ls -l hola.ko

# 3. Inspeccionar el modulo antes de cargarlo
modinfo hola.ko

# 4. Cargar con parametros
sudo insmod hola.ko saludo="sistemas operativos" veces=3

# 5. Ver la salida en el registro del kernel
sudo dmesg | tail -6

# 6. Comprobar que esta cargado y ver su contador de uso
lsmod | grep hola
cat /proc/modules | grep hola

# 7. Leer sus parametros expuestos en sysfs
cat /sys/module/hola/parameters/saludo
cat /sys/module/hola/parameters/veces

# 8. Descargar
sudo rmmod hola
sudo dmesg | tail -2

Si el paso 4 falla con “Invalid parameters”, es la manera del kernel de reportar que hola_init devolvio -EINVAL. Ese es el mecanismo: un modulo que no puede inicializarse correctamente debe devolver un error negativo, y el kernel lo descarga sin dejar rastro.

Herramientas de administracion de modulos

ComandoQue haceDiferencia clave
lsmodLista modulos cargados con tamano y usuariosFormatea /proc/modules
modinfo <mod>Muestra metadatos, parametros, dependencias, firmaFunciona sobre archivo o nombre
insmod <archivo.ko>Carga un archivo concretoNo resuelve dependencias
modprobe <mod>Carga por nombre desde /lib/modulesSi resuelve dependencias
modprobe -r <mod>Descarga el modulo y sus dependencias huerfanasMas seguro que rmmod
rmmod <mod>Descarga un modulo por nombreFalla si el contador de uso no es cero
depmodRecalcula el mapa de dependenciasSe ejecuta al instalar modulos

Los archivos que importan: /proc/modules es la lista viva, /sys/module/ expone parametros y estado por modulo, /lib/modules/$(uname -r)/ guarda los modulos instalados, y /etc/modprobe.d/ contiene reglas de carga, opciones y listas negras.

Firmas, taint y confianza

Cargar codigo arbitrario en ring 0 es la operacion mas peligrosa que permite un sistema operativo. Por eso los kernels modernos verifican firmas criptograficas de los modulos. Con Secure Boot activo y el kernel en modo lockdown, un modulo sin firmar simplemente no carga, aunque seas root.

El kernel tambien lleva una bandera global de “contaminacion” (taint): si cargas un modulo propietario o sin firmar, el kernel se marca como contaminado, y esa marca aparece en cualquier reporte de fallo. Los desarrolladores del kernel usan esa bandera para descartar reportes de error que no pueden reproducir.

# Valor numerico de la bandera de taint (0 = kernel limpio)
cat /proc/sys/kernel/tainted

# Estado de Secure Boot en sistemas UEFI
sudo dmesg | grep -i secure

# Ver si un modulo esta firmado
modinfo hola.ko | grep -i sig

eBPF: extender el kernel sin cargar codigo nativo

Existe una tercera via entre “recompilar el kernel” y “cargar un modulo”. eBPF permite cargar programas en un lenguaje restringido que un verificador dentro del kernel analiza antes de admitirlos. El verificador demuestra que el programa termina, que no accede a memoria fuera de sus limites y que no tiene ciclos no acotados. Luego se compila a codigo nativo y se ejecuta en puntos de enganche definidos: entrada de syscalls, eventos de red, funciones internas del kernel.

Es el mecanismo detras de herramientas modernas de observabilidad y de filtrado de red de alto rendimiento, y la diferencia conceptual con un modulo es exactamente la que discutimos en la seccion de arquitecturas: codigo verificado con garantias frente a codigo con confianza total.

# Contar llamadas al sistema por proceso en tiempo real (paquete bpfcc-tools)
sudo syscount-bpfcc -P

# Ver los programas eBPF cargados actualmente
sudo bpftool prog list

El planificador

Que decide y en que momento

El planificador (scheduler) responde una sola pregunta, muchisimas veces por segundo: de todos los hilos que estan listos para ejecutarse, cual ocupa cada CPU ahora, y por cuanto tiempo.

Los momentos en que el planificador entra en accion son cinco, y conviene memorizarlos porque explican casi todo el comportamiento observable de un sistema:

  1. Un hilo se bloquea voluntariamente esperando entrada/salida, un mutex o un temporizador.
  2. Un hilo termina.
  3. Un hilo pasa de bloqueado a listo, por ejemplo porque llego el dato que esperaba, y su prioridad es mayor que la del hilo en ejecucion.
  4. Se agota el quantum de tiempo asignado al hilo actual, detectado en la interrupcion del temporizador.
  5. Se retorna de una llamada al sistema o de una interrupcion y hay una marca de replanificacion pendiente.

Un planificador que solo actua en los casos 1 y 2 se llama no expropiativo (cooperativo): un hilo conserva la CPU hasta que la cede. Un planificador que tambien actua en 3, 4 y 5 es expropiativo (preemptive), y es lo que usan todos los sistemas de proposito general. La diferencia practica: en un sistema cooperativo, un bucle infinito en un programa cuelga la maquina; en uno expropiativo, solo consume su cuota.

Estados por los que pasa un hilo

stateDiagram-v2
    [*] --> Nuevo: fork o clone
    Nuevo --> Listo: el planificador lo<br/>encola en una cola de ejecucion
    Listo --> Ejecutando: context_switch,<br/>el hilo toma una CPU
    Ejecutando --> Listo: quantum agotado o<br/>llega un hilo de mayor prioridad
    Ejecutando --> Bloqueado: espera E/S, mutex,<br/>senal o temporizador
    Bloqueado --> Listo: llega el evento esperado,<br/>wake_up
    Ejecutando --> Zombi: exit, pero el padre<br/>aun no hizo wait
    Zombi --> [*]: el padre lee el codigo<br/>de salida con wait4
    Ejecutando --> Detenido: senal SIGSTOP
    Detenido --> Listo: senal SIGCONT

En Linux estos estados son visibles directamente. La columna STAT de ps los muestra con letras:

# Estados de todos los procesos
ps -eo pid,stat,comm --sort=-pcpu | head -15

# R = ejecutando o listo   S = bloqueado interrumpible
# D = bloqueado no interrumpible (tipicamente E/S de disco)
# T = detenido             Z = zombi
# I = hilo de kernel ocioso

# Estado detallado de un proceso concreto
grep -E 'State|voluntary' /proc/self/status

El estado D merece una nota: es un bloqueo que no puede ser interrumpido por senales. Un proceso en D no responde ni siquiera a kill -9, porque el kernel esta a mitad de una operacion con el hardware y no puede abandonarla sin corromper estructuras. Si ves muchos procesos en D, el problema no es la CPU, es el almacenamiento.

Las clases de planificacion de Linux

Linux no tiene un planificador, tiene varios organizados en una jerarquia estricta. Cuando hay que elegir un hilo, se consulta cada clase en orden y la primera que tenga algo que ejecutar gana. Una clase mas alta siempre desplaza a una mas baja.

OrdenClasePoliticasPrioridadPara que sirve
1stopinternamaximaDetener CPUs, migrar tareas; no accesible desde usuario
2deadlineSCHED_DEADLINEsobre las de tiempo realTareas con plazo, periodo y presupuesto declarados
3rtSCHED_FIFO, SCHED_RR1 a 99Tiempo real blando: audio, control, robotica
4fairSCHED_OTHER, SCHED_BATCHnice de -20 a 19Todo lo normal del sistema
5idleSCHED_IDLEminimaTrabajo que solo corre si no hay nada mas

Las diferencias entre las politicas de tiempo real:

  • SCHED_FIFO: el hilo de mayor prioridad corre hasta que se bloquea o cede la CPU voluntariamente. No tiene quantum. Un bucle infinito con SCHED_FIFO a prioridad 99 puede congelar una CPU entera.
  • SCHED_RR: igual que FIFO pero con quantum entre hilos de la misma prioridad, que rotan por turnos.
  • SCHED_DEADLINE: en lugar de una prioridad, declaras tres numeros: cuanto tiempo de CPU necesitas (runtime), cada cuanto (period) y antes de cuando debe terminar (deadline). El kernel aplica un algoritmo de plazo mas cercano primero y rechaza admitir la tarea si el sistema no puede garantizarla. Es la unica politica de Linux con control de admision.

CFS y EEVDF: como se reparte el tiempo “normal”

La inmensa mayoria de los hilos usa SCHED_OTHER. Durante casi dos decadas, esa clase la manejo CFS (Completely Fair Scheduler), y desde la version 6.6 del kernel la reemplazo EEVDF (Earliest Eligible Virtual Deadline First). Comparten la idea central, asi que vale la pena entenderla.

CFS no asigna quantums fijos. Lleva, por cada hilo, un contador llamado tiempo virtual de ejecucion (vruntime): cuanto tiempo de CPU ha consumido, ponderado por su peso. La regla es de una simplicidad notable: siempre ejecuta el hilo con el menor vruntime. Como todos los hilos ejecutandose acumulan vruntime, el que corre se va “encareciendo” hasta que otro pasa a ser el minimo, y ahi ocurre el cambio.

El valor nice no es una prioridad absoluta, es un peso que altera la velocidad a la que crece el vruntime. Un hilo con nice = -5 acumula tiempo virtual mas lento que uno con nice = 0, asi que sigue siendo el minimo mas tiempo y recibe mas CPU real. Cada unidad de nice cambia el peso aproximadamente en un 25%, de modo que la diferencia entre nice = 0 y nice = 5 es del orden de tres veces mas CPU para el primero.

EEVDF agrega a ese esquema el concepto de latencia deseada por tarea: ademas de la proporcion de CPU, un hilo puede pedir que cuando le toque, le toque pronto. Eso resuelve mejor el caso de las tareas interactivas cortas (mover el mouse, responder una tecla) que necesitan poca CPU pero la necesitan ya. CFS aproximaba ese comportamiento con heuristicas de “bonus para tareas interactivas”; EEVDF lo modela explicitamente con un plazo virtual.

# Politica y prioridad actual de un proceso
chrt -p $$

# Estadisticas del planificador para el shell actual
cat /proc/self/sched

# Numero de cambios de contexto voluntarios e involuntarios
grep ctxt_switches /proc/self/status

# Latencias de planificacion por tarea (requiere CONFIG_SCHED_DEBUG)
sudo cat /proc/schedstat | head -5

La distincion entre cambios de contexto voluntarios e involuntarios es una herramienta de diagnostico excelente: muchos voluntarios significa que el proceso pasa el tiempo esperando entrada/salida; muchos involuntarios significa que compite por CPU y el planificador se la quita.

El cambio de contexto, paso a paso

flowchart TD
    A["Interrupcion del temporizador<br/>o retorno de syscall"] --> B{"Hay marca<br/>TIF_NEED_RESCHED?"}
    B -->|"no"| Z["Continua el mismo hilo"]
    B -->|"si"| C["Llamada a schedule"]
    C --> D["pick_next_task recorre<br/>las clases en orden"]
    D --> E{"Es el mismo<br/>hilo actual?"}
    E -->|"si"| Z
    E -->|"no"| F["context_switch"]
    F --> G["switch_mm: cambia la tabla<br/>de paginas si cambia el proceso"]
    G --> H["Se recarga CR3, la TLB<br/>se invalida salvo entradas globales"]
    H --> I["switch_to: guarda registros del<br/>hilo saliente en su task_struct"]
    I --> J["Restaura registros y puntero de<br/>pila del hilo entrante"]
    J --> K["Se reanuda la ejecucion en el punto<br/>donde el hilo entrante fue suspendido"]
    K --> L["Caches y predictores parten frios:<br/>este es el costo oculto real"]

El paso L explica un fenomeno contraintuitivo. El costo directo de un cambio de contexto (guardar y restaurar registros) es de aproximadamente uno a pocos microsegundos. Pero el costo indirecto puede ser mucho mayor: el hilo entrante encuentra la cache de datos llena de informacion del hilo saliente y debe repoblar sus lineas de cache desde memoria principal. Si dos hilos que comparten datos se planifican en CPUs distintas, ademas aparece trafico de coherencia de cache entre nucleos.

De ahi la utilidad de la afinidad de CPU: fijar un hilo a un nucleo concreto para que conserve su cache caliente.

# Ver la afinidad actual del shell
taskset -p $$

# Ejecutar un comando fijado al nucleo 2
taskset -c 2 ./mi_programa

# Cambiar la afinidad de un proceso en marcha a los nucleos 0 y 1
taskset -cp 0,1 1234

Ejemplo 3: cambiar la politica de planificacion desde C

/* politica.c
 * Compilar: gcc -O2 -Wall -o politica politica.c
 * Ejecutar : ./politica            (falla al pedir tiempo real, es lo esperado)
 *            sudo ./politica       (lo consigue)
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sched.h>
#include <unistd.h>
#include <time.h>

static const char *nombre_politica(int p)
{
    switch (p) {
        case SCHED_OTHER:    return "SCHED_OTHER (fair)";
        case SCHED_FIFO:     return "SCHED_FIFO (tiempo real, sin quantum)";
        case SCHED_RR:       return "SCHED_RR (tiempo real, por turnos)";
        case SCHED_BATCH:    return "SCHED_BATCH (lotes, poca interactividad)";
        case SCHED_IDLE:     return "SCHED_IDLE (solo si sobra CPU)";
#ifdef SCHED_DEADLINE
        case SCHED_DEADLINE: return "SCHED_DEADLINE (con plazo)";
#endif
        default:             return "desconocida";
    }
}

static void informar(void)
{
    int politica = sched_getscheduler(0);
    struct sched_param sp;
    struct timespec q;

    if (politica == -1) {
        perror("sched_getscheduler");
        return;
    }
    if (sched_getparam(0, &sp) == -1) {
        perror("sched_getparam");
        return;
    }

    printf("Politica actual : %s\n", nombre_politica(politica));
    printf("Prioridad       : %d\n", sp.sched_priority);
    printf("Rango valido    : %d a %d\n",
           sched_get_priority_min(politica),
           sched_get_priority_max(politica));

    if (sched_rr_get_interval(0, &q) == 0)
        printf("Quantum         : %ld ns\n", (long) q.tv_nsec);
}

int main(void)
{
    printf("=== Estado inicial ===\n");
    informar();

    printf("\n=== Bajando a SCHED_IDLE (no requiere privilegios) ===\n");
    struct sched_param bajo = { .sched_priority = 0 };
    if (sched_setscheduler(0, SCHED_IDLE, &bajo) == -1)
        fprintf(stderr, "No se pudo pasar a SCHED_IDLE: %s\n", strerror(errno));
    else
        informar();

    printf("\n=== Volviendo a SCHED_OTHER ===\n");
    if (sched_setscheduler(0, SCHED_OTHER, &bajo) == -1)
        fprintf(stderr, "No se pudo volver: %s\n", strerror(errno));

    printf("\n=== Intentando SCHED_FIFO con prioridad 10 ===\n");
    struct sched_param alto = { .sched_priority = 10 };
    if (sched_setscheduler(0, SCHED_FIFO, &alto) == -1) {
        fprintf(stderr,
                "Rechazado: %s\n"
                "Es el comportamiento esperado sin privilegios: subir a tiempo\n"
                "real exige CAP_SYS_NICE o un limite RLIMIT_RTPRIO adecuado.\n",
                strerror(errno));
    } else {
        informar();
        /* Devolvemos la politica normal de inmediato: dejar un proceso en
         * SCHED_FIFO por descuido es una forma eficaz de colgar una CPU. */
        sched_setscheduler(0, SCHED_OTHER, &bajo);
        printf("Politica restaurada a SCHED_OTHER\n");
    }

    printf("\nNumero de CPUs disponibles para este proceso: %ld\n",
           sysconf(_SC_NPROCESSORS_ONLN));
    return 0;
}

Ejecutalo dos veces, con y sin sudo. La diferencia te muestra en la practica que la politica de planificacion es un recurso privilegiado: si cualquier programa pudiera declararse de tiempo real, la planificacion dejaria de ser una politica del sistema y pasaria a ser una negociacion entre programas egoistas.

Los equivalentes de linea de comandos hacen lo mismo sin escribir codigo:

# Lanzar un comando con nice bajo (mas amable con el resto)
nice -n 19 ./mi_programa

# Cambiar la amabilidad de un proceso en marcha
sudo renice -n -5 -p 1234

# Lanzar un comando con politica de turnos y prioridad 20
sudo chrt -r 20 ./mi_programa

# Consultar la politica de un proceso
chrt -p 1234

Ejemplo 4: prioridades de tareas en Ada

Ada trata la concurrencia como parte del lenguaje, no como una biblioteca. Una task es una unidad de ejecucion concurrente declarada en el propio codigo, y el estandar define politicas de despacho que se traducen a las politicas del sistema operativo subyacente.

--  prioridades.adb
--  Compilar: gnatmake prioridades.adb
--  Ejecutar : ./prioridades

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);

with Ada.Text_IO;  use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;
with System;

procedure Prioridades is

   --  Tarea parametrizada por identificador y prioridad.
   task type Trabajador (Id : Natural; Prio : System.Priority) is
      pragma Priority (Prio);
   end Trabajador;

   task body Trabajador is
      Inicio     : constant Time := Clock;
      Acumulador : Long_Long_Integer := 0;
      Fin        : Time;
   begin
      --  Carga de CPU pura, sin entrada/salida ni bloqueos.
      for I in 1 .. 200_000_000 loop
         Acumulador := Acumulador + Long_Long_Integer (I mod 7);
      end loop;
      Fin := Clock;

      Put_Line
        ("Tarea" & Natural'Image (Id) &
         " con prioridad" & System.Priority'Image (Prio) &
         " termino en" &
         Duration'Image (To_Duration (Fin - Inicio)) & " s" &
         " (suma =" & Long_Long_Integer'Image (Acumulador) & ")");
   end Trabajador;

   --  Las tareas arrancan al llegar al begin del bloque que las declara.
   Baja  : Trabajador (1, System.Default_Priority);
   Media : Trabajador (2, System.Default_Priority + 5);
   Alta  : Trabajador (3, System.Default_Priority + 10);

begin
   Put_Line ("Tres tareas lanzadas con prioridades distintas.");
   Put_Line ("Prioridad por defecto:" &
             System.Priority'Image (System.Default_Priority));
   Put_Line ("Rango de prioridades:" &
             System.Priority'Image (System.Priority'First) & " a" &
             System.Priority'Image (System.Priority'Last));
   --  El procedimiento principal no termina hasta que todas las tareas
   --  declaradas en su parte declarativa hayan finalizado.
end Prioridades;

Detalle importante para no sacar conclusiones equivocadas del experimento: con GNAT sobre Linux, cada tarea Ada se implementa como un hilo del sistema operativo, y FIFO_Within_Priorities se traduce a SCHED_FIFO. Establecer prioridades de tiempo real exige privilegios, exactamente como vimos en el ejemplo en C. Sin ellos, el runtime aplica el mejor esfuerzo dentro de SCHED_OTHER y las diferencias de tiempo entre las tres tareas seran pequenas. Ejecutado con privilegios y sobre un solo nucleo (taskset -c 0 sudo ./prioridades), el orden de finalizacion pasa a ser estrictamente el de prioridad.

Ese contraste, el mismo programa comportandose distinto segun los privilegios y la afinidad, es la mejor demostracion practica de que el planificador es una politica del sistema, no del programa.

Ejemplo 5: el planificador dentro de la BEAM con Elixir

Elixir corre sobre la maquina virtual BEAM de Erlang, que implementa su propio planificador por encima del planificador del sistema operativo. Es un caso de estudio excelente porque resuelve el mismo problema con un mecanismo distinto.

La BEAM arranca un hilo de sistema operativo por cada nucleo, y cada uno de esos hilos es un planificador que administra su propia cola de procesos ligeros. Un proceso de la BEAM no es un proceso del sistema operativo: pesa unos cientos de bytes al crearse, y una maquina puede sostener millones.

La expropiacion no se basa en un temporizador sino en reducciones: un contador que se decrementa con cada llamada de funcion y con cada operacion costosa. Cuando un proceso agota su presupuesto de reducciones (aproximadamente 4000 por turno), el planificador se lo lleva y pone a correr al siguiente. Como en la BEAM no existen bucles sin llamadas de funcion (la recursion es la unica forma de iterar), todo proceso consume reducciones y ninguno puede monopolizar un planificador.

# planificador.exs
# Ejecutar: elixir planificador.exs

defmodule Planificador do
  @doc "Bucle que quema CPU sin hacer entrada/salida ni dormir."
  def quemar_cpu do
    quemar_cpu()
  end

  @doc "Cuenta regresiva recursiva; cada llamada consume una reduccion."
  def contar(0), do: :listo
  def contar(n), do: contar(n - 1)

  def informar_sistema do
    IO.puts("Planificadores en linea : #{:erlang.system_info(:schedulers_online)}")
    IO.puts("Planificadores totales  : #{:erlang.system_info(:schedulers)}")
    IO.puts("Planificadores dirty CPU: #{:erlang.system_info(:dirty_cpu_schedulers)}")
    IO.puts("Procesos vivos ahora    : #{:erlang.system_info(:process_count)}")
    IO.puts("Limite de procesos      : #{:erlang.system_info(:process_limit)}")
  end

  @doc """
  Satura todos los nucleos con bucles infinitos y luego mide cuanto tarda
  un proceso nuevo en recibir su primer turno. Si la expropiacion funciona,
  la latencia se mantiene en el orden de milisegundos.
  """
  def medir_latencia_bajo_carga do
    nucleos = :erlang.system_info(:schedulers_online)
    saturadores = nucleos * 4

    IO.puts("\nLanzando #{saturadores} procesos que queman CPU sin parar...")
    pids = for _ <- 1..saturadores, do: spawn(&quemar_cpu/0)

    # Damos un instante para que todos entren en las colas de ejecucion.
    Process.sleep(200)

    padre = self()
    inicio = System.monotonic_time(:microsecond)

    spawn(fn ->
      primer_turno = System.monotonic_time(:microsecond)
      Planificador.contar(1_000_000)
      fin = System.monotonic_time(:microsecond)
      send(padre, {:medicion, primer_turno - inicio, fin - primer_turno})
    end)

    receive do
      {:medicion, espera_us, trabajo_us} ->
        IO.puts("Latencia hasta el primer turno : #{espera_us} us")
        IO.puts("Tiempo de un millon de llamadas: #{div(trabajo_us, 1000)} ms")
    after
      10_000 -> IO.puts("El proceso nunca fue planificado: eso seria inanicion")
    end

    IO.puts("Deteniendo los #{saturadores} saturadores...")
    Enum.each(pids, &Process.exit(&1, :kill))
    Process.sleep(100)
  end
end

Planificador.informar_sistema()
Planificador.medir_latencia_bajo_carga()

Ejecuta ese script y observa el resultado clave: la latencia hasta el primer turno se mantiene en el orden de milisegundos aunque haya cuatro veces mas procesos saturando la CPU que nucleos disponibles. Eso es expropiacion funcionando. La carga ademas se reparte de forma pareja entre todos los planificadores gracias al robo de trabajo (work stealing), en el que un planificador con la cola vacia toma tareas de la cola de otro; puedes medirlo activando :erlang.system_flag(:scheduler_wall_time, true) y comparando dos lecturas sucesivas de :erlang.statistics(:scheduler_wall_time).

La BEAM ademas separa planificadores sucios (dirty schedulers) para trabajo que no puede ser expropiado por reducciones: llamadas a codigo nativo largas o entrada/salida bloqueante. Sin esa separacion, un solo NIF lento congelaria un planificador completo y todos los procesos en su cola. Es exactamente el mismo problema que resuelve el kernel al separar el manejo de interrupciones en una parte corta y una parte diferida.

NivelUnidad planificadaQuien decideCriterio de expropiacionCosto de crear una unidad
Kernel LinuxHilo del sistemaKernel en ring 0Quantum por temporizador, prioridadKilobytes, decenas de microsegundos
BEAMProceso de ErlangPlanificador en espacio de usuarioPresupuesto de reduccionesCientos de bytes, sub-microsegundo
Runtime de GoGoroutinePlanificador en espacio de usuarioPuntos de seguridad y senales asincronasKilobytes, sub-microsegundo
Corrutinas cooperativasTarea asyncBiblioteca en espacio de usuarioSolo en puntos de espera explicitosBytes

La leccion transversal: planificar es una funcion, no un lugar. Aparece en el kernel, en las maquinas virtuales, en los runtimes de lenguaje y en los orquestadores de contenedores. Los mecanismos se repiten con nombres distintos.


Recorrido integrador: que pasa cuando ejecutas cat archivo.txt

Juntemos todas las piezas del capitulo en un solo recorrido.

  1. El shell ejecuta fork() (en la practica clone()), y el kernel crea una copia del proceso: nuevo task_struct, nuevas tablas de paginas marcadas como copia al escribir.
  2. El hijo ejecuta execve("/usr/bin/cat", ...). El kernel descarta el espacio de direcciones anterior, lee la cabecera ELF, mapea los segmentos del binario, mapea el enlazador dinamico y el vDSO, y coloca el contador de programa en el punto de entrada.
  3. El planificador pone el proceso en estado listo y, cuando le toca turno, un context_switch lo lleva a ejecutar.
  4. El enlazador dinamico resuelve las dependencias de bibliotecas compartidas usando openat() y mmap().
  5. cat llama a openat() sobre archivo.txt. La syscall entra al kernel, la capa VFS resuelve la ruta consultando la cache de directorios, y el sistema de archivos correspondiente (que puede ser un modulo cargado) devuelve un inodo. El kernel asigna el descriptor de archivo mas bajo disponible.
  6. cat llama a read(). Si los datos ya estan en la cache de paginas, se copian con copy_to_user y la llamada retorna de inmediato. Si no, el proceso pasa a estado D, el planificador elige otro hilo, el controlador de disco programa una transferencia por DMA y, al terminar, una interrupcion despierta al proceso.
  7. cat llama a write() sobre el descriptor 1, que suele ser un terminal. El controlador de tty entrega los bytes.
  8. Los pasos 6 y 7 se repiten hasta que read() devuelve 0, senal de fin de archivo.
  9. cat llama a exit_group(). El kernel libera el espacio de direcciones, cierra los descriptores y deja el proceso en estado zombi.
  10. El shell, que estaba bloqueado en wait4(), se despierta, lee el codigo de salida y el proceso desaparece por completo.

Cada linea de esa lista toca algo que discutimos: la frontera de privilegio, la trampa de syscall, el planificador, los modulos, la cache y las interrupciones. Ese es el kernel trabajando.


Errores comunes, su causa y su solucion

Sintoma o errorCausa realSolucion
insmod: ERROR: could not insert module: Invalid module formatEl .ko se compilo contra headers de una version de kernel distinta a la que corre; el vermagic no coincideRecompilar contra /lib/modules/$(uname -r)/build, o arrancar el kernel para el que se compilo
insmod: ERROR: could not insert module: Unknown symbol in moduleEl modulo usa un simbolo que otro modulo exporta y ese modulo no esta cargadoUsar modprobe en lugar de insmod, que resuelve dependencias; revisar dmesg para ver que simbolo falta
rmmod: ERROR: Module is in useEl contador de uso no es cero: algo montado o abierto depende del moduloCerrar los usuarios primero (lsof, desmontar); nunca forzar la descarga
insmod falla con Secure Boot activoEl kernel esta en modo lockdown y rechaza modulos sin firma validaFirmar el modulo con una clave inscrita en el MOK, o desactivar Secure Boot en un equipo de pruebas
sched_setscheduler devuelve EPERMSubir a SCHED_FIFO o SCHED_RR requiere CAP_SYS_NICE o un RLIMIT_RTPRIO suficienteEjecutar con privilegios, otorgar la capacidad al binario, o subir el limite en /etc/security/limits.conf
El sistema se congela al probar SCHED_FIFOUn hilo de tiempo real sin bloqueos monopoliza una CPU; FIFO no tiene quantumProbar siempre con afinidad a un solo nucleo y con sched_rt_runtime_us activo; usar SCHED_RR en lugar de FIFO al experimentar
Un programa consume sys cerca del 100% en topDemasiadas llamadas al sistema, tipicamente lecturas o escrituras muy pequenasMedir con strace -c, agrupar en buffers mas grandes, usar writev o io_uring
Procesos atascados en estado D que no mueren con kill -9Bloqueo no interrumpible dentro del kernel esperando al almacenamientoEl problema es el dispositivo o la red del almacenamiento, no el proceso; revisar dmesg y el estado del disco
EFAULT al invocar una syscall a manoSe paso un puntero que no pertenece al espacio de direcciones del proceso, o una longitud mayor que el buffer realVerificar que el puntero venga de memoria valida y que el tamano coincida con lo reservado
Codigo de error negativo inesperado al usar ensamblador en lineaEl kernel devuelve -errno en el registro de retorno y no toca la variable errnoInterpretar los valores entre -1 y -4095 como errores y negarlos para obtener el codigo
Un proceso de la BEAM parece congelar un nucleo enteroUn NIF o una llamada nativa larga corre en un planificador normal en lugar de uno sucioMarcar la operacion para planificador sucio o dividirla en fragmentos que devuelvan el control
Las prioridades de tareas Ada no producen ninguna diferenciaSin privilegios, el runtime no puede aplicar FIFO_Within_Priorities y todo cae en SCHED_OTHEREjecutar con privilegios y fijar la afinidad a un solo nucleo para observar el efecto
strace no puede adjuntarse a un proceso propioLa proteccion ptrace_scope restringe el trazado entre procesos no emparentadosEjecutar con privilegios, o ajustar /proc/sys/kernel/yama/ptrace_scope en un equipo de pruebas

Ejercicios propuestos

1. Contar la frontera. Escribe dos programas en C que copien un archivo de 100 MB: uno leyendo y escribiendo de a 1 byte, y otro de a 64 KB. Mide ambos con strace -c y con time. Explica la diferencia entre el tiempo real, el user y el sys de cada uno, y calcula cuantos microsegundos cuesta en promedio cada llamada al sistema en tu maquina.

2. La syscall desnuda. Extiende el ejemplo de ensamblador en linea para invocar getpid (numero 39) y openat (numero 257) directamente, sin usar ninguna funcion de glibc mas alla de las que ya escribiste. Provoca deliberadamente un error pasando una ruta inexistente y comprueba que el valor devuelto es -2, es decir ENOENT.

3. Modulo con parametro escribible. Modifica el modulo hola.c para que su parametro saludo tenga permisos 0644 en lugar de 0444. Comprueba que ahora puedes cambiarlo escribiendo en /sys/module/hola/parameters/saludo sin descargar el modulo, y agrega una segunda funcion que imprima el valor actual cuando cambie.

4. Medir el cambio de contexto. Escribe un programa con dos hilos que se pasen un byte por una tuberia un millon de veces (ping-pong). Ejecutalo primero sin afinidad, luego con ambos hilos fijados al mismo nucleo con taskset, y por ultimo con cada hilo en un nucleo distinto. Compara los tiempos y explica los resultados en terminos de cache y de cambios de contexto.

5. Inanicion controlada. Usando el ejemplo politica.c como base, escribe un programa que se ponga en SCHED_FIFO a prioridad 50 y entre en un bucle sin bloqueos. Ejecutalo con taskset -c 3 para confinarlo a un nucleo, y observa desde otro terminal que pasa con procesos normales asignados a ese mismo nucleo. Investiga que hace el parametro kernel.sched_rt_runtime_us y por que existe.

6. Reducciones frente a quantums. Modifica el script de Elixir para que uno de los procesos saturadores llame a una funcion que bloquee sin consumir reducciones, por ejemplo una operacion de entrada/salida sincrona larga. Observa el efecto en la utilizacion por planificador y explica por que existen los planificadores sucios.

7. Comparar dos arquitecturas. Instala MINIX 3 o una imagen de QNX en una maquina virtual y ejecuta ahi el equivalente de los ejercicios 1 y 4. Documenta las diferencias de latencia y explica cuales atribuyes al diseno de microkernel y cuales a la madurez de la implementacion.

8. Ada bajo presion. Amplia el programa Ada de prioridades para que las tres tareas escriban en un mismo objeto protegido en lugar de solo calcular. Ejecutalo con y sin FIFO_Within_Priorities y con distintas afinidades. Anota que garantias del lenguaje se mantienen en cada caso y cuales dependen del sistema operativo subyacente.

9. Mapa de tu propio sistema. Redacta un informe corto de tu maquina que responda: cuantos modulos tiene cargados y cuales son los cinco mas grandes, que mitigaciones de CPU tiene activas, cuantos cambios de contexto acumula desde el arranque, cuantos procesos hay en cada estado, y cual es la politica de planificacion de los diez procesos que mas CPU consumen.


Lo que viene

Ya sabes que es el kernel, donde vive, como se le habla, con que arquitecturas se construye, como se extiende con modulos y como reparte el tiempo de CPU. Pero durante todo el capitulo usamos una palabra sin definirla con rigor: proceso. Hablamos de hilos listos, de espacios de direcciones, de task_struct, de estados y de colas de ejecucion, apoyandonos en una intuicion.

En el capitulo 6 esa intuicion se vuelve precisa. Vamos a abrir el bloque de control de proceso y ver cada campo que lo compone, desarmar fork(), exec() y wait() hasta el ultimo detalle, entender la copia al escribir y los procesos zombis y huerfanos, distinguir procesos de hilos con precision, y estudiar los algoritmos de planificacion clasicos (FIFO, SJF, por turnos, colas multinivel con retroalimentacion) con sus calculos de tiempo de espera y de retorno. Tambien construiremos ejemplos concretos de creacion y coordinacion de procesos en C, Ada y Elixir, para ver como tres modelos muy distintos resuelven el mismo problema.

El indice completo del curso, con todos los capitulos disponibles, esta en el indice de Conceptos de Sistemas Operativos.