Propojení je poslední fází procesu kompilace gcc.
V procesu propojení jsou soubory objektů propojeny a všechny odkazy na externí symboly jsou vyřešeny, konečné adresy jsou přiřazeny k volání funkcí atd.
V tomto článku se zaměříme především na následující aspekty procesu propojení gcc:
- Soubory objektů a jejich vzájemné propojení
- Přemístění kódu
Než přečtete tento článek, ujistěte se, že rozumíte všem 4 fázím, kterými musí program C projít, než se stane spustitelným souborem (předzpracování, kompilace, sestavení a propojení).
PROPOJOVÁNÍ SOUBORŮ OBJEKTŮ
Pojďme pochopit tento první krok pomocí příkladu. Nejprve vytvořte následující program main.c.
$ vi main.c #include <stdio.h> extern void func(void); int main(void) { printf("\n Inside main()\n"); func(); return 0; }
Dále vytvořte následující program func.c. V souboru main.c jsme deklarovali funkci func() prostřednictvím klíčového slova ‚extern‘ a tuto funkci jsme definovali v samostatném souboru func.c
$ vi func.c void func(void) { printf("\n Inside func()\n"); }
Vytvořte soubor objektu pro func.c, jak je znázorněno níže. Tím se v aktuálním adresáři vytvoří soubor func.o.
$ gcc -c func.c
Podobně vytvořte soubor objektu pro main.c, jak je znázorněno níže. Tím se vytvoří soubor main.o v aktuálním adresáři.
$ gcc -c main.c
Nyní spusťte následující příkaz k propojení těchto dvou objektových souborů a vytvoření konečného spustitelného souboru. Tím se v aktuálním adresáři vytvoří soubor ‚main‘.
$ gcc func.o main.o -o main
Když spustíte tento „hlavní“ program, uvidíte následující výstup.
$ ./main Inside main() Inside func()
Z výše uvedeného výstupu je jasné, že jsme byli schopni úspěšně propojit dva objektové soubory do konečného spustitelného souboru.
Čeho jsme dosáhli, když jsme oddělili funkci func() od main.c a napsali ji do func.c?
Odpověď zní, že zde možná příliš nezáleželo na tom, kdybychom do stejného souboru napsali i funkci func(), ale uvažovali jsme o velmi velkých programech, kde bychom mohli mít tisíce řádků kódu. Změna jednoho řádku kódu může vést k rekompilaci celého zdrojového kódu, což ve většině případů není přijatelné. Takže velmi velké programy jsou někdy rozděleny do malých částí, které jsou nakonec spojeny dohromady, aby vytvořily spustitelný soubor.
Nástroj make, který pracuje s makefiles, přichází do hry ve většině těchto situací, protože tento nástroj ví, které zdrojové soubory byly změněny a které objektové soubory je třeba překompilovat. Soubory objektů, jejichž odpovídající zdrojové soubory nebyly změněny, jsou propojeny tak, jak jsou. Díky tomu je proces kompilace velmi snadný a zvládnutelný.
Nyní tedy chápeme, že když propojíme dva objektové soubory func.o a main.o, je linker gcc schopen vyřešit volání funkce func() a když se provede finální spustitelný main, uvidíme printf() uvnitř vykonávané funkce func().
Kde linker našel definici funkce printf()? Protože Linker neuvedl žádnou chybu, určitě to znamená, že linker našel definici printf(). printf() je funkce, která je deklarována v stdio.h a definována jako součást standardní sdílené knihovny ‚C‘ (libc.so)
Tento soubor sdíleného objektu jsme nepropojili s naším programem. Jak to tedy fungovalo? Pomocí nástroje ldd zjistěte, který vytiskne sdílené knihovny požadované každým programem nebo sdílenou knihovnou zadanou na příkazovém řádku.
Spusťte ldd na „hlavním“ spustitelném souboru, který zobrazí následující výstup.
$ ldd main linux-vdso.so.1 => (0x00007fff1c1ff000) libc.so.6 => /lib/libc.so.6 (0x00007f32fa6ad000) /lib64/ld-linux-x86-64.so.2 (0x00007f32faa4f000)
Výše uvedený výstup naznačuje, že hlavní spustitelný soubor závisí na třech knihovnách. Druhý řádek ve výše uvedeném výstupu je „libc.so.6“ (standardní knihovna „C“). Takto je linker gcc schopen vyřešit volání funkce printf().
První knihovna je vyžadována pro provádění systémových volání, zatímco třetí sdílená knihovna je ta, která načítá všechny ostatní sdílené knihovny požadované spustitelným souborem. Tato knihovna bude přítomna pro každý spustitelný soubor, jehož spuštění závisí na jiných sdílených knihovnách.
Během propojování je příkaz, který interně používá gcc, velmi dlouhý, ale z pohledu uživatelů prostě musíme napsat.
$ gcc <object files> -o <output file name>
PŘEMÍSTĚNÍ KÓDU
Přemístění jsou položky v binárním souboru, které jsou ponechány k vyplnění v době propojení nebo běhu. Typický záznam o přemístění říká:Najděte hodnotu ‚z‘ a vložte tuto hodnotu do konečného spustitelného souboru na offset ‚x‘
Pro tento příklad vytvořte následující reloc.c.
$ vi reloc.c extern void func(void); void func1(void) { func(); }
Ve výše uvedeném souboru reloc.c jsme deklarovali funkci func(), jejíž definice stále není poskytnuta, ale tuto funkci voláme ve funkci func1().
Vytvořte objektový soubor reloc.o z reloc.c, jak je znázorněno níže.
$ gcc -c reloc.c -o reloc.o
Použijte nástroj readelf k zobrazení přemístění v tomto objektovém souboru, jak je uvedeno níže.
$ readelf --relocs reloc.o Relocation section '.rela.text' at offset 0x510 contains 1 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000000005 000900000002 R_X86_64_PC32 0000000000000000 func - 4 ...
Adresa func() není známa v době, kdy děláme reloc.o, takže kompilátor ponechává relokaci typu R_X86_64_PC32. Toto přemístění nepřímo říká, že „vyplňte adresu funkce func() ve finálním spustitelném souboru na offsetu 000000000005“.
Výše uvedené přemístění odpovídalo části .text v objektovém souboru reloc.o (opět je potřeba porozumět struktuře souborů ELF, aby bylo možné porozumět různým sekcím), takže část .text rozebereme pomocí utility objdump:
$ objdump --disassemble reloc.o reloc.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <func1>: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: e8 00 00 00 00 callq 9 <func1+0x9> 9: c9 leaveq a: c3 retq
Ve výše uvedeném výstupu má offset ‚5‘ (záznam s hodnotou ‚4‘ vzhledem k počáteční adrese 0000000000000000) 4 bajty čekající na zápis s adresou funkce func().
Čeká se tedy na přemístění funkce func(), které bude vyřešeno, když propojíme reloc.o s objektovým souborem nebo knihovnou, která obsahuje definici funkce func().
Zkusme zjistit, zda se toto přemístění vyřeší nebo ne. Zde je další soubor main.c, který poskytuje definici funkce func() :
$ vi main.c #include<stdio.h> void func(void) // Provides the defination { printf("\n Inside func()\n"); } int main(void) { printf("\n Inside main()\n"); func1(); return 0; }
Vytvořte soubor objektu main.o z main.c, jak je ukázáno níže.
$ gcc -c main.c -o main.o
Propojte reloc.o s main.o a zkuste vytvořit spustitelný soubor, jak je ukázáno níže.
$ gcc reloc.o main.o -o reloc
Znovu proveďte objdump a zjistěte, zda bylo přemístění vyřešeno nebo ne:
$ objdump --disassemble reloc > output.txt
Přesměrovali jsme výstup, protože spustitelný soubor obsahuje mnoho a mnoho informací a my se nechceme na stdout ztratit.
Zobrazit obsah souboru output.txt.
$ vi output.txt ... 0000000000400524 <func1>: 400524: 55 push %rbp 400525: 48 89 e5 mov %rsp,%rbp 400528: e8 03 00 00 00 callq 400530 <func> 40052d: c9 leaveq 40052e: c3 retq 40052f: 90 nop ...
Na 4. řádku jasně vidíme, že prázdné bajty adresy, které jsme viděli dříve, jsou nyní vyplněny adresou funkce func().
Abych to uzavřel, propojení kompilátoru gcc je tak obrovské moře, do kterého se lze ponořit, že jej nelze pokrýt v jednom článku. Přesto se tento článek pokusil odloupnout první vrstvu procesu propojení, abyste získali představu o tom, co se děje pod příkazem gcc, který slibuje propojení různých objektových souborů za účelem vytvoření spustitelného souboru.