- Die Netzwerk-Switch-Firmware von Oxide ließ sich nach einem Test mit geänderter Power-Sequencing-Reihenfolge nicht mehr einschalten; die Ursache war ein Bug in der Prüfung ausgeliehener IPC-Speicherbereiche des Hubris-Kernels, der mit einer neuen Methode der Speicheranordnung kollidierte
- Hubris ist ein Embedded-Betriebssystem, das Tasks per MPU isoliert; wenn bei IPC Speicher an andere Tasks ausgeliehen wird, prüft der Kernel, ob dieser Speicher tatsächlich in einem zugänglichen Bereich liegt
- Das kürzlich eingeführte Task Packing gewann in einigen Firmware-Images 30 % RAM zurück, doch die bisherige Prüfung scheiterte, weil sie annahm, dass ausgeliehener Speicher vollständig in einer einzelnen MPU-Region liegt
- Der Task
sequencer starb mit einem synthetic memory fault, als er versuchte, Speicher mit der Adresse 0x801bffd an den I2C-Treiber auszuleihen; in humility tasks blieben 115 Neustarts und der Status mem fault... in syscall zurück
- Die Korrektur bestand darin, den Prüfalgorithmus so zu ändern, dass Ausleihvorgänge über mehrere benachbarte MPU-Regionen hinweg erlaubt werden; von der Entdeckung des Ausfalls bis zur Behebung des Kernel-Bugs vergingen etwa 3 Stunden
Ein Netzwerk-Switch, der nicht mehr angeht
- Arjen Roodselaar von Oxide testete in der Firmware eines Netzwerk-Switches Änderungen am Power Sequencing und an der Taktkonfiguration, als der Switch nach einer scheinbar kleinen Änderung nicht mehr startete
- Ein Teil der Firmware antwortete noch auf Anfragen, aber der wichtige Teil, der für den Power-Supply-Sequencer zuständig war, schien stehen geblieben zu sein
- Fehler im Power Sequencing können Hardware tatsächlich beschädigen, daher musste zuerst geklärt werden, ob der Switch wirklich tot war oder nur nicht antwortete
Hubris und begrenzter Speicher
- Hubris ist ein Betriebssystem für tief eingebettete Systeme wie Keyboard-Controller und wurde entwickelt, um die Aufgaben zu übernehmen, die zum Starten der großen Prozessoren im Oxide Rack nötig sind
- Hubris-basierte Firmware besteht aus mehreren separat kompilierten Programmen, den Tasks
- Jeder Task bringt benötigten Standardbibliothekscode usw. selbst mit
- Die Tasks werden durch die Hardware-MPU voneinander isoliert, damit sie einander weder zum Absturz bringen noch Speicher beschädigen können
- Bei den hauptsächlich verwendeten ARM Cortex-M-Systemen mit ARMv7-M müssen geschützte Speicherbereiche eine Größe als Zweierpotenz haben und entsprechend ausgerichtet sein
- Braucht ein Bereich von 1024 Byte zum Beispiel ein Byte mehr, wird daraus nicht ein Bereich von 1025 Byte, sondern einer von 2048 Byte
Neue Grenzen durch Task Packing
- Das ursprüngliche Hubris nutzte einen einfachen Ansatz mit je einer Region für Task-RAM und einer für Flash, doch zwischen den Tasks entstanden unbenutzbare Lücken, die Speicher verschwendeten
- Matt Keeter verbesserte das Build-System so, dass Tasks nach Möglichkeit durch die Kombination mehrerer Power-of-two-Regionen platziert werden
- Die Hardware erlaubt maximal 8 Regionen pro Task
- In manchen Firmware-Images wurden 30 % RAM zurückgewonnen
- Die kleinsten Geräte, bei denen bislang jede Optimierung nötig war, bekamen dadurch wieder Spielraum
- Durch diese Änderung konnten in Flash und RAM eines Tasks schwer vorhersagbare MPU-Regionsgrenzen entstehen
Die Hinweise aus humility tasks
- Arjen untersuchte den ausgefallenen Switch mit Humility, dem Hubris-Debugger; der für das Power Sequencing zuständige Service-Prozessor lebte und lief noch, daher schien ein Hardwareproblem unwahrscheinlich
- In der Ausgabe von
humility tasks zeigte der Task sequencer folgenden Status
mem fault (precise: 0x801bffd) in syscall (was: wait: reply from i2c_driver/gen0)
- Derselbe Task war bereits 115-mal neu gestartet worden; in Hubris ist ein Task-Neustart fast immer die Reaktion auf einen Crash
- Die Statuszeichenkette bedeutete Folgendes
mem fault: Verstoß gegen Regeln der Speicherverarbeitung
precise: 0x801bffd: die konkrete problematische Adresse ist bekannt
in syscall: der Task lief nicht gerade selbst, sondern befand sich in einem Systemaufruf
was: wait: reply from i2c_driver/gen0: er wartete auf die Antwort auf eine an den I2C-Treiber gesendete Nachricht
gen0 bedeutet, dass i2c_driver noch nie abgestürzt war, während sequencer bereits Generation 115 war
Hubris-IPC und das Ausleihen von Speicher
- Hubris-Tasks kommunizieren über IPC-Nachrichten, die sich wie Funktionsaufrufe verhalten
- Der sendende Task stoppt
- Der empfangende Task erhält die CPU-Kontrolle
- Wenn das Ergebnis zurückkommt, wird der sendende Task wieder aufgeweckt
- IPC ist so entworfen, dass es gut zum Ownership-Modell von Rust passt; ein Task kann zusammen mit einer IPC-Nachricht Teile seines Speichers an einen anderen Task ausleihen
- Tasks, die mit I2C-Geräten interagieren, leihen dem I2C-Bus-Treiber ihre Speicherbereiche aus, und der Treiber liest oder schreibt diesen Bereich direkt vor Ort
- Dadurch braucht der Bus-Treiber seltener einen separaten Buffer-Pool
- Die Anzahl der Datenkopien sinkt
- Wenn das falsch implementiert wäre, könnte daraus eine Sicherheitslücke entstehen; deshalb verbietet der Hubris-Kernel, Speicher auszuleihen, den ein Task nicht wirklich besitzt oder auf den er nicht zugreifen darf
- Der Server erhält einen Fehlercode
- Der Client bekommt einen fault und wird immer beendet
- Das wird als Zugriffsverletzung behandelt, die auf einen Bug, eine Beschädigung oder eine mögliche Exploit-Situation hinweist
Synthetic Fault und die eigentliche Ursache
- Hubris unterscheidet faults in real faults und synthetic faults
- Ein real fault ist ein Verstoß gegen Hardware-Regeln wie das Dereferenzieren eines Null-Pointers oder ein Schreibzugriff auf Code-Bereiche
- Ein synthetic fault ist ein Verstoß gegen zusätzliche Software-Regeln von Hubris, etwa bei IPC oder dem Ausleihen von Speicher
- Der fault von
sequencer war ein synthetic fault, der beim Ausleihen von Speicher an den I2C-Treiber über IPC auftrat
- Die problematische Adresse
0x801bffd war zwar eine gültige Flash-Adresse, lag aber 3 Byte unter einer Zweierpotenz-Grenze und zeigte daher ein auffälliges Muster
- Die Ausgabe von
humility mem zeigte, dass zwei Flash-Regionen des Tasks sequencer bei 0x801c000 direkt aneinandergrenzten
LOW HIGH SIZE ATTR ID TASK
0x08018000 - 0x0801bfff 16kiB r-x--- 17 sequencer
0x0801c000 - 0x0801dfff 8kiB r-x--- 17 sequencer
- Beide Regionen gehörten zum selben Task, daher hätte die Hardware-MPU bei normaler Programmausführung den Zugriff problemlos erlaubt; die Prüfung für ausgeliehenen IPC-Speicher im Kernel beruhte jedoch auf einer anderen Annahme
Wo eine alte Vereinfachung zum Bug wurde
- Die bisherige Kernel-Prüfung kontrollierte nur, ob der komplette auszuleihende memory slice vollständig innerhalb einer einzelnen Region des Tasks lag
self.region_table().iter().any(|region| {
region.covers(slice)
&& region.attributes.contains(desired)
&& !region.attributes.intersects(forbidden)
})
- Dieser Code passte zum damaligen Design, also zur Annahme von genau einer RAM-Region und einer Flash-Region pro Task
- Mit der Einführung von Task Packing konnte der Speicher desselben Tasks auf mehrere benachbarte MPU-Regionen aufgeteilt werden; die alte Annahme galt damit nicht mehr
- Gewöhnliche Speicherzugriffe waren nicht betroffen, weil die Hardware-MPU sie direkt prüft; sichtbar wurde das Problem nur, wenn man versuchte, diesen Speicher per IPC auszuleihen
Ein Ausfall, erzeugt durch das Zusammenspiel zweier Features
- Task Packing arbeitet opportunistisch
- Es gibt ein Limit von maximal 8 Regionen pro Task
- Hardware-Treiber-Tasks belegen wegen Memory-Mapped-Registern einige Regionen bereits im Voraus
- Nur wenn noch Regions-Slots frei sind, wird eine intelligentere Platzierung versucht
- Dadurch entstehen Regionsgrenzen an Stellen, die für Task-Entwickler schwer vorherzusagen sind
- Schon eine kleine Größenänderung in Task A kann die Lage der MPU-Regionsgrenzen eines eigentlich unabhängigen Task B verschieben
- Allein das Hinzufügen von Debug-Code konnte also die Platzierungsentscheidung und Regionsgrenzen ändern und damit den Crash verschwinden lassen
- Matt deaktivierte Task Packing sofort im Build-System, damit Arjen wieder ein funktionierendes Firmware-Image erzeugen konnte, während gleichzeitig Analyse und Behebung des Kernel-Bugs liefen
Wie der Kernel-Fix funktionierte
- Kern der Korrektur war, den Algorithmus zur Prüfung von Speicherzugriffen so zu ändern, dass auszuleihender Speicher auch dann erlaubt ist, wenn er sich über mehrere exakt benachbarte MPU-Regionen erstreckt
- Der neue Algorithmus wurde so entworfen, dass er die Regionstabelle nur einmal durchläuft
- Hubris will vermeiden, Operationen mit einer vom Task steuerbaren Zeitkomplexität offenzulegen
- Die Performance sollte nicht von der Größe des ausgeliehenen Speichers abhängen, sondern nur von der fest begrenzten Größe der Regionstabelle
- Die Regionstabelle hat eine feste Größe von 8 Einträgen
- Dafür wurde das Build-System so geändert, dass Task-Regionen in aufsteigender Adressreihenfolge sortiert werden
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);
- Der Fix-Commit sorgt dafür, dass der Kernel diese Sortiereigenschaft für eine günstigere Zugriffsprüfung nutzen kann
- Der komplexer gewordene Code wurde aus dem Kern des Hubris-Kernels herausgelöst und in ein portableres Crate verschoben; außerdem kamen Unit-Tests für wichtige corner cases hinzu
- Mit dem neuen Code konnte Task Packing wieder aktiviert werden, ohne bei Task-Entwicklern unvorhersehbare Abstürze zu hinterlassen
Warum sich der Ausfall nicht stärker ausbreitete
- Der gesamte Ablauf begann mit einem Netzwerk-Switch, der nicht mehr anging, und endete nach etwa 3 Stunden mit einem behobenen Kernel-Bug
- Dank fault isolation starb von den 23 isolierten Tasks der Switch-Firmware nur
sequencer immer wieder, während viele andere Komponenten weiterliefen
- das Firmware-Update-System
- der IP-Netzwerk-Stack für Verwaltungs- und Steuerungsinterfaces
- mehrere Netzwerkdienste, von der Implementierung des
echo-Protokolls bis zur Rack-Control-Plane-Schnittstelle
- I2C, SMBus und PMBus für Sensoren, Lüfter und die Überwachung anderer Systemzustände
- die Treiber für 32 QSFP-100G-Transceiver an der Frontseite
- Hubris-IPC ist unter der Annahme entworfen, dass andere Tasks ausfallen können; als idempotent markierte Operationen können daher transparent erneut versucht werden
- Der bestehende Bug in der Speicherzugriffsprüfung blockierte nur Zugriffe korrekter Programme; er erlaubte keine falschen oder bösartigen Zugriffe und hatte deshalb keine Sicherheitsauswirkungen
- In dem Moment, in dem
sequencer und der I2C-Treiber de facto Speicher teilten, starb zwar sequencer, aber der I2C-Treiber lief ohne Beschädigungsrisiko weiter
Debugging-Infrastruktur und Teamarbeit
- Humility ist ein zusammen mit dem Hubris-Kernel gewachsener Debugger; Arjen konnte innerhalb weniger Minuten die Stelle des abgestürzten Codes bis auf Zeilennummern-Ebene identifizieren und einen unabhängigen Snapshot des Service-Prozessors teilen
- Hubris schreibt komprimierte Coredumps abgestürzter Tasks in den RAM und kann sie über das Netzwerk einsammeln
- So lassen sich Crash-Dumps auch ohne beschreibbaren persistenten Speicher gewinnen
- Die Crashdump-Funktion liegt nicht im Kernel selbst, sondern in einem separaten Task
- Diese Prozessoren verarbeiten keine Kundendaten aus Workloads, sondern nur System-Management-Traffic, und Crash-Reports werden nicht automatisch hochgeladen
- Der architekturunabhängige Teil des Hubris-Kernels umfasst 1.789 Zeilen Code und 1.192 Zeilen Kommentare; die Unterstützung für ARMv6-M, ARMv7-M und ARMv8-M fügt 1.075 Zeilen Code und 534 Zeilen Kommentare hinzu
- Weil die Kernel-Konzepte und IPC einfach sind, gibt es bei einem fault, der auf IPC zeigt, nicht viele Stellen, die überhaupt geprüft werden müssen
1 Kommentare
Meinungen auf Hacker News
Hubris ist wirklich gut. Ich habe etwa 30 Minuten lang Kernel-Code gelesen, und er ist sehr klar und gut geschrieben – weit entfernt von dem C-Code, den ich früher gesehen habe, voller
ifdef-Makros, mit einer Vorliebe für zweibuchstabige Variablennamen und wenig Kommentaren.Taugt auch als Lektüre vor dem Schlafengehen; ich empfehle, einmal hineinzuschauen: https://github.com/oxidecomputer/hubris/blob/b44e677fb39cde8...
Speicherplatz für Source Code ist seit 40 Jahren kein großes Problem mehr, aber bei Variablennamen wird immer noch gespart.
Denn die KI hat die Vorlieben und Angewohnheiten eines bestimmten Coders genau gelernt.
Guter Artikel, aber die Platzierung des folgenden Kommentars ist schade.
Der Kommentar über
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);, dass „nach aufsteigender Adresse sortiert werden muss und der Kernel diese Eigenschaft nutzt, um Zugriffprüfungen billig zu machen“, ist weniger ein Detail dieser Funktion als vielmehr eine Feld-Invariante, die alle Schreibenden einhalten müssen und alle Lesenden nutzen können.Deshalb scheint er besser in den Dokumentations-String von
TaskDesc::regionszu passen: https://github.com/oxidecomputer/hubris/commit/b44e677fb39cd...Wahrscheinlich wäre der beste Ansatz,
TaskDesceine Konstruktor-Methode zu geben, die die Bereiche sortiert und so die Invariante erzwingt. Man sieht, dass der Code mit der Zeit komplexer wird; daher scheint es sich jetzt zu lohnen, etwas Zeit darauf zu verwenden, Komplexität in Methoden zu kapseln.Das ist eine der besten Stellenausschreibungen, die ich bisher gesehen habe. Mir gefällt, wie der Text ganz natürlich in eine Diskussion über Kultur übergeht und am Ende ein „übrigens, wir stellen ein“ anhängt.
Eine wirklich hervorragende Post-mortem-Analyse, der sogar ich als Entwickler auf Anwendungsebene folgen konnte. Zufällig lese ich gerade Rust in Action, daher war ich für solche Inhalte etwas besser vorbereitet.
Es ist auch immer schön, Leute zu sehen, die viel im Code kommentieren. Literate Programming funktioniert.
Den vorherigen Teil findet man offenbar hier:
Der Abschnitt über die „enge, nicht-hierarchische Integration des Teams“ sticht heraus. Das ist zwar keine Hubris-Funktion an sich, aber die Erklärung ist beeindruckend, dass Hubris und das Team dahinter kaum zu trennen sind und es im Engineering-Team von Oxide praktisch keine internen Silos gibt.
Ich würde gern mehr darüber hören, warum sie eine Kultur geschaffen haben, die Offenheit, Neugier und Kommunikation fördert und Defensivität, Imperienbildung und Gatekeeping unterdrückt, und wie sie das konkret umgesetzt haben. Mich interessiert auch, ob es Nachteile gibt, eine solche Kultur in einer Organisation aufzubauen.
Manche setzen auf strengere hierarchische Systeme, und die Organisationsstruktur muss unter Umständen strategisch gewählt werden; daher sind mir die Trade-offs nicht ganz klar.
Wenn diese Struktur nicht explizit ist, ist sie weniger offen, nicht bewusst gewählt und besonders für Menschen, die in sozialen Interaktionen nicht sehr geübt sind, schwerer zu verstehen. Aufgrund ihres schattenhaften Charakters kann sie daher pathologischeres Verhalten ermöglichen, und selbst wenn es nicht sehr schlimm wird, kann sie Koordination deutlich erschweren.
Ich habe das in mehreren Unternehmen erlebt. Ein großes Beratungsunternehmen hatte zwar eine formale Machtstruktur, tatsächlich wurde sie aber nicht wirklich befolgt; auch der Weg in Projekte führte eher darüber, mit Vertriebs- und Managementleuten befreundet zu sein, als über formale Kanäle. Wenn man das nötige soziale Netzwerk gut aufbauen konnte, war das gut, sonst funktionierte es schlecht.
Ein ähnliches Beispiel ist „The Tyranny of Structurelessness“, ein Vortrag einer Feministin, die beobachtet hatte, dass dasselbe in Organisationen geschah, die Hierarchie als patriarchal ablehnten; ähnliche Diskussionen gibt es auch bei Valve, wo die interne Struktur nicht klar ist. Open-Source-Projekte können unter demselben Problem leiden, und meiner Ansicht nach stammen auch einige Konflikte im Rust-Umfeld aus ähnlichen Ursachen.
Das heißt nicht, dass explizite Machtstrukturen zwingend hierarchisch sein müssen. Traditionelle Business-Organisationen sind hierarchisch, aber Oxides Struktur kann explizit und zugleich nicht-hierarchisch sein. Solche Ansätze funktionieren normalerweise besser, je kleiner die Organisation ist; das oben erwähnte Beratungsunternehmen war der größte mir bekannte Fall eines Unternehmens, das relativ frei geführt wurde, hatte aber dennoch gewisse stützende Gerüste.
Das ist keine Dichotomie, sondern ein Spektrum. Selbst unter der auf dem Papier starrsten Machtstruktur gibt es darunter eine komplexere implizite Struktur, und das liegt in der Natur von Menschengruppen.
Ich glaube nicht, dass explizite Strukturen immer besser sind als implizite. Ich habe nur die Nachteile beschrieben, die ich in weniger expliziten Organisationen beobachtet habe; stärker explizite Machtstrukturen haben ihre eigenen Probleme. Dazu passen auch „Seeing Like a State“ oder das Problem der Lesbarkeit.
Ein hervorragender Artikel, der den Prozess des Debuggens eines komplexen Problems sehr tiefgehend zeigt. Dass der Rest des Systems stabil blieb, zeigt sehr gut die Engineering-Qualität des Oxide-Teams.
Mich persönlich hat das ziemlich inspiriert, und ich will ähnliche Techniken in meiner täglichen Arbeit ausprobieren.
Wenn man diese Hardware wie einen softwarebefüllten TLB behandelt, könnte man auch mehr als acht Bereiche unterstützen.
Ich würde das nur als allerletzte Option tun. Virtuelles Paging ist unschön, und man will keine Zweifel zurücklassen.
Was Oxide macht, ist wirklich erstaunlich.
Mir gefällt alles, was die Leute bei Oxide machen, und das hier gehört dazu.
Sie haben das Betriebssystem Hubris genannt? Oh, das ist … mir fehlen die Worte.
Wer bei klarem Verstand würde heutzutage ein neues Betriebssystem schreiben? Die Antwort: jemand, der das Problem lösen will, das alle Betriebssysteme ignorieren – nämlich die Controller auf Mainboards und Erweiterungskarten, die das Betriebssystem weder kontrolliert noch kontrollieren kann.