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.