Varför Rust för kernelutveckling
Operativsystem förknippas oftast med C.
Det är begripligt. C är litet, förutsägbart, nära maskinen och historiskt dominerande i kernelarbete. Det ger programmeraren direkt åtkomst till minne, register, layout och anropskonventioner.
Det är exakt sådant en kernel behöver.
Det är också exakt sådant som gör kernels svåra att säkra.
EriX är huvudsakligen skrivet i Rust eftersom projektet byggs kring en central idé:
Auktoritet ska vara explicit.
Det gäller capabilities, boot-handoff-strukturer, IPC-meddelanden, minnesobjekt och enhetsåtkomst. Det gäller också implementationsspråket. Rust gör inte kernelutveckling automatiskt säker, men det ger kodbasen ett sätt att göra ägarskap, mutation, lifetimes och osäkerhet synliga.
Den synligheten spelar roll.
Kerneln är en obekväm plats för minnesbuggar⌗
Minnesbuggar är dåliga överallt.
I en kernel är de värre.
En use-after-free i en vanlig applikation kan korrumpera den applikationen. En use-after-free i kerneln kan korrumpera scheduler-tillstånd, capability-tabeller, adressrymder eller enhetsmappningar.
Ett gränsfel i en parser i användarrymd kan krascha en process. Ett gränsfel i en boot-parser kan påverka vilken kod maskinen kör härnäst.
En dålig pekare i en applikation är en lokal bugg. En dålig pekare i en kernel kan vara en auktoritetsbugg.
Därför är minnessäkerhet inte en kosmetisk fråga för EriX. Kerneln ingår i den betrodda beräkningsbasen. Bootloadern ingår i den betrodda beräkningsbasen. Flera parser- och ABI-bibliotek är TCB eller TCB-nära.
Om dessa komponenter hanterar minne fel kan isoleringsmodellen falla innan någon capability-kontroll hinner hjälpa.
Vad Rust ger en kernel⌗
Rust ger en kernel flera användbara standardlägen.
Det ger bounds-kontrollerade slices i stället för okontrollerade pekare-längd-par i vanlig kod.
Det ger ägarskapsregler som gör oavsiktlig aliasing svårare.
Det ger lifetimes som kan uttrycka lånade vyer in i bootbilder, ELF-binärer, handoff-blobbar och IPC-buffertar utan att låtsas att vyerna äger den underliggande lagringen.
Det ger Result och enums som normala verktyg för att göra felvägar explicita.
Det ger ett typsystem som är starkt nog att skilja många tillstånd som C-kod ofta skulle representera som kommentarer, flaggor eller konventioner.
Inget av detta tar bort behovet av kerneldesign.
Rust avgör inte vad en capability betyder. Det avgör inte vilken tjänst som ska få en enhets-endpoint. Det bevisar inte att ett IPC-protokoll är korrekt. Det förhindrar inte varje race i en scheduler.
Men det ändrar standardläget.
I C är standardläget att nästan varje funktion kan göra okontrollerad pekararitmetik, aliasa muterbart minne, tolka byte som strukturer och overflowa tyst om programmeraren inte är disciplinerad överallt.
I Rust kan vanlig kod inte göra sådant utan att passera unsafe eller en
explicit kontrollerad operation.
Det är en praktisk säkerhetsskillnad.
no_std är den normala miljön⌗
Kernel-Rust är inte vanlig applikations-Rust.
EriX kernel-crate är #![no_std]. Den kör inte ovanpå ett operativsystem. Den
är operativsystemets underlag.
Många EriX-bibliotek är också no_std, bland annat:
lib-bootimglib-elflib-handofflib-ipclib-capabilib-bootstraplib-consolelib-devicelib-driverlib-time
Flera av dessa crates använder också #![deny(unsafe_code)].
Den kombinationen är viktig. Den innebär att samma bibliotek kan användas i boot, kernel och fristående tjänster utan att bero på host-OS-tjänster. Den innebär också att parser- och ABI-kod ofta kan skrivas utan raw-pekare alls.
Detta är en av Rusts starkaste passningar för EriX.
Projektet har många små crates vars uppgift inte är att röra hårdvara direkt. Deras uppgift är att parsa, validera, koda, avkoda och avvisa. Det är exakt de uppgifter där säker Rust är värdefull.
Säkra parsers är säkerhetsgränser⌗
EriX behandlar parsers som säkerhetsgränser.
lib-bootimg parsar och verifierar boot.img. Dess läsväg är en del av bootens
tillitskedja. Den validerar struktur, alignment, gränser, hashvärden och
signaturer innan bootloadern litar på bilden.
lib-elf parsar ELF64-binärer innan bootloadern använder load-segment.
lib-handoff validerar versionerade handoff-strukturer mellan bootsteg och
mellan kerneln och rootd.
lib-capabi definierar capability-typer, rättigheter, slot-konstanter,
transfer-deskriptorer och startup-valideringshjälpare.
Dessa crates är avsiktligt tråkiga.
De använder byte-slices. De använder kontrollerad aritmetik. De returnerar strukturerade fel. De avvisar versioner som inte stöds, dåliga storlekar, dåliga offsets och malformed-tabeller.
De gör ingen I/O. De äger ingen runtime-auktoritet. De gissar inte.
Den designen vore möjlig i C, men den skulle kräva uthållig disciplin:
- varje pekare måste kontrolleras
- varje offset måste gränskontrolleras
- varje heltalsoperation måste granskas för overflow
- varje returnerad vy måste bindas manuellt till input-buffertens lifetime
Rust gör dessa regler billigare att upprätthålla.
Till exempel kan en parsad bootbild låna från den ursprungliga byte-slicen. Ett validerat ELF-load-segment kan exponera lånade bytes i stället för att skapa en ny okontrollerad pekare. En handoff-parser kan returnera en iterator som kontrollerar varje entries intervall innan den parsas.
Språket bevisar inte att parsern är semantiskt perfekt.
Det tar däremot bort en stor klass av oavsiktliga minnesmisstag från parserns normala kodväg.
unsafe försvinner inte⌗
Kernelutveckling kan inte vara helt säker Rust.
Hårdvarugränssnittet är inte type-safe. CPU-register, sidtabeller, interrupt-deskriptorer, port I/O, MMIO, DMA-buffertar och boot-entry-pekare kommer inte som vänliga Rust-värden.
Vid någon punkt måste kerneln säga:
Jag vet vad den här raw-adressen betyder.
Det uttalandet är unsafe.
EriX har unsafe på de platser där maskinen tvingar fram det:
- boot-entry-kod konverterar en raw handoff-pekare och längd till en byte-slice
- x86_64 syscall-glue använder inline assembly och en fast register-ABI
- context switching använder arkitekturspecifik assembly
- GDT, IDT, TSS och syscall-initiering rör CPU:ns deskriptortillstånd
- sidtabellsmanipulation använder volatile-läsningar och -skrivningar
- seriella och legacy-enhetsvägar använder port I/O
- MMIO- och DMA-drivrutiner använder volatile-accessors
- några entrådiga runtimes använder
UnsafeCellbakom dokumenterade statiska buffertar
Den listan är inte ett misslyckande för Rust.
Den är den ärliga gränsen mellan språket och maskinen.
Målet är inte att inte ha unsafe någonstans. Målet är att hålla unsafe
litet, lokalt, dokumenterat och omgivet av säkra gränssnitt.
Unsafe-gränser bör vara smala⌗
Den viktiga frågan är inte:
Innehåller kodbasen
unsafe?
Den viktiga frågan är:
Var finns
unsafe, och vilken invariant gör det giltigt?
I EriX är den raw syscall-bryggan i ipc-syscall_x86_64 ett bra exempel.
Craten exponerar säkra wrapper-funktioner som ipc_call, ipc_recv,
ipc_reply, query_local_cap och drop_local_cap. Internt har den en enda
raw assembly-gräns som mappar argument till x86_64 syscall-register-ABI:n och
returnerar raw-registervärden.
Wrappern derefererar inte användarpekare själv. Den skickar pekar- och längdvärden till kerneln, där capability-kontroller, endpoint-kontroller och meddelandelängdsvalidering sker.
Det är rätt form:
- raw assembly är isolerat
- ABI:n är dokumenterad
- det publika API:t är typat
- semantisk validering finns kvar i kerneln
Kernelns context switch-gräns följer samma mönster. Det finns
arkitekturspecifik assembly för att spara och återställa register, men resten av
schedulern kan arbeta med en KernelContext-struktur och ett
ContextSwitcher-trait. Host-tester kan använda en mjuk switcher som modellerar
tillståndsöverlämning utan att CPU:n faktiskt hoppar.
Den uppdelningen spelar roll eftersom den håller det mesta av schedulerlogiken testbar utan att köra privilegierade instruktioner.
Hårdvaruåtkomst är fortfarande hårdvaruåtkomst⌗
Rust gör inte MMIO säkert av sig självt.
Om en drivrutin skriver fel värde till fel register kommer Rust inte att rädda enheten. Om en DMA-buffert beskrivs fel kan hårdvaran fortfarande skriva till minne. Om en sidtabellspost är fel kommer CPU:n att tillämpa fel mapping mycket effektivt.
EriX hanterar detta på två sätt.
Först görs hårdvaruauktoritet explicit. En drivrutin ska inte få bred maskinauktoritet. Den ska bara få den device frame, I/O-range, interrupt-linje eller endpoint-familj den behöver.
För det andra hålls unsafe-koden som rör hårdvara nära hårdvarugränsen.
Till exempel har drv-virtio-block volatile MMIO- och DMA-hjälpare. Det är
förväntat för en blockdrivrutin. Den viktiga poängen är att koden körs i en
drivrutin i användarrymd med en smal enhetsauktoritet, inte inne i en stor
monolitisk kernel med full maskinauktoritet.
Rust hjälper inne i den drivrutinen, men EriX förlitar sig fortfarande på arkitekturen:
- explicita enhetscapabilities
- typat enhetsminne som
CAP_TYPE_DEVICE_FRAME - smala kernel-control-endpoints
- drivrutinsisolering i användarrymd
- fail-closed-startupvalidering
Språket och OS-designen förstärker varandra.
Inget av dem ersätter det andra.
Rust passar capability-tänkande⌗
Rusts ägarskapsmodell är inte samma sak som ett capability-system.
En Rust-borrow är inte en EriX-capability. En Rust-typ är inte kernel-enforced auktoritet. Kompilatorn kan inte veta om en process ska få mappa en frame eller skicka till en endpoint.
Ändå passar Rust och capabilities bra ihop.
Båda uppmuntrar explicithet.
I EriX kan en komponent bara agera när den har rätt capability med rätt rättigheter. I Rust kan kod bara mutera data när den har rätt sorts åtkomst till den datan.
Likheten är användbar både kulturellt och mekaniskt.
Den uppmuntrar API:er där auktoritet skickas som ett värde, inte hittas genom globalt tillstånd. Den uppmuntrar funktioner att säga vad de behöver. Den uppmuntrar startup-kod att validera det faktiska capability-bundle den fick i stället för att anta att ett slot-nummer betyder behörighet.
lib-capabi är ett exempel på den stilen. Den enforced inte auktoritet vid
runtime, men den definierar det delade språket för auktoritet:
capability-typ-ID:n, rättighetsflaggor, slot-konstanter, transfer-deskriptorer,
rollhjälpare och startup-validering.
Eftersom den är no_std och deny(unsafe_code) kan detta auktoritetsvokabulär
användas brett utan att varje konsument blir en raw-minneskonsument.
Jämförelsen med C⌗
C ger en kernel maximal kontroll med minimal abstraktionskostnad.
Det är dess styrka.
Det är också dess svaghet.
I C kan en capability-tabell indexeras utanför gränserna. En meddelandebuffert kan kopieras med fel längd. En packad struktur på disk kan läsas genom en oalignerad pekare. En pekare kan överleva lagringen den beskriver. Ett heltalsoverflow kan bli en för liten allokering. En funktion kan av misstag mutera tillstånd genom ett alias som anroparen inte väntade sig.
Allt detta kan undvikas i C.
Det undviks genom granskning, konvention, statisk analys, sanitizers, fuzzing och disciplin.
De verktygen spelar roll. EriX skulle fortfarande behöva granskning, tester och fuzzing om det skrevs helt i Rust.
Skillnaden är att Rust flyttar många av dessa kontroller in i språkets standardmodell.
När EriX parsar en bootbild, går igenom en transfer-tabell, bygger ett
IPC-meddelande eller validerar ett handoff-envelope är den normala
representationen en slice, enum, iterator, kontrollerad heltalsoperation eller
strukturerad Result.
I C skulle den normala representationen ofta vara en pekare, en längd, en cast och ett löfte.
Löften är inte värdelösa.
Men kernels samlar snabbt på sig löften.
Rust minskar hur många av dessa löften som måste betros.
Rust tar inte bort designbuggar⌗
Rust är inget säkerhetsbevis.
Det förhindrar inte att fel capability ges.
Det förhindrar inte att en endpoint får för breda rättigheter.
Det avgör inte om rootd, procd eller deviced ska äga ett policybeslut.
Det får inte automatiskt en parser att avvisa varje semantiskt ogiltig fil.
Det tar inte bort sidokanaler.
Det gör inte DMA koherent.
Det gör inte interrupt-ordning enkel.
Det gör inte schedulern rättvis.
Därför betonar EriX fortfarande arkitektur:
- liten TCB
- explicita capabilities
- smala endpoints
- deterministisk startup
- fail-closed-beteende
- clean-room-beroenden
- tjänster i användarrymd
- rollspecifik drivrutinsauktoritet
Rust förbättrar implementationsunderlaget. Det ersätter inte säkerhetsmodellen.
Panic är ingen felstrategi⌗
Kernelkod behöver en tydlig felmodell.
I EriX bör återhämtningsbara fel från extern input returneras som fel. Malformed handoff-data, versioner som inte stöds, ogiltiga transfer-deskriptorer, fel endpoint-typer och saknad auktoritet ska inte repareras tyst.
De ska misslyckas explicit.
Panic reserveras för interna invariantbrott, inte vanlig inputvalidering.
Det spelar roll eftersom Rust gör panic lätt, men en kernel kan inte behandla panic som en normal kontrollflödesmekanism. En parser i TCB ska inte panica på malformed input. En bootväg ska inte råka unwinda genom firmware-tillstånd. En runtime-tjänst ska inte behandla input kontrollerad av en angripare som skäl att gå in i en odefinierad återhämtningsväg.
Det användbara Rust-mönstret är:
- validera innan förtroende
- returnera
Result - håll felvarianter explicita
- reservera panic för omöjliga interna tillstånd
- fail closed när säkerhetstillståndet är tvetydigt
Det mönstret syns i EriX parser- och handoff-crates.
Testfördelar⌗
Rust hjälper också testning.
EriX-kerneln är en no_std-crate, men mycket av dess logik kan ändå testas på
hosten med #[cfg(test)]-stöd.
Det är användbart för kod som inte borde kräva en VM-boot bara för att validera en ren invariant:
- parser-bounds-kontroller
- handoff-validering
- parsing av capability-deskriptorer
- rättighetsmasklogik
- slot-policy-tabeller
- scheduler-tillståndsövergångar
- mjuka context-switch-modeller
De arkitekturspecifika delarna behöver fortfarande integrationstester. Inline assembly, sidtabellsmanipulation, interrupt-leverans och övergångar till user mode måste testas i den miljö där de faktiskt kör.
Men Rust gör det lättare att hålla ren logik ren.
Det är värdefullt i en microkernel, eftersom många komponenter kan reduceras till små deterministiska tillståndsmaskiner kring explicita meddelanden och capabilities.
Varför detta spelar roll för EriX⌗
EriX använder inte Rust för att Rust är trendigt.
Det använder Rust eftersom projektets mål stämmer med språkets styrkor:
- minska minnesosäkerhet i betrodd kod
- göra ägarskap och mutation synliga
- isolera raw-hårdvaruåtkomst bakom smala gränser
- hålla parserkod säker, deterministisk och granskningsbar
- uttrycka protokoll- och auktoritetsdata med typade strukturer
- stödja
no_std-kod delad mellan boot, kernel och användarrymdstjänster - göra många buggar svårare att skriva från början
Den kvarvarande unsafe-koden är fortfarande allvarlig.
Den måste granskas lika noggrant som C skulle granskas. Skillnaden är att EriX kan koncentrera den granskningen till de platser där maskinen verkligen tvingar fram raw-kontroll: entry-kod, CPU-setup, sidtabeller, syscalls, context switches, MMIO, DMA och port I/O.
Det är det praktiska värdet.
Rust gör inte kerneln säker.
Det får de unsafe delarna att sticka ut.
Framåt⌗
Nästa steg är att följa exekveringen från den första firmware-laddade koden in i kerneln.
Den vägen är där många idéer från de tidigare inläggen möts: TCB, Rust-gränser, validerade parsers, signerade bootbilder, handoff-strukturer och den första överföringen av maskinauktoritet till explicita kernelobjekt.
Nästa inlägg går igenom EriX bootprocess från firmware till kernel: UEFI som startpunkt, bootloaderns handoff-modell och varför kerneln körs in som en higher-half-exekverbar.