Blog
Die Instruktion zur Seiteninvalidierung
2022-06-28
In der Tafelübung wurde die Instruktion invlpg
gezeigt, welche im Gegensatz zum Schreiben des cr3
Registers nicht zwingend den ganzen TLB, sondern nur eine einzelne Zuordnung von einer (virtuelle) Adresse entfernen kann – und somit auch entsprechend positiv auf die Performance auswirken kann:
asm volatile("invlpg (%0)" :: "r"(addr) : "memory");
Sofern der TLB einen Eintrag hat, welcher der virtuellen Seitenadresse addr
auf die physikalische Seite zuordnet, wird dieser invalidiert – und beim nächsten Zugriff auf diese virtuelle Adresse wird die MMU die Seitentabellen neu durchlaufen um die Zuordnung zu bestimmen.
Laut Intel Manual kann es unter Umständen auch mehrere Einträge bis hin zum ganzen TLB leeren. Aber dies sind sonderfälle, es darf davon ausgegangen werden, dass die Operation meist eine bessere, zumindest aber keine schlechter Performance als das vollständige Spülen des TLBs (durch cr3
schreiben) bringt!
Verwendung
Die invlpg
Instruktion braucht laut Intel Manual eine effektive Adresse als Operand.
Der Operand darf direkt die Speicheradresse (z.B. 0x1234000
) adressieren:
invlpg (0x1234000)
Alternativ ist auch invlpg 0x1234000
erlaubt (was aber eine etwas verwirrende Schreibweise ist und in den Bereich des syntaktischen Zuckers anzusiedeln ist), es generiert den gleichen Maschinencode.
Mit Intelsyntax (statt AT&T) lautet die Instruktion invlpg [0x1234000]
(es gibt in NASM keine Kurzform)
Daneben darf natürlich auch registerindirekt adressiert werden – das gleiche Ergebnis wie oben erreicht man via:
mov $0x1234000,%rax
invlpg (%rax)
bzw in Intel Syntax
mov rax,0x1234000
invlpg [rax]
Weitere Adressierungsmöglichkeiten
Selbstverständlich sind auch die komplexeren Adressierungsarten erlaubt
Adressierungsart | AT&T Syntax | Intelsyntax | Maschinencode |
---|---|---|---|
displacement | invlpg (0x1234000) |
invlpg [0x1234000] |
0f 01 3c 25 00 40 23 01 |
base | invlpg (%rax) |
invlpg [rax] |
0f 01 38 |
base + index | invlpg (%rax,%rbx,1) |
invlpg [rax + rbx] |
0f 01 3c 18 |
base + displacement | invlpg 0x42(%rax) |
invlpg [rax + 0x42] |
0f 01 78 42 |
base + index + displacement | invlpg 0x42(%rax,%rbx,1) |
invlpg [rax + rbx + 0x42] |
0f 01 7c 18 42 |
base + (index * scale) | invlpg (%rax,%rcx,8) |
invlpg [rax + 8 * rcx] |
0f 01 3c c8 |
(index * scale) + displacement | invlpg var(,%rcx,8) |
invlpg [8 * rcx + var] |
0f 01 3c cd xx xx xx xx |
base + (index * scale) + displacement | invlpg var(%rax,%rcx,8) |
invlpg [rax + 8 * rcx + var] |
0f 01 bc c8 xx xx xx xx |
Im Beispiel ist var
eine Variable/Symbol, welches durch die Adresse der Speicherstelle ersetzt wird.
Für weitere Details (und wie die Adressierungsart aus einer Hochsprache generiert werden könnte) siehe blog.yossarian.net/2020/06/13/How-x86_64-addresses-memory
Bedienfehler
Was jedoch nicht geht, ist eine reine Registeradressierung (also ohne die Klammer, welche für die Indirektion steht:
mov $0x1234000,%rax
invlpg %rax
Das wirft bei der Übersetzung bzw. Assemblierung einen Fehler.
D.h. der inline-Assembler muss
uintptr_t addr = 0x1234000;
asm("invlpg (%0)" :: "r"(addr));
lauten (die Klammer um %0
nicht vergessen!)
Eine speicherindirekte Adressierung ist nicht möglich, der Code
push $0x1234000
invlpg (%rsp)
invalidiert nicht die Adresse 0x1234000
, sondern die Seite mit Stack! Die Instruktion ist somit eher mit lea (%rsp),X
(statt mit mov (%rsp),X
) vergleichbar.
Alternative Schreibweise
Kann man auch statt einem Registeroperand ("r"
) einen Speicheroperand ("m"
) im Inline-Assembler übergeben?
Ja, aber nur als Hack.
asm volatile("invlpg %0" :: "m"(*(void**)addr));
Wieso nun hier keine Klammer? Weil wir durch die Dereferenzierung den Übersetzer zwingen die Adressierungsart (Indirektion) zu generieren:
void invalidate(void **addr) {
asm volatile("invlpg %0" :: "m"(*addr));
}
wird zu
invalidate:
invlpg (%rdi)
retq
weil der Parameter addr
laut der System V ABI im Register rdi
übergeben wird, wird durch die Dereferenzierung der registerindirekte Zugriff (rdi)
, wodurch wieder die effektive Adresse gleich dem Inhalt von rdi
ist – und mit der Instruktion im TLB gespühlt wird.
Flüchtigkeit
Der Beispielaufruf hat ein volatile
– wozu?
Nun, das volatile
Schlüsselwort bei asm
verhindert (wie bei Variablen) Optimierungen. Es darf nicht weggelassen werden, noch umgeordnet. Beides wollen wir.
Allerdings wäre es im konkreten Fall nicht notwendig, da gemäß der Gcc-Doku dies mangels Ausgabeoperanden (nach dem ersten :
) implizit als volatile
betrachtet wird:
GCC’s optimizers sometimes discard asm statements if they determine there is no need for the output variables. Also, the optimizers may move code out of loops if they believe that the code will always return the same result (i.e. none of its input values change between calls). Using the volatile qualifier disables these optimizations. asm statements that have no output operands and asm goto statements, are implicitly volatile.
Aber, es schadet nicht wenn wir dennoch explizit volatile
hinzuschreiben.
Speicherbarriere
Der Beispielaufruf hat am Ende ein Annotation, welche im Gcc eine Speicherbarriere bewirkt
Wieso memory
nützlich sein kann
Beispiel:
// Zeiger auf Adresse 0x1234890 im virtuellen Speicher
int * ptr = (int*) 0x1234890;
// Zeiger auf den Eintrag der PageTable im aktiven Mapping welche `ptr` beinhaltet
0x1234000);
PageTableEntry * active = get_active_pagetable_entry(// Nun ist zum Beispiel `active->address = 0x1bac000`,
// d.h. die virt. Adresse 0x1234890 zeigt auf die physikalische Adresse 0x1bac890
// Wert in Speicher (an phys. Adresse 0x1bac890) schreiben
23;
*ptr =
// Wir mappen nun eine neue Seite (mit einer anderen physikalische Adresse) an den gleichen virtuellen Speicher
0x2cbd000;
active->address = // d.h. die virt. Adresse 0x1234890 zeigt nun auf die physikalische Adresse 0x2cbd890
// (virt.) Seite muss nun noch im TLB invalidiert werden
asm("invlpg (%0)" :: "r"(0x1234000));
// Wert in Speicher (nun an phys. Adresse 0x2cbd890) schreiben
42; *ptr =
Unsere Erwartung ist, dass nun im physikalischen Speicher an Adresse 0x1bac890
der Wert 23
und bei 0x2cbd890
der Wert 42
steht.
Wenn wir das aber mit Optimierungen kompilieren, wird das nicht der Fall sein: An der ersten Adresse steht nicht 23
… Der Übersetzer weiß nicht das wir an der MMU rumspielen und da den virtuellen Adressraum ändern – er sieht nur zwei Schreibzugriffe auf *ptr
(welcher auf den virtuellen Speicher 0x1234890
zeigt) ohne einen Lesezugriff und will schlau sein: “Wenn der erste Wert nur geschrieben, nicht gelesen, und kurz drauf wieder überschrieben wird, dann wird doch der erste Schreibzugriff gar nicht gebraucht?”.
Um den Compiler das nun auszutreiben, kann man im inline-Assembler nun noch den clobber memory
angeben (asm("invlpg (%0)" :: "r"(0x1234000) : "memory");
), dieser bewirkt, dass alle Speicherinhalte geschrieben werden:
The “memory” clobber tells the compiler that the assembly code performs memory reads or writes to items other than those listed in the input and output operands (for example, accessing the memory pointed to by one of the input parameters). To ensure memory contains correct values, GCC may need to flush specific register values to memory before executing the asm. Further, the compiler does not assume that any values read from memory before an asm remain unchanged after that asm; it reloads them as needed. Using the “memory” clobber effectively forms a read/write memory barrier for the compiler.
Der Unterschied ist bei -O3
Optimierungen mit objdump
sehr gut zu sehen:
1091: c7 04 25 90 48 23 01 movl $0x17,0x1234890 // *ptr = 23;
1098: 17 00 00 00
109c: 48 ba 00 00 00 bd 2c movabs $0x2cbd000000,%rdx // active->address = 0x2cbd000;
10a3: 00 00 00
...
10b7: b8 00 40 23 01 mov $0x1234000,%eax // asm("invlpg (%0)" ...);
10bc: 67 0f 01 38 invlpg (%eax)
10c0: c7 04 25 90 48 23 01 movl $0x2a,0x1234890 // *ptr = 42;
Nur wenn memory
angegeben wurde, ist die Instruktion bei 1091
vorhanden, sonst fehlt sie.
Alternativ könnte man natürlich auch durch volatile
bei ptr
den gleichen Effekt erzielen, das müsste man aber für jeden entsprechenden Zeiger machen.
Wieso memory
aber in den meisten Fällen doch nicht gebraucht wird
Der vorherige Beispielcode hat ganz bewusst keinen Funktionsaufurf zwischen den beiden Schreibzugriffen auf *ptr
.
In der Praxis haben wir aber vermutlich statt active->address = 0x2cbd000;
einen Funktions- oder Methodenaufruf wie current->map(0x1234000, 0x2cbd000);
, welcher im aktuellen Mapping (current
) die virtuelle Adresse 0x1234000
auf die physikalische Adresse 0x2cbd000
zeigen lässt.
Und der Übersetzer wird nun eine call
Instruktion bauen, und da er vermutlich nicht weiß, was diese Funktion macht, muss er zwangsweise auch vom worst-case ausgehen – in unserem Fall, dass diese auch auf auf die Adresse 0x1234890
zugreifen könnte. Entsprechend muss er den an diese Adresse auch vor dem Aufruf die 23
schreiben.
Diese Annahme stützt sich darauf, dass die Methode map
in einem anderen Modul ist, und da wir modulweise übersetzen sind übergreifende Optimierungen nicht möglich. In den wenigen Fällen, in dem das im gleichen Modul ist (oder zum Beispiel Link-Time-Optimization verwendet werden), kann es aber passieren, dass er den ersten Schreibzugriff wie oben gezeigt weg optimiert.
Deswegen: Hier immer die Speicherbarriere verwenden, in den meisten von unseren Fällen hat sie auch keinen Performancenachteil, der Code ist aber auch zukunftskompatibel für “intelligentere” Übersetzer 😉
Wann invlpg
einsetzen?
Um (Leichtsinns-)Fehler zu vermeiden, empfiehlt es sich, das invalidieren von Einträgen im TLB automatisch bei den entsprechenden Paging-Operationen durchzuführen – sofern notwendig. Ein map
zum Einblenden einer neuen Seite müsste entsprechend prüfen, ob es gerade auf dem aktiven virtuellen Adressraum arbeitet (cr3
zeigt auf die PML4, auf welcher das Mapping geändert wird). Ist dies der Fall, so wird nach Abschluss der Änderungen an den Seitentabellen die invlpg
Instruktion auf die entsprechende virtuelle Adresse ausgeführt.