Grundlegendes zu x64-Architekturcodemodellen

"Welches Codemodell soll ich verwenden?" - Eine häufig auftretende, aber selten diskutierte Frage beim Schreiben von Code für die x64-Architektur. Dies ist jedoch ein ziemlich interessantes Problem, und es ist nützlich, eine Vorstellung von den Codemodellen zu haben, um den von Compilern generierten x64-Maschinencode zu verstehen. Darüber hinaus wirkt sich die Auswahl des Codemodells auch auf die Optimierung aus, wenn Sie sich um die Leistung bis hin zu den kleinsten Anweisungen sorgen.



Informationen zu diesem Thema im Netzwerk oder anderswo sind selten. Die wichtigste der verfügbaren Ressourcen ist das offizielle x64-ABI. Sie können es hier herunterladen (im Folgenden als "ABI" bezeichnet). Einige der Informationen finden Sie auch auf den manSeitengcc... Der Zweck dieses Artikels besteht darin, zugängliche Empfehlungen zu einem Thema bereitzustellen, verwandte Themen zu diskutieren und einige Konzepte durch guten Code zu demonstrieren, der in der Arbeit verwendet wird.



Wichtiger Hinweis: Dieser Artikel ist nicht als Tutorial für Anfänger gedacht. Vor dem Kennenlernen wird empfohlen, C und Assembler gut zu beherrschen sowie die x64-Architektur grundlegend zu kennen.






Lesen Sie auch unseren vorherigen Beitrag zu einem verwandten Thema: Wie x86_x64 den Speicher adressiert






Codemodelle. Motivierender Teil



In der x64-Architektur werden sowohl Code als auch Daten über befehlsbezogene (oder im x64-Jargon RIP-relative) Adressierungsmodelle referenziert. In diesen Befehlen ist die Verschiebung von RIP auf 32 Bit begrenzt. Es kann jedoch vorkommen, dass ein Befehl beim Versuch, einen Teil des Speichers oder der Daten zu adressieren, einfach nicht genügend 32 Bit verschiebt, z. B. bei der Arbeit mit Programmen mit mehr als zwei Gigabyte.



Eine Möglichkeit, dieses Problem zu lösen, besteht darin, den RIP-relativen Adressierungsmodus vollständig zugunsten einer vollständigen 64-Bit-Verschiebung für alle Daten- und Code-Referenzen aufzugeben. Dieser Schritt ist jedoch sehr teuer: Um den (eher seltenen) Fall unglaublich großer Programme und Bibliotheken abzudecken, erfordern selbst die einfachsten Vorgänge im gesamten Code mehr als die übliche Anzahl von Befehlen.



So werden Codemodelle zu einem Kompromiss. [1] Ein Codemodell ist eine formale Vereinbarung zwischen einem Programmierer und einem Compiler, in der ein Programmierer seine Absichten hinsichtlich der Größe des erwarteten Programms (oder der erwarteten Programme) angibt, in die das aktuell kompilierte Objektmodul fallen wird. [2] Die Codemodelle werden benötigt, damit der Programmierer dem Compiler mitteilen kann: "Keine Sorge, dieses Objektmodul wird nur in kleinen Programmen verwendet, sodass Sie schnelle RIP-bezogene Adressierungsmodi verwenden können." Auf der anderen Seite kann er dem Compiler Folgendes mitteilen: "Wir werden dieses Modul in große Programme kompilieren. Verwenden Sie daher bitte gemächliche und sichere absolute Adressierungsmodi mit einer vollständigen 64-Bit-Verschiebung."



Worüber dieser Artikel berichten wird



Wir werden über die beiden oben beschriebenen Szenarien sprechen, ein kleines Codemodell und ein großes Codemodell: Das erste Modell teilt dem Compiler mit, dass eine relative 32-Bit-Verschiebung für alle Verweise auf den Code und die Daten im Objektmodul ausreichen sollte. Der zweite besteht darauf, dass der Compiler absolute 64-Bit-Adressierungsmodi verwendet. Darüber hinaus gibt es eine Zwischenversion, das sogenannte Middle-Code-Modell .



Jedes dieser Codemodelle wird in unabhängigen PIC- und Nicht-PIC-Varianten dargestellt, und wir werden über jedes der sechs sprechen.



Originalbeispiel in C.



Um die in diesem Artikel diskutierten Konzepte zu demonstrieren, werde ich das folgende C-Programm verwenden und es mit verschiedenen Codemodellen kompilieren. Wie Sie sehen können, mainerhält die Funktion Zugriff auf vier verschiedene globale Arrays und eine globale Funktion. Arrays unterscheiden sich in zwei Parametern: Größe und Sichtbarkeit. Die Größe ist wichtig, um das durchschnittliche Codemodell zu erklären, und wird bei der Arbeit mit kleinen und großen Modellen nicht benötigt. Die Sichtbarkeit ist wichtig für den Betrieb von PIC-Codemodellen und kann entweder statisch (Sichtbarkeit nur in der Quelldatei) oder global (Sichtbarkeit für alle im Programm kompilierten Objekte) sein.



int global_arr[100] = {2, 3};
static int static_arr[100] = {9, 7};
int global_arr_big[50000] = {5, 6};
static int static_arr_big[50000] = {10, 20};

int global_func(int param)
{
    return param * 10;
}

int main(int argc, const char* argv[])
{
    int t = global_func(argc);
    t += global_arr[7];
    t += static_arr[7];
    t += global_arr_big[7];
    t += static_arr_big[7];
    return t;
}


gccverwendet das Codemodell als Optionswert -mcmodel. Darüber hinaus kann das Flag -fpiczum Festlegen der PIC-Kompilierung verwendet werden.



Ein Beispiel für das Kompilieren zu einem Objektmodul über ein großes Codemodell mit PIC:



> gcc -g -O0 -c codemodel1.c -fpic -mcmodel=large -o codemodel1_large_pic.o


Kleines Codemodell



Übersetzung eines Zitats von man gcc auf dem kleinen Codemodell:



-mcmodel = small

Codegenerierung für ein kleines Modell: Das Programm und seine Symbole müssen in den unteren zwei Gigabyte des Adressraums angeordnet sein. Die Größe der Zeiger beträgt 64 Bit. Programme können statisch oder dynamisch verknüpft werden. Dies ist das grundlegende Codemodell.




Mit anderen Worten, der Compiler kann davon ausgehen, dass auf den Code und die Daten über einen relativen 32-Bit-RIP-Offset von jedem Befehl im Code aus zugegriffen werden kann. Schauen wir uns ein zerlegtes Beispiel eines C-Programms an, das wir mit einem Nicht-PIC-Small-Code-Modell kompiliert haben:



> objdump -dS codemodel1_small.o
[...]
int main(int argc, const char* argv[])
{
  15: 55                      push   %rbp
  16: 48 89 e5                mov    %rsp,%rbp
  19: 48 83 ec 20             sub    $0x20,%rsp
  1d: 89 7d ec                mov    %edi,-0x14(%rbp)
  20: 48 89 75 e0             mov    %rsi,-0x20(%rbp)
    int t = global_func(argc);
  24: 8b 45 ec                mov    -0x14(%rbp),%eax
  27: 89 c7                   mov    %eax,%edi
  29: b8 00 00 00 00          mov    $0x0,%eax
  2e: e8 00 00 00 00          callq  33 <main+0x1e>
  33: 89 45 fc                mov    %eax,-0x4(%rbp)
    t += global_arr[7];
  36: 8b 05 00 00 00 00       mov    0x0(%rip),%eax
  3c: 01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr[7];
  3f: 8b 05 00 00 00 00       mov    0x0(%rip),%eax
  45: 01 45 fc                add    %eax,-0x4(%rbp)
    t += global_arr_big[7];
  48: 8b 05 00 00 00 00       mov    0x0(%rip),%eax
  4e: 01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr_big[7];
  51: 8b 05 00 00 00 00       mov    0x0(%rip),%eax
  57: 01 45 fc                add    %eax,-0x4(%rbp)
    return t;
  5a: 8b 45 fc                mov    -0x4(%rbp),%eax
}
  5d: c9                      leaveq
  5e: c3                      retq


Wie Sie sehen können, ist der Zugriff auf alle Arrays auf dieselbe Weise organisiert - mithilfe der RIP-relativen Verschiebung. Im Code ist die Verschiebung jedoch 0, da der Compiler nicht weiß, wo das Datensegment platziert wird. Daher wird für jeden solchen Zugriff eine Verschiebung erstellt:



> readelf -r codemodel1_small.o

Relocation section '.rela.text' at offset 0x62bd8 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000002f  001500000002 R_X86_64_PC32     0000000000000000 global_func - 4
000000000038  001100000002 R_X86_64_PC32     0000000000000000 global_arr + 18
000000000041  000300000002 R_X86_64_PC32     0000000000000000 .data + 1b8
00000000004a  001200000002 R_X86_64_PC32     0000000000000340 global_arr_big + 18
000000000053  000300000002 R_X86_64_PC32     0000000000000000 .data + 31098


Lassen Sie uns den Zugriff auf vollständig entschlüsseln global_arr. Das zerlegte Segment, an dem wir interessiert sind, ist:



  t += global_arr[7];
36:       8b 05 00 00 00 00       mov    0x0(%rip),%eax
3c:       01 45 fc                add    %eax,-0x4(%rbp)


Die RIP-relative Adressierung ist relativ zum nächsten Befehl, daher muss die Verschiebung auf den Befehl gepatcht werden, movdamit sie 0x3s entspricht. Wir sind an der zweiten Verschiebung interessiert, R_X86_64_PC32sie zeigt auf den Operanden movan der Adresse 0x38und bedeutet Folgendes: Wir nehmen den Wert des Symbols, addieren den Term und subtrahieren die durch die Verschiebung angegebene Verschiebung. Wenn Sie alles richtig berechnet haben, werden Sie sehen, wie das Ergebnis eine relative Verschiebung zwischen dem nächsten Team und global_arrplus ergibt 01. Da es 01"das siebte int im Array" bedeutet (in der x64-Architektur beträgt die Größe jedes int4 Bytes), brauchen wir diese relative Verschiebung. Bei Verwendung der RIP-relativen Adressierung verweist der Befehl daher korrekt global_arr[7].



Es ist auch interessant, Folgendes zu beachten: Obwohl Zugriffsbefehle static_arrhier ähnlich sind, wird bei der Umleitung ein anderes Zeichen verwendet, wodurch auf einen Abschnitt anstelle eines bestimmten Zeichens verwiesen wird .data. Dies liegt an den Aktionen des Linkers. Er platziert das statische Array an einer bekannten Stelle im Abschnitt und kann daher nicht mit anderen gemeinsam genutzten Bibliotheken geteilt werden. Infolgedessen wird der Linker die Situation mit diesem Umzug lösen. Da es global_arrjedoch von einer anderen gemeinsam genutzten Bibliothek verwendet (oder überschrieben) werden kann, muss sich der bereits dynamische Loader mit dem Link zu befassen global_arr. [3]



Schauen wir uns zum Schluss den Link an global_func:



  int t = global_func(argc);
24:       8b 45 ec                mov    -0x14(%rbp),%eax
27:       89 c7                   mov    %eax,%edi
29:       b8 00 00 00 00          mov    $0x0,%eax
2e:       e8 00 00 00 00          callq  33 <main+0x1e>
33:       89 45 fc                mov    %eax,-0x4(%rbp)


Da der Operand callqauch RIP-relativ ist, R_X86_64_PC32funktioniert die Verschiebung hier ähnlich wie das Platzieren der tatsächlichen relativen Verschiebung zu global_func im Operanden.



Zusammenfassend stellen wir fest, dass der Compiler aufgrund des kleinen Codemodells alle Daten und den Code des zukünftigen Programms als über eine 32-Bit-Verschiebung zugänglich wahrnimmt und dadurch einfachen und effizienten Code für den Zugriff auf alle Arten von Objekten erstellt.



Großes Codemodell



Übersetzung eines Zitats aus man gcceinem großen Codemodell:



-mcmodel = large

Code für ein großes Modell generieren: Dieses Modell macht keine Annahmen über Adressen und Abschnittsgrößen.


Ein Beispiel für disassemblierten Code main, der mit einem großen Nicht-PIC-Modell kompiliert wurde:



int main(int argc, const char* argv[])
{
  15: 55                      push   %rbp
  16: 48 89 e5                mov    %rsp,%rbp
  19: 48 83 ec 20             sub    $0x20,%rsp
  1d: 89 7d ec                mov    %edi,-0x14(%rbp)
  20: 48 89 75 e0             mov    %rsi,-0x20(%rbp)
    int t = global_func(argc);
  24: 8b 45 ec                mov    -0x14(%rbp),%eax
  27: 89 c7                   mov    %eax,%edi
  29: b8 00 00 00 00          mov    $0x0,%eax
  2e: 48 ba 00 00 00 00 00    movabs $0x0,%rdx
  35: 00 00 00
  38: ff d2                   callq  *%rdx
  3a: 89 45 fc                mov    %eax,-0x4(%rbp)
    t += global_arr[7];
  3d: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  44: 00 00 00
  47: 8b 40 1c                mov    0x1c(%rax),%eax
  4a: 01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr[7];
  4d: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  54: 00 00 00
  57: 8b 40 1c                mov    0x1c(%rax),%eax
  5a: 01 45 fc                add    %eax,-0x4(%rbp)
    t += global_arr_big[7];
  5d: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  64: 00 00 00
  67: 8b 40 1c                mov    0x1c(%rax),%eax
  6a: 01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr_big[7];
  6d: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  74: 00 00 00
  77: 8b 40 1c                mov    0x1c(%rax),%eax
  7a: 01 45 fc                add    %eax,-0x4(%rbp)
    return t;
  7d: 8b 45 fc                mov    -0x4(%rbp),%eax
}
  80: c9                      leaveq
  81: c3                      retq


Noch einmal, es ist nützlich, sich die Umzüge anzusehen:



Relocation section '.rela.text' at offset 0x62c18 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000030  001500000001 R_X86_64_64       0000000000000000 global_func + 0
00000000003f  001100000001 R_X86_64_64       0000000000000000 global_arr + 0
00000000004f  000300000001 R_X86_64_64       0000000000000000 .data + 1a0
00000000005f  001200000001 R_X86_64_64       0000000000000340 global_arr_big + 0
00000000006f  000300000001 R_X86_64_64       0000000000000000 .data + 31080


Da keine Annahmen über die Größe von Codeabschnitten und Daten getroffen werden müssen, ist das große Codemodell ziemlich einheitlich und identifiziert den Zugriff auf alle Daten auf dieselbe Weise. Schauen wir uns noch einmal an global_arr:



  t += global_arr[7];
3d:       48 b8 00 00 00 00 00    movabs $0x0,%rax
44:       00 00 00
47:       8b 40 1c                mov    0x1c(%rax),%eax
4a:       01 45 fc                add    %eax,-0x4(%rbp)


Zwei Befehle müssen den gewünschten Wert aus dem Array erhalten. Der erste Befehl platziert die absolute 64-Bit-Adresse rax, die, wie wir bald sehen werden, die Adresse sein wird global_arr, während der zweite Befehl das Wort von (rax) + 01in lädt eax.



Also lassen Sie uns konzentrieren sich auf das Team bei 0x3d, movabsabsolute 64-Bit - Version movin der x64 - Architektur. Es kann die vollständige 64-Bit-Konstante direkt in das Register werfen, und da in unserem zerlegten Code der Wert dieser Konstante gleich Null ist, müssen wir uns für eine Antwort an die Verschiebungstabelle wenden. Darin finden wir die absolute Verschiebung R_X86_64_64für den Operanden an der Adresse 0x3fmit dem folgenden Wert: Platzieren des Werts des Symbols plus des Summanden zurück in die Verschiebung. Mit anderen Worten,raxenthält eine absolute Adresse global_arr.



Was ist mit der Anruffunktion?



  int t = global_func(argc);
24:       8b 45 ec                mov    -0x14(%rbp),%eax
27:       89 c7                   mov    %eax,%edi
29:       b8 00 00 00 00          mov    $0x0,%eax
2e:       48 ba 00 00 00 00 00    movabs $0x0,%rdx
35:       00 00 00
38:       ff d2                   callq  *%rdx
3a:       89 45 fc                mov    %eax,-0x4(%rbp)


Wir wissen bereits, was movabsdem Befehl folgt, der calldie Funktion bei aufruft rdx. Es reicht aus, die entsprechende Verlagerung zu betrachten, um zu verstehen, wie ähnlich sie dem Datenzugriff ist.



Wie Sie sehen können, macht das große Codemodell keine Annahmen über die Größe der Code- und Datenabschnitte sowie über die endgültige Position der Zeichen. Es bezieht sich lediglich auf Zeichen in absoluten 64-Bit-Schritten, eine Art "sicherer Pfad". Beachten Sie jedoch, dass das große Modell im Vergleich zum kleinen Codemodell gezwungen ist, für jedes Zeichen einen zusätzlichen Befehl zu verwenden. Dies ist der Preis für Sicherheit.



Wir haben uns also mit zwei völlig entgegengesetzten Modellen getroffen: Während das kleine Modell des Codes davon ausgeht, dass alles in die unteren zwei Gigabyte Speicher passt, geht das große Modell davon aus, dass nichts unmöglich ist und jedes Zeichen überall 64- sein kann. Bitadressraum. Der Kompromiss zwischen den beiden ist das mittlere Codemodell.



Durchschnittliches Codemodell



Schauen wir uns nach wie vor die Übersetzung des Zitats an aus man gcc:



-mcmodel=medium

: . . , -mlarge-data-threshold, bss . , .


Ähnlich wie beim kleinen Codemodell geht das mittlere Modell davon aus, dass der gesamte Code in den unteren zwei Gigabyte kompiliert wird. Die Daten sind jedoch in "kleine Daten" unterteilt, die angeblich in den unteren zwei Gigabyte angeordnet sind und unbegrenzt im Speicher "große Datenmengen" enthalten. Daten fallen in die große Kategorie, wenn sie per Definition den Grenzwert von 64 Kilobyte überschreiten.



Es ist auch wichtig zu beachten, dass bei der Arbeit mit einem mittleren Codemodell für große Datenmengen ähnlich den Abschnitten .dataund der .bssErstellung spezieller Abschnitte: .ldataund .lbss. Dies ist im Prisma des Themas des aktuellen Artikels nicht so wichtig, aber ich werde ein wenig davon abweichen. Weitere Details zu diesem Problem finden Sie in ABI.



Nun wird klar, warum diese Arrays im Beispiel erschienen sind_big: Sie werden vom Durchschnittsmodell benötigt, um die "Big Data" mit jeweils 200 Kilobyte zu interpretieren. Unten sehen Sie das Ergebnis der Demontage:



int main(int argc, const char* argv[])
{
  15: 55                      push   %rbp
  16: 48 89 e5                mov    %rsp,%rbp
  19: 48 83 ec 20             sub    $0x20,%rsp
  1d: 89 7d ec                mov    %edi,-0x14(%rbp)
  20: 48 89 75 e0             mov    %rsi,-0x20(%rbp)
    int t = global_func(argc);
  24: 8b 45 ec                mov    -0x14(%rbp),%eax
  27: 89 c7                   mov    %eax,%edi
  29: b8 00 00 00 00          mov    $0x0,%eax
  2e: e8 00 00 00 00          callq  33 <main+0x1e>
  33: 89 45 fc                mov    %eax,-0x4(%rbp)
    t += global_arr[7];
  36: 8b 05 00 00 00 00       mov    0x0(%rip),%eax
  3c: 01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr[7];
  3f: 8b 05 00 00 00 00       mov    0x0(%rip),%eax
  45: 01 45 fc                add    %eax,-0x4(%rbp)
    t += global_arr_big[7];
  48: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  4f: 00 00 00
  52: 8b 40 1c                mov    0x1c(%rax),%eax
  55: 01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr_big[7];
  58: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  5f: 00 00 00
  62: 8b 40 1c                mov    0x1c(%rax),%eax
  65: 01 45 fc                add    %eax,-0x4(%rbp)
    return t;
  68: 8b 45 fc                mov    -0x4(%rbp),%eax
}
  6b: c9                      leaveq
  6c: c3                      retq


Achten Sie darauf, wie auf die Arrays zugegriffen wird: Auf die Arrays _bigwird über die Methoden des großen Codemodells zugegriffen, während auf die übrigen Arrays über die Methoden des kleinen Modells zugegriffen wird. Die Funktion wird auch mit der Small-Code-Modellmethode aufgerufen, und Verschiebungen sind den vorherigen Beispielen so ähnlich, dass ich sie nicht einmal demonstrieren werde.



Das mittlere Codemodell ist ein geschickter Kompromiss zwischen großen und kleinen Modellen. Es ist unwahrscheinlich, dass sich der Programmcode als zu groß herausstellt [4], sodass nur große Datenmengen, die statisch mit ihm verknüpft sind, ihn möglicherweise im Rahmen einer umfangreichen Tabellensuche über die Grenze von zwei Gigabyte hinaus verschieben können. Da das mittlere Codemodell so große Datenblöcke herausfiltert und auf besondere Weise verarbeitet, sind Aufrufe durch den Funktionscode und kleine Symbole genauso effizient wie im kleinen Codemodell. Nur für den Zugriff auf große Symbole in Analogie zum großen Modell muss der Code die vollständige 64-Bit-Methode des großen Modells verwenden.



Kleines PIC-Codemodell



Schauen wir uns nun die PIC-Varianten der Codemodelle an und beginnen wie zuvor mit dem kleinen Modell. [5] Unten sehen Sie ein Beispiel für den Code, der mit dem kleinen PIC-Modell kompiliert wurde:



int main(int argc, const char* argv[])
{
  15:   55                      push   %rbp
  16:   48 89 e5                mov    %rsp,%rbp
  19:   48 83 ec 20             sub    $0x20,%rsp
  1d:   89 7d ec                mov    %edi,-0x14(%rbp)
  20:   48 89 75 e0             mov    %rsi,-0x20(%rbp)
    int t = global_func(argc);
  24:   8b 45 ec                mov    -0x14(%rbp),%eax
  27:   89 c7                   mov    %eax,%edi
  29:   b8 00 00 00 00          mov    $0x0,%eax
  2e:   e8 00 00 00 00          callq  33 <main+0x1e>
  33:   89 45 fc                mov    %eax,-0x4(%rbp)
    t += global_arr[7];
  36:   48 8b 05 00 00 00 00    mov    0x0(%rip),%rax
  3d:   8b 40 1c                mov    0x1c(%rax),%eax
  40:   01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr[7];
  43:   8b 05 00 00 00 00       mov    0x0(%rip),%eax
  49:   01 45 fc                add    %eax,-0x4(%rbp)
    t += global_arr_big[7];
  4c:   48 8b 05 00 00 00 00    mov    0x0(%rip),%rax
  53:   8b 40 1c                mov    0x1c(%rax),%eax
  56:   01 45 fc                add    %eax,-0x4(%rbp)
    t += static_arr_big[7];
  59:   8b 05 00 00 00 00       mov    0x0(%rip),%eax
  5f:   01 45 fc                add    %eax,-0x4(%rbp)
    return t;
  62:   8b 45 fc                mov    -0x4(%rbp),%eax
}
  65:   c9                      leaveq
  66:   c3                      retq


Umzug:



Relocation section '.rela.text' at offset 0x62ce8 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000002f  001600000004 R_X86_64_PLT32    0000000000000000 global_func - 4
000000000039  001100000009 R_X86_64_GOTPCREL 0000000000000000 global_arr - 4
000000000045  000300000002 R_X86_64_PC32     0000000000000000 .data + 1b8
00000000004f  001200000009 R_X86_64_GOTPCREL 0000000000000340 global_arr_big - 4
00000000005b  000300000002 R_X86_64_PC32     0000000000000000 .data + 31098


Da die Unterschiede zwischen großen und kleinen Daten im kleinen Codemodell keine Rolle spielen, konzentrieren wir uns auf die wichtigen Punkte beim Generieren von Code über PIC: die Unterschiede zwischen lokalen (statischen) und globalen Symbolen.



Wie Sie sehen, gibt es keinen Unterschied zwischen dem für statische Arrays generierten Code und dem Code im Nicht-PIC-Fall. Dies ist einer der Vorteile der x64-Architektur: Dank des IP-relativen Zugriffs auf Daten erhalten wir einen PIC als Bonus, zumindest bis ein externer Zugriff auf Zeichen erforderlich ist. Alle Befehle und Verschiebungen bleiben gleich, sodass sie nicht erneut verarbeitet werden müssen.



Es ist interessant, auf globale Arrays zu achten: Es sei daran erinnert, dass in PIC globale Daten die GOT passieren müssen, da sie irgendwann von gemeinsam genutzten Bibliotheken gespeichert oder verwendet werden können [6]. Unten sehen Sie den Code für den Zugriff global_arr:



  t += global_arr[7];
36:   48 8b 05 00 00 00 00    mov    0x0(%rip),%rax
3d:   8b 40 1c                mov    0x1c(%rax),%eax
40:   01 45 fc                add    %eax,-0x4(%rbp)


Die Verlagerung, an der wir interessiert sind, ist R_X86_64_GOTPCREL: die Position der Eingabe des Symbols in der GOT plus dem Term abzüglich der Verschiebung für die Anwendung der Verlagerung. Mit anderen Worten, die relative Verschiebung zwischen dem RIP (nächster Befehl) und dem global_arrim GOT reservierten Steckplatz wird in den Befehl gepatcht . Somit wird die tatsächliche Adresse nach Adresse raxin den Befehl 0x36eingefügt global_arr. Diesem Schritt folgt ein Zurücksetzen der Referenz auf die Adresse global_arrplus ein Versatz zu ihrem siebten Element in eax.



Schauen wir uns nun den Funktionsaufruf an:



  int t = global_func(argc);
24:   8b 45 ec                mov    -0x14(%rbp),%eax
27:   89 c7                   mov    %eax,%edi
29:   b8 00 00 00 00          mov    $0x0,%eax
2e:   e8 00 00 00 00          callq  33 <main+0x1e>
33:   89 45 fc                mov    %eax,-0x4(%rbp)


Es hat eine Verlagerung der callqOperandenadresse 0x2e, R_X86_64_PLT32: PLT Einsprungadresse für das Symbol zuzüglich Begriff negative Verschiebung für die Anwendung der Verlagerung. Mit anderen Worten, es callqsollte das PLT-Sprungbrett für korrekt aufrufen global_func.



Beachten Sie, welche impliziten Annahmen der Compiler trifft: Auf GOT und PLT kann über RIP-relative Adressierung zugegriffen werden. Dies ist wichtig, wenn Sie dieses Modell mit anderen PIC-Varianten von Codemodellen vergleichen.



Großes PIC-Code-Modell



Demontage:



int main(int argc, const char* argv[])
{
  15: 55                      push   %rbp
  16: 48 89 e5                mov    %rsp,%rbp
  19: 53                      push   %rbx
  1a: 48 83 ec 28             sub    $0x28,%rsp
  1e: 48 8d 1d f9 ff ff ff    lea    -0x7(%rip),%rbx
  25: 49 bb 00 00 00 00 00    movabs $0x0,%r11
  2c: 00 00 00
  2f: 4c 01 db                add    %r11,%rbx
  32: 89 7d dc                mov    %edi,-0x24(%rbp)
  35: 48 89 75 d0             mov    %rsi,-0x30(%rbp)
    int t = global_func(argc);
  39: 8b 45 dc                mov    -0x24(%rbp),%eax
  3c: 89 c7                   mov    %eax,%edi
  3e: b8 00 00 00 00          mov    $0x0,%eax
  43: 48 ba 00 00 00 00 00    movabs $0x0,%rdx
  4a: 00 00 00
  4d: 48 01 da                add    %rbx,%rdx
  50: ff d2                   callq  *%rdx
  52: 89 45 ec                mov    %eax,-0x14(%rbp)
    t += global_arr[7];
  55: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  5c: 00 00 00
  5f: 48 8b 04 03             mov    (%rbx,%rax,1),%rax
  63: 8b 40 1c                mov    0x1c(%rax),%eax
  66: 01 45 ec                add    %eax,-0x14(%rbp)
    t += static_arr[7];
  69: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  70: 00 00 00
  73: 8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax
  77: 01 45 ec                add    %eax,-0x14(%rbp)
    t += global_arr_big[7];
  7a: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  81: 00 00 00
  84: 48 8b 04 03             mov    (%rbx,%rax,1),%rax
  88: 8b 40 1c                mov    0x1c(%rax),%eax
  8b: 01 45 ec                add    %eax,-0x14(%rbp)
    t += static_arr_big[7];
  8e: 48 b8 00 00 00 00 00    movabs $0x0,%rax
  95: 00 00 00
  98: 8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax
  9c: 01 45 ec                add    %eax,-0x14(%rbp)
    return t;
  9f: 8b 45 ec                mov    -0x14(%rbp),%eax
}
  a2: 48 83 c4 28             add    $0x28,%rsp
  a6: 5b                      pop    %rbx
  a7: c9                      leaveq
  a8: c3                      retq


Umzüge: Diesmal spielen die Unterschiede zwischen großen und kleinen Datenmengen keine Rolle, daher konzentrieren wir uns auf und . Aber zuerst müssen Sie auf den Prolog in diesem Code achten, zuvor sind wir nicht auf Folgendes gestoßen:



Relocation section '.rela.text' at offset 0x62c70 contains 6 entries:

Offset Info Type Sym. Value Sym. Name + Addend

000000000027 00150000001d R_X86_64_GOTPC64 0000000000000000 _GLOBAL_OFFSET_TABLE_ + 9

000000000045 00160000001f R_X86_64_PLTOFF64 0000000000000000 global_func + 0

000000000057 00110000001b R_X86_64_GOT64 0000000000000000 global_arr + 0

00000000006b 000800000019 R_X86_64_GOTOFF64 00000000000001a0 static_arr + 0

00000000007c 00120000001b R_X86_64_GOT64 0000000000000340 global_arr_big + 0

000000000090 000900000019 R_X86_64_GOTOFF64 0000000000031080 static_arr_big + 0


static_arrglobal_arr



1e: 48 8d 1d f9 ff ff ff    lea    -0x7(%rip),%rbx
25: 49 bb 00 00 00 00 00    movabs $0x0,%r11
2c: 00 00 00
2f: 4c 01 db                add    %r11,%rbx


Unten können Sie die Übersetzung des zugehörigen Zitats aus dem ABI lesen:



( GOT) AMD64 IP- . GOT . GOT , AMD64 ISA 32 .


Schauen wir uns an, wie der oben beschriebene Prolog die GOT-Adresse berechnet. Zunächst 0x1elädt der Befehl an der Adresse seine eigene Adresse in die rbx. Dann wird zusammen mit der Verlagerung R_X86_64_GOTPC64ein absoluter 64-Bit-Schritt in ausgeführt r11. Diese Verlagerung bedeutet Folgendes: Nehmen Sie die GOT-Adresse, subtrahieren Sie die verschobene Verschiebung und fügen Sie den Begriff hinzu. Schließlich 0x2faddiert das Team an der Adresse beide Ergebnisse. Das Ergebnis ist die absolute Adresse von GOT rbx. [7]



Warum sollte man sich die Mühe machen, die GOT-Adresse zu berechnen? Erstens können wir, wie im Zitat erwähnt, in einem großen Codemodell nicht davon ausgehen, dass eine 32-Bit-RIP-relative Verschiebung für die GOT-Adressierung ausreicht, weshalb wir eine vollständige 64-Bit-Adresse benötigen. Zweitens möchten wir immer noch mit der PIC-Variante arbeiten, sodass wir die absolute Adresse nicht einfach in ein Register eintragen können. Vielmehr muss die Adresse selbst relativ zum RIP berechnet werden. Dazu benötigen wir einen Prolog: Er führt eine 64-Bit-RIP-relative Berechnung durch.



Da wir rbxjetzt eine GOT-Adresse haben, schauen wir uns an, wie wir darauf zugreifen können static_arr:



  t += static_arr[7];
69:       48 b8 00 00 00 00 00    movabs $0x0,%rax
70:       00 00 00
73:       8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax
77:       01 45 ec                add    %eax,-0x14(%rbp)


Die Verschiebung des ersten Befehls lautet R_X86_64_GOTOFF64: das Symbol plus der Minus-GOT-Term. In unserem Fall ist dies eine relative Verschiebung zwischen der Adresse static_arrund der GOT-Adresse. Die folgende Anweisung addiert das Ergebnis zu rbx(absolute GOT-Adresse) und setzt den Offset durch Referenz zurück 0x1c. Zur Vereinfachung der Visualisierung einer solchen Berechnung sehen Sie unten das Pseudo-C-Beispiel:



// char* static_arr
// char* GOT
rax = static_arr + 0 - GOT;  // rax now contains an offset
eax = *(rbx + rax + 0x1c);   // rbx == GOT, so eax now contains
                             // *(GOT + static_arr - GOT + 0x1c) or
                             // *(static_arr + 0x1c)


Beachten Sie einen interessanten Punkt: Die GOT-Adresse wird als Bindung an verwendet static_arr. Normalerweise enthält ein GOT keine Symboladresse, und da es sich static_arrnicht um ein externes Symbol handelt, gibt es keinen Grund, es in einem GOT zu speichern. In diesem Fall wird der GOT jedoch als Bindung an die relative Symboladresse des Datenabschnitts verwendet. Diese Adresse, die unter anderem ortsunabhängig ist, kann mit einer vollen 64-Bit-Verschiebung gefunden werden. Der Linker kann diese Verlagerung verarbeiten, sodass der Codeabschnitt beim Laden nicht geändert werden muss.



Aber was ist mit global_arr?



  t += global_arr[7];
55:       48 b8 00 00 00 00 00    movabs $0x0,%rax
5c:       00 00 00
5f:       48 8b 04 03             mov    (%rbx,%rax,1),%rax
63:       8b 40 1c                mov    0x1c(%rax),%eax
66:       01 45 ec                add    %eax,-0x14(%rbp)


Dieser Code ist etwas länger und die Verlagerung unterscheidet sich von der üblichen. Tatsächlich wird GOT auf traditionellere Weise verwendet: Die Verlagerung R_X86_64_GOT64für movabsweist die Funktion nur an, die Verschiebung in der GOT zu platzieren, in der sich die raxAdresse befindet global_arr. Der Befehl unter Adresse 0x5fnimmt die Adresse global_arraus dem GOT und gibt sie ein rax. Der folgende Befehl setzt den Link zurück global_arr[7]und platziert den Wert in eax.



Schauen wir uns nun den Code-Link für an global_func. Denken Sie daran, dass wir in einem großen Codemodell keine Annahmen über die Größe der Codeabschnitte treffen konnten. Daher sollten wir davon ausgehen, dass wir selbst für den Zugriff auf das PLT eine absolute 64-Bit-Adresse benötigen:



  int t = global_func(argc);
39: 8b 45 dc                mov    -0x24(%rbp),%eax
3c: 89 c7                   mov    %eax,%edi
3e: b8 00 00 00 00          mov    $0x0,%eax
43: 48 ba 00 00 00 00 00    movabs $0x0,%rdx
4a: 00 00 00
4d: 48 01 da                add    %rbx,%rdx
50: ff d2                   callq  *%rdx
52: 89 45 ec                mov    %eax,-0x14(%rbp)


Der Umzug, an dem wir interessiert sind, ist R_X86_64_PLTOFF64: die PLT-Eingangsadresse für global_funcminus die GOT-Adresse. Das Ergebnis wird dort abgelegt rdx, wo es dann abgelegt wird rbx(absolute Adresse des GOT). Als Ergebnis erhalten wir die Adresse des PLT-Eintrags für global_funcdie rdx.



Beachten Sie, dass das GOT erneut als Anker verwendet wird, diesmal um eine adressunabhängige Referenz auf den Offset des PLT-Eingangs bereitzustellen.



Durchschnittliches PIC-Code-Modell



Schließlich werden wir den generierten Code für das durchschnittliche PIC-Modell aufschlüsseln:



int main(int argc, const char* argv[])
{
  15:   55                      push   %rbp
  16:   48 89 e5                mov    %rsp,%rbp
  19:   53                      push   %rbx
  1a:   48 83 ec 28             sub    $0x28,%rsp
  1e:   48 8d 1d 00 00 00 00    lea    0x0(%rip),%rbx
  25:   89 7d dc                mov    %edi,-0x24(%rbp)
  28:   48 89 75 d0             mov    %rsi,-0x30(%rbp)
    int t = global_func(argc);
  2c:   8b 45 dc                mov    -0x24(%rbp),%eax
  2f:   89 c7                   mov    %eax,%edi
  31:   b8 00 00 00 00          mov    $0x0,%eax
  36:   e8 00 00 00 00          callq  3b <main+0x26>
  3b:   89 45 ec                mov    %eax,-0x14(%rbp)
    t += global_arr[7];
  3e:   48 8b 05 00 00 00 00    mov    0x0(%rip),%rax
  45:   8b 40 1c                mov    0x1c(%rax),%eax
  48:   01 45 ec                add    %eax,-0x14(%rbp)
    t += static_arr[7];
  4b:   8b 05 00 00 00 00       mov    0x0(%rip),%eax
  51:   01 45 ec                add    %eax,-0x14(%rbp)
    t += global_arr_big[7];
  54:   48 8b 05 00 00 00 00    mov    0x0(%rip),%rax
  5b:   8b 40 1c                mov    0x1c(%rax),%eax
  5e:   01 45 ec                add    %eax,-0x14(%rbp)
    t += static_arr_big[7];
  61:   48 b8 00 00 00 00 00    movabs $0x0,%rax
  68:   00 00 00
  6b:   8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax
  6f:   01 45 ec                add    %eax,-0x14(%rbp)
    return t;
  72:   8b 45 ec                mov    -0x14(%rbp),%eax
}
  75:   48 83 c4 28             add    $0x28,%rsp
  79:   5b                      pop    %rbx
  7a:   c9                      leaveq
  7b:   c3                      retq


Umzug:



Relocation section '.rela.text' at offset 0x62d60 contains 6 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000021  00160000001a R_X86_64_GOTPC32  0000000000000000 _GLOBAL_OFFSET_TABLE_ - 4
000000000037  001700000004 R_X86_64_PLT32    0000000000000000 global_func - 4
000000000041  001200000009 R_X86_64_GOTPCREL 0000000000000000 global_arr - 4
00000000004d  000300000002 R_X86_64_PC32     0000000000000000 .data + 1b8
000000000057  001300000009 R_X86_64_GOTPCREL 0000000000000000 global_arr_big - 4
000000000063  000a00000019 R_X86_64_GOTOFF64 0000000000030d40 static_arr_big + 0


Entfernen wir zunächst den Funktionsaufruf. Ähnlich wie beim kleinen Modell gehen wir im mittleren Modell davon aus, dass die Code-Referenzen die Grenzen der 32-Bit-RIP-Verschiebung nicht überschreiten. Daher ist der Code für den Aufruf global_funcdem gleichen Code im kleinen PIC-Modell sowie für die Fälle kleiner Datenfelder static_arrund vollständig ähnlich global_arr. Daher konzentrieren wir uns auf Big-Data-Arrays, aber lassen Sie uns zunächst über den Prolog sprechen: Hier unterscheidet er sich vom Prolog des Big-Data-Modells.



1e:   48 8d 1d 00 00 00 00    lea    0x0(%rip),%rbx


Dies ist der gesamte Prolog: Es war nur ein Befehl R_X86_64_GOTPC32erforderlich , um die GOT-Adresse zu verschieben rbx(im Vergleich zu drei im großen Modell). Was ist der Unterschied? Tatsache ist, dass, da das GOT im mittleren Modell nicht Teil der "Big Data-Abschnitte" ist, wir davon ausgehen, dass es innerhalb der 32-Bit-Verschiebung verfügbar ist. Im großen Modell konnten wir solche Annahmen nicht treffen und mussten die volle 64-Bit-Verschiebung verwenden.



Interessant ist die Tatsache, dass der Code für den Zugriff global_arr_bigdem gleichen Code in einem kleinen PIC-Modell ähnelt. Aus dem gleichen Grund ist der mittlere Modellprolog kürzer als der große Modellprolog: Wir gehen davon aus, dass der GOT innerhalb der relativen 32-Bit-RIP-Adressierung verfügbar ist. In der Tat für michglobal_arr_bigSie können keinen solchen Zugriff erhalten, aber dieser Fall deckt immer noch das GOT ab, da es sich tatsächlich global_arr_bigin Form einer vollständigen 64-Bit-Adresse darin befindet.



Die Situation ist jedoch anders für static_arr_big:



  t += static_arr_big[7];
61:   48 b8 00 00 00 00 00    movabs $0x0,%rax
68:   00 00 00
6b:   8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax
6f:   01 45 ec                add    %eax,-0x14(%rbp)


Dieser Fall ähnelt dem großen PIC-Modell des Codes, da hier immer noch die absolute Adresse des Zeichens angezeigt wird, die sich nicht im GOT selbst befindet. Da dies ein großes Symbol ist, von dem nicht angenommen werden kann, dass es in den unteren zwei Gigabyte liegt, benötigen wir wie im großen Modell eine 64-Bit-PIC-Verschiebung.



Anmerkungen:



[1] Verwechseln Sie Codemodelle nicht mit 64-Bit-Datenmodellen und Intel-Speichermodellen . Dies sind alles unterschiedliche Themen.



[2] Es ist wichtig zu beachten: Die eigentlichen Befehle werden vom Compiler erstellt, und die Adressierungsmodi werden genau in diesem Schritt festgelegt. Der Compiler kann nicht wissen, in welche Programme oder gemeinsam genutzten Bibliotheken das Objektmodul fallen wird. Einige können klein und andere groß sein. Der Linker kennt die Größe des endgültigen Programms, aber es ist zu spät: Der Linker kann die Verschiebung von Befehlen nur durch Verschieben patchen und die Befehle selbst nicht ändern. Daher muss die "Konvention" des Codemodells beim Kompilieren vom Programmierer "signiert" werden.



[3] Wenn etwas unklar bleibt, lesen Sie den nächsten Artikel .



[4] Das Volumen nimmt jedoch allmählich zu. Das letzte Mal, als ich den Clang-Build von Debug + Asserts überprüfte, erreichte er fast ein Gigabyte, wofür viele dank des automatisch generierten Codes.



[5] Wenn Sie immer noch nicht wissen, wie der PIC funktioniert (sowohl im Allgemeinen als auch im Besonderen für die x64-Architektur), lesen Sie die folgenden Artikel zum Thema: eins und zwei .



[6] Somit kann der Linker die Links nicht unabhängig auflösen und ist gezwungen, die GOT-Verarbeitung auf den dynamischen Loader zu verlagern.



[7] 0x25 - 0x7 + GOT - 0x27 + 0x9 = GOT









All Articles