Wie Größen von C-Arrays Teil der binären Schnittstelle der Bibliothek wurden

Mit den meisten C-Compilern können Sie auf ein extern Array mit undefinierten Grenzen zugreifen, zum Beispiel:

 extern int external_array[]; int array_get (long int index) { return external_array[index]; } 

Die Definition von external_array befindet sich möglicherweise in einer anderen Übersetzungseinheit und sieht folgendermaßen aus:

 int external_array[3] = { 1, 2, 3 }; 

Die Frage ist, was passiert, wenn sich diese separate Definition folgendermaßen ändert:

 int external_array[4] = { 1, 2, 3, 4 }; 

Oder so:

 int external_array[2] = { 1, 2 }; 

Wird die binäre Schnittstelle beibehalten (vorausgesetzt, es gibt einen Mechanismus, mit dem die Anwendung die Größe des Arrays zur Laufzeit bestimmen kann)?

Seltsamerweise verletzt bei vielen Architekturen das Erhöhen der Größe des Arrays die Kompatibilität der binären Schnittstelle (ABI). Das Reduzieren der Größe des Arrays kann auch Kompatibilitätsprobleme verursachen. In diesem Artikel werden wir uns die ABI-Kompatibilität genauer ansehen und erklären, wie Probleme vermieden werden können.

Links im Datenbereich der ausführbaren Datei


Um zu verstehen, wie die Größe des Arrays Teil der binären Schnittstelle wird, müssen wir zuerst die Links im Datenabschnitt der ausführbaren Datei untersuchen. Natürlich hängen die Details von der spezifischen Architektur ab, und hier konzentrieren wir uns auf die x86-64-Architektur.

Die x86-64-Architektur unterstützt die Adressierung relativ zum Programmzähler, array_get Zugriff auf die globale array_get kann, wie in der array_get gezeigten Funktion array_get , in einem einzigen movl Befehl kompiliert werden:

 array_get: movl external_array(,%rdi,4), %eax ret 

Daraus erstellt der Assembler eine Objektdatei, in der die Anweisung als R_X86_64_32S markiert R_X86_64_32S .

 0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq 

Diese Verschiebung teilt dem Linker ( ld ) mit, wie die entsprechende Position der Variablen external_array während der Verknüpfung beim Erstellen der ausführbaren Datei gefüllt werden soll.

Dies hat zwei wichtige Konsequenzen.

  • Da der Offset der Variablen zur Erstellungszeit bestimmt wird, gibt es zur Laufzeit keinen Overhead, um ihn zu bestimmen. Der einzige Preis ist der Zugriff auf den Speicher selbst.
  • Um den Versatz zu bestimmen, müssen Sie die Größe aller variablen Daten kennen. Andernfalls wäre es unmöglich, das Format des Datenabschnitts während des Layouts zu berechnen.

Bei C-Implementierungen, die auf Executable und Link Format (ELF) ausgerichtet sind , wie in GNU / Linux, enthalten Verweise auf extern Variablen keine Objektgrößen. Im Beispiel array_get Größe des Objekts selbst dem Compiler unbekannt. Tatsächlich sieht die gesamte Assembler-Datei folgendermaßen aus (wobei nur die -fno-asynchronous-unwind-tables aus -fno-asynchronous-unwind-tables , die technisch für die psABI-Konformität erforderlich sind):

  .file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits 

In dieser Assembler-Datei gibt es keine movl für external_array : Die einzige Zeichenreferenz befindet sich in der Zeile mit der Anweisung movl , und die einzigen numerischen Daten in der Anweisung sind die Größe des Array-Elements (impliziert durch movl multipliziert mit 4).

Wenn ELF Größen für undefinierte Variablen benötigt, ist es sogar unmöglich, die Funktion array_get zu kompilieren.

Wie erhält der Linker die tatsächliche Zeichengröße? Er sieht sich die Definition des Symbols an und verwendet die dort gefundenen Größeninformationen. Auf diese Weise kann der Compiler das Layout des Datenabschnitts berechnen und die Datenbewegungen mit den entsprechenden Offsets füllen.

Gemeinsame ELF-Objekte


Bei C-Implementierungen für ELF muss der Programmierer dem Quellcode kein Markup hinzufügen, um anzugeben, ob sich die Funktion oder Variable im aktuellen Objekt (möglicherweise der Bibliothek oder der ausführbaren Hauptdatei) oder in einem anderen Objekt befindet. Der Linker und der Dynamic Loader kümmern sich darum.

Gleichzeitig bestand der Wunsch nach ausführbaren Dateien, die Leistung nicht durch Ändern des Kompilierungsmodells zu verringern. Dies bedeutet, dass beim Kompilieren des Quellcodes für das Hauptprogramm ( -fPIC ohne -fPIC und in diesem speziellen Fall ohne -fPIE ) die Funktion array_get in genau dieselbe array_get kompiliert wird , bevor dynamische gemeinsam genutzte Objekte eingeführt werden. Darüber hinaus spielt es keine Rolle, ob die Variable external_array in der grundlegendsten ausführbaren Datei definiert ist oder ob ein freigegebenes Objekt zur Laufzeit separat geladen wird. Die vom Compiler erstellten Anweisungen sind in beiden Fällen gleich.

Wie ist das möglich? Gemeinsame ELF-Objekte sind schließlich positionsunabhängig. Sie werden zur Laufzeit an unvorhersehbaren, zufälligen Adressen geladen. Der Compiler generiert jedoch eine Maschinencodesequenz, bei der diese Variablen an einem festen Versatz liegen müssen, der während der Verknüpfung berechnet wird , lange bevor das Programm startet.

Tatsache ist, dass nur ein geladenes Objekt (die ausführbare Hauptdatei) diese festen Offsets verwendet. Alle anderen Objekte (der dynamische Loader selbst, die C-Laufzeitbibliothek und alle anderen vom Programm verwendeten Bibliotheken) werden kompiliert und als vollständig positionsunabhängige Objekte (PICs) kompiliert. Für solche Objekte lädt der Compiler die tatsächliche Adresse jeder Variablen aus der globalen Offset-Tabelle (GOT). Wir können diesen Kreisverkehr sehen, wenn wir das Beispiel -fPIC mit -fPIC , was zu einem solchen Assemblycode führt:

 array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret 

Infolgedessen ist die Adresse der Variablen external_array nicht mehr fest codiert und kann zur Laufzeit durch entsprechende Initialisierung des GOT-Datensatzes geändert werden. Dies bedeutet, dass sich die Definition von external_array zur Laufzeit im selben gemeinsam genutzten Objekt, einem anderen gemeinsam genutzten Objekt oder im Hauptprogramm befinden kann. Der dynamische Lader findet die geeignete Definition basierend auf den ELF-Zeichensuchregeln und ordnet die undefinierte Symbolreferenz seiner Definition zu, indem er den GOT-Datensatz auf seine tatsächliche Adresse aktualisiert.

Wir kehren zum ursprünglichen Beispiel zurück, in dem sich die Funktion array_get im Hauptprogramm befindet, sodass die Adresse der Variablen direkt angegeben wird. Die im Linker implementierte Schlüsselidee besteht darin, dass das Hauptprogramm eine Variablendefinition external_array bereitstellt, selbst wenn diese zur Laufzeit tatsächlich in einem gemeinsamen Objekt definiert ist . Anstatt die anfängliche Definition der Variablen im gemeinsam genutzten Objekt anzugeben, wählt der dynamische Lader eine Kopie der Variablen im Datenabschnitt der ausführbaren Datei aus.

Dies hat zwei wichtige Konsequenzen. Denken Sie zunächst daran, dass external_array wie folgt definiert ist:

 int external_array[3] = { 1, 2, 3 }; 

Hier gibt es einen Initialisierer, der auf die Definition in der ausführbaren Hauptdatei angewendet werden sollte. Zu diesem Zweck wird in der ausführbaren Hauptdatei ein Link zum Kopierort des Symbols platziert. Der readelf -rW zeigt an, dass R_X86_64_COPY .

  Der Umzugsabschnitt '.rela.dyn' am Offset 0x408 enthält 3 Einträge:
     Offset Info Type Symbol Wert Symbol Name + Addend
 0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
 0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
 0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 external_array + 0 

Wie bei anderen Bewegungen wird die Kopierbewegung vom dynamischen Lader ausgeführt. Es enthält eine einfache, bitweise Kopieroperation. Das Ziel der Kopie wird durch den Verschiebungsversatz bestimmt (im Beispiel 0000000000404020 ). Die Quelle wird zur Laufzeit anhand des Symbolnamens ( external_array ) und seines Werts ermittelt. Beim Erstellen einer Kopie überprüft der dynamische Lader auch die Größe des Zeichens, um die Anzahl der zu kopierenden Bytes zu ermitteln. Um dies zu ermöglichen, wird das Symbol external_array automatisch als bestimmtes Symbol aus der ausführbaren Datei exportiert, damit es zur Laufzeit für den dynamischen Loader sichtbar ist. Die dynamische Symboltabelle ( .dynsym ) spiegelt dies wider, wie der readelf -sW :

  Die Symboltabelle '.dynsym' enthält 4 Einträge:
    Num: Wert Größe Typ Bind Vis Ndx Name
      0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 
      1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
      2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
      3: 0000000000404020 12 OBJECT GLOBAL DEFAULT 22 external_array 

Woher kommen die Informationen über die Größe des Objekts (in diesem Beispiel 12 Byte)? Der Linker öffnet alle gängigen Objekte, sucht nach ihrer Definition und nimmt Informationen über die Größe entgegen. Auf diese Weise kann der Linker nach wie vor das Layout des Datenabschnitts berechnen, sodass feste Offsets verwendet werden können. Auch hier ist die Größe der Definition in der ausführbaren Hauptdatei festgelegt und kann zur Laufzeit nicht geändert werden.

Der dynamische Linker leitet auch symbolische Links in freigegebenen Objekten an die verschobene Kopie in der ausführbaren Hauptdatei um. Dies stellt sicher, dass im gesamten Programm nur eine Kopie der Variablen vorhanden ist, wie es die Semantik von C erfordert. Andernfalls werden Aktualisierungen der ausführbaren Hauptdatei für dynamische freigegebene Objekte nicht sichtbar, wenn sich die Variable nach der Initialisierung ändert.

Auswirkungen auf die Binärkompatibilität


Was passiert, wenn wir die Definition von external_array in einem gemeinsam genutzten Objekt ändern, ohne das Hauptprogramm zu verknüpfen (oder neu zu kompilieren)? Ziehen Sie zunächst in Betracht , ein Array-Element hinzuzufügen .

 int external_array[4] = { 1, 2, 3, 4 }; 

Dadurch wird zur Laufzeit eine Warnung vom dynamischen Lader generiert:

main-program: Symbol `external_array' has different size in shared object, consider re-linking

Das Hauptprogramm enthält weiterhin eine external_array Definition mit Platz für nur 12 Bytes. Dies bedeutet, dass die Kopie unvollständig ist: Nur die ersten drei Elemente des Arrays werden kopiert. Daher ist der Zugriff auf das Array-Element extern_array[3] nicht definiert. Dieser Ansatz betrifft nicht nur das Hauptprogramm, sondern auch den gesamten Code im Prozess, da alle Verweise auf extern_array auf die Definition im Hauptprogramm umgeleitet wurden. Dies umfasst ein generisches Objekt, das eine extern_array Definition extern_array . Er ist wahrscheinlich nicht bereit, sich einer Situation zu stellen, in der ein Array-Element in seiner eigenen Definition verschwunden ist.

Wie wäre es, in die entgegengesetzte Richtung zu wechseln und ein Element zu entfernen?

 int external_array[2] = { 1, 2 }; 

Wenn das Programm den Zugriff auf das Array-Element extern_array[2] vermeidet, weil es die reduzierte Länge des Arrays irgendwie erkennt, funktioniert dies. Nach dem Array befindet sich nicht verwendeter Speicher, der das Programm jedoch nicht beschädigt.

Dies bedeutet, dass wir die folgende Regel erhalten:

  • Das Hinzufügen von Elementen zu einer globalen Array-Variablen verletzt die Binärkompatibilität.
  • Das Entfernen von Elementen kann die Kompatibilität beeinträchtigen, wenn es keinen Mechanismus gibt, der den Zugriff auf gelöschte Elemente verhindert.

Leider sieht die Warnung des dynamischen Laders harmloser aus als sie tatsächlich ist, und für entfernte Elemente gibt es überhaupt keine Warnung.

So vermeiden Sie diese Situation


Das Erkennen von ABI-Änderungen ist mit Tools wie libabigail ziemlich einfach.

Der einfachste Weg, diese Situation zu vermeiden, besteht darin, eine Funktion zu implementieren, die die Adresse des Arrays zurückgibt:

 static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; } 

Wenn die Definition des Arrays aufgrund der Art und Weise, wie es in der Bibliothek verwendet wird, nicht statisch gemacht werden kann, können wir stattdessen seine Sichtbarkeit verbergen und auch seinen Export verhindern und daher das Kürzungsproblem vermeiden:

 int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 }; 

Alles ist viel komplizierter, wenn die Array-Variable aus Gründen der Abwärtskompatibilität exportiert wird. Da das Array aus der Bibliothek abgeschnitten ist, kann das alte Hauptprogramm mit einer kürzeren Array-Definition keinen Zugriff auf das vollständige Array für den neuen Client-Code gewähren, wenn es mit demselben globalen Array verwendet wird. Stattdessen kann die Zugriffsfunktion ein separates (statisches oder verstecktes) Array oder möglicherweise ein separates Array für hinzugefügte Elemente am Ende verwenden. Der Nachteil ist, dass es nicht möglich ist, alles in einem kontinuierlichen Array zu speichern, wenn die Arrayvariable aus Gründen der Abwärtskompatibilität exportiert wird. Das Design der sekundären Schnittstelle sollte dies widerspiegeln.

Mit der Versionskontrolle von Zeichen können Sie mehrere Versionen mit unterschiedlichen Größen exportieren, ohne die Größe in einer bestimmten Version zu ändern. Bei Verwendung dieses Modells verwenden neue verwandte Programme immer die neueste Version, vermutlich mit der größten Größe. Da die Version und Größe des Symbols gleichzeitig vom Link-Editor festgelegt werden, sind sie immer konsistent. Die GNU C-Bibliothek verwendet diesen Ansatz für die historischen Variablen sys_errlist und sys_siglist . Dies liefert jedoch immer noch kein einziges kontinuierliches Array.

Alles in allem ist eine Accessor-Funktion (wie die get_external_array Funktion get_external_array oben) der beste Ansatz, um dieses ABI-Kompatibilitätsproblem zu vermeiden.

Source: https://habr.com/ru/post/de451182/


All Articles