Pourquoi Rust pour le développement de noyaux
Les systèmes d’exploitation sont généralement associés au C.
Cette association est compréhensible. C est petit, prévisible, proche de la machine, et historiquement dominant dans le travail sur les noyaux. Il donne au programmeur un accès direct à la mémoire, aux registres, aux layouts et aux conventions d’appel.
Ce sont exactement les choses dont un noyau a besoin.
Ce sont aussi exactement les choses qui rendent les noyaux difficiles à sécuriser.
EriX est écrit principalement en Rust parce que le projet repose sur une idée centrale :
L’autorité doit être explicite.
Cela s’applique aux capacités, aux structures de handoff de démarrage, aux messages IPC, aux objets mémoire et à l’accès aux périphériques. Cela s’applique aussi au langage d’implémentation. Rust ne rend pas le développement de noyau automatiquement sûr, mais il donne au code une façon de rendre visibles la propriété, la mutation, les lifetimes et l’insécurité.
Cette visibilité compte.
Le noyau est un endroit inconfortable pour les bugs mémoire⌗
Les bugs mémoire sont mauvais partout.
Dans un noyau, ils sont pires.
Un use-after-free dans une application ordinaire peut corrompre cette application. Un use-after-free dans le noyau peut corrompre l’état du scheduler, les tables de capacités, les espaces d’adressage ou les mappings de périphériques.
Une erreur de bornes dans un parser en espace utilisateur peut faire planter un processus. Une erreur de bornes dans un parser de démarrage peut affecter le code que la machine exécutera ensuite.
Un mauvais pointeur dans une application est un bug local. Un mauvais pointeur dans un noyau peut être un bug d’autorité.
C’est pourquoi la sécurité mémoire n’est pas une préoccupation cosmétique pour EriX. Le noyau fait partie de la base de calcul de confiance. Le bootloader fait partie de la base de calcul de confiance. Plusieurs bibliothèques de parsing et d’ABI font partie de la TCB ou sont adjacentes à la TCB.
Si ces composants manipulent mal la mémoire, le modèle d’isolation peut échouer avant même qu’une vérification de capacité puisse aider.
Ce que Rust apporte à un noyau⌗
Rust donne à un noyau plusieurs bons défauts.
Il donne des slices vérifiés aux bornes plutôt que des paires pointeur-longueur non vérifiées dans le code ordinaire.
Il donne des règles de propriété qui rendent l’aliasing accidentel plus difficile.
Il donne des lifetimes qui peuvent exprimer des vues empruntées sur des images de démarrage, des binaires ELF, des blobs de handoff et des buffers IPC sans prétendre que ces vues possèdent le stockage sous-jacent.
Il donne Result et les enums comme outils ordinaires pour rendre explicites
les chemins d’erreur.
Il donne un système de types assez fort pour distinguer de nombreux états que du code C représenterait souvent par des commentaires, des flags ou des conventions.
Rien de cela ne supprime le besoin de conception du noyau.
Rust ne décidera pas ce qu’une capacité signifie. Il ne décidera pas quel service doit recevoir un endpoint de périphérique. Il ne prouvera pas qu’un protocole IPC est correct. Il n’empêchera pas toutes les races dans un scheduler.
Mais il change le comportement par défaut.
En C, le comportement par défaut est que presque toute fonction peut faire de l’arithmétique de pointeurs non vérifiée, aliaser de la mémoire mutable, réinterpréter des octets comme des structures et déborder silencieusement si le programmeur n’est pas discipliné partout.
En Rust, le code ordinaire ne peut pas faire ces choses sans passer par
unsafe ou par une opération vérifiée explicite.
C’est une différence de sécurité pratique.
no_std est l’environnement normal⌗
Le Rust de noyau n’est pas du Rust d’application ordinaire.
La crate du noyau EriX est #![no_std]. Elle ne s’exécute pas au-dessus d’un
système d’exploitation. Elle est le substrat du système d’exploitation.
Beaucoup de bibliothèques EriX sont aussi no_std, notamment :
lib-bootimglib-elflib-handofflib-ipclib-capabilib-bootstraplib-consolelib-devicelib-driverlib-time
Plusieurs de ces crates utilisent aussi #![deny(unsafe_code)].
Cette combinaison est importante. Elle signifie que les mêmes bibliothèques peuvent être utilisées dans le boot, le noyau et des services freestanding sans dépendre des services de l’OS hôte. Elle signifie aussi que le code de parser et d’ABI peut souvent être écrit sans pointeurs raw.
C’est l’un des meilleurs ajustements de Rust à EriX.
Le projet a beaucoup de petites crates dont le rôle n’est pas de toucher directement le matériel. Leur rôle est de parser, valider, encoder, décoder et rejeter. Ce sont exactement les tâches où le Rust sûr est précieux.
Les parsers sûrs sont des frontières de sécurité⌗
EriX traite les parsers comme des frontières de sécurité.
lib-bootimg parse et vérifie boot.img. Son chemin de lecture fait partie de
la chaîne de confiance du boot. Il valide la structure, l’alignement, les
bornes, les hashes et les signatures avant que le bootloader fasse confiance à
l’image.
lib-elf parse les binaires ELF64 avant que le bootloader utilise les segments
de chargement.
lib-handoff valide les structures de handoff versionnées entre les étapes de
boot et entre le noyau et rootd.
lib-capabi définit les types de capacités, les droits, les constantes de slot,
les descripteurs de transfert et les helpers de validation de démarrage.
Ces crates sont volontairement ennuyeuses.
Elles utilisent des slices d’octets. Elles utilisent de l’arithmétique vérifiée. Elles retournent des erreurs structurées. Elles rejettent les versions non supportées, les mauvaises tailles, les mauvais offsets et les tables mal formées.
Elles ne font pas d’I/O. Elles ne possèdent pas d’autorité runtime. Elles ne devinent pas.
Ce design serait possible en C, mais il demanderait une discipline constante :
- chaque pointeur doit être vérifié
- chaque offset doit être vérifié contre des bornes
- chaque opération entière doit être auditée pour l’overflow
- chaque vue retournée doit être liée manuellement au lifetime du buffer d’entrée
Rust rend ces règles moins coûteuses à maintenir.
Par exemple, une image de boot parsée peut emprunter le slice d’octets d’origine. Un segment ELF validé peut exposer des octets empruntés au lieu de fabriquer un nouveau pointeur non vérifié. Un parser de handoff peut retourner un itérateur qui vérifie la plage de chaque entrée avant de la parser.
Le langage ne prouve pas que le parser est sémantiquement parfait.
Il retire en revanche une grande classe d’erreurs mémoire accidentelles du chemin normal du parser.
unsafe ne disparaît pas⌗
Le développement de noyau ne peut pas être entièrement du Rust sûr.
L’interface matérielle n’est pas type-safe. Les registres CPU, les tables de pages, les descripteurs d’interruption, le port I/O, le MMIO, les buffers DMA et les pointeurs d’entrée de boot n’arrivent pas sous forme de valeurs Rust amicales.
À un moment, le noyau doit dire :
Je sais ce que signifie cette adresse raw.
Cette déclaration est unsafe.
EriX a du unsafe aux endroits où la machine l’impose :
- le code d’entrée de boot convertit un pointeur et une longueur raw de handoff en slice d’octets
- le glue syscall x86_64 utilise de l’assembly inline et une ABI de registres fixe
- le context switch utilise de l’assembly spécifique à l’architecture
- GDT, IDT, TSS et l’initialisation syscall touchent l’état descripteur du CPU
- la manipulation des tables de pages utilise des lectures et écritures volatiles
- les chemins série et legacy utilisent le port I/O
- les drivers MMIO et DMA utilisent des accesseurs volatiles
- quelques runtimes mono-thread utilisent
UnsafeCellderrière des buffers statiques documentés
Cette liste n’est pas un échec de Rust.
C’est la frontière honnête entre le langage et la machine.
L’objectif n’est pas de n’avoir aucun unsafe nulle part. L’objectif est de
garder unsafe petit, local, documenté et entouré d’interfaces sûres.
Les frontières unsafe doivent être étroites⌗
La question importante n’est pas :
Le code contient-il
unsafe?
La question importante est :
Où est le
unsafe, et quel invariant le rend valide ?
Dans EriX, le pont syscall raw dans ipc-syscall_x86_64 est un bon exemple.
La crate expose des wrappers sûrs comme ipc_call, ipc_recv, ipc_reply,
query_local_cap et drop_local_cap. En interne, elle a une seule frontière
d’assembly raw qui mappe les arguments vers l’ABI de registres syscall x86_64 et
retourne les valeurs raw des registres.
Le wrapper ne déréférence pas lui-même les pointeurs utilisateur. Il passe les valeurs pointeur et longueur au noyau, où les vérifications de capacités, d’endpoints et de longueur de message ont lieu.
C’est la bonne forme :
- l’assembly raw est isolé
- l’ABI est documentée
- l’API publique est typée
- la validation sémantique reste dans le noyau
La frontière de context switch du noyau suit le même modèle. Il y a de l’assembly
spécifique à l’architecture pour sauvegarder et restaurer les registres, mais le
reste du scheduler peut travailler avec une structure KernelContext et un
trait ContextSwitcher. Les tests hôte peuvent utiliser un switcher logiciel
qui modélise le passage d’état sans vraiment faire sauter le CPU.
Cette séparation compte parce qu’elle garde la plupart de la logique du scheduler testable sans exécuter d’instructions privilégiées.
L’accès matériel reste de l’accès matériel⌗
Rust ne rend pas le MMIO sûr à lui seul.
Si un driver écrit la mauvaise valeur dans le mauvais registre, Rust ne sauvera pas le périphérique. Si un buffer DMA est décrit incorrectement, le matériel peut toujours écrire en mémoire. Si une entrée de table de pages est fausse, le CPU appliquera le mauvais mapping très efficacement.
EriX traite cela de deux façons.
D’abord, l’autorité matérielle est rendue explicite. Un driver ne devrait pas recevoir une autorité large sur la machine. Il devrait recevoir seulement le device frame, la plage I/O, la ligne d’interruption ou la famille d’endpoints dont il a besoin.
Ensuite, le code unsafe qui touche le matériel reste près de la frontière matérielle.
Par exemple, drv-virtio-block a des helpers MMIO et DMA volatiles. C’est
attendu pour un driver bloc. Le point important est que ce code s’exécute dans
un driver en espace utilisateur avec une surface étroite d’autorité de
périphérique, pas dans un grand noyau monolithique avec pleine autorité machine.
Rust aide dans ce driver, mais EriX s’appuie encore sur l’architecture :
- capacités de périphérique explicites
- mémoire de périphérique typée comme
CAP_TYPE_DEVICE_FRAME - endpoints kernel-control étroits
- isolation des drivers en espace utilisateur
- validation de démarrage fail-closed
Le langage et le design de l’OS se renforcent mutuellement.
Aucun ne remplace l’autre.
Rust correspond à la pensée par capacités⌗
Le modèle de propriété de Rust n’est pas la même chose qu’un système de capacités.
Un borrow Rust n’est pas une capacité EriX. Un type Rust n’est pas une autorité enforcée par le noyau. Le compilateur ne peut pas savoir si un processus devrait avoir le droit de mapper un frame ou d’envoyer vers un endpoint.
Pourtant, Rust et les capacités s’accordent bien.
Les deux encouragent l’explicite.
Dans EriX, un composant ne peut agir que lorsqu’il possède la bonne capacité avec les bons droits. En Rust, le code ne peut muter des données que lorsqu’il a le bon type d’accès à ces données.
Cette similarité est utile culturellement et mécaniquement.
Elle encourage des API où l’autorité est passée comme valeur, non découverte via un état global. Elle encourage les fonctions à déclarer ce dont elles ont besoin. Elle encourage le code de démarrage à valider le bundle réel de capacités reçu au lieu de supposer qu’un numéro de slot signifie permission.
lib-capabi est un exemple de ce style. Elle n’enforce pas l’autorité au
runtime, mais elle définit le langage partagé de l’autorité : IDs de types de
capacités, flags de droits, constantes de slot, descripteurs de transfert,
helpers de rôle et validation de démarrage.
Parce qu’elle est no_std et deny(unsafe_code), ce vocabulaire d’autorité peut
être utilisé largement sans transformer chaque consommateur en consommateur de
mémoire raw.
La comparaison avec C⌗
C donne à un noyau un contrôle maximal avec un coût d’abstraction minimal.
C’est sa force.
C’est aussi sa faiblesse.
En C, une table de capacités peut être indexée hors bornes. Un buffer de message peut être copié avec la mauvaise longueur. Une structure packée sur disque peut être lue via un pointeur non aligné. Un pointeur peut vivre plus longtemps que le stockage qu’il décrit. Un overflow entier peut se transformer en allocation trop petite. Une fonction peut muter accidentellement l’état via un alias que l’appelant n’attendait pas.
Tout cela peut être évité en C.
C’est évité par la revue, les conventions, l’analyse statique, les sanitizers, le fuzzing et la discipline.
Ces outils comptent. EriX aurait encore besoin de revue, de tests et de fuzzing s’il était écrit entièrement en Rust.
La différence est que Rust déplace beaucoup de ces vérifications dans le modèle par défaut du langage.
Quand EriX parse une image de boot, parcourt une table de transferts, construit
un message IPC ou valide une enveloppe de handoff, la représentation ordinaire
est un slice, une enum, un itérateur, une opération entière vérifiée ou un
Result structuré.
En C, la représentation ordinaire serait souvent un pointeur, une longueur, un cast et une promesse.
Les promesses ne sont pas sans valeur.
Mais les noyaux accumulent vite les promesses.
Rust réduit le nombre de ces promesses qui doivent être confiées.
Rust ne supprime pas les bugs de conception⌗
Rust n’est pas une preuve de sécurité.
Il n’empêche pas d’accorder la mauvaise capacité.
Il n’empêche pas un endpoint d’avoir des droits trop larges.
Il ne décide pas si rootd, procd ou deviced doit posséder une décision de
politique.
Il ne rend pas automatiquement un parser capable de rejeter chaque fichier sémantiquement invalide.
Il ne supprime pas les canaux auxiliaires.
Il ne rend pas le DMA cohérent.
Il ne rend pas l’ordre des interruptions simple.
Il ne rend pas le scheduler équitable.
C’est pourquoi EriX met toujours l’accent sur l’architecture :
- petite TCB
- capacités explicites
- endpoints étroits
- démarrage déterministe
- comportement fail-closed
- dépendances clean-room
- services en espace utilisateur
- autorité de driver spécifique au rôle
Rust améliore le substrat d’implémentation. Il ne remplace pas le modèle de sécurité.
Panic n’est pas une stratégie d’erreur⌗
Le code de noyau a besoin d’un modèle d’échec clair.
Dans EriX, les échecs récupérables venant d’entrées externes doivent être retournés comme erreurs. Les données de handoff mal formées, les versions non supportées, les descripteurs de transfert invalides, les mauvais types d’endpoint et l’autorité manquante ne doivent pas être réparés silencieusement.
Ils doivent échouer explicitement.
Panic est réservé aux violations d’invariants internes, pas à la validation ordinaire d’entrées.
C’est important parce que Rust rend panic facile, mais un noyau ne peut pas traiter panic comme un mécanisme normal de contrôle de flux. Un parser dans la TCB ne devrait pas paniquer sur une entrée mal formée. Un chemin de boot ne devrait pas unwinder accidentellement à travers l’état du firmware. Un service runtime ne devrait pas traiter une entrée contrôlée par un attaquant comme une raison d’entrer dans un chemin de récupération indéfini.
Le modèle Rust utile est :
- valider avant de faire confiance
- retourner
Result - garder les variantes d’erreur explicites
- réserver panic aux états internes impossibles
- échouer fermé quand l’état de sécurité est ambigu
Ce modèle apparaît dans les crates de parser et de handoff d’EriX.
Bénéfices pour les tests⌗
Rust aide aussi les tests.
Le noyau EriX est une crate no_std, mais une grande partie de sa logique peut
encore être testée sur l’hôte avec le support #[cfg(test)].
C’est utile pour du code qui ne devrait pas nécessiter un démarrage de VM juste pour valider un invariant pur :
- vérifications de bornes des parsers
- validation de handoff
- parsing de descripteurs de capacité
- logique de masques de droits
- tables de politique de slots
- transitions d’état du scheduler
- modèles logiciels de context switch
Les parties spécifiques à l’architecture ont toujours besoin de tests d’intégration. L’assembly inline, la manipulation des tables de pages, la livraison d’interruptions et les transitions en mode utilisateur doivent être testés dans l’environnement où ils s’exécutent réellement.
Mais Rust facilite le maintien de la logique pure comme logique pure.
C’est précieux dans un microkernel, parce que beaucoup de composants peuvent être réduits à de petites machines à états déterministes autour de messages et de capacités explicites.
Pourquoi cela compte pour EriX⌗
EriX n’utilise pas Rust parce que Rust est à la mode.
Il l’utilise parce que les objectifs du projet correspondent aux forces du langage :
- réduire l’insécurité mémoire dans le code de confiance
- rendre visibles la propriété et la mutation
- isoler l’accès matériel raw derrière des frontières étroites
- garder le code de parser sûr, déterministe et auditable
- exprimer les données de protocole et d’autorité avec des structures typées
- supporter du code
no_stdpartagé entre boot, noyau et services en espace utilisateur - rendre beaucoup de bugs plus difficiles à écrire dès le départ
Le code unsafe restant est toujours sérieux.
Il doit être revu aussi soigneusement que du C. La différence est qu’EriX peut concentrer cet examen sur les endroits où la machine impose vraiment le contrôle raw : code d’entrée, configuration CPU, tables de pages, syscalls, context switches, MMIO, DMA et port I/O.
C’est la valeur pratique.
Rust ne rend pas le noyau sûr.
Il fait ressortir les parties unsafe.
Pour la suite⌗
L’étape suivante est de suivre l’exécution depuis le premier code chargé par le firmware jusqu’au noyau.
Ce chemin est l’endroit où beaucoup d’idées des articles précédents se rencontrent : la TCB, les frontières Rust, les parsers validés, les images de boot signées, les structures de handoff et le premier transfert d’autorité machine vers des objets explicites du noyau.
Le prochain article parcourra le processus de démarrage d’EriX du firmware au noyau : le point de départ UEFI, le modèle de handoff du bootloader, et pourquoi le noyau est entré comme exécutable higher-half.