Lekce 1 - Kompilace v jazyce C a C++
Vítejte u první lekce kurzu pokročilých konstrukcí jazyka C. Z kurzu Základní konstrukce jazyka C již známe ty nejzákladnější konstrukce strukturovaného programování a z kurzu Dynamická práce s pamětí v jazyce C umíme používat ukazatele. Jak asi tušíte, jazyk C má kromě těchto 2 zmíněných kategorií další konstrukce, které by měl každý dobrý programátor znát. Právě těm se budeme věnovat v tomto pokročilém navazujícím kurzu.
Kompilace
Každý program napsaný v C (nebo v C++) musí být před spuštěním zkompilován. Základní průběh kompilace se pro jazyk C vůbec neliší od kompilace pro jazyk C++, proto budu dále mluvit o jazyce C, ale všechny informace jsou obecné a týkají se i C++. Protože jazyk C není jazykem interpretovaným (jako je například Java nebo C#), musí být před spuštěním program zkompilován přímo do binárního kódu, který procesor dokáže přímo dekódovat a spustit. Ukážeme si jednotlivé fáze kompilace a jak lze s nimi pracovat.
Pro dnešní díl nebudeme pracovat v žádném IDE, ale přímo s příkazovou řádkou. Postup bude obecně uveden pro Linux, ale může být spuštěn i ve Windows pomocí Cygwinu, který jste si instalovali na začátku seriálu o programování v C. Jednoduše najděte aplikaci Cygwin a spustěte ji. Výchozí adresář je zpravidla C:/cygwin/home/jmenoUctu/ popřípadě jiné umístění na základě místa, kam jste Cygwin nainstalovali. Zde budeme vkládat všechny soubory, se kterými budeme pracovat.
Při testování jsem se setkal s problémy v Cygwinu. Řešením bylo do cygwinu doinstalovat gcc-g++:GNU Compiller Collection (C++).
Preproccessor
Nejdříve si vytvoříme soubor, se kterým budeme pracovat. Pojmenujeme si ho main.c a bude vypadat následovně:
#define SIRKA 25 #define SECTI(a,b) ((a)+(b)) #ifdef SIRKA const int SIRKA_INT = SIRKA; #endif int main() { int a = 5; int b = SIRKA; int c = a + b; int d = SECTI(a,b); return 0; }
Program je velice přímočarý. Ačkoliv nedává žádný smysl, ukážeme si na něm právě průběh kompilace.
Nejdříve probíhá fáze preproccessingu. To je fáze, kdy kompilátor nahradí makra, naincluduje soubory, které jsme zvolili, a vůbec vyhodnotí všechny řádky, které začínají mřížkou (#). Pokud chceme spustit pouze fázi preprocessingu, přidáme přepínač -E do kompilace. Výsledek si můžete prohlédnout na obrázku:
$ gcc -E main.c # 1 "main.c" # 1 "<built-in>" # 1 "<command-line>" # 1 "main.c" const int SIRKA_INT = 25; int main() { int a = 5; int b = 25; int c = a + b; int d = ((a) + (b)); return 0; }
Vidíme, že na začátku zůstaly nějaké řádky s mřížkou na začátku. To pomáhá kompilátoru zpětně určit, z kama následující kód pochází. Důležité je, že dále žádný řádek s mřížkou nejsou. Podmínka se vyhodnotila a vytvořila nám globální proměnnou. Také vidíme, že se makro SIRKA nahradilo v kódu hodnotou a funkční makro SECTI se nahradilo operací, kterou jsme si zadefinovali.
Zmínil jsem, že preproccessor také nahradí všechny include obsahem souboru. Zkusíme si na začátek kódu přidat #include <stdlib.h> a spustit příkaz znovu. Tentokrát bude výpis obsahovat mnohonásobně více řádků, protože preproccessor nahradil include celým obsahem souboru. Ale na tento soubor se spustil preproccessor také. Preproccessor bude pracovat tak dlouho, dokud neodstraní všechny makra a includy. To také znamená, že lze preproccessor snadno zacyklit. Spustíme-li preproccessor na následující soubory, preproccessing skončí až ve chvíli, kdy kompilátor vyhodnotí zanoření jako příliš hluboké.
//first.h #include "second.h" //second.h #include "first.h" //příkaz gcc -E first.h
Od výše zmíněné situace nám pomáhají právě include guardy, které byl zmíněny v článku o makrech. Následující program proběhne preproccessingem bez problémů, přitom se každý soubor includuje právě jednou.
//first.h #ifndef _FIRST_H_ #define _FIRST_H_ #include "second.h" #endif //second.h #ifndef _SECOND_H_ #define _SECOND_H_ #include "first.h" #endif
Překlad do objektových souborů
Pro fázi preproccessingu probíhá překlad souborů do souborů objektových. To jsou soubory, které již obsahují binární kód. Tento kód ale není spustitelný, protože nemá vyřešené závislosti na jiné části programu (například volání funkce, která je v jiném .c souboru). Nepředpokládám, že by zde někdo rozuměl binárnímu kódu, proto si zatím kód přeložíme pouze do jazyka symbolických adres - to uděláme přepínačem S (gcc -S main.c).
.file "main.c" .globl SIRKA_INT .section .rdata,"dr" .align 4 SIRKA_INT: .long 25 .def __main; .scl 2; .type 32; .endef .text .globl main .def main; .scl 2; .type 32; .endef .seh_proc main main: pushq %rbp ;inicializace zasobniku .seh_pushreg %rbp ;inicializace zasobniku movq %rsp, %rbp ;inicializace zasobniku .seh_setframe %rbp, 0 ;inicializace zasobniku subq $48, %rsp ;inicializace zasobniku .seh_stackalloc 48 ;inicializace zasobniku .seh_endprologue ;inicializace zasobniku call __main ;volání __main movl $5, -4(%rbp) ;začátek naší implementace movl $25, -8(%rbp) movl -4(%rbp), %edx movl -8(%rbp), %eax addl %edx, %eax movl %eax, -12(%rbp) movl -4(%rbp), %edx movl -8(%rbp), %eax addl %edx, %eax movl %eax, -16(%rbp) movl $0, %eax addq $48, %rsp popq %rbp ret .seh_endproc .ident "GCC: (GNU) 5.4.0"
Pro nás je důležitá část od main. Nejdříve se provádí inicializace zásobníku a následně volání funkce __main. __main není naše funkce, ale funkce GCC, která se stará o přilinkování potřebných knihoven, které program používá. Poté následuje už samotné tělo naší funkce main.
Ještě je třeba poznamenat, že kód výše je přeložen pro 64 bitovou architekturu. Pro 32 bitovou architekturu by kód vypadal poněkud jinak:
.file "main.c" .globl _SIRKA_INT .section .rdata,"dr" .align 4 _SIRKA_INT: .long 25 .def ___main; .scl 2; .type 32; .endef .text .globl _main .def _main; .scl 2; .type 32; .endef _main: LFB0: .cfi_startproc pushl %ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 movl %esp, %ebp .cfi_def_cfa_register 5 andl $-16, %esp subl $16, %esp call ___main movl $5, 12(%esp) movl $25, 8(%esp) movl 12(%esp), %edx movl 8(%esp), %eax addl %edx, %eax movl %eax, 4(%esp) movl 12(%esp), %edx movl 8(%esp), %eax addl %edx, %eax movl %eax, (%esp) movl $0, %eax leave .cfi_restore 5 .cfi_def_cfa 4, 4 ret .cfi_endproc LFE0: .ident "GCC: (GNU) 5.4.0"
Změn není mnoho, ale vidíme rozdílné názvy registrů a pár drobných změn. Ačkoliv se jazyk C považuje za multiplatformní, je multiplatformní právě díky tomu, že dokáže přeložit program pro více platforem. Výsledný spustitelný soubor je vždy vázán na konkrétní architekturu. Ten stejný program nespustíme na procesorech x86, x64 nebo ARM. Pro každou architekturu se program musí zkompilovat zvlášť.
Pořád jsme si ale neřekli, jak objektový soubor vytvořit. Přidáme pouze přepínač -c. Kompilátor poté vygeneruje soubor se stejným názvem, ale s koncovkou o. V tomto souboru je binárně zapsáno přesně to, co jsme si ukazovali výše v jazyku symbolických adres. Znovu upozorňuji, že tento soubor není spustitelný, pouze obsahuje implementaci jednotlivých funkcí ze zdrojového souboru.
Volání funkcí
Nyní se podíváme na to, jak vypadá volání funkce. Náš soubor main.c bude vypadat následovně:
int secti(int a, int b) { return a+b; } int main() { int a = 5; int b = 6; int c = secti(a,b); return 0; }
Následně uvedu klíčové části pouze pro 32 bitovou architekturu.
0: ; 32-bitova architektura 1: _secti: 2: pushl %ebp //uložení registru 3: movl %esp, %ebp //uložení aktuální místo na zásobníku do registru ebp 4: movl 8(%ebp), %edx //načte ze zásobníku číslo 5 5: movl 12(%ebp), %eax //načte ze zásobníku číslo 6 6: addl %edx, %eax //sečte čísla 7: popl %ebp //obnovení registru se začátku funkce 8: ret //návrat z funkce 9: _main: 10: movl $5, 28(%esp) //načtení hodnoty 5 do zásobníku na 28. bajt 11: movl $6, 24(%esp) //načtení hodnoty 6 do zásobníku na 24. bajt 12: movl 24(%esp), %eax //přesun honoty 6 do registru EAX 13: movl %eax, 4(%esp) //přesun hodnoty z registru EAX do zásobníku na 4. bajt 14: movl 28(%esp), %eax //přesun hodnoty 5 do registru EAX 15: movl %eax, (%esp) //přesun hodnoty z registru EAX do zasobníku na 0. bajt 16: call _secti //volání funkce, uloží do zásobníku aktuální místo v programu 17: movl %eax, 20(%esp) //načtení do zásobníku na 20. bajtu hodnotu z registru EAX 18: movl $0, %eax //načtení hodnoty 0 do registru EAX 19: leave //ukončení programu
Nyní se nad tím na chvíli zamyslíme. Vidíme, že se parametry nejprve uloží do zásobníku a poté se volá samotná funkce (tzv. předání hodnotou). To také znamená, že kompilátor nepotřebuje vědět, co funkce dělá. Když kompilátor ví, jaké parametry funkce přijímá a jakou zabírají část paměti (protože je C jazykem silně typovým, dokáže na zákaldě typu odvodit jeho velikost), poté samotnou implementaci v této části kódu ani nepotřebuje. Dále je (spíše z historických důvodů) určeno, že kompilátor zdrojový kód projde právě jednou a pouze shora dolů. Teď by mělo být jasné, proč lze rozdělit funkci na deklaraci a definici a proč musí být deklarace před samotným voláním funkce:
int secti(int,int); int main() { int a = 5; int b = 6; int c = secti(a,b); return 0; } int secti(int a, int b) { return a+b; }
Kompilátor jde shora. Nejprve zjistí, že někde existuje nějaké funkce secti, která má dva parametry typu int a návratový typ int (ví kolik má vyhradit místa). Následně projde funkci main a zjistí, že se volá funkce secti. Zatím ani neví, jestli tato funkce skutečně existuje (jestli je implementovaná) a kde je. O to se ve skutečnosti nestará kompilátor, ale linker (ten bude probrán v dalším článku). Důležité je pro něj pouze to, kolik má vyhradit místa pro návratovou hodnotu a parametry. Teprve poté příjde k funkci secti a přeloží ji. Tentokrát je výstup v jazyce symbolických adres převrácený podle toho, v jakém pořadí jsme funkce definovali:
.file "main.c" .def ___main; .scl 2; .type 32; .endef .text .globl _main .def _main; .scl 2; .type 32; .endef _main: LFB0: .cfi_startproc pushl %ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 movl %esp, %ebp .cfi_def_cfa_register 5 andl $-16, %esp subl $32, %esp call ___main movl $5, 28(%esp) movl $6, 24(%esp) movl 24(%esp), %eax movl %eax, 4(%esp) movl 28(%esp), %eax movl %eax, (%esp) call _secti movl %eax, 20(%esp) movl $0, %eax leave .cfi_restore 5 .cfi_def_cfa 4, 4 ret .cfi_endproc LFE0: .globl _secti .def _secti; .scl 2; .type 32; .endef _secti: LFB1: .cfi_startproc pushl %ebp .cfi_def_cfa_offset 8 .cfi_offset 5, -8 movl %esp, %ebp .cfi_def_cfa_register 5 movl 8(%ebp), %edx movl 12(%ebp), %eax addl %edx, %eax popl %ebp .cfi_restore 5 .cfi_def_cfa 4, 4 ret .cfi_endproc LFE1: .ident "GCC: (GNU) 5.4.0"
V příští lekci, Kompilace v jazyce C a C++ pokračování, se podíváme na poslední část kompilace - linkování.