Per què Rust per al desenvolupament de kernels
Els sistemes operatius se solen associar amb C.
Aquesta associació és comprensible. C és petit, previsible, proper a la màquina i històricament dominant en el treball de kernel. Dona al programador accés directe a memòria, registres, layout i convencions de crida.
Això és exactament el que necessita un kernel.
També és exactament el que fa que els kernels siguin difícils d’assegurar.
EriX està escrit principalment en Rust perquè el projecte es construeix al voltant d’una idea central:
L’autoritat ha de ser explícita.
Això s’aplica a capacitats, estructures de handoff d’arrencada, missatges IPC, objectes de memòria i accés a dispositius. També s’aplica al llenguatge d’implementació. Rust no fa que el desenvolupament de kernels sigui automàticament segur, però dona al codi una manera de fer visibles propietat, mutació, lifetimes i inseguretat.
Aquesta visibilitat importa.
El kernel és un lloc incòmode per als bugs de memòria⌗
Els bugs de memòria són dolents a tot arreu.
En un kernel són pitjors.
Un use-after-free en una aplicació ordinària pot corrompre aquella aplicació. Un use-after-free en el kernel pot corrompre l’estat del scheduler, taules de capacitats, espais d’adreces o mappings de dispositiu.
Un error de límits en un parser d’espai d’usuari pot fer caure un procés. Un error de límits en un parser d’arrencada pot afectar quin codi executa després la màquina.
Un punter dolent en una aplicació és un bug local. Un punter dolent en un kernel pot ser un bug d’autoritat.
Per això la seguretat de memòria no és una preocupació cosmètica per a EriX. El kernel forma part de la base de còmput de confiança. El bootloader forma part de la base de còmput de confiança. Diverses biblioteques de parser i ABI són TCB o properes a la TCB.
Si aquests components gestionen malament la memòria, el model d’aïllament pot fallar abans que cap comprovació de capacitat pugui ajudar.
Què aporta Rust a un kernel⌗
Rust dona a un kernel diversos valors per defecte útils.
Dona slices amb comprovació de límits en lloc de parelles punter-longitud sense comprovació en el codi ordinari.
Dona regles de propietat que fan més difícil l’aliasing accidental.
Dona lifetimes que poden expressar vistes prestades a imatges d’arrencada, binaris ELF, blobs de handoff i buffers IPC sense fingir que aquestes vistes posseeixen l’emmagatzematge subjacent.
Dona Result i enums com a eines normals per fer explícites les rutes d’error.
Dona un sistema de tipus prou fort per distingir molts estats que el codi C sovint representaria com a comentaris, flags o convencions.
Res d’això elimina la necessitat de dissenyar el kernel.
Rust no decidirà què significa una capacitat. No decidirà quin servei ha de rebre un endpoint de dispositiu. No demostrarà que un protocol IPC sigui correcte. No evitarà totes les curses en un scheduler.
Però canvia el valor per defecte.
En C, el valor per defecte és que gairebé qualsevol funció pot fer aritmètica de punters sense comprovació, aliasar memòria mutable, reinterpretar bytes com a estructures i desbordar silenciosament si el programador no és disciplinat a tot arreu.
En Rust, el codi ordinari no pot fer aquestes coses sense passar per unsafe o
per una operació comprovada explícita.
Aquesta és una diferència de seguretat pràctica.
no_std és l’entorn normal⌗
Rust de kernel no és Rust d’aplicació ordinària.
La crate del kernel d’EriX és #![no_std]. No s’executa damunt d’un sistema
operatiu. És el substrat del sistema operatiu.
Moltes biblioteques d’EriX també són no_std, incloses:
lib-bootimglib-elflib-handofflib-ipclib-capabilib-bootstraplib-consolelib-devicelib-driverlib-time
Diverses d’aquestes crates també usen #![deny(unsafe_code)].
Aquesta combinació és important. Vol dir que les mateixes biblioteques es poden usar en contextos d’arrencada, kernel i serveis freestanding sense dependre de serveis de l’OS host. També vol dir que el codi de parser i ABI sovint es pot escriure sense punters raw.
Aquest és un dels encaixos més forts de Rust per a EriX.
El projecte té moltes crates petites que no tenen com a feina tocar hardware directament. La seva feina és parsejar, validar, codificar, decodificar i rebutjar. Aquestes són exactament les feines on Rust segur és valuós.
Els parsers segurs són fronteres de seguretat⌗
EriX tracta els parsers com a fronteres de seguretat.
lib-bootimg parseja i verifica boot.img. La seva ruta de lectura forma part
de la cadena de confiança d’arrencada. Valida estructura, alineació, límits,
hashes i signatures abans que el bootloader confiï en la imatge.
lib-elf parseja binaris ELF64 abans que el bootloader faci servir segments de
càrrega.
lib-handoff valida estructures de handoff versionades entre etapes
d’arrencada i entre el kernel i rootd.
lib-capabi defineix tipus de capacitat, drets, constants de slot,
descriptors de transferència i helpers de validació d’arrencada.
Aquestes crates són deliberadament avorrides.
Usen slices de bytes. Usen aritmètica comprovada. Retornen errors estructurats. Rebutgen versions no suportades, mides dolentes, offsets dolents i taules mal formades.
No fan I/O. No posseeixen autoritat runtime. No endevinen.
Aquest disseny seria possible en C, però exigiria disciplina sostinguda:
- cada punter s’ha de comprovar
- cada offset s’ha de validar contra rangs
- cada operació entera s’ha d’auditar per overflow
- cada vista retornada s’ha de lligar manualment al lifetime del buffer d’entrada
Rust fa que aquestes regles siguin més barates de mantenir.
Per exemple, una imatge d’arrencada parsejada pot prendre prestat del slice de bytes original. Un segment de càrrega ELF validat pot exposar bytes prestats en lloc de fabricar un punter nou sense comprovació. Un parser de handoff pot retornar un iterador que comprova el rang de cada entrada abans de parsejar-la.
El llenguatge no demostra que el parser sigui semànticament perfecte.
Sí que elimina una gran classe d’errors accidentals de memòria de la ruta normal del parser.
unsafe no desapareix⌗
El desenvolupament de kernels no pot ser Rust segur completament.
La interfície amb el hardware no és type-safe. Registres de CPU, taules de pàgines, descriptors d’interrupció, port I/O, MMIO, buffers DMA i punters d’entrada d’arrencada no arriben com a valors Rust amables.
En algun moment el kernel ha de dir:
Sé què significa aquesta adreça raw.
Aquesta declaració és unsafe.
EriX té unsafe als llocs on la màquina ho força:
- el codi d’entrada d’arrencada converteix un punter i una longitud raw de handoff en un slice de bytes
- el glue syscall x86_64 usa assembly inline i una ABI de registres fixa
- el context switch usa assembly específic d’arquitectura
- GDT, IDT, TSS i inicialització syscall toquen estat descriptor de CPU
- la manipulació de taules de pàgines usa lectures i escriptures volàtils
- les rutes serials i de dispositius legacy usen port I/O
- drivers amb MMIO i DMA usen accessors volàtils
- alguns runtimes d’un sol fil usen
UnsafeCelldarrere de buffers estàtics documentats
Aquesta llista no és un fracàs de Rust.
És la frontera honesta entre el llenguatge i la màquina.
L’objectiu no és no tenir unsafe enlloc. L’objectiu és mantenir unsafe
petit, local, documentat i envoltat d’interfícies segures.
Les fronteres unsafe han de ser estretes⌗
La pregunta important no és:
El codebase conté
unsafe?
La pregunta important és:
On és l’
unsafe, i quin invariant el fa vàlid?
En EriX, el pont syscall raw a ipc-syscall_x86_64 és un bon exemple.
La crate exposa funcions wrapper segures com ipc_call, ipc_recv,
ipc_reply, query_local_cap i drop_local_cap. Internament té una sola
frontera d’assembly raw que mapeja arguments a l’ABI de registres syscall
x86_64 i retorna valors raw de registres.
El wrapper no desreferencia punters d’usuari per si mateix. Passa valors de punter i longitud al kernel, on es fan les comprovacions de capacitat, endpoint i longitud de missatge.
Aquesta és la forma correcta:
- l’assembly raw queda aïllat
- l’ABI està documentada
- l’API pública és tipada
- la validació semàntica continua al kernel
La frontera de context switch del kernel segueix el mateix patró. Hi ha
assembly específic d’arquitectura per guardar i restaurar registres, però la
resta del scheduler pot treballar amb una estructura KernelContext i un trait
ContextSwitcher. Les proves de host poden usar un switcher tou que modela el
traspàs d’estat sense saltar realment la CPU.
Aquesta divisió importa perquè manté testeable la major part de la lògica del scheduler sense executar instruccions privilegiades.
L’accés al hardware continua sent accés al hardware⌗
Rust no fa que MMIO sigui segur per si sol.
Si un driver escriu el valor equivocat al registre equivocat, Rust no salvarà el dispositiu. Si un buffer DMA es descriu incorrectament, el hardware encara pot escriure a memòria. Si una entrada de taula de pàgines és incorrecta, la CPU aplicarà el mapping incorrecte amb molta eficiència.
EriX ho gestiona de dues maneres.
Primer, l’autoritat de hardware es fa explícita. Un driver no hauria de rebre autoritat àmplia sobre la màquina. Hauria de rebre només el device frame, rang I/O, línia d’interrupció o família d’endpoints que necessita.
Segon, el codi unsafe que toca hardware es manté prop de la frontera de hardware.
Per exemple, drv-virtio-block té helpers volàtils per a MMIO i DMA. Això és
normal en un driver de bloc. El punt important és que aquest codi s’executa en
un driver d’espai d’usuari amb una superfície estreta d’autoritat de dispositiu,
no dins d’un gran kernel monolític amb autoritat completa sobre la màquina.
Rust ajuda dins d’aquest driver, però EriX encara es recolza en l’arquitectura:
- capacitats de dispositiu explícites
- memòria de dispositiu tipada com
CAP_TYPE_DEVICE_FRAME - endpoints estrets de kernel-control
- aïllament de drivers en espai d’usuari
- validació d’arrencada fail-closed
El llenguatge i el disseny de l’OS es reforcen mútuament.
Cap dels dos substitueix l’altre.
Rust encaixa amb el pensament de capacitats⌗
El model de propietat de Rust no és el mateix que un sistema de capacitats.
Un borrow de Rust no és una capacitat d’EriX. Un tipus de Rust no és autoritat aplicada pel kernel. El compilador no pot saber si un procés hauria de poder mapar un frame o enviar a un endpoint.
Tot i així, Rust i les capacitats encaixen bé.
Tots dos fomenten l’explicitud.
En EriX, un component només pot actuar quan té la capacitat correcta amb els drets correctes. En Rust, el codi només pot mutar dades quan té el tipus correcte d’accés a aquestes dades.
Aquesta semblança és útil culturalment i mecànicament.
Fomenta APIs on l’autoritat es passa com a valor, no es descobreix mitjançant estat global. Fomenta que les funcions declarin què necessiten. Fomenta que el codi d’arrencada validi el bundle real de capacitats rebut en lloc d’assumir que un número de slot significa permís.
lib-capabi és un exemple d’aquest estil. No aplica autoritat en runtime, però
defineix el llenguatge compartit de l’autoritat: IDs de tipus de capacitat,
flags de drets, constants de slot, descriptors de transferència, helpers de rol
i validació d’arrencada.
Com que és no_std i deny(unsafe_code), aquest vocabulari d’autoritat es pot
usar àmpliament sense convertir cada consumidor en un consumidor de memòria raw.
La comparació amb C⌗
C dona a un kernel control màxim amb cost mínim d’abstracció.
Aquesta és la seva fortalesa.
També és la seva feblesa.
En C, una taula de capacitats es pot indexar fora de límits. Un buffer de missatge es pot copiar amb la longitud equivocada. Una estructura empaquetada en disc es pot llegir mitjançant un punter no alineat. Un punter pot viure més que l’emmagatzematge que descriu. Un overflow enter pot convertir-se en una assignació més petita. Una funció pot mutar estat accidentalment mitjançant un àlies que el cridador no esperava.
Tot això es pot evitar en C.
S’evita amb revisió, convenció, anàlisi estàtica, sanitizers, fuzzing i disciplina.
Aquestes eines importen. EriX encara necessitaria revisió, tests i fuzzing si estigués escrit completament en Rust.
La diferència és que Rust mou moltes d’aquestes comprovacions al model per defecte del llenguatge.
Quan EriX parseja una imatge d’arrencada, recorre una taula de transferències,
construeix un missatge IPC o valida un sobre de handoff, la representació
ordinària és un slice, enum, iterador, operació entera comprovada o Result
estructurat.
En C, la representació ordinària seria sovint un punter, una longitud, un cast i una promesa.
Les promeses no són inútils.
Però els kernels acumulen promeses ràpidament.
Rust redueix quantes d’aquestes promeses s’han de confiar.
Rust no elimina els bugs de disseny⌗
Rust no és una prova de seguretat.
No impedeix que es concedeixi la capacitat equivocada.
No impedeix que un endpoint tingui drets massa amplis.
No decideix si rootd, procd o deviced ha de posseir una decisió de
política.
No fa automàticament que un parser rebutgi cada fitxer semànticament invàlid.
No elimina canals laterals.
No fa coherent el DMA.
No fa senzill l’ordre d’interrupcions.
No fa just el scheduler.
Per això EriX continua emfatitzant l’arquitectura:
- TCB petita
- capacitats explícites
- endpoints estrets
- arrencada determinista
- comportament fail-closed
- dependències clean-room
- serveis d’espai d’usuari
- autoritat de drivers específica per rol
Rust millora el substrat d’implementació. No substitueix el model de seguretat.
Panic no és una estratègia d’error⌗
El codi de kernel necessita un model de fallada clar.
En EriX, les fallades recuperables provinents d’input extern s’haurien de retornar com a errors. Dades de handoff mal formades, versions no suportades, descriptors de transferència invàlids, tipus d’endpoint incorrectes i autoritat absent no s’haurien de reparar silenciosament.
Han de fallar explícitament.
Panic es reserva per a violacions d’invariants interns, no per a validació ordinària d’input.
Això importa perquè Rust fa fàcil panic, però un kernel no pot tractar panic com un mecanisme normal de control de flux. Un parser a la TCB no hauria de fer panic davant input mal format. Una ruta d’arrencada no hauria de fer unwinding accidentalment a través de l’estat de firmware. Un servei runtime no hauria de tractar input controlat per un atacant com a raó per entrar en una ruta de recuperació indefinida.
El patró útil en Rust és:
- validar abans de confiar
- retornar
Result - mantenir explícites les variants d’error
- reservar panic per a estats interns impossibles
- fallar tancat quan l’estat de seguretat és ambigu
Aquest patró apareix a les crates de parser i handoff d’EriX.
Beneficis per a les proves⌗
Rust també ajuda les proves.
El kernel d’EriX és una crate no_std, però gran part de la seva lògica encara
es pot provar al host amb suport #[cfg(test)].
Això és útil per al codi que no hauria de requerir una arrencada de VM només per validar un invariant pur:
- comprovacions de límits de parsers
- validació de handoff
- parsing de descriptors de capacitat
- lògica de màscares de drets
- taules de política de slots
- transicions d’estat del scheduler
- models tous de context switch
Les parts específiques d’arquitectura encara necessiten tests d’integració. Assembly inline, manipulació de taules de pàgines, entrega d’interrupcions i transicions a mode usuari s’han de provar en l’entorn on realment s’executen.
Però Rust fa més fàcil mantenir pura la lògica pura.
Això és valuós en un microkernel, perquè molts components es poden reduir a petites màquines d’estat deterministes al voltant de missatges i capacitats explícits.
Per què això importa per a EriX⌗
EriX no usa Rust perquè Rust estigui de moda.
L’usa perquè els objectius del projecte encaixen amb les fortaleses del llenguatge:
- reduir la inseguretat de memòria en codi de confiança
- fer visibles propietat i mutació
- aïllar l’accés raw al hardware darrere de fronteres estretes
- mantenir el codi de parser segur, determinista i auditable
- expressar dades de protocol i autoritat amb estructures tipades
- suportar codi
no_stdcompartit entre arrencada, kernel i serveis d’espai d’usuari - fer que molts bugs siguin més difícils d’escriure d’entrada
El codi unsafe restant continua sent seriós.
S’ha de revisar amb tanta cura com es revisaria C. La diferència és que EriX pot concentrar aquest escrutini en els llocs on la màquina realment força control raw: codi d’entrada, configuració de CPU, taules de pàgines, syscalls, context switches, MMIO, DMA i port I/O.
Aquest és el valor pràctic.
Rust no fa segur el kernel.
Fa que les parts unsafe destaquin.
Mirant endavant⌗
El pas següent és seguir l’execució des del primer codi carregat pel firmware fins al kernel.
Aquesta ruta és on es troben moltes de les idees dels articles anteriors: la TCB, fronteres de Rust, parsers validats, imatges d’arrencada signades, estructures de handoff i la primera transferència d’autoritat de màquina cap a objectes explícits del kernel.
El proper article recorrerà el procés d’arrencada d’EriX des del firmware fins al kernel: el punt de partida UEFI, el model de handoff del bootloader i per què s’entra al kernel com un executable higher-half.