Virtualisierung ermöglicht es, mehrere OS gleichzeitig auf einer HW-Plattform zu betreiben. Es wird auch für Embedded-Systeme eingesetzt, z.B. im Cockpit eines Autos. Fehler erfordern neue Ansätze im Debugging. Ein konkretes Beispiel auf Renesas R-Car M3 mit Lauterbachs Trace32 zeigt, wie es geht.
Im Automobil laufen Echtzeitanwendungen, die klassisch von einem Autosar-Betriebssystem überwacht werden, parallel zu Android-basierten User-Interfaces auf derselben Hardware-Plattform. Ein Hypervisor, das Kernstück der Virtualisierung, sorgt dafür, dass alles sicher und effizient funktioniert. Er bietet vom Grundsatz einmal den Vorteil, erstens verschiedene Anwendungs-Szenarien auf einer Hardware parallelisieren zu können und Kosten zu sparen, da verschiedene Funktionalitäten auf einer Hardware konsolidiert werden. Ein Beispiel ist, Motormanagement, Armaturenbrett und Infotainment auf einer ECU zu konsolidieren.
Allerdings bringt sein Einsatz auch Herausforderungen mit sich, zum Beispiel im Bereich der Sicherheit, wenn es um unautorisierten Zugriff auf sicherheitsrelevante Teile geht oder im Bereich der Zuverlässigkeit, man denke an den Absturz eines Teilsystems. Man stelle sich vor, ein Web-Player im Infotainment würde abstürzen und damit das Motormanagement runterziehen – ein undenkbares Szenario.
Das Grundkonzept eines Hypervisors ist in Bild 1 ersichtlich. Er stellt sogenannte virtuelle Maschinen (VM) für verschiedene Funktionalitäten zur Verfügung, die jede für sich ein eigenes Betriebssystem starten kann und die gegeneinander geschützt sind. Der Hypervisor überwacht die VMs und kann sie im Fall eines Crashs ggf. neu starten.
Um dieses Konzept umsetzen zu können, bedient sich der Hypervisor einer speziellen Hardware-Logik des Mikrocontrollers für das VM-Management. In den VMs laufen Betriebssysteme (in der Folge Gast-OS genannt) genauso wie ohne Hypervisor, ein Gast-OS weiß selbst gar nicht, dass es unter einem Hypervisor läuft und kann sogar seine eigene MMU-Verwaltung haben (Speicherverwaltungseinheit, Memory Management Unit). Dies ist ein großer Vorteil von hardwarebasierten Lösungen gegenüber rein softwarebasierten Angeboten, da bei letzteren die Gast-Systeme gepatcht werden müssen, um überhaupt zu wissen, dass sie unter einem Hypervisor laufen.
Da natürlich nicht jedes Gast-OS auf den nur einmal vorhandenen physikalischen Speicher zugreifen darf, wird dieser letztendlich über eine sogenannte Second-Stage-MMU vom Hypervisor verwaltet. Gleiches gilt für weitere geteilte Systemressourcen wie Interrupts und Peripherie. Der Hypervisor weist dem virtuellen System Teilbereiche des gesamten Hauptspeichers zu. Diese erscheinen dem virtuellen System als zusammenhängender Adressraum, so wie der physische Speicher einem nicht virtuellen System erscheint. Er kann vom virtuellen System entsprechend und exklusiv genutzt werden.
In Bild 2 oben ist die bereits erwähnte zweistufige Speicher-Adressumsetzung per MMU ersichtlich. Der Gast denkt, er hat physikalische Adressen, greift aber tatsächlich nur auf intermediate Adressen zu. Diese werden vom Hypervisor transparent für die VM auf echte physikalische Adressen umgesetzt. Bild 2 zeigt unten die Verwaltung der Interrupts und I/O-Zugriffe durch den Hypervisor, der sie an die jeweilige VM weitergeben oder auch VMs direkten Zugriff auf bestimmte I/Os und (externe) Interrupts geben kann, wenn dies zum Beispiel wegen Latenzzeitanforderungen notwendig sein sollte. Im Fall eines Kontextwechsels von einem Gast zu einem anderen wird die MMU der 1. Stufe vom Hypervisor umprogrammiert, sodass sie zu dem jeweiligen Gast passt.
Zu einer bestimmten Zeit ist für jeden Core nur eine bestimmte MMU-Übersetzung aktiv, das heißt, der Core sieht nur die aktuell laufende VM und den aktuell laufenden Prozess. Dadurch kann ein Fehler in einer VM sich nicht auf eine andere VM auswirken. Der Hypervisor veranlasst bei einem Gastwechsel, dass die MMU umprogrammiert wird und der Core dann den neuen Kontext sieht. Die hat natürlich Auswirkungen auf das Debugging, da der Debugger durch die begrenzte Sicht des Cores zunächst auch nur einen Bruchteil des Systems sieht.
Debugging-Modes mit Hypervisor
Beim sogenannten Run-Mode-Debugging kommuniziert der Debugger (zum Beispiel gdb) mit einer Software im Target (Debug Agent, zum Beispiel gdbserver). Die eigentliche Debugging-Funktionalität, wie etwa Single-Step-Modus aktivieren oder Breakpoints setzen, läuft auf dem Debug-Agent, während auf dem Host nur die übergeordneten Funktionen laufen. Wird der zu debuggende Prozess angehalten, läuft alles andere (OS, Hypervisor, andere Prozesse) weiter (daher Run-Mode).
Der Debug-Agent läuft im Kontext einer VM, kennt also nur diese und hat keinen Zugriff auf Software außerhalb der VM. Das Kommunikations-Interface (zum Beispiel UART oder Ethernet) muss zur jeweiligen VM geroutet werden. Das Debugging ist unabhängig vom Hypervisor und benötigt keine weiteren Anpassungen. Dieser Modus ist ideal, um Funktionalitäten innerhalb eines Prozesses zu debuggen (Bild 3). Das Schöne an dem Konzept ist, da der Debug-Agent im Kontext der VM läuft, weiß er selbst gar nichts davon, dass er unter einem Hypervisor läuft. Alles was man tun muss ist, von der Host-Maschine das Routing zum Agent sicherstellen. So ein System kann man debuggen, ohne etwas im Kontext eines Hypervisors beachten zu müssen. Hinsichtlich des Routings kann man im Hypervisor zum Beispiel beim Einsatz von Ethernet eine IP-Adresse spezifizieren, die direkt zum Gast durchgeroutet wird.
Beim sogenannten Stop-Mode-Debugging kommuniziert der Debugger per Hardware (typischerweise JTAG) mit der CPU selbst ohne irgendeine zusätzliche Software im Zielsystem. Beim Halten oder Erreichen eines Breakpoints wird das gesamte System angehalten, inklusive aller CPUs (bei Multicore-Architekturen), VMs und des Hypervisors (daher Stop-Mode). Nur eine VM ist beim Anhalten aktiv, nämlich die aktuelle VM. Die CPU sieht nur den aktuellen Kontext (z. B. die aktuelle VM und den aktuellen Prozess), somit ist kein Zugriff auf andere Prozesse oder VMs möglich. Da der Debugger über die CPU zugreift, hat er (zunächst) die gleichen Einschränkungen.
Grundsätzlich kann der Debugger nur auf den kompletten physikalischen Adressraum zugreifen (indem er kurzzeitig die MMU abschaltet), den intermediate (= guest physical) Adressraum der aktuellen VM und den virtuellen Adressraum des aktuellen Prozesses innerhalb der aktuellen VM.
Um auf andere Prozesse oder VMs zuzugreifen, muss der Debugger die MMU-Übersetzung des Hypervisors und aller VMs selbst wissen, was über eine sogenannte Hypervisor Awareness und OS Awareness erreicht wird. Jede Maschine hat einen eigenen Registersatz, ihre eigene MMU-Übersetzung, Betriebssystem, Breakpoints, Symbole und Tasks. Hypervisor Awareness heißt somit, dass der Debugger mit allem arbeiten können muss:
der physikalischen Host-Maschine (auf der der Hypervisor läuft),
allen virtuellen Gast-Maschinen, (fast) als ob sie echte Systeme wären und
allen diesen Maschinen parallel.
Eine Hypervisor-Awareness wird als zusätzliches ausführbares Modul ähnlich einer DLL in den Debugger geladen (ähnlich einer OS Awareness), ermittelt die Konfiguration aller VMs anhand der Hypervisor-Symbole (Registersatz, MMU Setup, …) und ist spezifisch für den Hypervisor, der im Host läuft (Bild 4).
Insbesondere benötigt er die Debug-Symbol-Information des Hypervisors. Beim Hypervisor Xen (dazu später mehr) gibt es zum Beispiel eine Variable Gastliste, über welche der Debugger ermitteln kann, welche Gäste sich überhaupt auf dem System befinden. Ein Debugger-Hersteller kann folgerichtig eine Hypervisor-Awareness nur dann implementieren, wenn er technische Informationen und Unterstützung vom Hersteller des Hypervisors erhält, auch zum Beispiel darüber, wie der Hypervisor die MMU-Adressumsetzung realisiert, die im Debugger nachgebildet werden muss.
Deswegen gibt es auch eine Liste mit unterstützten Hypervisoren und eben auch Hypervisor, die nicht unterstützt werden – zum Beispiel weil der Hersteller diese internen Informationen nicht herausrücken will.
Vergleichsweise einfach ist – wenn die Informationen vorliegen – die Sache bei Xen, das komplett im Armv8-A-Hypervisor-Modus läuft.
Besonders kompliziert ist es hingegen zum Beispiel bei QNX, wo der Hypervisor ein komplettes QNX-Betriebssystem ist und die Gäste als Prozesse in diesem QNX-OS laufen. Der Hypervisor kann dabei logischerweise nicht im Hypervisor-Modus laufen, sondern läuft im Non-Secure-Modus, man muss also in den Non-Secure-Modus schauen, was schon mal eine zusätzliche MMU-Auflösung bedeutet und im Non-Secure-Modus die Prozessliste von QNX abfragen. In der Prozessliste muss man dann nachschauen, ob es sich um einen Gast, also eine VM, oder einen ganz normalen QNX-Prozess handelt und dann in einem VM-Prozess nachsehen, welche Charakteristika er hat.
Debugging mit Hypervisor – das Praxisbeispiel
In unserem konkreten Beispiel soll eine der meistverbreiteten Plattformen in der Automobilindustrie, Renesas R-Car M3, gegeben sein. R-Car M3 ist ein 64-Bit-Hepta-Core-Arm-SoC, das von Renesas für die Automobilindustrie entwickelt und 2016 eingeführt wurde. Der M3 beinhaltet vier Cortex-A53-CPUs, zwei Cortex-A57 und einen zusätzlichen Cortex-R7 für die Echtzeitverarbeitung. Die Cache-Organisation sieht so aus, dass die Cortex-M57 jeweils 48 KB L1-Cache für Befehle und die restlichen CPUs 32 KB L1-Cache für Instruktionen bekommen. Als L1-Datencache sind für alle sieben CPUs einheitlich 32 KB vorgesehen. Daneben teilen sich alle CPUs einen 1,5 MB großen L2-Cache.
Der Chip unterstützt über eine 32 bit breite Schnittstelle bis zu zweikanaligen LPDDR4-3200-Speicher mit einer Bandbreite von bis zu 11,92 GB/s im zweikanaligen Betrieb. Dieser Chip beinhaltet weiterhin die PowerVR GX6250-GPU von Imagination sowie zahlreiche Schnittstellen wie USB 3.0, PCIe, CAN-FD, Ethernet AVB und viele mehr. Gefertigt wird das SoC bei TSMC in einem 16-nm-Prozess.
Hypersisor Xen auf Arm: Das passt!
In unserer Konfiguration soll der Hypervisor Xen auf den Dual-Core-Arm-Cortex-A57 laufen. Xen ist ein kleiner, leistungsstarker Open-Source-Hypervisor mit sehr geringem Platzbedarf: Die Arm-Portierung von x86 beträgt weniger als 90K Zeilen-Code und damit rund 1/6 der Größe seines x86-Pendants. Xen ist lizenziert unter der GPLv2 und hat eine gesunde und vielfältige Community, die es unterstützt und seine Entwicklung finanziert.
Xen ist ein Typ-1-Hypervisor: Er läuft direkt auf der Hardware, alles andere im System läuft als virtuelle Maschine auf Xen, einschließlich Dom0, der ersten virtuellen Maschine. Dom0 wird von Xen erstellt, ist privilegiert und steuert die Geräte auf der Plattform. Xen virtualisiert CPU, Speicher, Interrupts und Timer und stellt virtuelle Maschinen mit einer oder mehreren virtuellen CPUs, einem Bruchteil des Speichers des Systems, einem virtuellen Interrupt-Controller und einem virtuellen Timer bereit. Xen ordnet Dom0 Geräte zu und kümmert sich um die Neuzuordnung von MMIO-Regionen und IRQs. Dom0 (typischerweise wie auch in unserem Beispiel Linux, aber es könnten auch andere Betriebssysteme sein) führt die gleichen Gerätetreiber für diese Geräte aus, die bei einer nativen Ausführung verwendet werden.
Dom0 führt auch eine Reihe von Treibern aus, die paravirtualisierte Backends genannt werden, um den anderen unprivilegierten virtuellen Maschinen Zugriff auf Peripherie zu gewähren. Das Betriebssystem, das als DomU läuft (unprivilegierter Gast in Xen-Terminologie), erhält Zugriff auf eine Reihe von generischen virtuellen Geräten, indem es die entsprechenden paravirtualisierten Frontend-Treiber ausführt. Ein einziges Backend bedient mehrere Frontends. Ein Paar paravirtualisierter Treiber existiert für alle gängigen Geräteklassen. Sie liegen in der Regel im Betriebssystem-Kernel, das heißt, Linux.
Xen für ARM ist keine 1:1-Portierung von x86-Xen. Die Architektur, die einen gewissen Overhead in den vielen Jahren der x86-Entwicklung angesammelt hatte, wurde bereinigt. Zuerst wurde die Notwendigkeit der Emulation beseitigt. Emulierte Schnittstellen sind langsam und unsicher. QEMU, das für die Emulation auf x86-Xen verwendet wird, ist ein gut gepflegtes Open-Source-Projekt, ist aber sowohl in Bezug auf die Binärgröße als auch auf die Zeilen des Quellcodes groß. Je kleiner, umso einfacher, desto besser. Xen auf Arm benötigt QEMU nicht, da es keine Emulation durchführt. Das Ziel wird erreicht, indem die Virtualisierungsunterstützung in der Hardware so weit wie möglich genutzt wird und paravirtualisierte Schnittstellen für I/O verwendet werden. Dadurch ist Xen auf Arm schneller und sicherer.
Die neue Architektur, die für Xen auf Arm entwickelt wurde, ist viel sauberer und einfacher und hat sich als sehr gute Ergänzung zur Hardware erwiesen. Arms Virtualisierungserweiterungen bieten 3 Ausführungsebenen: EL0 (Benutzermodus), EL1 (Kernelmodus) und EL2 (Hypervisor-Modus). Sie führen eine neue Anweisung HVC ein, um zwischen Kernelmodus und Hypervisor-Modus zu wechseln. Die MMU unterstützt 2 Übersetzungsstufen. Die generischen Timer und der GIC-Interrupt-Controller sind virtualisierungsfähig.
Arm-Virtualisierungserweiterungen passen hervorragend zur Xen-Architektur:
Xen läuft vollständig und nur im Hypervisor-Modus EL2. Xen überlässt den Kernelmodus EL1 für den Kernel des Gastbetriebssystems und EL0 für Anwendungen, die unter den Gast-OSen laufen. Typ-2-Hypervisoren müssen häufig zwischen Hypervisor-Modus und Kernel-Modus wechseln. Durch die vollständige Ausführung in EL2 reduziert Xen die Anzahl der erforderlichen Kontextwechsel deutlich. Die Anweisung HVC wird vom Kernel verwendet, um Hypercalls an Xen zu senden.
Xen verwendet eine zweistufige Übersetzung in der MMU, um den virtuellen Maschinen Speicher zuzuweisen und generische Timer, um Timer-Interrupts zu empfangen, Timer-Interrupts einzuspeisen und Timer den virtuellen Maschinen zur Verfügung zu stellen. Xen verwendet den GIC, um Interrupts zu empfangen und Interrupts an Gäste zu schicken.
Autosar als Gast-OS
Bild 5 zeigt unsere Beispielkonfiguration, bei der unter dem Hypervisor in drei VMs auf Xen-Domäne 0 Linux 4.9 (VM 1) und als Gast-OS einmal Autosar AP (Adaptive Plattform) unter Linux 4.9 (VM 2) und einmal Autosar CP (Classic Plattform) auf dem Open-Source-RTOS Erika v2 (VM 3) laufen. Da Xen kein eigenes User-Interface hat, wird in der Regel die Domäne 0 als solches verwendet. Autosar AP wird typischerweise für Dashboard-Anwendungen genutzt und Autosar CP für das Motormanagement, so dass es sich bei unserem Beispiel um eine praxisnahe Konfiguration handelt.
In unserem Beispiel soll VM3 periodisch Daten an VM2 senden und zwar über einen zuvor vom Hypervisor konfigurierten geteilten Speicherbereich. Es könnte sich zum Beispiel um Sensoren im Motorumfeld handeln, welche die Motordrehzahl zur Darstellung an das Dashboard senden. Nachdem der Prozess in VM1 gestartet wurde, muss leider eine gestörte Kommunikation zwischen VM2 und VM3 festgestellt werden – in unregelmäßigen Abständen werden die Daten ganz offensichtlich nicht empfangen (Bild 6). Dieser Fehler kann im oben beschriebenen Run-Mode-Debugging unmöglich gefunden werden.
Zunächst soll die Sende-Funktion analysiert werden. Dazu werden mit Hilfe des Debuggers die virtuellen (0x90000000) und physikalischen Adresse (0x63ffff000) des Sende-Puffers in VM3 ermittelt (Bild 7). Ohne die Informationen des Hypervisor-Herstellers hinsichtlich der Adressumsetzung wäre man schon an diesem Punkt gescheitert, da der Debugger diese intern nachbilden muss.
Auf ähnliche Weise wird im nächsten Schritt die Empfangs-Funktion analysiert. Nach dem Laden der Symbole des empfangenden Prozesses (shm_com) in VM2 werden die virtuellen (0xffffa06b7000) und physikalischen Adresse (0x63ffff000) des Empfangs-Puffers in VM2 ermittelt (Bild 8).
An dieser Stelle könnte man nunmehr vermuten, dass ein neu gestarteter Prozess in VM1 den Puffer beeinflussen könnte. Um diese Vermutung bestätigt oder wiederlegt zu bekommen, werden zunächst die Symbole des gestarteten Prozesses (der bug genannt wurde) in VM1 geladen. Dann wird die MMU-Umsetzung dieses Prozesses (ID 0x0C60) gescannt und nach der physikalischen Adresse (0x63ffff000) des Kommunikations-Puffers gesucht, gefolgt von der Ermittlung der virtuellen Adresse dieses Puffers in diesem Prozess. Am Ende wird ein Read/Write-Breakpoint auf diese virtuelle Adresse gesetzt, um nachschauen zu können, was bei einem Zugriff auf sie passiert (Bild 9).
Zur Ermittlung des Fehlers wird das Zielsystem laufen gelassen. Würde der Prozess bug fehlerhafterweise in den Datenpuffer, der die Kommunikation zwischen VM2 und VM3 abbildet, schreiben, müsste durch die zuvor beschriebenen Maßnahmen der Breakpoint auslösen.
Und genau so kommt es dann auch (Bild 10): Der Breakpoint schlägt zu, weil der Prozess in VM1 auf den Puffer geschrieben hat. Das Listing zeigt die aktuelle Code-Zeile und die Variable, die fehlerhaft schreibt.
Im Ergebnis kann man gleich zwei Fehler im System feststellen: Das System wurde falsch konfiguriert, da es VM1 den physikalischen Zugriff auf den Kommunikationskanal von VM2 und VM3 erlaubt. Zum anderen greift der Prozess in VM1 vermutlich auf eine falsche Adresse zu.
Fazit
Eine Hypervisor-Awareness wie die im Trace32 von Lauterbach erlaubt das gleichzeitige Debugging aller Komponenten im System, die Anzeige aller VMs und deren Prozesse und den Zugriff auf Funktionen und Variablen aller Komponenten (Hypervisor, Gast-Betriebssysteme, Prozesse und so weiter).
Die wichtigste Zielsetzung für alle Erweiterungen des Trace32 war ein nahtloses Debugging des Gesamtsystems, was bedeutet, wenn das System an einem Breakpoint angehalten hat, kann man den aktuellen Zustand jedes einzelnen Prozesses, aller VMs, den aktuellen Zustand des Hypervisors und der realen Hardware-Plattform überprüfen. Zudem kann man an jede beliebige Stelle des Codes einen Programm-Breakpoint setzen.
Die Darstellung aller Systemkomponenten umfasst in unserem Beispiel die Anzeige der VMs (Xen-Domänen), der Prozesse in der Linux-VM, der Prozesse in der AP-VM, des AP-Executions-Managers, der Tasks in Erika, einen Tree View des Systems und die physikalische Core-Belegung.
Durch die Hypervisor-Awareness erhält der Debugger alle Informationen über den auf der Hardware-Plattform laufenden Hypervisor. Die Awareness für die jeweiligen Hypervisoren wird von Lauterbach erstellt und seinen Kunden verfügbar gemacht.
Bei der Bedienung eines Debuggers stehen sich meist widersprüchliche Anforderungen gegenüber. Die eine Nutzergruppe wünscht sich eine einfache und intuitive Bedienung, während eine andere maximale Flexibilität und volle Skriptbarkeit einfordert.
Die Grundidee ist eigentlich ganz einfach: Hält der Debugger an einem Breakpoint an, visualisiert die Gui den Anwendungsprozess, durch den der Breakpoint ausgelöst wurde. Interessiert man sich für einen anderen Anwendungsprozess, öffnet man die globale Taskliste von Trace32. Dort werden alle Tasks des Gesamtsystems gelistet. Mit einem Doppelklick lässt sichdann der Task auswählen, den man in der Gui angezeigt haben möchte. Die globale Taskliste bietet zudem die Möglichkeit, Programm-Breakpoints gezielt für einen Task zu setzen. Funktionen und Variablen können während des Debuggings mit Namen angesprochen werden. (fr)