1. Cuando una instrucción no puede completarse
El procesador ejecuta lo que encuentra, sin juzgarlo. Pero hay instrucciones que no tienen forma de llevarse a cabo: dividir un número por cero no tiene resultado, y leer una dirección de memoria que no corresponde a nada no tiene qué devolver. En esos casos la CPU no puede seguir adelante ni tampoco detenerse, y hace lo único razonable: pedir ayuda al kernel.
Una excepción es una entrada al kernel provocada por el propio procesador al detectar que la instrucción en curso no puede ejecutarse normalmente. Igual que en una interrupción, la CPU pasa a modo kernel y salta a la rutina indicada por la tabla de vectores.
El mecanismo es el del tema 17: la misma tabla, la misma secuencia de guardar el estado y cambiar de modo. Lo que cambia es el origen. La comparación que hicimos allí se resume en una diferencia:
| Interrupción de hardware | Excepción | |
|---|---|---|
| La detecta | Un dispositivo, fuera de la CPU | La CPU, al ejecutar una instrucción |
| Relación con el programa | Ninguna: es asíncrona | Total: la causó una instrucción concreta. Es síncrona |
| ¿Se repite si se ejecuta de nuevo? | No | Sí, en el mismo lugar |
| El kernel actúa para | Quien esperaba al dispositivo | El programa que estaba en la CPU |
| ¿Se puede enmascarar? | Sí, salvo las NMI | No |
Ya provocamos dos excepciones en el tema 12, para comprobar la barrera entre modos: la de la instrucción privilegiada hlt y la de la lectura de memoria del kernel. Ahora las estudiamos como mecanismo general.
El nombre coincide, pero son cosas distintas. Las excepciones de un lenguaje de programación son una forma de organizar el código, que resuelven el compilador o el intérprete sin intervención del procesador. Cuando Python informa ZeroDivisionError, no hubo ninguna excepción de la CPU: el intérprete comprobó el divisor antes de dividir. Las de este tema son un mecanismo del hardware. A veces unas se construyen sobre las otras, como veremos en la sección 10.
2. Tres clases: fault, trap y abort
No todas las excepciones significan lo mismo. Los fabricantes de procesadores las dividen en tres clases, que se distinguen por una pregunta precisa: al volver del kernel, ¿qué instrucción se ejecuta?
| Clase | Cuándo se detecta | Contador de programa guardado | Sentido | Ejemplo |
|---|---|---|---|---|
| Fault (fallo) | Antes de completar la instrucción | Apunta a la misma instrucción | «No pude. Si lo arreglas, lo intento otra vez.» | Fallo de página |
| Trap (trampa) | Después de completarla | Apunta a la instrucción siguiente | «Lo hice, y me pidieron avisar.» | Punto de parada |
| Abort (aborto) | En cualquier momento | No es fiable | «Algo está roto. No se puede continuar.» | Error de hardware |
La diferencia entre las dos primeras es la clave del tema. Un fault deja todo como si la instrucción no hubiera empezado, de modo que el kernel puede corregir la causa y hacer que se repita, sin que el programa lo advierta. Un trap es una notificación: la instrucción ya surtió efecto y el programa sigue por la siguiente.
«Trap» se emplea también en sentido amplio, para cualquier entrada al kernel causada por el programa en ejecución, por contraste con las interrupciones. Así lo usamos en el tema 13 al hablar de las llamadas al sistema, y así lo usa el propio Linux, que en sus mensajes llama traps a todas las excepciones. En este tema, cuando contrapongamos trap a fault, nos referimos al sentido estricto de la tabla.
3. El catálogo del procesador
En x86-64, las excepciones ocupan los primeros 32 vectores de la tabla, que el fabricante reserva para ese fin. Estas son las que tienen interés para un programador:
| Vector | Nombre | Clase | La provoca |
|---|---|---|---|
| 0 | Error de división | Fault | Una división entera por cero, o cuyo resultado no cabe en el destino. |
| 1 | Depuración | Trap | La ejecución paso a paso que solicita un depurador. |
| 3 | Punto de parada | Trap | La instrucción int3. |
| 6 | Código de operación inválido | Fault | Unos bytes que no corresponden a ninguna instrucción. |
| 8 | Doble fallo | Abort | Una excepción ocurrida mientras se intentaba atender otra. |
| 13 | Protección general | Fault | Una instrucción privilegiada en modo usuario, entre otras violaciones. |
| 14 | Fallo de página | Fault | Un acceso a memoria que la unidad de gestión de memoria no puede resolver. |
| 18 | Comprobación de máquina | Abort | Un error detectado en el propio hardware. |
La lista la define el procesador, no el sistema operativo, y cambia de una arquitectura a otra. El ejemplo más llamativo aparece en la sección 5: la división por cero es una excepción en los procesadores de Intel y AMD, y no lo es en los ARM.
4. Cómo reacciona el kernel
Cuando la excepción llega, el kernel dispone del contexto completo: qué vector fue, qué instrucción la provocó, en qué modo estaba la CPU y, según el caso, datos adicionales como la dirección de memoria implicada. Con eso decide entre tres desenlaces:
- La causa tiene arreglo. El kernel la corrige y vuelve; la instrucción se ejecuta de nuevo y esta vez funciona. El programa no se entera. Es el caso más frecuente con diferencia, y lo vemos en la sección 8.
- El error es del programa. El kernel no puede hacer nada por esa instrucción, y avisa al proceso enviándole una señal.
- El error es del propio kernel. La excepción ocurrió en modo kernel y no estaba prevista. Es un defecto del sistema operativo, y lo tratamos en la sección 12.
Una señal es el aviso con que el kernel comunica a un proceso que le ocurrió algo. Cada tipo tiene un nombre y un número, y un comportamiento por defecto si el programa no dispuso otra cosa. Las señales tienen muchos otros usos, que se estudian en el curso de procesos; aquí nos interesan las cinco que nacen de una excepción:
| Señal | Número | Excepción de origen | Mensaje de la shell | Código de salida |
|---|---|---|---|---|
SIGILL | 4 | Código de operación inválido | Illegal instruction | 132 |
SIGTRAP | 5 | Punto de parada, depuración | Trace/breakpoint trap | 133 |
SIGBUS | 7 | Algunos accesos inválidos a memoria | Bus error | 135 |
SIGFPE | 8 | Error de división | Floating point exception | 136 |
SIGSEGV | 11 | Fallo de página sin arreglo, protección general | Segmentation fault | 139 |
Para todas ellas el comportamiento por defecto es el mismo: finalizar el proceso y guardar una copia de su memoria. Los mensajes de la cuarta columna no los escribe el kernel ni el programa que falló: los escribe la shell, cuando el kernel le informa cómo terminó su proceso hijo. La última columna sigue una convención de la shell: cuando un programa termina por una señal, su código de salida es 128 más el número de la señal.
5. Experimento 1: dividir por cero
El primer programa divide un entero por cero. La variable se declara volatile para obligar al compilador a hacer la división durante la ejecución, en lugar de detectarla al compilar.
#include <stdio.h>
int main(void)
{
volatile int divisor = 0;
int resultado;
printf("Antes de dividir\n");
resultado = 100 / divisor; /* la CPU no puede completar esta instrucción */
printf("Resultado: %d\n", resultado);
return 0;
}
$ gcc -Wall -Wextra -g division.c -o division
$ ./division
Antes de dividir
Floating point exception (core dumped)
$ echo $?
136
$ sudo dmesg | tail -1
[12840.551207] traps: division[10211] trap divide error ip:55d0c3a4b15b sp:7ffe2b1c9a40 error:0 in division[55d0c3a4b000+1000]
$ strace -o traza.txt ./division
Antes de dividir
Floating point exception (core dumped)
$ tail -3 traza.txt
write(1, "Antes de dividir\n", 17) = 17
--- SIGFPE {si_signo=SIGFPE, si_code=FPE_INTDIV, si_addr=0x55d0c3a4b15b} ---
+++ killed by SIGFPE (core dumped) +++
Las tres salidas cuentan la misma historia desde tres lugares:
- La CPU intentó ejecutar la instrucción de división, comprobó que el divisor era cero y generó la excepción del vector 0.
- El kernel la recibió, vio que venía de modo usuario y que no tenía arreglo, anotó el hecho en su registro —la línea de
dmesg, con la dirección de la instrucción en el campoip— y envióSIGFPEal proceso. - El programa no había previsto esa señal, de modo que se aplicó el comportamiento por defecto: finalizarlo.
- La shell fue informada, escribió el mensaje y dejó 136 en
$?: 128 + 8.
La traza de strace confirma algo que anticipamos en el tema 16: entre el write y la muerte del proceso no hay ninguna llamada al sistema. El programa entró al kernel, pero no por la puerta de las llamadas, y strace solo ve la consecuencia. El campo si_addr coincide con el ip de dmesg: es la dirección de la instrucción culpable.
El nombre de la señal es engañoso. SIGFPE significa «excepción de punto flotante» por razones históricas, pero aquí la división fue entre enteros. Y ocurre lo contrario de lo que el nombre sugiere: la división por cero entre números reales no produce ninguna excepción.
#include <stdio.h>
int main(void)
{
volatile double cero = 0.0;
printf("1.0 / 0.0 = %f\n", 1.0 / cero);
printf("0.0 / 0.0 = %f\n", cero / cero);
return 0;
}
$ gcc -Wall -Wextra -g division_real.c -o division_real
$ ./division_real
1.0 / 0.0 = inf
0.0 / 0.0 = -nan
El formato con que se representan los números reales reserva valores especiales para el infinito y para «no es un número», de modo que toda operación tiene un resultado y el programa continúa. Los enteros no tienen ningún valor disponible para eso, y al procesador no le queda otra salida que la excepción.
En aarch64, la instrucción de división entera por cero no genera ninguna excepción: devuelve cero y el programa sigue. division imprime allí «Resultado: 0». El estándar de C dice que el resultado de esa operación está indefinido, lo que permite ambos comportamientos. Es un buen recordatorio de que las excepciones son una propiedad de cada procesador, y de que un programa correcto no debe depender de ellas.
6. Experimento 2: el fallo de segmentación
Es el error más conocido de la programación en C. Su nombre viene de un mecanismo antiguo de protección de memoria, la segmentación; hoy lo produce casi siempre un fallo de página que el kernel no puede resolver. El caso típico es usar un puntero que vale cero:
#include <stdio.h>
int main(void)
{
int *p = NULL;
printf("Antes de escribir\n");
*p = 42; /* escribir en la dirección 0 */
printf("Esta linea no se ejecuta\n");
return 0;
}
$ gcc -Wall -Wextra -g segfault.c -o segfault
$ ./segfault
Antes de escribir
Segmentation fault (core dumped)
$ echo $?
139
$ sudo dmesg | tail -1
[12911.204418] segfault[10240]: segfault at 0 ip 000055a1e2c3d14d sp 00007ffc8e21b6f0 error 6 in segfault[55a1e2c3d000+1000] …
La instrucción es un mov corriente. Al ejecutarla, la unidad de gestión de memoria intentó traducir la dirección 0 y no encontró ninguna página asignada: el kernel deja siempre sin asignar la zona más baja del espacio de direcciones, precisamente para que este error tan común se detecte. La CPU generó un fallo de página, y el kernel comprobó que la dirección no pertenecía a ninguna región válida del proceso.
La línea del registro es más informativa de lo que parece. Tiene cuatro datos:
| Campo | Valor | Significado |
|---|---|---|
segfault at | 0 | La dirección de memoria a la que se intentó acceder. |
ip | …d14d | La dirección de la instrucción que lo intentó. |
sp | …b6f0 | El puntero de pila en ese momento. |
error | 6 | Un código que describe el tipo de acceso. |
El código de error lo aporta el procesador junto con la excepción. Es un número cuyos bits responden, cada uno, a una pregunta:
| Bit | Valor | Si está en 0 | Si está en 1 |
|---|---|---|---|
| 0 | 1 | La página no estaba asignada. | La página existía, pero no permitía ese acceso. |
| 1 | 2 | Fue una lectura. | Fue una escritura. |
| 2 | 4 | Ocurrió en modo kernel. | Ocurrió en modo usuario. |
| 4 | 16 | Se accedía a un dato. | Se intentaba ejecutar código en esa dirección. |
El 6 de nuestro programa es 4 + 2: un acceso desde modo usuario, de escritura, a una página no asignada. Con esa tabla se interpreta cualquier línea de este tipo, y se distinguen errores que para el usuario tienen el mismo mensaje:
| Error del programa | Código | Lectura |
|---|---|---|
Leer a través de un puntero nulo: x = *p; | 4 | Usuario, lectura, página no asignada. |
Escribir a través de un puntero nulo: *p = 42; | 6 | Usuario, escritura, página no asignada. |
Modificar una cadena literal: char *s = "hola"; s[0] = 'H'; | 7 | Usuario, escritura, página existente de solo lectura. |
| Recursión infinita, que agota la pila | 6 | Como el segundo, pero con una dirección cercana al valor de sp. |
El tercer caso se relaciona con el tema 9: las cadenas literales se guardan en la sección .rodata del ejecutable, que el kernel mapea sin permiso de escritura. La protección no la aplica el compilador, sino la unidad de gestión de memoria en cada acceso.
7. Experimento 3: una instrucción inválida
No toda secuencia de bytes es una instrucción. Cuando la CPU capta unos bytes que no corresponden a nada en su repertorio, genera la excepción del vector 6. Existe incluso una instrucción, ud2, definida con el único propósito de ser inválida:
#include <stdio.h>
int main(void)
{
printf("Antes de la instruccion invalida\n");
__asm__("ud2"); /* garantizada como inválida */
printf("Esta linea no se ejecuta\n");
return 0;
}
$ gcc -Wall -Wextra -g ilegal.c -o ilegal
$ ./ilegal
Antes de la instruccion invalida
Illegal instruction (core dumped)
$ echo $?
132
$ sudo dmesg | tail -1
[12977.880133] traps: ilegal[10266] trap invalid opcode ip:5617a0b2c151 sp:7ffd41c0e2a0 error:0 in ilegal[5617a0b2c000+1000]
Fuera de los experimentos, este error tiene dos causas habituales, y conviene reconocerlas:
- Un programa compilado para un procesador más nuevo. Los procesadores incorporan instrucciones con cada generación. Un ejecutable que usa las más recientes funciona en el equipo donde se compiló y termina con
Illegal instructionen uno anterior, que no las conoce. - Un salto a un lugar que no contiene código. Si un programa defectuoso corrompe una dirección de retorno, la CPU continúa ejecutando datos como si fueran instrucciones, hasta que encuentra una combinación sin sentido.
Los compiladores usan ud2 de manera deliberada: la colocan en los puntos del código a los que la ejecución nunca debería llegar, para que el programa se detenga de inmediato si llega.
8. El fallo de página que no es un error
Los tres experimentos anteriores terminaron mal, y podrían dejar la impresión de que una excepción es siempre un error. Es al revés: la inmensa mayoría de las excepciones que atiende un sistema son parte de su funcionamiento normal, se resuelven en microsegundos y ningún programa las percibe.
El caso central es el fallo de página. En el tema 9 vimos que, al cargar un programa, el kernel no copia el ejecutable a la memoria: solo anota qué parte del archivo corresponde a cada rango de direcciones. Lo mismo hace cuando un programa pide memoria. La memoria se promete, no se entrega. La entrega ocurre después, y la desencadena una excepción:
- El programa accede por primera vez a una dirección de una región que le fue concedida. modo usuario
- La unidad de gestión de memoria no encuentra una página asignada y la CPU genera un fallo de página. excepción
- El kernel comprueba que la dirección pertenece a una región válida. No es un error: es una promesa por cumplir. modo kernel
- Asigna una página de memoria física y, si corresponde a un archivo, la llena con su contenido. Actualiza las tablas. modo kernel
- Vuelve de la excepción. Como es un fault, la instrucción se ejecuta otra vez, y ahora la página existe. modo usuario
Es la aplicación exacta de la definición de la sección 2: la instrucción no pudo completarse, el kernel arregló la causa, y la instrucción se repitió. El siguiente programa cuenta cuántas veces le ocurre, con getrusage, una llamada de POSIX que informa los recursos consumidos por el proceso:
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#define TAM (100L * 1024 * 1024) /* 100 MB */
/* Devuelve los fallos de página que el kernel resolvió sin leer del disco. */
static long fallos_de_pagina(void)
{
struct rusage uso;
getrusage(RUSAGE_SELF, &uso);
return uso.ru_minflt;
}
int main(void)
{
long inicio, reservado, usado;
char *bloque;
inicio = fallos_de_pagina();
bloque = malloc(TAM); /* pedir 100 MB */
if (bloque == NULL) {
perror("malloc");
return 1;
}
reservado = fallos_de_pagina();
for (long i = 0; i < TAM; i += 4096)
bloque[i] = 1; /* tocar un byte de cada página */
usado = fallos_de_pagina();
printf("Fallos de pagina al reservar: %ld\n", reservado - inicio);
printf("Fallos de pagina al usar: %ld\n", usado - reservado);
free(bloque);
return 0;
}
$ gcc -Wall -Wextra -g paginas.c -o paginas
$ ./paginas
Fallos de pagina al reservar: 1
Fallos de pagina al usar: 25599
Pedir 100 MB costó un solo fallo de página: el que provocó malloc al anotar sus datos de control al comienzo del bloque. El kernel aceptó el pedido sin entregar memoria. Fue al escribir cuando ocurrieron las excepciones, una por cada página de 4096 bytes: 100 MB son 25 600 páginas, y la primera ya estaba presente. El programa sufrió más de veinticinco mil excepciones en una fracción de segundo y terminó con normalidad. El número exacto puede variar; en sistemas configurados para usar páginas de 2 MB será mucho menor.
El mismo mecanismo sostiene buena parte de lo que hace el kernel con la memoria:
| Situación | Qué hace el kernel ante el fallo de página | Resultado |
|---|---|---|
| Primer uso de memoria recién pedida | Asigna una página vacía. | El programa continúa |
| Primera ejecución de una zona del programa | Trae esa parte del ejecutable desde el disco o desde la caché. | El programa continúa |
| La pila crece más allá de lo asignado | Amplía la pila con una página más. | El programa continúa |
| La página había sido enviada al disco por falta de memoria | La recupera. | El programa continúa, con demora |
| La dirección no pertenece a ninguna región del proceso | Nada: no hay qué arreglar. | SIGSEGV |
El procesador genera la misma excepción en las cinco filas. La diferencia entre un programa que funciona y un fallo de segmentación está en lo que el kernel encuentra al examinar la dirección. Los fallos que se resuelven sin acceder al disco se llaman menores, y los que requieren leerlo, mayores; el comando ps -o pid,min_flt,maj_flt,comm muestra ambos contadores para cada proceso. La memoria virtual es el contenido del tema 27.
9. Traps: excepciones a propósito
Las excepciones de la clase trap no señalan un problema. Se provocan deliberadamente, para que el kernel tome el control en un punto elegido. El ejemplo más importante es el que hace posible un depurador.
En el tema 6 usamos gdb para detener un programa en una línea. Lo logra así: cuando se le pide un punto de parada, sustituye el primer byte de la instrucción elegida por el byte 0xCC, que es la instrucción int3. Cuando la ejecución llega allí, la CPU genera la excepción del vector 3; el kernel la convierte en la señal SIGTRAP y, como el proceso está bajo control de un depurador mediante ptrace —la llamada que conocimos en el tema 16—, se la entrega al depurador en lugar de al programa. El depurador restaura el byte original y muestra su indicador.
La instrucción se puede escribir directamente en un programa, que así lleva incorporado su propio punto de parada:
#include <stdio.h>
int main(void)
{
printf("Antes del punto de parada\n");
__asm__("int3");
printf("Despues del punto de parada\n");
return 0;
}
$ gcc -Wall -Wextra -g pausa.c -o pausa
$ ./pausa # sin depurador
Antes del punto de parada
Trace/breakpoint trap (core dumped)
$ gdb -q ./pausa # con depurador
(gdb) run
Antes del punto de parada
Program received signal SIGTRAP, Trace/breakpoint trap.
main () at pausa.c:7
7 printf("Despues del punto de parada\n");
(gdb) continue
Continuing.
Despues del punto de parada
[Inferior 1 (process 10301) exited normally]
La misma excepción tuvo dos desenlaces. Sin depurador, nadie esperaba la señal y el proceso fue finalizado. Con depurador, el programa se detuvo, y al continuar terminó normalmente.
Hay un detalle que confirma la clasificación de la sección 2. Al detenerse, gdb muestra la línea 7, la siguiente a la de int3, y al continuar no vuelve a detenerse. Es un trap: la instrucción ya se ejecutó y el contador de programa guardado apunta a la que sigue. Si fuera un fault, al continuar se repetiría y el programa no avanzaría nunca.
El vector 1, el de depuración, funciona de modo parecido: el depurador activa un bit del registro de estado que hace que la CPU genere una excepción después de cada instrucción. Es el mecanismo de los comandos de ejecución paso a paso.
10. Atrapar la señal
Finalizar el proceso es solo el comportamiento por defecto. Un programa puede pedirle al kernel que, en lugar de eso, ejecute una función suya cuando llegue cierta señal. Se llama instalar un manejador de señal:
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
/* El kernel hace que el programa ejecute esta función al recibir SIGSEGV. */
static void manejador(int senal)
{
const char mensaje[] = "Recibi SIGSEGV: acceso invalido a memoria\n";
(void)senal;
write(STDERR_FILENO, mensaje, sizeof(mensaje) - 1);
_exit(1);
}
int main(void)
{
int *p = NULL;
signal(SIGSEGV, manejador); /* instalar el manejador */
printf("Antes de escribir\n");
*p = 42;
printf("Esta linea no se ejecuta\n");
return 0;
}
$ gcc -Wall -Wextra -g atrapar.c -o atrapar
$ ./atrapar
Antes de escribir
Recibi SIGSEGV: acceso invalido a memoria
$ echo $?
1
Esta vez la shell no informó nada: el programa terminó por decisión propia, con código 1. La excepción ocurrió igual, y el kernel la trató igual; cambió el último paso.
El manejador tiene dos particularidades, y las dos provienen de temas anteriores:
- Usa
writey noprintf. La señal interrumpe al programa en un punto cualquiera, quizá en mitad de otroprintfcon el búfer a medio modificar. Dentro de un manejador solo es seguro llamar a funciones que no dependen de un estado interno, como los envoltorios directos de las llamadas al sistema. - Termina con
_exity no vuelve. El fallo de página es un fault: si el manejador retornara, el kernel reanudaría el programa en la misma instrucción, que fallaría otra vez, y el manejador se ejecutaría de nuevo, indefinidamente. La causa no fue corregida, así que repetir no sirve.
Un manejador así no «arregla» el fallo, pero tiene usos legítimos: guardar el trabajo del usuario antes de cerrar, registrar un informe de error o restaurar el estado de la terminal. Y es el puente entre los dos sentidos de la palabra que separamos en la sección 1: cuando un programa en Java accede a una referencia nula, la máquina virtual recibe SIGSEGV, y su manejador la convierte en una NullPointerException del lenguaje.
11. Encontrar la línea culpable
Saber que un programa murió por SIGSEGV es poco. Lo que hace falta es saber dónde. Como la excepción es síncrona, el kernel conoce la dirección exacta de la instrucción, y un depurador puede traducirla a una línea del código fuente. Para eso sirve la opción -g que venimos pasando a gcc desde el tema 4: incluye en el ejecutable la correspondencia entre direcciones y líneas.
$ gdb -q ./segfault
(gdb) run
Antes de escribir
Program received signal SIGSEGV, Segmentation fault.
0x000055555555514d in main () at segfault.c:8
8 *p = 42; /* escribir en la dirección 0 */
(gdb) print p
$1 = (int *) 0x0
(gdb) quit
En dos comandos, el depurador da el archivo, la línea y el valor de la variable responsable. A diferencia del caso de pausa, aquí muestra la línea que falló y no la siguiente: es un fault.
Cuando el fallo ocurrió sin depurador, queda otro recurso. El texto (core dumped) indica que el kernel guardó un volcado de memoria: una copia del contenido del proceso en el instante de la señal. Con ese archivo, gdb puede examinar el programa muerto como si estuviera detenido. Dónde se guarda depende de la distribución:
| Sistema | Dónde queda el volcado | Cómo examinarlo |
|---|---|---|
Con systemd-coredump (Fedora, entre otras) | Lo almacena el sistema. | coredumpctl list y coredumpctl gdb |
| Ubuntu | Lo recoge el servicio de informes de errores, en /var/crash. | — |
| Configuración clásica | Un archivo core en el directorio actual, si ulimit -c lo permite. | gdb ./programa core |
Hay que recordar además una lección del tema 15. En los experimentos de este tema, el texto impreso antes del fallo apareció porque la salida iba a la terminal y terminaba en salto de línea. Con la salida redirigida a un archivo, ese texto queda en el búfer y se pierde con el proceso: el último mensaje que se ve no indica dónde murió el programa.
12. Cuando falla el kernel
Queda el tercer desenlace de la sección 4: la excepción se produce mientras la CPU ejecuta código del kernel. Hay que distinguir dos situaciones muy diferentes.
La primera es prevista. Cuando el kernel copia datos desde la memoria de un programa o hacia ella, usa las direcciones que el programa le pasó, y esas direcciones pueden ser inválidas. El kernel lo sabe, y marca esos tramos de su código como lugares donde un fallo de página es admisible. Si ocurre, no hay ningún drama: la copia se interrumpe y la llamada al sistema devuelve EFAULT. Es exactamente el mecanismo que hay detrás de las funciones copy_from_user y copy_to_user que mencionamos en el tema 13.
La segunda es un defecto: el kernel, o más a menudo un driver, usa un puntero inválido propio. Aquí no hay nadie por encima a quien avisar. Linux distingue dos grados:
| Oops | Pánico (kernel panic) | |
|---|---|---|
| Cuándo | El error ocurrió atendiendo a un proceso concreto | El error impide confiar en el estado del sistema, u ocurrió en un manejador de interrupción |
| Qué hace el kernel | Registra un informe detallado y finaliza ese proceso | Muestra el informe y detiene la máquina |
| El sistema | Sigue funcionando, en un estado dudoso | Deja de funcionar |
Un oops se reconoce en dmesg por líneas como BUG: kernel NULL pointer dereference, seguidas del contenido de los registros y de la lista de funciones que se estaban ejecutando. El pánico es el equivalente en Linux de la pantalla azul de Windows. La decisión de detener todo no es exceso de cautela: un kernel con sus estructuras dañadas podría escribir datos erróneos en el disco, y parar es menos grave que corromper los archivos.
El caso extremo es el doble fallo del vector 8: una excepción que ocurre mientras el procesador intenta entregar otra, por ejemplo porque la pila del kernel se agotó. Es un abort, y casi siempre termina en pánico.
Todo esto refuerza una observación del tema 12. Un error en un programa de usuario lo paga ese programa: el kernel lo finaliza y los demás no lo notan. El mismo error en modo kernel compromete la máquina completa. Es el argumento más fuerte para mantener fuera del kernel todo lo que no necesite estar dentro, y volveremos sobre él al comparar arquitecturas en el tema 24.
13. Resumen
- Una excepción es una entrada al kernel provocada por la CPU cuando la instrucción en curso no puede ejecutarse normalmente. Es síncrona y usa la misma tabla de vectores que las interrupciones.
- Un fault se detecta antes de completar la instrucción y permite repetirla; un trap, después, y continúa con la siguiente; un abort no permite continuar.
- El catálogo de excepciones lo define cada procesador: la división entera por cero lo es en x86-64 y no en ARM.
- Ante una excepción, el kernel la resuelve y reanuda, envía una señal al proceso, o registra un error propio.
- La división por cero produce
SIGFPE; el acceso inválido a memoria,SIGSEGV; la instrucción inválida,SIGILL. Por defecto finalizan el proceso, y la shell informa 128 más el número de la señal. - La línea de
dmesgde un fallo de segmentación indica la dirección accedida, la de la instrucción y un código que describe el tipo de acceso. - La mayoría de los fallos de página no son errores: son la forma en que el kernel entrega la memoria en el momento en que se usa.
- Los puntos de parada de un depurador son excepciones provocadas con la instrucción
int3. - Un programa puede instalar un manejador para recibir la señal en lugar de ser finalizado; dentro de él debe usar
writey no puede simplemente volver. - Como la excepción es síncrona, un depurador puede señalar la línea exacta que la causó.
- Una excepción imprevista dentro del kernel produce un oops o un pánico, y afecta a todo el sistema.
14. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿En qué se diferencia una excepción de una interrupción de hardware?
La excepción la genera la propia CPU al ejecutar una instrucción, y es síncrona: está causada por el programa en curso y se repetiría en el mismo punto. La interrupción la genera un dispositivo, llega en cualquier momento y no guarda relación con lo que se estaba ejecutando.
2. ¿A qué instrucción apunta el contador de programa guardado en un fault, y a cuál en un trap?
En un fault, a la misma instrucción que provocó la excepción, para que pueda repetirse. En un trap, a la siguiente, porque la instrucción ya se completó.
3. ¿Por qué el fallo de página tiene que ser un fault y no un trap?
Porque la instrucción no llegó a hacer su trabajo: el dato no pudo leerse o escribirse. Una vez que el kernel asigna la página, hay que ejecutarla de nuevo. Si se continuara por la siguiente, el programa seguiría con una operación sin hacer.
4. Un programa termina con el mensaje «Floating point exception» y código 136. ¿Qué ocurrió, y quién escribió el mensaje?
Hizo una división entera por cero. La CPU generó la excepción del vector 0, el kernel envió SIGFPE, la señal número 8, y el proceso fue finalizado. El mensaje lo escribió la shell al saber cómo terminó su hijo; 136 es 128 + 8.
5. ¿Por qué 1.0 / 0.0 no produce ninguna excepción?
Porque el formato de los números reales incluye valores especiales para el infinito y para «no es un número». La operación tiene un resultado representable y la CPU puede completar la instrucción.
6. En dmesg aparece segfault at 0 … error 4. ¿Qué intentó hacer el programa?
Leer la dirección 0. El código 4 significa acceso desde modo usuario, de lectura, a una página no asignada. Lo más probable es que haya usado un puntero nulo.
7. ¿Y si el código fuera 7?
7 es 4 + 2 + 1: modo usuario, escritura, sobre una página que existe pero no permite escribir. Es lo que ocurre al modificar una cadena literal o cualquier otro dato de solo lectura.
8. paginas sufre más de veinticinco mil excepciones y termina bien. ¿Cómo es posible?
Son fallos de página sobre direcciones válidas. El kernel había concedido la memoria sin asignarla; en el primer acceso a cada página recibe la excepción, asigna la página y hace que la instrucción se repita. El programa no percibe nada.
9. El procesador genera la misma excepción para un primer acceso a memoria válida y para un puntero nulo. ¿Quién los distingue, y cómo?
El kernel. Al recibir el fallo de página, busca la dirección entre las regiones de memoria del proceso. Si pertenece a una, asigna la página y reanuda; si no pertenece a ninguna, envía SIGSEGV.
10. ¿Cómo implementa un depurador un punto de parada?
Reemplaza el primer byte de la instrucción por 0xCC, la instrucción int3. Al ejecutarla, la CPU genera una excepción, el kernel la convierte en SIGTRAP y, como el proceso está bajo ptrace, avisa al depurador, que restaura el byte original.
11. ¿Por qué el manejador de atrapar.c termina con _exit en lugar de retornar?
Porque la excepción es un fault. Al volver del manejador se reanudaría la misma instrucción, con el mismo puntero nulo, que fallaría otra vez y volvería a ejecutar el manejador, sin fin.
12. ¿Por qué un puntero nulo en un programa finaliza solo ese programa, y en un driver puede detener el sistema?
En modo usuario, el kernel está por encima para contener el fallo: finaliza el proceso y los demás no se enteran. En modo kernel no hay nadie por encima, y las estructuras del propio sistema pueden haber quedado dañadas; continuar podría corromper datos.
Para practicar
- Ejecuta
./division > salida.txty mira el archivo. ¿Dónde quedó «Antes de dividir»? Corrige el programa para que el mensaje no se pierda. - En
division.c, reemplaza el cálculo porINT_MIN / menos_uno, con una variablevolatile int menos_uno = -1y la cabeceralimits.h. ¿Qué ocurre, y por qué? Relee la primera fila de la tabla de la sección 3. - Modifica
segfault.cpara provocar cada caso de la última tabla de la sección 6, y comprueba el código de error endmesg. - Escribe una función que se llame a sí misma sin condición de corte y ejecútala. Compara en
dmesgla dirección del fallo con el valor desp. - Ejecuta
ilegaldentro degdb. ¿Qué línea muestra al detenerse: la deud2o la siguiente? ¿Qué clase de excepción es? - En
paginas.c, cambia el paso del bucle de 4096 a 1, y luego a 8192. ¿Cómo cambia la cantidad de fallos de página en cada caso? - Consulta los contadores de tu shell con
ps -o pid,min_flt,maj_flt,comm -p $$, ejecuta algunos comandos y vuelve a consultarlos. - En
atrapar.c, quita la llamada a_exity ejecuta el programa. Detenlo con Ctrl+C y explica lo que viste. - Instala en
division.cun manejador paraSIGFPEque escriba un mensaje y termine con código 2. - Ejecuta
pausaconstrace -o traza.txty localiza en la traza la línea de la señal. ¿Qué valor tienesi_code?