wandres.dev
KPROBES Y PERF · sondas dinámicas, perfilado

kprobes: instrumentar cualquier instrucción en caliente

Cómo un kprobe reemplaza una instrucción del kernel en marcha por un breakpoint int3 para desviar la ejecución hacia tus manejadores sin recompilar ni reiniciar: el struct kprobe, register_kprobe, el mecanismo de copia y single-step, los kprobes optimizados con un salto directo, la lista negra y por qué esta primitiva es el cimiento de perf probe y de BPF.

⏱ 17 min

Imagina poder detener el kernel en cualquier instrucción —el interior de vfs_read, la tercera línea de kernel_clone, un punto arbitrario a mitad de una función— ejecutar código tuyo, leer los registros de la CPU y continuar, todo sobre un sistema en producción, sin recompilar, sin reiniciar, sin un solo printk sembrado a mano. Eso es un kprobe: la capacidad de plantar un desvío dinámico en el flujo de instrucciones de un kernel vivo. No es un depurador externo que congela la máquina; es cirugía sobre un corazón que late, y su truco es tan viejo como los depuradores mismos: sustituir un byte por un breakpoint.

🎯 Al terminar esta lección sabrás
  • Registrar un kprobe con register_kprobe y sus manejadores pre_handler y post_handler.
  • Entender el mecanismo: la instrucción original se copia y su primer byte se sustituye por int3.
  • Distinguir el kprobe por trap del kprobe optimizado con un salto directo.
  • Conocer la lista negra con NOKPROBE_SYMBOL y por qué ciertas funciones no se pueden sondear.

Un breakpoint donde tú quieras

Un kprobe se declara con una estructura y se activa con una llamada. Le dices dónde plantar la sonda —por nombre de símbolo más un desplazamiento, o por dirección cruda— y qué funciones ejecutar antes y después de la instrucción sondeada. El pre_handler corre justo antes; el post_handler, justo después.

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>

static struct kprobe kp = {
	.symbol_name = "kernel_clone",   /* la ruta moderna de fork() */
	.offset      = 0,                /* la primera instruccion de la funcion */
};

static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
	pr_info("kprobe: %s ip=%px flags=%lx\n",
		p->symbol_name, (void *)regs->ip, regs->flags);
	return 0;   /* 0 = continua normal; distinto de 0 esta reservado */
}

static void handler_post(struct kprobe *p, struct pt_regs *regs,
			 unsigned long flags)
{
	pr_info("kprobe: %s post-ejecucion\n", p->symbol_name);
}

static int __init mi_init(void)
{
	int ret;

	kp.pre_handler  = handler_pre;
	kp.post_handler = handler_post;

	ret = register_kprobe(&kp);
	if (ret < 0) {
		pr_err("register_kprobe fallo: %d\n", ret);
		return ret;
	}
	pr_info("kprobe plantado en %s (%px)\n", kp.symbol_name, kp.addr);
	return 0;
}

static void __exit mi_exit(void)
{
	unregister_kprobe(&kp);
	pr_info("kprobe retirado\n");
}

module_init(mi_init);
module_exit(mi_exit);
MODULE_LICENSE("GPL");

register_kprobe resuelve symbol_name contra kallsyms, le suma offset y deja la dirección efectiva en kp.addr. El pre_handler recibe un struct pt_regs: es el estado congelado de la CPU en ese punto, con lo que puedes leer argumentos, el puntero de instrucción regs->ip o el de pila. Corre en contexto atómico, con interrupciones deshabilitadas por defecto: no puede dormir, no puede tomar un mutex, no puede llamar a copy_to_user. Es la misma disciplina del contexto de interrupción, y por la misma razón.

El mecanismo: int3, copia y single-step

Aquí está el corazón. Cuando registras el kprobe, el núcleo hace tres cosas. Guarda una copia de la instrucción original en un búfer propio del kprobe (p->ainsn). Luego, con text_poke, reemplaza el primer byte de esa instrucción por el opcode del breakpoint: en x86-64 es int3, cuyo código de operación es 0xcc, un byte único diseñado exactamente para esto. La instrucción ha quedado saboteada de forma controlada.

Antes:   48 89 e5        mov  %rsp, %rbp     <- instruccion real
Despues: cc 89 e5        int3 ...            <- primer byte pisado por 0xcc
Copia:   48 89 e5        mov  %rsp, %rbp     <- intacta, en el buffer del kprobe

Cuando cualquier CPU llega a ese byte, dispara la excepción de breakpoint #BP, que el núcleo enruta a exc_int3 y de ahí a kprobe_int3_handler. Ese manejador ejecuta tu pre_handler. Después necesita ejecutar la instrucción original que pisó, y lo hace con un truco elegante: apunta el puntero de instrucción a la copia del búfer y activa el trap flag (TF) del procesador, de modo que la CPU ejecute esa única instrucción y dispare de inmediato la excepción de depuración #DB. En kprobe_debug_handler corre tu post_handler y la ejecución se reanuda en la instrucción siguiente al punto sondeado, como si nada hubiera pasado. Dos excepciones por cada golpe de sonda.

sequenceDiagram
participant CPU
participant BP as exc_int3
participant K as kprobe_int3_handler
participant C as Copia de la instruccion
CPU->>BP: ejecuta el byte 0xcc
BP->>K: entrega el breakpoint
K->>K: ejecuta pre_handler
K->>C: single-step de la instruccion original con TF
C->>K: dispara el trap de depuracion
K->>K: ejecuta post_handler
K->>CPU: reanuda tras el punto sondeado

kprobes optimizados: del trap al salto

Dos excepciones cuestan caras: cada #BP y cada #DB es un viaje completo por la maquinaria de excepciones, del orden de medio microsegundo por golpe. Por eso el núcleo, si está compilado con CONFIG_OPTPROBES, intenta optimizar el kprobe en segundo plano. Un hilo de trabajo, kprobe_optimizer, sustituye el int3 por un salto relativo de cinco bytes (jmp rel32) hacia un búfer de desvío propio del kprobe. Ese búfer salva los registros en un pt_regs sintético, llama a tu pre_handler, ejecuta las instrucciones desplazadas y salta de vuelta. Se elimina por completo la doble excepción y el coste cae un orden de magnitud.

# ver el estado de cada sonda: [OPTIMIZED] indica el salto directo
sudo cat /sys/kernel/debug/kprobes/list
# ffffffff81234560  k  kernel_clone+0x0    [FTRACE]
# ffffffff81456780  k  vfs_read+0x0        [OPTIMIZED]

La optimización no siempre es posible. El salto ocupa cinco bytes, así que las instrucciones que pisa deben sumar al menos ese tamaño y ser reubicables, y ninguna otra parte del código puede saltar al interior de esa ventana de cinco bytes —si lo hiciera, aterrizaría a mitad del jmp—. Cuando no se cumple, el kprobe se queda en su forma segura por int3. La propia instalación y retirada del salto usa text_poke_bp, que aplica el parche de forma atómica frente a las demás CPUs valiéndose, otra vez, de un int3 transitorio: la técnica que hace posible modificar el texto ejecutable de un kernel vivo sin detener el mundo.

La lista negra y los usos

No todo el kernel es sondeble. El propio camino que atiende el kprobe —el manejador de int3, las funciones que toca antes de llegar a tu pre_handler— no puede llevar una sonda, o entraría en recursión infinita al golpearse a sí mismo. Esas funciones se marcan con NOKPROBE_SYMBOL(func) o con el atributo __kprobes, y forman una lista negra consultable. El código de la sección noinstr, que gobierna la entrada y salida del kernel, también queda vedado.

# funciones prohibidas para kprobes
sudo cat /sys/kernel/debug/kprobes/blacklist | head
# 0xffffffff81a01000-0xffffffff81a01260  do_int3
# 0xffffffff81a02100-0xffffffff81a02390  kprobe_int3_handler

La razón por la que esta primitiva importa tanto es que casi nadie escribe un módulo con register_kprobe: kprobes es el sustrato. perf probe planta kprobes desde el espacio de usuario en un símbolo, un desplazamiento o incluso una línea de fuente. La interfaz de tracefs en /sys/kernel/tracing/kprobe_events los define escribiendo texto. Y sobre todo, los programas BPF de tipo BPF_PROG_TYPE_KPROBE se enganchan a un kprobe: tu programa BPF corre dentro del pre_handler, a velocidad nativa, agregando en mapas sin salir del núcleo. Cada vez que un observabilidad moderna dice “engancho una función del kernel”, debajo hay un byte convertido en 0xcc.

ℹ️
El offset no es solo la entrada de la función

Con .offset puedes sondear cualquier instrucción del interior de una función, no solo su primer byte. perf probe --add 'vfs_read:12' planta la sonda en la línea 12 de la fuente, y perf probe -L vfs_read lista los puntos sondeados con sus variables visibles. Esto es lo que distingue a kprobes de un simple hook de entrada: la granularidad es la instrucción, no la función.

Un kernel que se observa a sí mismo sin dejar de ejecutarse

Detente en la rareza ontológica de lo que acabas de aprender, porque rompe una frontera que parecía inviolable. En casi todo software, el código es estático una vez cargado: el texto ejecutable es de solo lectura, inmutable, y observarlo exige o bien pararlo con un depurador externo, o bien haberlo instrumentado antes de compilar. El kprobe disuelve esa dicotomía. El kernel modifica su propio texto ejecutable —convierte una instrucción real en un breakpoint, ejecuta la original desde una copia, y opcionalmente se reescribe un salto a sí mismo— mientras sigue corriendo, atendiendo interrupciones y planificando procesos, sin perder un latido. La máquina se convierte en observadora de sí misma sin dejar de ser lo observado, y lo hace con la misma técnica humilde que un depurador de los años setenta: un solo byte, 0xcc, la trampa más antigua del oficio. Piensa en lo que esto implica para el método. La observabilidad deja de ser una propiedad que decides en tiempo de compilación —qué instrumentar, qué registrar— y pasa a ser una pregunta que formulas en tiempo de ejecución, sobre un sistema que nunca se pensó para responderla, sin coste alguno mientras no preguntas. El kernel no se prepara para ser observado: se deja abrir en caliente. Y esa inversión —de la instrumentación anticipada a la interrogación dinámica— es la idea que sostiene todo lo que viene después, porque perf y BPF no son más que formas civilizadas de plantar millones de estos breakpoints y de darles sentido estadístico.

⚔️ Planta tu primer breakpoint dinámico
  1. Escribe el módulo del ejemplo sondeando kernel_clone y comprueba en dmesg que tu pre_handler dispara cada vez que se crea un proceso en la máquina.
  2. Localiza tu sonda en /sys/kernel/debug/kprobes/list y determina si aparece como [OPTIMIZED] o no; razona por qué.
  3. Intenta sondear una función de la lista negra, por ejemplo kprobe_int3_handler, y explica qué error devuelve register_kprobe y por qué esa recursión colgaría la máquina.
  4. Cambia .offset para sondear una instrucción interna de la función en lugar de su entrada, ayudándote de objdump -d sobre vmlinux para elegir un desplazamiento válido.
  5. Reproduce el mismo enganche sin escribir C: usa perf probe --add kernel_clone seguido de perf record -e probe:kernel_clone -a sleep 5, y argumenta qué te ha ahorrado el módulo.