Por qué Rust para el desarrollo de kernels
Los sistemas operativos suelen asociarse con C.
Esa asociación es comprensible. C es pequeño, predecible, cercano a la máquina y ha dominado históricamente el trabajo de kernel. Da al programador acceso directo a memoria, registros, layout y convenciones de llamada.
Esas son exactamente las cosas que necesita un kernel.
También son exactamente las cosas que hacen que los kernels sean difíciles de asegurar.
EriX está escrito principalmente en Rust porque el proyecto se construye alrededor de una idea central:
La autoridad debe ser explícita.
Eso se aplica a capacidades, estructuras de handoff de arranque, mensajes IPC, objetos de memoria y acceso a dispositivos. También se aplica al lenguaje de implementación. Rust no hace que el desarrollo de kernels sea automáticamente seguro, pero da al código una forma de hacer visibles propiedad, mutación, lifetimes e inseguridad.
Esa visibilidad importa.
El kernel es un lugar incómodo para bugs de memoria⌗
Los bugs de memoria son malos en todas partes.
En un kernel son peores.
Un use-after-free en una aplicación ordinaria puede corromper esa aplicación. Un use-after-free en el kernel puede corromper el estado del scheduler, tablas de capacidades, espacios de direcciones o mappings de dispositivos.
Un error de límites en un parser de espacio de usuario puede tumbar un proceso. Un error de límites en un parser de arranque puede afectar qué código ejecuta la máquina a continuación.
Un puntero malo en una aplicación es un bug local. Un puntero malo en un kernel puede ser un bug de autoridad.
Por eso la seguridad de memoria no es una preocupación cosmética para EriX. El kernel forma parte de la base de cómputo confiable. El bootloader forma parte de la base de cómputo confiable. Varias bibliotecas de parser y ABI son TCB o adyacentes a la TCB.
Si esos componentes manejan mal la memoria, el modelo de aislamiento puede fallar antes de que una comprobación de capacidad tenga oportunidad de ayudar.
Qué aporta Rust a un kernel⌗
Rust da a un kernel varios valores por defecto útiles.
Da slices con comprobación de límites en lugar de pares puntero-longitud sin comprobación en el código ordinario.
Da reglas de propiedad que hacen más difícil el aliasing accidental.
Da lifetimes que pueden expresar vistas prestadas a imágenes de arranque, binarios ELF, blobs de handoff y buffers IPC sin fingir que esas vistas poseen el almacenamiento subyacente.
Da Result y enums como herramientas normales para hacer explícitas las rutas
de error.
Da un sistema de tipos lo bastante fuerte como para distinguir muchos estados que el código C a menudo representaría como comentarios, flags o convenciones.
Nada de esto elimina la necesidad de diseñar el kernel.
Rust no decidirá qué significa una capacidad. No decidirá qué servicio debe recibir un endpoint de dispositivo. No probará que un protocolo IPC sea correcto. No evitará toda carrera en un scheduler.
Pero cambia el valor por defecto.
En C, el valor por defecto es que casi cualquier función puede hacer aritmética de punteros sin comprobar, aliasar memoria mutable, reinterpretar bytes como estructuras y desbordar silenciosamente si el programador no es disciplinado en todas partes.
En Rust, el código ordinario no puede hacer esas cosas sin pasar por unsafe o
por una operación comprobada explícita.
Esa es una diferencia de seguridad práctica.
no_std es el entorno normal⌗
Rust de kernel no es Rust de aplicación ordinaria.
El crate del kernel de EriX es #![no_std]. No se ejecuta encima de un sistema
operativo. Es el sustrato del sistema operativo.
Muchas bibliotecas de EriX también son no_std, entre ellas:
lib-bootimglib-elflib-handofflib-ipclib-capabilib-bootstraplib-consolelib-devicelib-driverlib-time
Varias de estas crates también usan #![deny(unsafe_code)].
Esa combinación es importante. Significa que las mismas bibliotecas pueden usarse en contextos de arranque, kernel y servicios freestanding sin depender de servicios del OS host. También significa que el código de parser y ABI a menudo puede escribirse sin punteros raw.
Este es uno de los encajes más fuertes de Rust para EriX.
El proyecto tiene muchas crates pequeñas cuyo trabajo no es tocar hardware directamente. Su trabajo es parsear, validar, codificar, decodificar y rechazar. Esos son exactamente los trabajos donde Rust seguro tiene valor.
Los parsers seguros son fronteras de seguridad⌗
EriX trata los parsers como fronteras de seguridad.
lib-bootimg parsea y verifica boot.img. Su ruta de lectura forma parte de la
cadena de confianza de arranque. Valida estructura, alineación, límites, hashes
y firmas antes de que el bootloader confíe en la imagen.
lib-elf parsea binarios ELF64 antes de que el bootloader use segmentos de
carga.
lib-handoff valida estructuras de handoff versionadas entre etapas de
arranque y entre el kernel y rootd.
lib-capabi define tipos de capacidad, derechos, constantes de slot,
descriptores de transferencia y helpers de validación de arranque.
Estas crates son deliberadamente aburridas.
Usan slices de bytes. Usan aritmética comprobada. Devuelven errores estructurados. Rechazan versiones no soportadas, tamaños incorrectos, offsets inválidos y tablas mal formadas.
No hacen I/O. No poseen autoridad runtime. No adivinan.
Ese diseño sería posible en C, pero exigiría disciplina sostenida:
- cada puntero debe comprobarse
- cada offset debe validarse contra rangos
- cada operación entera debe auditarse por overflow
- cada vista devuelta debe ligarse manualmente al lifetime del buffer de entrada
Rust hace que esas reglas sean más baratas de mantener.
Por ejemplo, una imagen de arranque parseada puede tomar prestado del slice de bytes original. Un segmento de carga ELF validado puede exponer bytes prestados en lugar de fabricar un puntero nuevo sin comprobar. Un parser de handoff puede devolver un iterador que comprueba el rango de cada entrada antes de parsearla.
El lenguaje no prueba que el parser sea semánticamente perfecto.
Sí elimina una gran clase de errores accidentales de memoria de la ruta normal del parser.
unsafe no desaparece⌗
El desarrollo de kernels no puede ser Rust seguro por completo.
La interfaz de hardware no es type-safe. Registros de CPU, tablas de páginas, descriptores de interrupción, port I/O, MMIO, buffers DMA y punteros de entrada de arranque no llegan como valores Rust amigables.
En algún momento el kernel debe decir:
Sé qué significa esta dirección raw.
Esa declaración es unsafe.
EriX tiene unsafe en los lugares donde la máquina lo fuerza:
- el código de entrada de arranque convierte un puntero y longitud raw de handoff en un slice de bytes
- el glue syscall x86_64 usa assembly inline y una ABI de registros fija
- el context switch usa assembly específico de arquitectura
- GDT, IDT, TSS e inicialización syscall tocan estado descriptor de la CPU
- la manipulación de tablas de páginas usa lecturas y escrituras volátiles
- rutas seriales y de dispositivos legacy usan port I/O
- drivers con MMIO y DMA usan accessors volátiles
- algunos runtimes de un solo hilo usan
UnsafeCelldetrás de buffers estáticos documentados
Esa lista no es un fracaso de Rust.
Es la frontera honesta entre el lenguaje y la máquina.
El objetivo no es no tener unsafe en ninguna parte. El objetivo es mantener
unsafe pequeño, local, documentado y rodeado de interfaces seguras.
Las fronteras unsafe deben ser estrechas⌗
La pregunta importante no es:
¿Contiene
unsafeel código?
La pregunta importante es:
¿Dónde está el
unsafe, y qué invariante lo hace válido?
En EriX, el puente syscall raw en ipc-syscall_x86_64 es un buen ejemplo.
La crate expone funciones wrapper seguras como ipc_call, ipc_recv,
ipc_reply, query_local_cap y drop_local_cap. Internamente tiene una sola
frontera de assembly raw que mapea argumentos a la ABI de registros syscall de
x86_64 y devuelve valores raw de registros.
El wrapper no desreferencia punteros de usuario por sí mismo. Pasa valores de puntero y longitud al kernel, donde ocurren las comprobaciones de capacidad, de endpoint y de longitud de mensaje.
Esa es la forma correcta:
- el assembly raw queda aislado
- la ABI está documentada
- la API pública está tipada
- la validación semántica sigue en el kernel
La frontera de context switch del kernel sigue el mismo patrón. Hay assembly
específico de arquitectura para guardar y restaurar registros, pero el resto del
scheduler puede trabajar con una estructura KernelContext y un trait
ContextSwitcher. Las pruebas de host pueden usar un switcher blando que modela
la entrega de estado sin saltar realmente la CPU.
Esa división importa porque mantiene testeable la mayor parte de la lógica del scheduler sin ejecutar instrucciones privilegiadas.
El acceso al hardware sigue siendo acceso al hardware⌗
Rust no vuelve seguro a MMIO por sí solo.
Si un driver escribe el valor equivocado en el registro equivocado, Rust no salvará el dispositivo. Si un buffer DMA se describe incorrectamente, el hardware aún puede escribir en memoria. Si una entrada de tabla de páginas es incorrecta, la CPU aplicará el mapping incorrecto con mucha eficiencia.
EriX maneja esto de dos maneras.
Primero, la autoridad de hardware se hace explícita. Un driver no debería recibir autoridad amplia sobre la máquina. Debería recibir solo el device frame, rango I/O, línea de interrupción o familia de endpoints que necesita.
Segundo, el código unsafe que toca hardware se mantiene cerca de la frontera de hardware.
Por ejemplo, drv-virtio-block tiene helpers volátiles para MMIO y DMA. Eso es
esperable en un driver de bloque. El punto importante es que este código se
ejecuta en un driver de espacio de usuario con una superficie estrecha de
autoridad de dispositivo, no dentro de un gran kernel monolítico con autoridad
total sobre la máquina.
Rust ayuda dentro de ese driver, pero EriX sigue apoyándose en la arquitectura:
- capacidades de dispositivo explícitas
- memoria de dispositivo tipada como
CAP_TYPE_DEVICE_FRAME - endpoints estrechos de kernel-control
- aislamiento de drivers en espacio de usuario
- validación de arranque fail-closed
El lenguaje y el diseño del OS se refuerzan mutuamente.
Ninguno reemplaza al otro.
Rust encaja con el pensamiento de capacidades⌗
El modelo de propiedad de Rust no es lo mismo que un sistema de capacidades.
Un borrow de Rust no es una capacidad de EriX. Un tipo de Rust no es autoridad aplicada por el kernel. El compilador no puede saber si un proceso debería poder mapear un frame o enviar a un endpoint.
Aun así, Rust y las capacidades encajan bien.
Ambos fomentan la explicitud.
En EriX, un componente puede actuar solo cuando posee la capacidad correcta con los derechos correctos. En Rust, el código puede mutar datos solo cuando tiene el tipo correcto de acceso a esos datos.
Esa similitud es útil cultural y mecánicamente.
Fomenta APIs donde la autoridad se pasa como un valor, no se descubre mediante estado global. Fomenta que las funciones declaren qué necesitan. Fomenta que el código de arranque valide el bundle real de capacidades recibido en lugar de asumir que un número de slot significa permiso.
lib-capabi es un ejemplo de este estilo. No aplica autoridad en runtime, pero
define el lenguaje compartido de la autoridad: IDs de tipo de capacidad, flags
de derechos, constantes de slot, descriptores de transferencia, helpers de rol y
validación de arranque.
Como es no_std y deny(unsafe_code), este vocabulario de autoridad puede
usarse ampliamente sin convertir cada consumidor en un consumidor de memoria
raw.
La comparación con C⌗
C da a un kernel control máximo con coste mínimo de abstracción.
Esa es su fortaleza.
También es su debilidad.
En C, una tabla de capacidades puede indexarse fuera de límites. Un buffer de mensaje puede copiarse con la longitud equivocada. Una estructura empaquetada en disco puede leerse mediante un puntero no alineado. Un puntero puede vivir más que el almacenamiento que describe. Un overflow entero puede convertirse en una asignación más pequeña. Una función puede mutar estado accidentalmente mediante un alias que el llamador no esperaba.
Todo esto puede evitarse en C.
Se evita con revisión, convención, análisis estático, sanitizers, fuzzing y disciplina.
Esas herramientas importan. EriX seguiría necesitando revisión, tests y fuzzing si estuviera escrito enteramente en Rust.
La diferencia es que Rust mueve muchas de esas comprobaciones al modelo por defecto del lenguaje.
Cuando EriX parsea una imagen de arranque, recorre una tabla de transferencias,
construye un mensaje IPC o valida un sobre de handoff, la representación
ordinaria es un slice, enum, iterador, operación entera comprobada o Result
estructurado.
En C, la representación ordinaria sería a menudo un puntero, una longitud, un cast y una promesa.
Las promesas no carecen de valor.
Pero los kernels acumulan promesas rápidamente.
Rust reduce cuántas de esas promesas deben ser confiadas.
Rust no elimina los bugs de diseño⌗
Rust no es una prueba de seguridad.
No impide que se conceda la capacidad equivocada.
No impide que un endpoint tenga derechos demasiado amplios.
No decide si rootd, procd o deviced debe poseer una decisión de política.
No hace automáticamente que un parser rechace todo archivo semánticamente inválido.
No elimina canales laterales.
No vuelve coherente el DMA.
No hace sencillo el orden de interrupciones.
No hace justo al scheduler.
Por eso EriX sigue enfatizando la arquitectura:
- TCB pequeña
- capacidades explícitas
- endpoints estrechos
- arranque determinista
- comportamiento fail-closed
- dependencias clean-room
- servicios de espacio de usuario
- autoridad de drivers específica por rol
Rust mejora el sustrato de implementación. No reemplaza el modelo de seguridad.
Panic no es una estrategia de error⌗
El código de kernel necesita un modelo claro de fallos.
En EriX, los fallos recuperables procedentes de input externo deberían devolverse como errores. Datos de handoff mal formados, versiones no soportadas, descriptores de transferencia inválidos, tipos de endpoint incorrectos y autoridad ausente no deberían repararse silenciosamente.
Deben fallar explícitamente.
Panic se reserva para violaciones de invariantes internos, no para validación ordinaria de input.
Esto importa porque Rust hace fácil panic, pero un kernel no puede tratar panic como un mecanismo normal de control de flujo. Un parser en la TCB no debería hacer panic ante input mal formado. Una ruta de arranque no debería unwinding accidentalmente a través del estado de firmware. Un servicio runtime no debería tratar input controlado por un atacante como razón para entrar en una ruta de recuperación indefinida.
El patrón útil en Rust es:
- validar antes de confiar
- devolver
Result - mantener explícitas las variantes de error
- reservar panic para estados internos imposibles
- fallar cerrado cuando el estado de seguridad es ambiguo
Ese patrón aparece en todas las crates de parser y handoff de EriX.
Beneficios para las pruebas⌗
Rust también ayuda a las pruebas.
El kernel de EriX es una crate no_std, pero gran parte de su lógica aún puede
probarse en el host con soporte #[cfg(test)].
Esto es útil para código que no debería requerir un arranque de VM solo para validar un invariante puro:
- comprobaciones de límites de parsers
- validación de handoff
- parsing de descriptores de capacidad
- lógica de máscaras de derechos
- tablas de política de slots
- transiciones de estado del scheduler
- modelos blandos de context switch
Las partes específicas de arquitectura siguen necesitando tests de integración. Assembly inline, manipulación de tablas de páginas, entrega de interrupciones y transiciones a modo usuario deben probarse en el entorno donde realmente se ejecutan.
Pero Rust facilita mantener pura la lógica pura.
Eso tiene valor en un microkernel, porque muchos componentes pueden reducirse a pequeñas máquinas de estado deterministas alrededor de mensajes y capacidades explícitos.
Por qué esto importa para EriX⌗
EriX no usa Rust porque Rust esté de moda.
Lo usa porque los objetivos del proyecto encajan con las fortalezas del lenguaje:
- reducir la inseguridad de memoria en código confiable
- hacer visibles propiedad y mutación
- aislar acceso raw al hardware detrás de fronteras estrechas
- mantener el código de parser seguro, determinista y auditable
- expresar datos de protocolo y autoridad con estructuras tipadas
- soportar código
no_stdcompartido entre arranque, kernel y servicios de espacio de usuario - hacer que muchos bugs sean más difíciles de escribir desde el principio
El código unsafe restante sigue siendo serio.
Debe revisarse con tanto cuidado como se revisaría C. La diferencia es que EriX puede concentrar ese escrutinio en los lugares donde la máquina realmente exige control raw: código de entrada, configuración de CPU, tablas de páginas, syscalls, context switches, MMIO, DMA y port I/O.
Ese es el valor práctico.
Rust no hace seguro al kernel.
Hace que las partes unsafe destaquen.
Mirando adelante⌗
El siguiente paso es seguir la ejecución desde el primer código cargado por el firmware hasta el kernel.
Esa ruta es donde se encuentran muchas ideas de las publicaciones anteriores: la TCB, fronteras de Rust, parsers validados, imágenes de arranque firmadas, estructuras de handoff y la primera transferencia de autoridad de máquina a objetos explícitos del kernel.
La próxima publicación recorrerá el proceso de arranque de EriX desde firmware hasta kernel: el punto de partida UEFI, el modelo de handoff del bootloader y por qué el kernel se entra como ejecutable higher-half.