no_std: el panic_handler y el punto de entrada
En `no_std` desaparece el andamiaje que `std` montaba en silencio: qué hacer cuando salta un `panic!` y el punto de entrada real que acaba llamando a tu `main`. El compilador se niega a construir hasta que lo proveas tú. Dos piezas obligatorias: un `#[panic_handler]` con firma `fn(&PanicInfo) -> !` y, en bare-metal, el símbolo de entrada. Por qué no hay un `main` normal.
std no solo te daba Vec y ficheros: te daba, en silencio, un andamiaje que nunca notaste. Algo que hacer cuando un panic! salta. Un punto de entrada real que, tras preparar el terreno, terminaba llamando a tu main. Un asignador ya conectado. Ese andamiaje era tan discreto que la mayoría de programadores viven su carrera entera sin sospechar que existe. Quita std y se evapora de golpe, y el compilador —fiel a su costumbre— se niega en rotundo a construir nada hasta que lo repongas con tu propia mano. Esa negativa no es un fastidio: es la pedagogía más honesta que ofrece Rust. Te obliga a firmar, cláusula por cláusula, el contrato entre un programa y su entorno que std firmaba por ti sin que te enteraras.
- Entender por qué en
no_stddesaparece el andamiaje que el runtime destdproveía en silencio. - Escribir el
#[panic_handler]obligatorio con firmafn(&PanicInfo) -> !. - Comprender por qué no hay un
mainnormal y cómo se define el punto de entrada real. - Relacionar
panic = "abort",#![no_main]y el lang itemeh_personality.
El andamiaje que std montaba
En un programa con std, el runtime hacía por ti tres cosas que jamás viste. Primero, instalaba un manejador de pánico que desenrollaba la pila, ejecutaba destructores e imprimía el mensaje y la traza. Segundo, proveía lang_start: el punto de entrada de verdad que el sistema invoca, que prepara argv y el entorno, monta la salida estándar, llama a tu main y traduce su retorno en un código de salida. Tercero, conectaba el asignador global al malloc del sistema. En no_std los tres desaparecen. El asignador ya lo repusiste en la lección anterior; quedan los otros dos, y sin ellos no hay binario.
panic_handler
Qué hacer cuando salta panic!. core define la macro pero no la política; la política la pones tú.
Punto de entrada
El símbolo que el hardware o el runtime saltan al arrancar. Sin lang_start, nadie llamaría a tu main.
panic = abort
Sin desenrollador en no_std, el pánico aborta. Evita además el lang item eh_personality.
no_main
El atributo que le dice al compilador que no espere el main habitual ni su envoltorio.
El panic_handler obligatorio
core define la macro panic!, pero se niega deliberadamente a decidir qué ocurre cuando salta: esa es una política, y la política depende del destino. Un microcontrolador quizá quiera parpadear un LED y colgarse; un núcleo, registrar y reiniciar. Por eso core exige que exactamente una función en todo el grafo de dependencias lleve el atributo #[panic_handler], con la firma fn(&PanicInfo) -> !. El tipo de retorno ! es tajante: un manejador de pánico no puede regresar jamás, porque un pánico es terminal. Si falta, la compilación se detiene con un error inequívoco: se requiere una función #[panic_handler] y no se encontró.
#![no_std]
use core::panic::PanicInfo;
#[panic_handler]
fn al_entrar_en_panico(_info: &PanicInfo) -> ! {
loop {} // en un micro: quiza señalar el fallo por un pin y colgar
}
Qué pones en el cuerpo depende del destino: un bucle infinito en bare-metal, una instrucción de breakpoint, volcar el mensaje por un puerto serie o invocar un intrínseco de aborto. Como en no_std no hay desenrollador —desenrollar exige tablas en tiempo de ejecución y el lang item eh_personality—, casi siempre se fija la estrategia de aborto en el manifiesto:
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
Eso convierte todo pánico en un aborto inmediato y, de paso, te libra de tener que proveer el eh_personality que el camino de desenrollado reclamaría.
La firma -> ! no es decorativa: el manejador está obligado a divergir, sea colgándose, abortando o reiniciando. Y hay una regla de oro: nunca debe entrar en pánico él mismo. No hay nada que atrape un pánico dentro del manejador de pánico; sería un fallo dentro del fallo, sin fondo. Mantén su cuerpo mínimo y sin operaciones que puedan fallar.
El &PanicInfo que recibes no es opaco: transporta la ubicación del pánico y, desde Rust 1.81, su mensaje ya formateado. En un destino con un puerto serie puedes volcarlos antes de colgarte, que es lo más parecido a un diagnóstico que tendrás sin std:
use core::fmt::Write;
use core::panic::PanicInfo;
#[panic_handler]
fn al_entrar_en_panico(info: &PanicInfo) -> ! {
if let Some(lugar) = info.location() {
// suponiendo un uart() que implementa core::fmt::Write:
let _ = writeln!(uart(), "PANICO en {}:{}", lugar.file(), lugar.line());
}
loop {}
}
Aquí se cierra el círculo con la maquinaria de pánico que ya estudiaste: el core::panic::PanicInfo que recibe un #[panic_handler] es el primo austero del PanicHookInfo que en std recibe un hook. Mismo concepto, distinta capa: uno para el mundo sin SO, otro para el mundo con él.
El punto de entrada: por qué no hay main
Aquí está el malentendido que no_std disuelve para siempre: el fn main que escribes nunca fue el punto de entrada. En un programa con std, el sistema salta a _start (que aporta la libc), _start llama a lang_start de Rust, este prepara el entorno y solo entonces llama a tu main. Toda esa cadena vive en std. Sin std no hay lang_start, así que nada sabe que debe llamar a tu main; y en bare-metal ni siquiera hay un SO que llame a nada. De ahí dos consecuencias que se escriben explícitamente:
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panico(_: &PanicInfo) -> ! { loop {} }
#[unsafe(no_mangle)] // Rust 2024: no_mangle es un atributo unsafe
pub extern "C" fn _start() -> ! {
// aqui empieza tu mundo; nadie lo llamara despues, asi que no retorna
loop {}
}
#![no_main] le dice al compilador que no genere el envoltorio habitual que esperaría un main y su lang_start. Y _start es el símbolo real de entrada: el que el vector de reset o el guion del enlazador saltan al arrancar. Lleva #[unsafe(no_mangle)] para que su nombre sobreviva al mangling y extern "C" para exponer la ABI que el arranque espera. Devuelve ! porque en bare-metal no hay ningún sitio al que regresar. En un proyecto real, una crate de runtime como cortex-m-rt esconde todo esto tras un atributo #[entry] y un guion de enlazador, pero por debajo es exactamente esto. En un destino no_std hospedado —con un SO debajo y la libc enlazada— definirías en su lugar un #[unsafe(no_mangle)] pub extern "C" fn main, porque el _start de la libc sigue existiendo y busca ese símbolo.
Hay una responsabilidad más que en bare-metal recae sobre ti o sobre la crate de runtime: el guion del enlazador, que decide dónde va cada sección en memoria y fija la dirección a la que salta el vector de reset. std lo daba por sentado porque el SO cargaba tu binario en un espacio ya preparado; sin SO, la disposición de la memoria pasa a ser parte del contrato que firmas. Por eso un proyecto embebido serio incluye, junto al #[panic_handler] y el _start, un fichero que describe el mapa de memoria del chip.
flowchart TD RESET[vector de reset o runtime del arranque] --> START[_start que defines tu] START --> LOGICA[tu logica corre y nunca retorna] PANIC[panic invocado en cualquier punto] --> PH[tu panic_handler] PH --> FIN[aborta o bucle infinito nunca regresa] style START fill:#cba6f7,color:#11111b style PANIC fill:#f38ba8,color:#11111b style FIN fill:#f9e2af,color:#11111b
Todo lenguaje que corre “sobre un sistema operativo” tiene un runtime que hace calladamente estas mismas tres cosas —preparar el terreno antes de main, definir qué es un fallo fatal, mediar la memoria— pero casi todos lo ocultan tan bien que olvidas que existe. Crees que tu programa empieza en main y que un crash “simplemente ocurre”, como si fueran hechos de la naturaleza. no_std deshace esa ilusión con una franqueza brutal: al escribir #![no_std], Rust te entrega esas responsabilidades una a una y se niega a compilar hasta que cumplas cada cláusula. Y esa negativa es el mejor profesor que tendrás, porque hace explícito el contrato entre un programa y su entorno que llevabas toda la vida firmando en blanco. main nunca fue el principio: fue una función que el código de otro eligió llamar después de preparar el mundo. Un panic! nunca fue magia: fue una política que alguien instaló por ti. La salida estándar, el asignador, el desenrollado: todo eran servicios prestados por una capa que dabas por descontada. Escribir un binario no_std es firmar a mano, con plena conciencia, el mismo contrato que std firmaba en tu nombre, y comprender ese contrato es comprender la forma verdadera de un programa en ejecución: no una lista de instrucciones que arranca sola, sino un huésped que negocia con su entorno cada capacidad que usa. Quien ha escrito un #[panic_handler] y un _start con sus propias manos ya no vuelve a mirar fn main de la misma manera.
En no_std tú repones lo que el runtime de std daba gratis. Obligatorio: un #[panic_handler] con firma fn(&PanicInfo) -> ! (exactamente uno en todo el grafo), porque core define panic! pero no su política. Habitual: panic = "abort", que evita el desenrollador y el lang item eh_personality. En bare-metal, además, #![no_main] y un punto de entrada propio —#[unsafe(no_mangle)] pub extern "C" fn _start() -> !—, porque sin lang_start nadie llamaría a tu main. El main nunca fue el principio: era una función que otro código elegía llamar tras preparar el terreno.
- Escribe el binario
no_stdmínimo:#![no_std],#![no_main], un#[panic_handler]y un_start. Compílalo para un destino bare-metal. - Quita el
#[panic_handler]y lee el error exacto del compilador; explica por quécoreno puede proveer uno por defecto. - Añade
panic = "abort"a los perfiles y razona qué lang item dejas de necesitar y por qué. - Cambia la firma del manejador a que devuelva
()en vez de!y observa el rechazo; conéctalo con la idea de que un pánico es terminal. - Investiga cómo
cortex-m-rtimplementa#[entry]y localiza, bajo el azúcar, el_starty el guion de enlazador equivalentes.