Un viaje que haces miles de veces por segundo
Guardas un archivo. Pulsas Ctrl+S, tu editor pide escribir en disco y, en microsegundos, el archivo está a salvo. Parece un paso trivial.
No lo es. En ese instante la CPU ha cambiado de mundo: ha dejado de ejecutar el código de tu editor, un programa sin privilegios que no puede tocar el disco directamente, y ha pasado a ejecutar código del kernel del sistema operativo, que puede hacerlo todo. Después ha vuelto, como si nada. Y eso ocurre miles de veces por segundo en cualquier ordenador que uses.
Esa frontera tiene nombre: el salto de Ring 3 a Ring 0. Entenderla explica por qué tu navegador puede colgarse sin arrastrar al resto del sistema, por qué un driver defectuoso produce una pantalla azul, y por qué en julio de 2024 millones de ordenadores con Windows dejaron de arrancar a la vez.
Qué es un anillo de privilegio
x86 define cuatro niveles de privilegio, numerados del 0 al 3 y dibujados tradicionalmente como anillos concéntricos. Cuanto más bajo el número, más poder tiene el código que se ejecuta ahí.
Ring 0 es donde vive el kernel y tiene acceso total al hardware. Ring 3 es donde viven tus aplicaciones y solo pueden pedirle cosas al kernel.
La CPU sabe en qué anillo está gracias al CPL (Current Privilege Level), guardado en los dos bits más bajos del registro de segmento de código. No hace falta consultar ninguna tabla externa: cada instrucción lleva pegada la pregunta de si quien la ejecuta tiene permiso para hacerlo.
Puedes comprobarlo tú mismo. Este programa lee el registro CS y se queda con esos dos bits:
// cpl.cpp — g++ -std=c++20 cpl.cpp -o cpl (Linux x86-64)
#include <cstdint>
#include <format>
#include <iostream>
int main() {
std::uint16_t cs;
asm volatile("mov %%cs, %0" : "=r"(cs));
std::cout << std::format("CS = {:#06x} -> CPL = {}\n", cs, cs & 3);
}
$ ./cpl
CS = 0x0033 -> CPL = 3
CPL 3: tu programa, como todos, vive en el anillo exterior.
¿Por qué casi nadie usa los anillos 1 y 2? Por dos razones prácticas: el sistema de memoria por páginas solo distingue dos niveles (usuario y supervisor, un único bit), así que separar en más niveles no añade protección real de memoria; y un kernel que solo usa dos niveles se porta mucho mejor a otras arquitecturas como ARM o RISC-V, donde el sistema operativo también trabaja con dos: usuario y kernel (los niveles extra quedan para el hipervisor o el firmware).
| Ring 3 — modo usuario | Ring 0 — modo kernel | |
|---|---|---|
| Quién vive ahí | Tus aplicaciones | El kernel y sus drivers |
| Memoria que ve | Solo la de su propio proceso | Toda la memoria del sistema |
| Instrucciones privilegiadas | Prohibidas | Permitidas |
| Acceso al hardware | Siempre mediado por el kernel | Directo |
| Si algo falla ahí | Se cierra el proceso | Cae el sistema entero |
Esa última fila es la que de verdad importa, y volvemos a ella al final.
Cruzar la frontera: qué es una syscall
Si tu programa no puede tocar el disco, ¿cómo llega a escribir un archivo? Pidiéndoselo al kernel. Esa petición se llama system call (syscall), y es la única puerta legítima entre los dos mundos.
Lo interesante es que tu programa no decide a dónde salta. Si pudiera elegir la dirección exacta, cualquier aplicación podría intentar ejecutar código arbitrario en Ring 0. En su lugar, durante el arranque el kernel configura un registro especial de la CPU con la dirección exacta de su propio punto de entrada. Cuando el programa ejecuta la instrucción de syscall, la CPU siempre salta ahí, nunca a otro sitio. Es una puerta con una sola cerradura, y la llave la tiene el kernel, no el programa que llama.
El recorrido, simplificado, es siempre el mismo:
- Tu programa prepara los parámetros de la llamada y ejecuta la instrucción de syscall.
- La CPU cambia el CPL de 3 a 0 y salta al punto de entrada fijado por el kernel. Lo primero que hace el kernel al llegar es cambiar a su propia pila: la de usuario no es de fiar.
- El kernel valida los parámetros —nunca se fía de lo que venga de Ring 3— y ejecuta la operación real.
- El kernel devuelve el control, la CPU vuelve el CPL a 3, y tu programa continúa como si no hubiera pasado nada.
El paso 3 es la clave de toda la seguridad del sistema: cada syscall es un punto donde el kernel decide si confía en ti o no. Ahí se comprueban permisos de archivo, cuotas de memoria, límites del proceso... Es la frontera donde vive buena parte de la seguridad de un sistema operativo.
Normalmente esto lo hace la biblioteca estándar por ti (std::cout acaba llamando a write()), pero no tiene nada de mágico. Así se ve una syscall hecha a mano, sin pasar por ninguna biblioteca:
// syscall.cpp — write(1, msg, len) sin pasar por libc (Linux x86-64)
#include <cstddef>
#include <string_view>
static long raw_write(int fd, const void* buf, std::size_t len) {
long ret;
asm volatile(
"syscall"
: "=a"(ret) // resultado en rax
: "a"(1L), "D"(fd), "S"(buf), "d"(len) // rax = 1 = SYS_write
: "rcx", "r11", "memory"); // pisa rcx y r11
return ret;
}
int main() {
constexpr std::string_view msg = "Ring 3 -> Ring 0 -> Ring 3\n";
raw_write(1, msg.data(), msg.size());
}
Fíjate en la línea de rcx y r11: al ejecutar syscall, la CPU guarda ahí la dirección de retorno y los flags para poder volver después. Es la prueba de que el salto lo controla el hardware, no tu programa. Y el número 1 no es una dirección, es un índice: le dices al kernel qué quieres, nunca a dónde saltar.
¿Cuánto cuesta cruzar?
Cambiar de anillo no es gratis: hay que cambiar de pila, guardar y restaurar registros y, con las mitigaciones modernas, a veces hasta cambiar de tablas de páginas. Puedes medirlo:
// coste.cpp — g++ -std=c++20 -O2 coste.cpp -o coste (Linux x86-64)
#include <chrono>
#include <format>
#include <iostream>
#include <unistd.h>
#include <sys/syscall.h>
int main() {
using clock = std::chrono::steady_clock;
constexpr int N = 1'000'000;
auto t0 = clock::now();
for (int i = 0; i < N; ++i)
syscall(SYS_getppid); // ida y vuelta al kernel
auto t1 = clock::now();
std::chrono::duration<double, std::nano> ns = t1 - t0;
std::cout << std::format("{:.0f} ns por syscall\n", ns.count() / N);
}
Según tu CPU y las mitigaciones activas, verás del orden de decenas a cientos de nanosegundos por viaje. Una llamada a una función normal cuesta alrededor de un nanosegundo. Por eso el software de alto rendimiento intenta cruzar la frontera las menos veces posibles: agrupa escrituras, usa buffers y, en los casos extremos, APIs como io_uring que permiten encolar muchas operaciones con muy pocas syscalls.
El propio reloj que usa el programa para medir (std::chrono::steady_clock, que por debajo llama a clock_gettime()) casi nunca entra en el kernel. Linux mapea en cada proceso una pequeña zona llamada vDSO con código y datos del kernel de solo lectura, para que las llamadas más frecuentes, como leer la hora, se resuelvan sin cruzar la frontera.
Ring 0 no perdona: por qué un fallo ahí es distinto
En Ring 3, un fallo de memoria (leer donde no debes, por ejemplo) normalmente solo mata tu proceso. El sistema operativo lo detecta, libera sus recursos y sigue funcionando con total normalidad.
Lo mismo pasa si intentas hacer algo reservado a Ring 0. La instrucción hlt, que detiene la CPU hasta la siguiente interrupción, es una de ellas:
// hlt.cpp — g++ hlt.cpp -o hlt (Linux x86-64)
#include <iostream>
int main() {
// std::endl vacía el buffer: la línea sale antes de morir
std::cout << "Intentando 'hlt' desde Ring 3..." << std::endl;
asm volatile("hlt");
std::cout << "Esto nunca se imprime.\n";
}
$ ./hlt
Intentando 'hlt' desde Ring 3...
Segmentation fault (core dumped)
La CPU compara el CPL con el nivel que exige la instrucción, ve que es 3 y lanza una excepción de protección general (#GP). El kernel la recibe, ve que viene de un proceso de usuario y lo termina. El resto del sistema ni se entera. Esa es la red de seguridad de Ring 3.
Si lo compilas en Windows (con MinGW, por ejemplo), el resultado es el mismo con otro nombre: el proceso termina con el código 0xC0000096, STATUS_PRIVILEGED_INSTRUCTION.
En Ring 0 no hay esa red de seguridad. El kernel es el que decide qué hacer cuando algo va mal, así que si el fallo ocurre dentro del propio kernel —o en un driver, que se ejecuta con los mismos privilegios— no queda nadie por encima para contenerlo. La respuesta habitual es parar la máquina antes de que el daño se extienda: eso es una pantalla azul en Windows (BSOD) o un kernel panic en Linux. No es una decisión torpe del sistema operativo: es la única opción segura que le queda.
Esto se agrava porque los drivers suelen ejecutarse a un IRQL (nivel de interrupción) elevado, donde ni siquiera se permite acceder a memoria paginable ni tomar ciertos tipos de bloqueo. Un error que en modo usuario sería un simple crash, en modo kernel puede ser un fallo mucho más profundo.
En julio de 2024, una actualización de contenido del sensor de CrowdStrike Falcon —un driver que se carga en Ring 0 en Windows— hizo que el driver leyera memoria fuera de los límites válidos al procesar un nuevo archivo de configuración. El resultado no fue un simple error de la aplicación: fueron pantallas azules en cadena en millones de máquinas en todo el mundo, muchas de ellas en aeropuertos, hospitales y bancos. El incidente es el ejemplo más citado de por qué el software que corre en Ring 0 exige un nivel de rigor muy distinto al del software de usuario.
Las defensas que protegen esa frontera hoy
Windows y Linux no confían en que "el kernel nunca falla". Han ido añadiendo capas de defensa, cada una pensada para un tipo distinto de fallo o ataque:
- SMEP (Supervisor Mode Execution Prevention): impide que el kernel ejecute código que esté en páginas de memoria marcadas como de usuario. Sin esto, un fallo que redirigiera la ejecución del kernel hacia datos de un proceso normal sería mucho más peligroso.
- SMAP (Supervisor Mode Access Prevention): el equivalente para lectura y escritura. El kernel no puede tocar memoria de usuario por accidente ni sin pasar por rutinas explícitas que saben que están manejando datos no confiables.
- KASLR (Kernel Address Space Layout Randomization): coloca el kernel en una dirección de memoria distinta en cada arranque, para que un atacante que conozca un fallo no pueda asumir dónde está el código o los datos que quiere alcanzar.
- KPTI (Kernel Page Table Isolation): separa casi por completo las tablas de páginas del kernel de las de cada proceso de usuario. Se introdujo de forma masiva tras Meltdown (2018), un fallo de ejecución especulativa que permitía a código en Ring 3 leer memoria del kernel que no debía ver. Si al ejecutar
coste.cppte salen números más altos de lo esperado, KPTI es uno de los sospechosos: cada cruce implica cambiar de tablas de páginas. - Firma de drivers: en Windows moderno, un driver no puede cargarse en Ring 0 si no está firmado digitalmente por un certificado válido para ese propósito. No es burocracia: es la última puerta antes de dejar entrar código nuevo al anillo que no perdona.
Ninguna de estas defensas hace que Ring 0 sea "seguro" en un sentido absoluto. Lo que hacen es encarecer muchísimo convertir un fallo cualquiera en algo explotable, y eso, en la práctica, es la diferencia entre un incidente contenido y uno catastrófico.
Por qué te debería importar aunque no escribas drivers
Casi ningún desarrollador escribe código de kernel en su día a día, y sin embargo esta frontera te afecta constantemente:
- Cada vez que un antivirus, un sistema de detección de intrusiones o una herramienta de monitorización dice "requiere un componente de kernel", está pidiendo vivir en Ring 0, con todo lo que eso implica si algo sale mal.
- Las tecnologías de virtualización basada en seguridad de Windows (VBS) y HVCI usan un nivel todavía más privilegiado que Ring 0 —el hipervisor— precisamente para poder vigilar al propio kernel desde fuera. Ese anillo, informalmente llamado Ring -1, tiene su propia entrada.
- Todo el cloud moderno —máquinas virtuales, contenedores con aislamiento reforzado— se apoya en esta misma jerarquía de privilegios llevada un nivel más abajo.
Lo aprendí por las malas escribiendo un hipervisor académico como driver de kernel para Windows. Lo primero que descubres en Ring 0 es que tu C++ deja de ser C++: sin excepciones, sin RTTI, sin STL, y cada reserva de memoria sale de un pool no paginable que tú eliges a conciencia, porque un error ahí no lanza una excepción: reinicia la máquina. Lo segundo me costó más: Windows se negó a cargar mi driver, firmado y con el modo de pruebas activado. Averiguar por qué me obligó a entender cuántas capas tiene la cadena de integridad del kernel (Secure Boot, el tipo de certificado, la integridad de memoria…) y a trabajar siempre en una máquina virtual de pruebas. Desde entonces veo esa firma como lo que es: la puerta que evita que cualquiera entre en el anillo que no perdona.
La próxima vez que tu editor guarde un archivo sin que lo notes, ya sabes lo que ha pasado por debajo: tu código ha cruzado la frontera más vigilada del sistema operativo, y ha vuelto justo a tiempo para que ni te dieras cuenta.
A trip you take thousands of times a second
You save a file. You hit Ctrl+S, your editor asks to write to disk, and within microseconds the file is safe. It looks trivial.
It isn't. In that instant your CPU switched worlds: it stopped running your editor's code, an unprivileged program that can't touch the disk directly, and started running code from the operating system's kernel, which can do anything. Then it came back, as if nothing happened. And that occurs thousands of times a second on any computer you use.
That border has a name: the jump from Ring 3 to Ring 0. Understanding it explains why your browser can freeze without taking down the rest of the system, why a faulty driver produces a blue screen, and why in July 2024 millions of Windows machines failed to boot at the same time.
What a privilege ring actually is
x86 defines four privilege levels, numbered 0 to 3 and traditionally drawn as concentric rings. The lower the number, the more power the code running there has.
Ring 0 is where the kernel lives and has full access to the hardware. Ring 3 is where your applications live, and all they can do is ask the kernel for things.
The CPU knows which ring it's in through the CPL (Current Privilege Level), stored in the two lowest bits of the code segment register. No external table lookup is needed: every instruction carries with it the question of whether whoever is running it is allowed to.
You can check it yourself. This program reads the CS register and keeps those two bits:
// cpl.cpp — g++ -std=c++20 cpl.cpp -o cpl (Linux x86-64)
#include <cstdint>
#include <format>
#include <iostream>
int main() {
std::uint16_t cs;
asm volatile("mov %%cs, %0" : "=r"(cs));
std::cout << std::format("CS = {:#06x} -> CPL = {}\n", cs, cs & 3);
}
$ ./cpl
CS = 0x0033 -> CPL = 3
CPL 3: your program, like every other, lives in the outer ring.
Why does almost nobody use rings 1 and 2? Two practical reasons: the paged memory system only distinguishes two levels (user and supervisor, a single bit), so splitting into more levels adds no real memory protection; and a kernel that only uses two levels ports far better to other architectures such as ARM or RISC-V, where the operating system also works with two: user and kernel (the extra levels are reserved for the hypervisor or firmware).
| Ring 3 — user mode | Ring 0 — kernel mode | |
|---|---|---|
| Who lives there | Your applications | The kernel and its drivers |
| Memory it can see | Only its own process | All of system memory |
| Privileged instructions | Forbidden | Allowed |
| Hardware access | Always mediated by the kernel | Direct |
| If something fails there | The process is killed | The whole system goes down |
That last row is the one that really matters, and we come back to it at the end.
Crossing the border: what a syscall is
If your program can't touch the disk, how does it manage to write a file? By asking the kernel. That request is called a system call (syscall), and it's the only legitimate door between the two worlds.
The interesting part is that your program doesn't choose where it jumps to. If it could pick the exact address, any application could try to run arbitrary code in Ring 0. Instead, during boot the kernel configures a special CPU register with the exact address of its own entry point. When a program executes the syscall instruction, the CPU always jumps there, never anywhere else. It's a door with a single lock, and the kernel holds the key, not the caller.
The trip, simplified, is always the same:
- Your program sets up the call's parameters and executes the syscall instruction.
- The CPU switches the CPL from 3 to 0 and jumps to the entry point the kernel fixed. The first thing the kernel does on arrival is switch to its own stack: the user stack can't be trusted.
- The kernel validates the parameters — it never trusts what comes from Ring 3 — and performs the actual operation.
- The kernel returns control, the CPU switches the CPL back to 3, and your program continues as if nothing happened.
Step 3 is the key to the whole system's security: every syscall is a point where the kernel decides whether to trust you or not. That's where file permissions, memory quotas, and process limits get checked. It's the border where a large part of an operating system's security actually lives.
Normally the standard library does this for you (std::cout ends up calling write()), but there's nothing magic about it. Here's a syscall made by hand, without going through any library:
// syscall.cpp — write(1, msg, len) bypassing libc (Linux x86-64)
#include <cstddef>
#include <string_view>
static long raw_write(int fd, const void* buf, std::size_t len) {
long ret;
asm volatile(
"syscall"
: "=a"(ret) // result in rax
: "a"(1L), "D"(fd), "S"(buf), "d"(len) // rax = 1 = SYS_write
: "rcx", "r11", "memory"); // clobbers rcx, r11
return ret;
}
int main() {
constexpr std::string_view msg = "Ring 3 -> Ring 0 -> Ring 3\n";
raw_write(1, msg.data(), msg.size());
}
Look at the rcx and r11 line: when syscall runs, the CPU stores the return address and the flags there so it can come back later. It's proof that the hardware controls the jump, not your program. And the number 1 isn't an address, it's an index: you tell the kernel what you want, never where to jump.
How much does crossing cost?
Switching rings isn't free: stacks have to be switched, registers saved and restored and, with modern mitigations, sometimes even page tables switched. You can measure it:
// cost.cpp — g++ -std=c++20 -O2 cost.cpp -o cost (Linux x86-64)
#include <chrono>
#include <format>
#include <iostream>
#include <unistd.h>
#include <sys/syscall.h>
int main() {
using clock = std::chrono::steady_clock;
constexpr int N = 1'000'000;
auto t0 = clock::now();
for (int i = 0; i < N; ++i)
syscall(SYS_getppid); // round trip to the kernel
auto t1 = clock::now();
std::chrono::duration<double, std::nano> ns = t1 - t0;
std::cout << std::format("{:.0f} ns per syscall\n", ns.count() / N);
}
Depending on your CPU and active mitigations, you'll see somewhere between tens and hundreds of nanoseconds per round trip. A normal function call costs about one nanosecond. That's why high-performance software tries to cross the border as rarely as possible: it batches writes, uses buffers and, in extreme cases, relies on APIs like io_uring that queue many operations with very few syscalls.
The very clock the program uses to measure (std::chrono::steady_clock, which calls clock_gettime() under the hood) almost never enters the kernel. Linux maps a small area called the vDSO into every process, with read-only kernel code and data, so the most frequent calls, like reading the time, are answered without crossing the border.
Ring 0 doesn't forgive: why a bug there is different
In Ring 3, a memory fault (reading somewhere you shouldn't, say) normally just kills your process. The operating system detects it, frees its resources, and keeps running completely normally.
The same happens if you try to do something reserved for Ring 0. The hlt instruction, which halts the CPU until the next interrupt, is one of them:
// hlt.cpp — g++ hlt.cpp -o hlt (Linux x86-64)
#include <iostream>
int main() {
// std::endl flushes, so the line gets out before we die
std::cout << "Trying 'hlt' from Ring 3..." << std::endl;
asm volatile("hlt");
std::cout << "This never gets printed.\n";
}
$ ./hlt
Trying 'hlt' from Ring 3...
Segmentation fault (core dumped)
The CPU compares the CPL with the level the instruction requires, sees it's 3, and raises a general protection exception (#GP). The kernel receives it, sees it came from a user process, and terminates it. The rest of the system never notices. That's Ring 3's safety net.
If you compile it on Windows (with MinGW, for example), the result is the same under a different name: the process exits with code 0xC0000096, STATUS_PRIVILEGED_INSTRUCTION.
In Ring 0 there's no such safety net. The kernel is the thing that decides what to do when something goes wrong, so if the fault happens inside the kernel itself — or inside a driver, which runs with the same privileges — there's nobody above it to contain it. The usual response is to stop the machine before the damage spreads: that's a blue screen on Windows (BSOD) or a kernel panic on Linux. It isn't a clumsy decision by the OS: it's the only safe option left.
This is made worse because drivers often run at an elevated IRQL (interrupt request level), where you aren't even allowed to touch pageable memory or take certain kinds of locks. An error that would be a simple crash in user mode can be a much deeper failure in kernel mode.
In July 2024, a content update to CrowdStrike Falcon's sensor — a driver loaded in Ring 0 on Windows — made the driver read memory outside valid bounds while processing a new configuration file. The result wasn't a simple application error: it was cascading blue screens across millions of machines worldwide, many of them at airports, hospitals, and banks. The incident is the most-cited example of why software running in Ring 0 demands a very different level of rigor than user-space software.
The defenses guarding that border today
Windows and Linux don't trust that "the kernel never fails." They've layered on defenses over the years, each aimed at a different kind of bug or attack:
- SMEP (Supervisor Mode Execution Prevention): stops the kernel from executing code that lives in pages marked as user memory. Without this, a bug that redirected kernel execution toward a normal process's data would be far more dangerous.
- SMAP (Supervisor Mode Access Prevention): the equivalent for reads and writes. The kernel can't touch user memory by accident or outside routines that explicitly know they're handling untrusted data.
- KASLR (Kernel Address Space Layout Randomization): places the kernel at a different memory address on every boot, so an attacker who knows about a bug can't assume where the code or data they want to reach actually is.
- KPTI (Kernel Page Table Isolation): almost completely separates the kernel's page tables from each user process's. It was rolled out broadly after Meltdown (2018), a speculative-execution flaw that let Ring 3 code read kernel memory it had no business seeing. If
cost.cppgives you higher numbers than expected, KPTI is one of the suspects: every crossing means switching page tables. - Driver signing: on modern Windows, a driver can't load into Ring 0 unless it's digitally signed by a certificate valid for that purpose. It isn't bureaucracy: it's the last gate before letting new code into the ring that doesn't forgive.
None of these defenses make Ring 0 "safe" in an absolute sense. What they do is make it far more expensive to turn an ordinary bug into something exploitable, and in practice that's the difference between a contained incident and a catastrophic one.
Why this should matter to you even if you never write drivers
Almost no developer writes kernel code day to day, and yet this border affects you constantly:
- Every time an antivirus, an intrusion detection system, or a monitoring tool says it "requires a kernel component," it's asking to live in Ring 0, with everything that implies if something goes wrong.
- Windows' Virtualization-Based Security (VBS) and HVCI use an even more privileged level than Ring 0 — the hypervisor — precisely so they can watch over the kernel itself from outside. That ring, informally called Ring -1, has a post of its own.
- All of modern cloud computing — virtual machines, hardened containers — rests on this same privilege hierarchy pushed one level further down.
I learned this the hard way while writing an academic hypervisor as a Windows kernel driver. The first thing you discover in Ring 0 is that your C++ stops being C++: no exceptions, no RTTI, no STL, and every memory allocation comes from a non-paged pool you pick deliberately, because a mistake there doesn't throw an exception: it reboots the machine. The second lesson cost me more: Windows refused to load my driver, even signed and with test mode enabled. Figuring out why forced me to understand how many layers the kernel's integrity chain has (Secure Boot, the certificate type, memory integrity…) and to always work inside a test virtual machine. Since then I see that signature for what it is: the gate that keeps just anyone out of the ring that doesn't forgive.
Next time your editor saves a file without you noticing, you'll know what just happened underneath: your code crossed the most closely guarded border in the operating system, and made it back just in time for you not to notice at all.