Virtuelle Maschinen

Eine Zusammenfassung, die versucht die grundlegenden Ideen aus der Vorlesung zu übermitteln (Was angesichts des fehlenden Skripts nicht trivial ist).

Emulation

Generelle Performance

Operationen dekodieren
Operationen müssen jedes mal dekodiert werden
Flags
Berechnung ist teuer
Speicherzugriff
Bus ist langsam (mehrere Buslevel…)
z.B. out:
  1. Value mit PortNr. an Bus geben
  2. Bus callt alle mit Callback registrierte Komponenten. Jede prüft ob sie angesprochen ist.

Speicherhierarchie

Bei einem Speicherzugriff müssen viele einzelne Schritte durchlaufen werden:

Segmentierungseinheit
  • ist Segment ein NULL-Segment? ⇒ Protection Fault
  • ist Segment nicht schreibbar ⇒ Protection Fault
  • ist Adresse plus Operandengröße innerhalb/außerhalb des %es-Segments?
  • lineare Adresse berechnen (virtuelle Adresse plus Segmentbasisadresse)
Alignment-Check
ist der Operand nicht korrekt aligned? ⇒ Alignment Fault
Debug-Einheit

ist Adresse ein Watchpoint? wenn ja und

  • Operandengröße entspricht Watchpointgröße
  • Schreib/Lese-Richtung stimmt

Debug Trap

MMU
  • TLB
  • physische Adresse berechnen
  • Zugriffskontrolle
  • Access/Dirty-Bits setzen
Caches
Caches weglassen
  • bringen eh keinen Mehrwert
JIT
  • Segmentierungscheck nur dazu kompilieren wenn Segmentlimit wirklich limitiert (≠ 4GiB) (ist er z.B in MSDOS)
  • Segmentbasiadresse nur dazu kompilieren wenn ≠ 0
  • Alignmentcheck nur dazu kompilieren wenn eingeschaltet (meistens der Fall)
  • selbiges für Watchpoints usw.
MMU
  • in TLB direkt Pointer zu emulierten Speicherseiten eintragen
Pseudo-TLB

Wir benutzen Cache der

  • über Parameter des Speicherzugriffs indiziert wird (Read/Write/Execute, Privilegstufe, Segment-Nummer, Segment-Offset)
  • direkte Pointer auf emulierten Speicher enthält

Die naive Implementierung braucht zuviel Speicher (hier ~ 766 GiB):

/*                 ring seg offset] */
static uint8_t *mem[4]  [6] [1 << 32];

daher

#define COUNT 4096
static struct entry {
  uint32_t offset;
  uint8_t  *ptr;
} mem[4][6][COUNT]

void store (int ring, int seg, uint32_t offset, unint8_t val) {
  int hash = offset % COUNT;
  struct entry *ent = &mem[ring][seg][hash];
  uint8_t *ptr;

  if (ent->offset != offset) {
    ent->offset = offset;
    ent->ptr = get_ptr(ring, seg, offset);
  }

  ptr = ent->ptr;

  if ( ptr == NULL) {
    store_slow(ring, seg, offset, val);
  } else {
    *ptr = val;
  }
}

Da bis auf Seitengrenzen get_ptr(ring, seg, offset + 1) \eq get_ptr(ring, seg, offset) + 1 gilt:

#define MASK 0x1f

void store (int ring, int seg, uint32_t offset, uint8_t val) {
  int hash = (offset / (MASK + 1)) % COUNT;
  struct entry *ent = &mem[ring][seg][hash];
  uint8_t *ptr;

  if (ent->offset != offset & ~MASK) {
    ent->offset = offset & ~MASK;
    ent->ptr = get_ptr(ring, seg, offset & ~MASK);
  }

  ptr = ent->ptr;

  if (ptr == NULL) {
    store_slow(ring, seg, offset, val);
  } else {
    *(ptr + (offset & MASK)) = val;
  }
}

JIT

Emulation ist in der Regel zu langsam:

z.B. ein Assemblerbefehl wie

add eax [esp + 0x4]
  1. Liegt Strom an?
  2. Hat der letzte Befehl eine Exception ausgelöst?
  3. Liegt ein System-Management-Interrupt vor?
  4. Liegt ein Interrupt vor?
  5. Ist die CPU im HALT-Zustand?

Optimierungen

  • Instruktionen werden nur einmal dekodiert
  • IP wird nur am Ende eines Basisblocks inkrementiert
  • Lazy Flags

Beispiel JVM

Die JVM ist optimiert auf gute JIT-Performance:

  • keine Interrupts (statdessen Multithreading)
  • keine berechneten Sprünge
  • kein selbstmodifizierender Code
  • keine MMU, keine Segmentierung, sichere Pointer:
    Wenn eine Code-Zeile ausgeführt wird
    • liegen immer die gleichen Typen von Argumenten auf dem Stack
    • liegen immer die gleichen Typen in den lokalen Variablen

Für Hardware aber ungeignet:

  • Garbage Collection
  • Code-Prüfung

Exceptions

Exceptions lassen keine linearen Blöcke zu

Rückkehr in die Hauptschleife

Dann greifen aber einige Optimierungen nicht mehr:

  • IP muss u.U. auch im Block gesetzt sein
  • Flags müssen u.U. berechnet werden

Hardware-basierte Virtualisierung

kritische Instruktionen:

inb, outb
Ein-/Ausgabe muss emuliert werden
cli, sti
sollen virtuelle Interrupt-Flags setzen
mov *, %cr
muss überprüft werden
popf
verhält sich in User/Supervisor-Mode unterschiedlich
sidt, sgdt
nicht priviligiert, fragt aber emulierte Informationen ab

Pacifica/Vanderpool

x86-Erweiterungen die kritische Instruktionen filtern.
vmlaunch und vmresume starten CPU-Emulation, wechseln CPU-Zustand
Ein exit-Event tritt auf, wenn

  • in oder out
  • hlt
  • Control-Register wird beschrieben
  • Interrupt tritt auf

Dann wird der aktuelle Zustand (rsp, rip, rflags, cr*, Segmentregister) der emulierten CPU gespeichert und der Host geladen.
Problematisch ist dabei die Umschaltzeit.

Zugriff auf die Grafikkarte:

  • viele mov-Instruktionen, die überprüft werden müssen
  • also sehr viele Moduswechsel

Möglichkeiten:

  • mov auf Funktionsaufrufe abbilden (schlechte Performance bei Grafikoperationen)
  • mov in Grafikspeicher schreiben lassen, Bildschirm-Update per Polling
  • spezielle Grafiktreiber verwenden:
    z.B mov in Buffer, dann Grafikkarter Pointer übergeben (nur ein exit-Event)

Interrupts

cli, popf sehr häufig, also viel Umschaltzeit.

Also Interrupt-Enable-Flag der VM merken, Host unberührt lassen. Interrupts gehen an den Host.
Bei vmresume können Interrupts für die VM übergeben werden.

Exceptions/System-Calls

in der VM selbst, da extra Register für VM existiert.

Segmentierung

zusätzliche Privilege-Level für Guest/Host

MMU

Kritisch ist

mov *, %cr3
Page-Directory-Basis-Register laden
mov *, %cr{0,4}
selten

Für %cr3 in Hardware unkritische Werte bereitstellen, die nicht Exception generieren.
Sonst wird Exception generiert (langsam).

Paravirtualisierung

Guest-OS wird portiert auf spezielle Hardware (Supervisor)
Supervisor-instruktionen müssen also ersetzt werden. z.B in x86:

in, out
Ein-/Ausgabe
mov *, %cr3
Pointer auf Pagetabelle laden
cli, sti
Disable/Enable Interrupts
pushf, popf
Interrupt Status speichern/laden
lidt
Pointer auf Interrupttabelle laden
physischer Speicher
eine Datei
virtueller Speicher
eine gemappte Datei
Interrupts
Signale
Real-Time-Clock/Timer
System-Clock, Timer
Konsole
Terminal oder GUI
Netzwerkkarte
Socket

MMU

MMU emulieren mittels mmap und munmap.

  • viele (langsame) System-Calls nötig für Kontextwechsel
  • nicht beliebig viele Mappings möglich (Limitierung im Linux-Kernel)
  • Host-OS belegt Teil des virtuellen Adressraums, also muss Guest verschoben werden.
  • Guest läuft im User-Modus, Guestapplikationen laufen im User-Modus, also keine Kernel Protection

Interrupts

Statt Interrupts Signale verwenden.

Programmieren der Signale
sigaction
Setzen des Signal-/Supervisor-Stacks
sigstack
Enable/Disable der Signale
sicprocmask
  • Interrupts werden sehr häufig ein-/ausgeschaltet
  • sicprocmask ist langsam

Signale immer zulassen. Der Signal-Handler wird aber nur aufgerufen, wenn virtuelle Interrupts eingeschaltet sind.
Sonst Signale als deferred vormerken.

Exceptions/System-Calls

Exceptions
  • gehen zunächst an den Host statt an Guest
  • normalerweiße sendet OS Exceptions an Applikation
System-Calls
  • gehen an Host
  • werden nicht wie Exceptions weitergeleitet

Umleitung von System-Calls via ptrace (möglich, aber langsam)

Zeit

Zeitmessung in paravirtualiserten Systemen sind ungenau:

  • setitimer kann maximal 100hz, also ist eine virtuelle System-Clock ungenau
  • Guest kann ge-scheduled werden

Beispiel User-Mode Linux

Vorteile Nachteile
integriert im Linux-Kern nur mit bestimmter Versionsnummer
einfach konfigurierbar nur Text-Modus
relativ effizient nur eingeschränkt konfigurierbar

Hypervisor

Hypervisor
abgespecktes Host-OS
Domain
modifiziertes Guest-OS mit Guestapplikationen

Im Allgemeinen muss der Hypervisor nur wenige Calls unterstützen:

  • Ersatz für priviligierte Instruktionen
    • Page-Tabelle verwalten
    • Segment-Tabelle verwalten
    • Exception/Interrupt/System-Call-Verwaltung
    • Ein/Ausgabe
  • VMs verwalten

Der Hypervisor kann also sehr kompakt implementiert werden (~ wenige KiB)

Hypervisor-Calls werden oft kurz nacheinander aufgerufen, also mehrere Calls zusammenfassen (multicall)

Speicher

VMs sollen soviel Speicher reservieren, wie sie gerade wirklich benötigen (balloon memory).
Der Speicher wird dadurch fragmentiert.
Betriebssysteme gehen i.d.R. von kontinuierlichem Speicher aus

Umrechnungstabellen, eingetragen wird beim Eintragen der Pagenummer in die Pagetabelle.

MMU

Hypervisor muss Richtigkeit der Pagetabellen überprüfen. Sonst u.U. Zugriff von einer VM auf Speicher einer Anderen möglich.

  • Ändern der Pagetabellen über Hypervisor-Calls
  • Überprüfen der Tabelle bevor das Pagetabellenbasisregister (%cr3) gesetzt wird.
    Pagetabelle ab da read-only. Beim Schreiben Unterbaum abschneiden und Pagetabelle wieder read-write.
  • Guest ändert nur seine Tabellen. Host passt an bei
    Page-Fault
    Host trägt neue Page ein
    Hypervisor-Call
    Host trägt Page aus

IO

Möglichkeiten:

Hypervisor-System-Calls
  • Guest unabhängig von Hardware
  • Geräte können geteilt werden
  • Hypervisor u.U. sehr groß
  • Performance nicht optimal
Hypervisor gibt VMs Kontrolle
  • Optimale Performance
  • Hypervisor klein und portabel
  • Treiber in Guest eh schon vorhanden
  • VMs können sich gegenseitig stören
  • Geräte können nicht geteilt werden
Hypervisor gibt einer VM (Domain 0) Kontrolle
  • Hypervisor klein und portabel
  • Treiber in Guest eh schon vorhanden
  • Geräte können geteilt werden
  • Performance nicht optimal

Domain {0,N}

Domain 0
Domain mit IO-Permissions
  • muss zuerst gestartet werden
  • kann weitere Domains starten
Domain N
alle anderen Domains

Datentransfer muss sehr schnell sein.

Daten werden nicht kopiert:

Schreiben
  1. Domain N schreibt Daten in Buffer
  2. Physikalische Adresse des Buffers wird mit Schreibauftrag über Hypervisor an Domain 0 gereicht
  3. Domain 0 nutzt phys. Adress zum Schreiben per DMA
  4. Domain 0 schickt Meldung zurück
Lesen
  1. Domain N schickt phys. Adress eines Buffers mit Leseauftrag über Hypervisor an Domain 0
  2. Domain 0 liest per DMA in den Buffer und schickt Meldung zurück
  3. Domain N kann Daten lesen

Betriebssystem-basierte Virtualisierung

mehrere VMs mit gleichem Betriebssystem ⇒ unnötiger Overhead

aber pro VM:

eigene Dateien
chroot
gesharte Systemdateien
read-only mounten mit copy-on-write
IPC
eigene IPC-Objekte
Prozesse
eigene Prozess-Liste, init
User
eigene User
IP
eigene IP-{Adresse, Ports}

außerdem

  • VMs müssen gestartet / gestoppt werden können
Vorteile Nachteile
geringer Overhead nur gleiches OS
Resource-sharing Resourcenbeschränkung schwierig
Plattenspeicher Updates
Speicher Abschottung u.U unsicher

Bibliotheksbasierte Virtualisierung

Um Applikation auszuführen muss nur ABI

CPU-Instruktionen
müssen passen
Memory-Layout
Speicher muss richtig gemappt werden können
(z.B in Windows ist <1MB und >2GiB nicht erlaubt, Linux >3GiB)
Betriebssystem-Interface

System-Calls umbiegen (entfällt wenn libc)

Entweder

  • Wrapper um Programm, der lädt, linkt und startet (wine)
  • Betriebssytem stellt fremde System-Calls bereit (in FreeBSD: Personalities, Execution Domain)

Wine

Tja, ich wüsste auch gern mehr.