NOVINKA – Víkendový online kurz Software tester, který tě posune dál. Zjisti, jak na to!
NOVINKA - Online rekvalifikační kurz Java programátor. Oblíbená a studenty ověřená rekvalifikace - nyní i online.

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/jme­noUctu/ 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í.


 

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