1. El problema: confiar en los programas
A lo largo del curso hemos dado por hecho que los programas no pueden hacer ciertas cosas: escribir en el disco directamente, leer la memoria de otro proceso, quedarse con la CPU para siempre. Pero un programa es una secuencia de instrucciones de máquina, y la CPU ejecuta lo que encuentra. ¿Qué impide que un programa contenga la instrucción que da órdenes al disco?
No puede impedirlo el sistema operativo, por una razón que vimos en el tema 11: mientras el programa se ejecuta, el kernel está detenido. No hay nadie mirando. Tampoco serviría revisar los programas antes de ejecutarlos, porque es imposible saber de antemano todo lo que un programa hará.
La única solución es que la restricción la aplique quien ejecuta cada instrucción: el propio procesador. Así lo señalamos en el tema 5, al enumerar lo que le faltaba al modelo de von Neumann, y así nació en los años sesenta, cuando los monitores por lotes necesitaron defenderse de los trabajos defectuosos.
2. Dos modos de ejecución
Todos los procesadores capaces de sostener un sistema operativo moderno pueden trabajar, como mínimo, en dos modos. El modo vigente es parte del estado de la CPU, igual que el contador de programa, y afecta a cada instrucción que se ejecuta.
| Modo kernel | Modo usuario | |
|---|---|---|
| Otros nombres | Modo supervisor, modo privilegiado | Modo no privilegiado |
| Quién se ejecuta en él | El kernel y sus drivers | Todo lo demás |
| Instrucciones permitidas | Todas | Todas menos las privilegiadas |
| Memoria accesible | Toda | Solo la que el kernel le asignó |
| Dispositivos | Acceso directo | Ninguno |
| Si intenta algo prohibido | No hay nada prohibido | La CPU lo impide y avisa al kernel |
La última fila describe el mecanismo. Cuando el código en modo usuario intenta una operación reservada, la CPU no la ejecuta. En su lugar genera una excepción: pasa a modo kernel y salta a una rutina del kernel, que decide qué hacer con el infractor. Normalmente, finalizarlo.
Conviene subrayar lo que esto significa. La protección no depende de que los programas se porten bien, ni de que el kernel los vigile. Es una propiedad física del procesador: en modo usuario, las operaciones peligrosas sencillamente no funcionan.
3. Qué queda restringido
Para que el kernel conserve el control de la máquina, hay tres cosas que un programa de usuario no debe poder hacer. Cada una se protege de una manera.
Instrucciones privilegiadas
Un pequeño grupo de instrucciones de máquina solo se puede ejecutar en modo kernel. Son las que modifican el estado global de la máquina:
| Instrucción (x86-64) | Qué hace | Por qué no puede usarla un programa |
|---|---|---|
hlt | Detiene el procesador hasta la próxima interrupción. | Un programa podría paralizar la máquina. |
cli / sti | Desactivan y activan las interrupciones. | Sin interrupciones, el temporizador no avisa y el kernel no recupera la CPU nunca. |
in / out | Leen y escriben puertos de E/S. | Darían acceso directo a los dispositivos. |
mov a cr3 | Cambia el espacio de direcciones activo. | Permitiría ver la memoria de cualquier proceso. |
lidt | Indica dónde están las rutinas de atención de interrupciones. | Un programa podría sustituir las del kernel por las suyas. |
wrmsr | Escribe registros de configuración del procesador. | Entre ellos está la dirección de entrada de las llamadas al sistema. |
El resto de las instrucciones —mover datos, calcular, comparar, saltar— no tiene restricciones. Son la inmensa mayoría, y por eso un programa de usuario se ejecuta directamente sobre la CPU a toda velocidad, sin que nadie intervenga: solo unas pocas operaciones necesitan permiso.
Memoria
La memoria se divide en páginas, y cada página lleva unas marcas que la unidad de gestión de memoria comprueba en cada acceso. Una de esas marcas indica si la página es accesible en modo usuario o solo en modo kernel. El espacio de kernel que vimos en el tema 11 está formado por páginas del segundo tipo: están ahí, pero un programa no puede leerlas ni escribirlas.
Dispositivos
Se protegen con los dos mecanismos anteriores. Los puertos de E/S requieren las instrucciones privilegiadas in y out; los registros mapeados en memoria están en páginas reservadas al kernel. Lo vimos en el tema 8.
4. Los anillos de protección
La arquitectura x86 no define dos niveles de privilegio, sino cuatro, numerados del 0 al 3. Se los representa como anillos concéntricos, con el más privilegiado en el centro:
Los anillos intermedios se pensaron para drivers y servicios del sistema con privilegios parciales, pero casi ningún sistema operativo los adoptó: usar solo dos niveles es más simple y es lo que ofrecen todas las arquitecturas, lo que facilita llevar el sistema de una a otra. Por eso «anillo 0» y «anillo 3» se usan como sinónimos de modo kernel y modo usuario.
Las demás arquitecturas tienen el mismo concepto con otros nombres:
| Arquitectura | Modo usuario | Modo kernel | Niveles superiores |
|---|---|---|---|
| x86-64 | Anillo 3 | Anillo 0 | Modo de virtualización |
| ARM (aarch64) | EL0 | EL1 | EL2 (hipervisor), EL3 (firmware seguro) |
| RISC-V | Modo U | Modo S | Modo M (máquina) |
La última columna muestra que por debajo del kernel puede haber todavía más niveles. Un hipervisor, el programa que ejecuta máquinas virtuales, necesita controlar a varios kernels del mismo modo que un kernel controla a varios procesos, y los procesadores actuales le reservan un nivel propio. Es el tema del curso 13 de esta ruta.
5. En qué anillo estoy
En x86-64, el nivel de privilegio vigente está guardado en los dos bits inferiores de un registro llamado cs. Leer ese registro no es una operación privilegiada, de modo que un programa puede averiguar en qué anillo se ejecuta. Hace falta una línea de ensamblador incrustada en el código C:
#include <stdio.h>
int main(void)
{
unsigned long cs;
__asm__("mov %%cs, %0" : "=r"(cs)); /* copiar el registro cs a la variable */
printf("Registro cs: 0x%lx\n", cs);
printf("Anillo actual: %lu\n", cs & 3);
return 0;
}
$ gcc -Wall -Wextra -g anillo.c -o anillo
$ ./anillo
Registro cs: 0x33
Anillo actual: 3
$ sudo ./anillo
Registro cs: 0x33
Anillo actual: 3
La operación cs & 3 se queda con los dos bits inferiores. El resultado es 3: modo usuario. Y sigue siendo 3 al ejecutar el programa con sudo. Guarda ese segundo resultado; lo explicamos en la sección 11.
Los tres experimentos de este tema usan instrucciones y direcciones propias de los procesadores Intel y AMD. Funcionan en Linux nativo, en máquinas virtuales y en WSL. En un equipo ARM no compilan; allí, además, la instrucción para consultar el nivel de privilegio está ella misma prohibida en modo usuario.
6. Ejecutar una instrucción privilegiada
Comprobemos ahora la barrera. El siguiente programa intenta ejecutar hlt, la instrucción que detiene el procesador:
#include <stdio.h>
int main(void)
{
printf("Intentando detener el procesador...\n");
__asm__("hlt"); /* instrucción privilegiada */
printf("Esta linea no se ejecuta\n");
return 0;
}
$ gcc -Wall -Wextra -g privilegiada.c -o privilegiada
$ ./privilegiada
Intentando detener el procesador...
Segmentation fault (core dumped)
$ sudo dmesg | tail -1
[ 9402.118340] traps: privilegiada[9321] general protection fault ip:5d0e2a4c9164 sp:7ffd3c1a5b30 error:0 in privilegiada[5d0e2a4c9000+1000]
El compilador no puso ninguna objeción: hlt es una instrucción válida, y el ejecutable la contiene. La decisión se tomó en el momento de ejecutarla, y se puede reconstruir paso a paso:
- La CPU captó la instrucción
hlty, al decodificarla, comprobó el nivel de privilegio: anillo 3. - Como la instrucción exige anillo 0, no la ejecutó. Generó una excepción llamada fallo de protección general.
- La excepción hizo pasar la CPU a modo kernel y saltar a la rutina del kernel que atiende ese fallo.
- El kernel anotó lo ocurrido en su búfer de mensajes —es la línea de
dmesg, con el nombre del programa, su PID y la dirección de la instrucción culpable— y finalizó el proceso. - La shell informó el motivo de la terminación.
El procesador no se detuvo, y ningún otro programa notó nada. El mensaje que ve el usuario, «Segmentation fault», es algo engañoso, porque Linux lo usa para varias excepciones distintas; el registro del kernel dice con precisión cuál fue.
7. Leer la memoria del kernel
El segundo tipo de restricción es el de la memoria. En el tema 11 vimos que el kernel ocupa la mitad alta del espacio de direcciones de cada proceso. Este programa intenta leer una dirección de esa zona:
#include <stdio.h>
int main(void)
{
/* una dirección del espacio de kernel */
unsigned long *p = (unsigned long *)0xffffffff81000000UL;
printf("Intentando leer memoria del kernel...\n");
printf("Contenido: %lx\n", *p);
return 0;
}
$ gcc -Wall -Wextra -g espiar.c -o espiar
$ ./espiar
Intentando leer memoria del kernel...
Segmentation fault (core dumped)
$ sudo dmesg | tail -1
[ 9455.730912] espiar[9388]: segfault at ffffffff81000000 ip 00005c1e3a2f6169 sp 00007ffd0b4e1a20 …
Aquí la instrucción no tiene nada de especial: es un mov que lee de memoria, de los que un programa ejecuta millones por segundo. Lo que cambia es la dirección. La unidad de gestión de memoria, al traducirla, encontró que ese rango no está disponible para el modo usuario y generó una excepción distinta, un fallo de página. El kernel la recibió, comprobó que el acceso no era legítimo y finalizó el proceso. El registro incluye la dirección exacta que se intentó leer.
Los dos experimentos muestran el mismo esquema con dos causas: una instrucción reservada y una dirección reservada. En ambos, quien detecta la infracción es el hardware y quien decide la consecuencia es el kernel.
8. Cómo se cambia de modo
Si en modo usuario no se puede hacer nada interesante con el hardware, y los programas necesitan leer archivos y usar la red, tiene que existir una forma de pasar a modo kernel. La hay, pero con una condición estricta: el programa puede provocar el paso a modo kernel, pero no puede elegir qué código se ejecuta a continuación.
Las únicas vías de entrada al modo kernel son las tres del tema 11, y en todas ocurre lo mismo:
- La CPU cambia a modo kernel.
- En el mismo acto, carga en el contador de programa una dirección que el kernel dejó establecida de antemano.
Los dos pasos son inseparables. No existe una instrucción «pasar a modo kernel y seguir con lo mío». Subir de privilegio implica siempre ceder el control a una rutina del kernel. Las direcciones de esas rutinas están en tablas que el procesador consulta, y esas tablas se configuran con instrucciones privilegiadas —lidt y wrmsr, de la tabla de la sección 3—, de modo que un programa de usuario no puede alterarlas.
El camino de vuelta es más simple. Cuando el kernel termina, ejecuta una instrucción especial de retorno que restaura el contador de programa del programa y baja el nivel de privilegio. Bajar de privilegio no encierra ningún riesgo, así que no necesita más control: lo delicado es subir.
La figura muestra un tramo de la vida de un procesador. El programa A pide un servicio, el kernel lo atiende y le devuelve el control. Más tarde llega una interrupción; el kernel la atiende y, esta vez, decide que continúe el programa B. En cada franja inferior, el kernel tiene la oportunidad de tomar decisiones; en las superiores, no interviene.
9. Qué ocurre en la transición
El paso de modo usuario a modo kernel tiene que cumplir dos requisitos: que el programa interrumpido pueda continuar después exactamente donde estaba, y que el kernel no dependa de nada que el programa controle. La CPU y el kernel lo resuelven entre los dos:
- La CPU guarda lo imprescindible. El contador de programa, el puntero de pila y el registro de estado del programa interrumpido se salvan automáticamente.
- Se cambia de pila. El kernel no usa la pila del programa, sino una pila de kernel propia de cada proceso, situada en memoria protegida. Si usara la del programa, este podría dejarle un puntero de pila inválido y hacer fallar al kernel.
- Se cambia el nivel de privilegio y se salta a la rutina del kernel.
- El kernel guarda el resto de los registros en su pila. Con eso tiene el contexto completo del programa, tal como lo definimos en el tema 6.
- El kernel hace su trabajo.
- Se restauran los registros y se ejecuta la instrucción de retorno, que vuelve al modo usuario y a la instrucción siguiente del programa.
Todo esto explica el costo que medimos en el tema 11: unos cientos de nanosegundos por llamada al sistema, aunque la llamada no haga nada. No es el trabajo pedido lo que cuesta, sino el viaje de ida y vuelta. Lo recorremos instrucción por instrucción en el tema 13.
10. Quién llega primero
Queda una pregunta de fondo: si las tablas que protegen al kernel solo pueden configurarse en modo kernel, ¿cómo consigue el kernel ese privilegio en primer lugar?
Por orden de llegada. Al encenderse, un procesador arranca siempre en su modo más privilegiado. El primer código que se ejecuta es el firmware, que carga el cargador de arranque, que carga el kernel; todos ellos corren con privilegio total. El kernel aprovecha esa posición para preparar la máquina: construye las tablas de interrupciones, fija la dirección de entrada de las llamadas al sistema y configura la protección de memoria.
Solo cuando todo está en su sitio crea el primer proceso y le entrega la CPU en modo usuario. A partir de ese momento, ningún código de usuario llega a ejecutarse jamás con privilegios, y la única forma de volver al modo kernel es por las entradas que el kernel dejó preparadas. El arranque completo es el contenido del bloque 5.
11. root no es modo kernel
En la sección 5, sudo ./anillo mostró anillo 3, igual que sin sudo. Y si pruebas sudo ./privilegiada, obtendrás el mismo «Segmentation fault». Es una de las confusiones más comunes al empezar, y conviene deshacerla: ser administrador no es lo mismo que ejecutarse en modo kernel. Son dos formas de privilegio de naturaleza distinta.
| Modo de la CPU | Identidad del usuario | |
|---|---|---|
| Valores | Usuario o kernel | Un número: 0 para root, 1000 para un usuario común… |
| Dónde está definido | En el hardware | En las tablas del kernel |
| Quién lo hace cumplir | El procesador, en cada instrucción | El kernel, en cada llamada al sistema |
| Qué limita | Qué instrucciones y qué memoria | Qué pedidos acepta el kernel |
Un proceso de root es un proceso de usuario como cualquier otro, en anillo 3. No puede ejecutar hlt ni leer la memoria del kernel. Lo que cambia es la respuesta del kernel cuando ese proceso pide algo: al comprobar la identidad de quien llama, acepta peticiones que a un usuario común le negaría, como abrir cualquier archivo o finalizar cualquier proceso.
El experimento del reloj del tema 8 es un buen ejemplo. El programa rtc pedía, con ioperm, permiso para usar dos puertos de E/S. Sin privilegios, el kernel rechazaba la llamada; con sudo, la aceptaba y configuraba el procesador para permitir esos dos puertos a ese proceso. En ningún momento el programa pasó a modo kernel: fue el kernel quien, a pedido de un usuario autorizado, abrió una excepción concreta.
¿Significa esto que root no puede ejecutar código en modo kernel? Puede, pero de nuevo pidiéndolo: el kernel ofrece una llamada al sistema para cargar un módulo, y solo la acepta de un administrador. El código del módulo pasa entonces a formar parte del kernel. Por eso el control de quién es root es tan importante para la seguridad: no por lo que ese usuario puede ejecutar, sino por lo que puede pedir.
12. Observar los modos
El kernel contabiliza cuánto tiempo pasa cada procesador en cada modo. En el tema 11 lo vimos para un solo programa, con time. El comando top lo muestra para todo el sistema en su tercera línea:
$ top
%Cpu(s): 3.1 us, 1.2 sy, 0.0 ni, 95.4 id, 0.2 wa, 0.0 hi, 0.1 si, 0.0 st
| Campo | Significado | Modo |
|---|---|---|
us | Ejecutando programas. | Usuario |
ni | Ejecutando programas de baja prioridad. | Usuario |
sy | Atendiendo llamadas al sistema. | Kernel |
hi, si | Atendiendo interrupciones. | Kernel |
id | Sin nada que hacer. | — |
wa | Sin nada que hacer, con operaciones de disco pendientes. | — |
st | Tiempo que el hipervisor dedicó a otras máquinas virtuales. | — |
Los datos provienen de la primera línea de /proc/stat, donde el kernel acumula esos contadores desde el arranque. Son un buen instrumento de diagnóstico: un sistema con us alto tiene programas haciendo cálculo; uno con sy alto está haciendo muchas llamadas al sistema, y vale la pena averiguar cuáles con strace; uno con wa alto está esperando al disco.
13. Cuando la barrera falla
La separación entre modos es sólida, pero no infalible. Los fallos de seguridad más graves de un sistema operativo son justamente los que permiten atravesarla, y hay dos familias:
- Errores en el kernel. Si una llamada al sistema no valida bien sus argumentos, un programa puede engañar al kernel para que escriba donde no debe o ejecute código elegido por el atacante, ya en anillo 0. Se llama escalada de privilegios. Cuanto más código hay en el kernel, más oportunidades existen, lo que da otro argumento para mantenerlo pequeño.
- Errores en el procesador. En 2018 se descubrió que una optimización de los procesadores modernos —la ejecución anticipada de instrucciones que mencionamos en el tema 6— dejaba rastros que permitían deducir, desde modo usuario, el contenido de la memoria del kernel. El fallo se conoció como Meltdown.
La respuesta a Meltdown obligó a matizar el diseño que describimos allí. En los procesadores afectados, Linux ya no mantiene todo el kernel mapeado mientras se ejecuta código de usuario: usa dos juegos de tablas de memoria y cambia de uno a otro al entrar y salir del kernel. La protección mejora y cada llamada al sistema cuesta algo más. Es un ejemplo actual del equilibrio entre rendimiento y seguridad.
14. Resumen
- La protección del sistema la impone el procesador, porque mientras un programa se ejecuta el kernel no puede vigilarlo.
- La CPU tiene al menos dos modos: en modo kernel todo está permitido; en modo usuario, ciertas operaciones no se ejecutan y provocan una excepción.
- Están restringidas las instrucciones privilegiadas, la memoria marcada como exclusiva del kernel y, mediante ambas, el acceso a los dispositivos.
- En x86 los niveles se llaman anillos: el 0 es el modo kernel y el 3 el modo usuario. Otras arquitecturas usan otros nombres para lo mismo.
- Solo se entra en modo kernel por una llamada al sistema, una interrupción o una excepción, y siempre saltando a una dirección fijada por el kernel.
- En la transición se guarda el contexto del programa y se pasa a una pila de kernel. Ese viaje es lo que hace costosa una llamada al sistema.
- El kernel obtiene el privilegio porque se ejecuta primero, durante el arranque, y solo después entrega la CPU en modo usuario.
rootno se ejecuta en modo kernel: es un usuario al que el kernel le concede más peticiones.
15. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Por qué la protección tiene que hacerla el hardware y no el sistema operativo?
Porque mientras un programa de usuario tiene la CPU, el kernel no se está ejecutando y no puede comprobar nada. El único que interviene en cada instrucción es el procesador, así que es él quien debe rechazar las operaciones prohibidas y avisar al kernel.
2. Menciona tres instrucciones privilegiadas y el daño que podría causar cada una en manos de un programa.
hlt detendría el procesador. cli desactivaría las interrupciones, con lo que el kernel no recuperaría la CPU. out permitiría enviar órdenes directamente a un dispositivo, por ejemplo al disco, sin control de permisos.
3. ¿Qué ocurre exactamente cuando un programa en modo usuario ejecuta hlt?
La CPU no ejecuta la instrucción. Genera una excepción de protección, pasa a modo kernel y salta a la rutina del kernel que la atiende. El kernel registra el hecho y finaliza el proceso, que la shell informa como «Segmentation fault».
4. ¿Por qué un programa no puede simplemente «pasar a modo kernel» y continuar ejecutando su propio código?
Porque el cambio a modo kernel va siempre unido a un salto a una dirección que el kernel estableció previamente. No hay ninguna instrucción que eleve el privilegio y mantenga el control en el programa. Y las tablas con esas direcciones solo pueden modificarse en modo kernel.
5. ¿Para qué necesita cada proceso una pila de kernel separada de su pila normal?
Para que el kernel no dependa de memoria controlada por el programa. Si usara la pila de usuario, un programa podría dejar un puntero de pila inválido o manipular su contenido y provocar un fallo dentro del kernel o alterar su comportamiento.
6. Un programa se ejecuta con sudo. ¿En qué anillo corre? ¿Qué cambia entonces?
En el anillo 3, como cualquier programa. Lo que cambia es su identidad de usuario: el kernel, al atender sus llamadas al sistema, comprueba que es root y le concede operaciones que negaría a un usuario común.
7. Si el kernel necesita privilegios para configurar la protección, ¿cómo los obtiene?
El procesador arranca en su modo más privilegiado, y el kernel es de lo primero que se ejecuta. Prepara las tablas de interrupciones, la entrada de las llamadas al sistema y la protección de memoria, y solo después inicia el primer proceso en modo usuario.
8. En top, un servidor muestra 5 us y 70 sy. ¿Qué te sugiere?
Que la CPU pasa la mayor parte del tiempo en modo kernel atendiendo llamadas al sistema, y muy poco ejecutando código de los programas. Algún proceso hace una cantidad enorme de llamadas; conviene identificarlo y examinarlo con strace -c.
Para practicar
- Ejecuta
privilegiadacon y sinsudo. ¿Cambia el resultado? ¿Por qué? - Sustituye
hltporcliy luego pornop, la instrucción que no hace nada. Compila y ejecuta cada versión, y explica las diferencias. - En
espiar.c, cambia la dirección por0x10. El programa falla igual: compara consudo dmesg | tail -1la dirección informada en uno y otro caso. ¿Qué tienen en común y en qué se diferencian las dos situaciones? - Lanza
./tiempos calculary, en otra terminal, mira la tercera línea detop. Repite con./tiempos pedir. ¿Qué campos suben en cada caso? - Lee la primera línea de
/proc/statdos veces con un segundo de diferencia. Resta los valores de las columnas primera y tercera, que corresponden a los modos usuario y kernel. Las unidades son centésimas de segundo.