Leer un oops y un panic del kernel
La anatomía de un crash del kernel: RIP, los registros, CR2, el call trace y las marcas de taint. La diferencia entre un oops recuperable y un panic que detiene la máquina, y cómo faddr2line y decode_stacktrace traducen func+offset a fichero y línea de fuente.
Cuando el kernel deja de saber qué hacer —un puntero nulo dereferenciado, un BUG_ON que salta, una instrucción ilegal— no puede lanzar una excepción ni volver a un main: no hay nadie por encima. Lo único que le queda es fotografiar su estado moribundo y volcarlo por consola. Ese volcado, el oops, parece un muro de hexadecimal, pero es un informe forense rigurosamente estructurado. Leerlo con soltura es la diferencia entre “se colgó” y “falló en esta línea, con este puntero, viniendo de aquí”.
- Distinguir un oops de un panic y saber qué convierte uno en otro.
- Leer la anatomía de un oops: RIP, registros, CR2 y call trace.
- Interpretar las marcas de taint y el contador de oops.
- Traducir func+offset a fichero y línea con faddr2line y decode_stacktrace.
Oops frente a panic
Un oops es la reacción del kernel a un error interno del que quizá pueda sobrevivir: mata la tarea culpable, marca el kernel como contaminado (tainted) y sigue corriendo, aunque en un estado en el que ya no se debe confiar. Un panic es la parada total: el kernel imprime Kernel panic - not syncing: y detiene o reinicia la máquina. La frontera entre ambos es política, no física, y se gobierna con parámetros:
# convertir cualquier oops en un panic (lo estandar en servidores y en CI)
sysctl kernel.panic_on_oops=1
# reiniciar 10 segundos despues de un panic (0 = no reiniciar nunca)
sysctl kernel.panic=10
# tambien elevan a panic: un warning, o un hung task
sysctl kernel.panic_on_warn=1
En una máquina de producción se prefiere panic_on_oops=1: un kernel contaminado que sigue corriendo puede corromper datos, así que es más seguro pararlo y —si hay kdump (nivel 51.5)— capturar el cadáver. En desarrollo se deja en oops para poder seguir inspeccionando.
Anatomía de un oops
Un BUG_ON o un puntero nulo bastan. Este es el volcado típico en x86-64, campo por campo:
BUG: kernel NULL pointer dereference, address: 0000000000000000
#PF: supervisor read access in kernel mode
#PF: error_code(0x0000) - not-present page
PGD 0 P4D 0
Oops: 0000 [#1] PREEMPT SMP NOPTI
CPU: 3 PID: 1801 Comm: insmod Tainted: G O 7.3.0 #1
Hardware name: QEMU Standard PC (Q35 + ICH9, 2009)
RIP: 0010:mi_probe+0x2a/0x60 [mi_modulo]
Code: 48 8b 47 08 48 8b 00 8b 40 04 <8b> 00 5d c3 0f 1f 40 00 66 90
RSP: 0018:ffffc9000123fbc0 EFLAGS: 00010246
RAX: 0000000000000000 RBX: ffff888104a1c000 RCX: 0000000000000000
RDX: 0000000000000000 RSI: ffffffffc0a02008 RDI: ffff888104a1c000
CR2: 0000000000000000 CR3: 0000000104e2a000 CR4: 0000000000770ef0
Call Trace:
<TASK>
? __die_body+0x1a/0x60
? page_fault_oops+0x15e/0x4e0
? exc_page_fault+0x7a/0x180
mi_probe+0x2a/0x60 [mi_modulo]
really_probe+0xdb/0x380
do_one_initcall+0x77/0x3c0
</TASK>
Modules linked in: mi_modulo(O+)
La primera línea nombra la falta: un puntero nulo leído en modo supervisor. Oops: 0000 [#1] da el código de error de la falta de página y el contador de oops: [#1] es el primero desde el arranque; un [#2] posterior suele ser daño colateral del primero. La línea de la CPU da el núcleo, el PID y el Comm de la tarea, más las marcas de taint. Y el corazón del informe:
- RIP
mi_probe+0x2a/0x60 [mi_modulo]: la instrucción exacta que falló, como símbolo más desplazamiento (0x2a) dentro de una función de0x60bytes, en el módulomi_modulo. Es el dato más importante. - CR2
0000000000000000: en una falta de página, la dirección que se intentó tocar. Aquí, cero: un desreferenciado nulo confirmado. - Code: los bytes máquina alrededor de RIP, con la instrucción culpable entre
<y>;scripts/decodecodelos desensambla. - Call Trace: la pila de llamadas. Los marcados con
?son entradas poco fiables, restos en la pila que el desenrollador no pudo confirmar; las líneas sin?son el camino real hasta el fallo.
No todos los oops son punteros nulos. El error_code de la línea Oops: es un mapa de bits que dice por qué falló la página: bit 0, si la página estaba presente; bit 1, si fue una escritura; bit 2, si ocurrió en modo usuario; bit 4, si fue una búsqueda de instrucción. Un general protection fault en lugar de una falta de página suele delatar un puntero envenenado —el clásico 6b6b6b6b6b6b6b6b de la memoria liberada bajo depuración (nivel 21)— o una dirección no canónica. Y un escalón por debajo del oops vive el WARN / WARN_ON: imprime un volcado con la misma anatomía —RIP, registros, call trace— pero no mata a nadie, solo avisa de una condición que no debería darse y sigue. Con panic_on_warn=1 se eleva a panic, lo habitual en fuzzing para no dejar pasar ni una anomalía. Lo que aprendes a leer aquí sirve igual para un oops, un WARN o un panic: es el mismo formato con distinto grado de letalidad.
Tainted: G O no es decorativo. Cada letra documenta algo que ensucia el diagnóstico: O es un módulo fuera del árbol, G que todo lo cargado es GPL, B que hubo una página mala detectada, W un warning previo, D un oops anterior. cat /proc/sys/kernel/tainted da el valor numérico. La primera pregunta ante un crash de un mantenedor es “¿está contaminado?”: si un módulo propietario (P) está cargado, el bug podría no ser del kernel en absoluto.
De la dirección a la línea de fuente
RIP dice mi_probe+0x2a, pero tú quieres el fichero y la línea. Como el desplazamiento es relativo al símbolo, es inmune a KASLR: no importa dónde se cargara el módulo. La herramienta directa es scripts/faddr2line, que valida además que el desplazamiento cae dentro del tamaño de la función:
# de func+offset/size a fichero:linea (necesita CONFIG_DEBUG_INFO)
scripts/faddr2line mi_modulo.ko mi_probe+0x2a/0x60
# -> mi_probe+0x2a/0x60:
# mi_probe at /home/dev/mi_modulo.c:42
# lo mismo para el vmlinux y un simbolo del nucleo
scripts/faddr2line vmlinux really_probe+0xdb/0x380
Para no traducir línea a línea, scripts/decode_stacktrace.sh toma el oops entero por la entrada estándar y lo reescribe anotando cada marco con su fichero y línea, resolviendo también las direcciones de los módulos:
# anotar un oops guardado, con la ruta de fuentes y la de modulos
scripts/decode_stacktrace.sh vmlinux . ./modulos < oops.txt
# y para el volcado Code: -> ensamblado con la instruccion culpable marcada
scripts/decodecode < oops.txt
El resultado deja el call trace legible de un vistazo, cada marco con su fichero y su línea:
mi_probe (/home/dev/mi_modulo.c:42) mi_modulo
really_probe (drivers/base/dd.c:658)
do_one_initcall (init/main.c:1245)
Debajo de ambas trabaja addr2line, y en un apuro gdb hace lo mismo con list *(mi_probe+0x2a) sobre el .ko con símbolos. La condición para todo esto es que el binario se compilara con CONFIG_DEBUG_INFO; sin él, faddr2line solo puede confirmar el nombre de la función, no la línea. Si no tienes el binario con símbolos pero sí la máquina viva, /proc/kallsyms mapea cada símbolo a su dirección ya recolocada —solo como root; para el resto sale a cero por seguridad—, y con él se revierte a mano una dirección cruda del trace. Pero es el último recurso: faddr2line sobre el vmlinux correcto es más fiable porque conoce el tamaño de cada función y detecta si el desplazamiento se salió de ella, señal de una pila corrompida o de un símbolo equivocado.
flowchart LR OOPS[Oops en dmesg] --> RIP[RIP igual a func mas offset] RIP --> FA[faddr2line valida offset y size] RIP --> DS[decode_stacktrace anota toda la pila] FA --> SRC[fichero y linea de fuente] DS --> SRC SRC --> FIX[Causa localizada] style SRC fill:#89b4fa,color:#11111b style FIX fill:#a6e3a1,color:#11111b
El salto mental que separa a quien depura el kernel de quien solo lo sufre es dejar de ver el oops como un desastre ilegible y empezar a leerlo como lo que es: una confesión estructurada y completa del momento exacto de la muerte. Cada campo responde una pregunta forense precisa. RIP contesta dónde murió, hasta la instrucción. CR2 contesta qué tocó cuando murió. Los registros congelan el estado de los argumentos y las variables en vuelo. El call trace reconstruye cómo llegó hasta ahí. Las marcas de taint dicen en qué medida fiarse de todo lo anterior. Nada de eso es azar: el kernel, en su último aliento, está diseñado para gastar sus últimas instrucciones en dejar un informe que su autor pueda reconstruir. Y la pieza que lo cierra es la traducción de func+offset a fichero:línea, porque el desplazamiento relativo al símbolo es un invariante que sobrevive a KASLR, a la recolocación de módulos y a la carga dinámica: da igual dónde acabara el código en memoria, +0x2a siempre significa la misma línea de fuente. Esa estabilidad es lo que permite que un volcado copiado de una consola serie, sin la máquina delante y sin el binario cargado, baste para señalar con el dedo una línea de C. Aprender a leerlo es adquirir la capacidad de hacer arqueología forense sobre un sistema que ya no existe, con las huellas que dejó al caer. Es, después de gdb en vivo, la herramienta más poderosa que tienes.
- Escribe un módulo cuyo
initdereferencie un puntero nulo; cárgalo en QEMU y captura el oops completo dedmesg. - Identifica RIP, CR2 y el contador
[#1], y explica qué dice cada uno. - Pasa
mi_modulo.ko mi_probe+0x...porscripts/faddr2liney llega a la línea exacta. - Anota el oops entero con
scripts/decode_stacktrace.shy desensambla la líneaCode:condecodecode. - Activa
panic_on_oops=1, repite y observa cómo el mismo fallo ahora detiene la máquina.