IT rekvalifikace s garancí práce. Seniorní programátoři vydělávají až 160 000 Kč/měsíc a rekvalifikace je prvním krokem. Zjisti, jak na to!
Hledáme nové posily do ITnetwork týmu. Podívej se na volné pozice a přidej se do nejagilnější firmy na trhu - Více informací.

Lekce 2 - Kompilace v jazyce C a C++ pokračování

V minulé lekci, Kompilace v jazyce C a C++, jsme si řekli o preproccessingu a kompilaci do objektových souborů.

V dnešním C tutoriálu se podíváme na linkování a řekneme si, proč C používá hlavičkové a implementační soubory.

Linkování

Při kompilování do objektových souborů jsme si řekli, že kompilátor neví, zda volaní funkce existuje a kde je. Použití správných adres v paměti řeší až linker. Ve finálním spustitelném souboru již nefigurují žádné jména funkcí. Funkce začíná na nějakém místě v paměti (offset), od kterého se začnou instrukce provádět do té doby, dokud procesor nenarazí na instrukci ret. Ta vezme poslední záznam v zásobníku a vrátí se na místo v paměti určené vyzvednutou hodnotou - tak jak jsme to viděli v minulém díle. Pro připomenutí přikládám ještě jednou zdrojové soubory z minulé lekce.

int secti(int a, int b)
{
    return a+b;
}

int main()
{
    int a = 5;
    int b = 6;
    int c = secti(a,b);
    return 0;
}

Vygeneruje následující kód:

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

Ve zdrojovém kódu tedy nejsou žádné označení jako _main a _secti, ale pouze adresy v paměti. Úkolem linkeru je, aby příkaz call _secti nahradil voláním místa v paměti. Budou-li čísla (které původně označovali číslo řádku) nyní označovat (pouze zjednodušení, instrukce mohou být různě dlouhé) místo v paměti, kde je funkce umístěna, bude kód vypadat následovně:

1:
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:
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    0x2     //volání funkce na základě její adresy
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

Problém je, že objektové soubory můžeme spojovat i mezi sebou a tím se bude offset měnit. Úkolem linkeru je offsety přepočítat tak, aby volaná adresa odpovídala umístění funkce.

Operace linkování

Vytvoříme si několik souborů, na kterých předvedeme operaci linkování (pro jednoduchost vynechávám include guardy).

//soucet.h
int secti(int,int);

//soucet.c
#include "soucet.h"
int secti(int a,int b)
{
    return a+b;
}

//soucin.h
int vynasob(int,int);

//soucin.c
#include "soucin.h"
int vynasob(int a,int b)
{
    return a*b;
}

//main.c
#include "soucet.h"
#include "soucin.h"
int main()
{
    int a = 5;
    int b = 6;
    secti(a,b);
    vynasob(a,b);
}

Nyní všechny soubory zkompilujeme do objektových souborů. Poté spustíme linker a spojíme objektové soubory součinu a součtu do objektového souboru lib.o. Poslední fází je vytvoření spustitelného souboru.

gcc -c main.c               //objektový soubor main.o
gcc -c soucet.c             //objektový soubor soucet.o
gcc -c soucin.c             //objektový soubor soucin.o
ld -r -o lib.o soucet.o soucin.o    //spuštění linkeru
                    //  -r znamená vytvořit opět objektový soubor
                    //  -o určuje výstupní soubor
gcc -o program.exe main.o lib.o     //vytvoření spustitelného souboru
                    //interně se volá opět linker ale s dalšími parametry

A k čemu nám to vlastně všechno je? Kompilace je operace velice náročná na výpočetní čas. Pokud bychom při každé změně museli zkompilovat celý program, většinu času bychom jenom čekali. Díky objektovým souborům máme již některé soubory zkompilované, a tak je nemusíme kompilovat znovu. Například kdybychom něco změnili v souboru main.c, stačí nám pouze spustit první a poslední příkaz a získáme spustitelný soubor. Zbylé dva soubory se vůbec kompilovat nemusí a ušetříme čas.

Hlavičkové soubory

Po tématu linkování by již mělo být jasné, proč se v C používají hlavičkové (obsahují deklaraci) a implementační (obsahují implementaci) soubory. Z implementačních souborů můžeme vytvořit soubory objektové a tím ušetřit výkon. Kompilovat hlavičkové soubory do objektových by nedávalo smysl. Pouze říkají, že někde existuje určitá funkce (popřípadě struktura, třída apod.), ale dále o ní nic neříkají. Po zkompilování by soubor neobsahoval žádný spustitelný kód. Zároveň ale zbylé části aplikace (ostatní .c/.cpp soubory) musí vědět, co je v jiných implementačních souborech obsaženo - právě tuto informaci poskytují hlavičkové soubory.

Implementace v hlavičkových souborech

Ukažme si jednoduchý příklad, proč by neměla být implementace v hlavičkových souborech:

//superFunkce.h
#ifndef __SUPERFUNKCE_H_
#define __SUPERFUNKCE_H_
int funkce(int a,int b,char operace)
{
    switch(operace)
    {
        case '*':
            return a*b;
        case '+':
            return a+b;
    }
}
#endif

//soucin.h
#include "superFunkce.h"
int vynasob(int a,int b)
{
    return funkce(a,b,'*');
}

//soucet.h
#include "superFunkce.h"
int secti(int a,int b)
{
    return funkce(a,b,'+');
}

Nyní provedeme postupnou kompilaci, jak jsme si ji ukázali.

$ gcc -c soucet.c
$ gcc -c soucin.c
$ ld -r -o lib.o soucin.o soucet.o
soucet.o:soucet.c:(.text+0x0): multiple definition of `funkce'
soucin.o:soucin.c:(.text+0x0): first defined here

Jednotlivé kompilace do objektových souborů prošli v pořádku, ale jejich linkování neprošlo. Nepomohly nám ani include guardy. Pro první dva příkazy se vždy include nahradilo obsahem souboru, který v tuto chvíli již obsahoval implementaci. Při pokusu o spojení linker zjistil, že funkce je nadefinována dvakrát a linkování zastavil. Někoho možná napadlo, že i při přesunu implementace do implementačních souborů může nastat chyba. Chyba nastane ve chvíli, kdy jeden soubor linkujeme dvakrát. Soubor superFunkce.h upravíme následovně:

//superFunkce.h
#ifndef __SUPERFUNKCE_H_
#define __SUPERFUNKCE_H_
int funkce(int a,int b,char operace);
#endif

//superFunkce.c
#include "superFunkce.h"
int funkce(int a,int b,char operace)
{
    switch(operace)
    {
        case '*':
            return a*b;
        case '+':
            return a+b;
    }
}

a nyní zkusíme nejdříve linkovat naši funkci s operacemi, a teprve potom je spojit:

$ gcc -c superFunkce.c
$ gcc -c soucet.c
$ gcc -c soucin.c
$ ld -r -o soucetLink.o soucet.o superFunkce.o
$ ld -r -o soucinLink.o soucin.o superFunkce.o
$ ld -r -o lib.o soucinLink.o soucetLink.o
soucetLink.o:superFunkce.c:(.text+0x24): multiple definition of `funkce'
soucinLink.o:superFunkce.c:(.text+0x24): first defined here

Vidíme, že máme ten stejný problém. Naštěstí moderní IDE už kompilaci zvládají (včetně objektových souborů) a tak dokáží sami vyhodnotit, co je potřeba zkompilovat a jak finální program linkovat. Důležité je uvědomit si, že implementace patří do implementačních souborů (.c pro C nebo .cpp pro C++) a deklarace patří do souborů hlavičkových (.h pro C a .hpp pro C++). Také je potřeba nezapomínat na include guardy.

Touto lekcí máme dokončenou celou teorii ohledně kompilace programu. Od samotných zdrojových kódů až po spustitelný soubor. Všechny tyto operace zpravidla obstarává IDE, ale znalost těchto postupů pomáhá psát správný kód, který nebude problém rozšiřovat a spravovat.

V příští lekci, Pokročilé cykly v jazyce C, přejdeme zpět na něco praktického.


 

Předchozí článek
Kompilace v jazyce C a C++
Všechny články v sekci
Pokročilé konstrukce jazyka C
Přeskočit článek
(nedoporučujeme)
Pokročilé cykly v jazyce C
Článek pro vás napsal Patrik Valkovič
Avatar
Uživatelské hodnocení:
9 hlasů
Věnuji se programování v C++ a C#. Kromě toho také programuji v PHP (Nette) a JavaScriptu (NodeJS).
Aktivity