Když začínáte programovat, máte všechno v jednom souboru. To je v pohodě, dokud je program krátký. Jakmile ale začne růst, stane se z něj nepřehledný moloch. V Céčku se tohle řeší tak, že si program rozdělíte do více souborů — modulů. Každý modul obsahuje funkce, které spolu logicky souvisí. Pojďme se podívat, jak se k tomu dopracovat.

Všechno v jednom souboru

Představte si jednoduchý program, který bliká LEDkou a používá k tomu funkci delay. Celý program máte v jednom souboru main.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "stm8s.h"

void delay(uint16_t ms)
{
    uint16_t i;
    while (ms--) {
        for (i = 0; i < 1000; i++) { }
    }
}

void setup(void)
{
    GPIO_Init(GPIOC, GPIO_PIN_5, GPIO_MODE_OUT_PP_LOW_SLOW);
}

int main(void)
{
    setup();

    while (1) {
        GPIO_WriteReverse(GPIOC, GPIO_PIN_5);
        delay(500);
    }
}

Tohle funguje. Ale co když budu chtít funkci delay použít i v jiném projektu? Nebo co když bude program mít 20 takových funkcí a celý soubor naroste na 500 řádků? Začne to být nepřehledné a špatně se v tom orientuje.

Přesunu funkci do jiného souboru

Přirozeně nás napadne: dám funkci delay do samostatného souboru. Vytvořím tedy soubor delay.c:

1
2
3
4
5
6
7
8
9
#include "stm8s.h"

void delay(uint16_t ms)
{
    uint16_t i;
    while (ms--) {
        for (i = 0; i < 1000; i++) { }
    }
}

A v main.c ji budu chtít zavolat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include "stm8s.h"

void setup(void)
{
    GPIO_Init(GPIOC, GPIO_PIN_5, GPIO_MODE_OUT_PP_LOW_SLOW);
}

int main(void)
{
    setup();

    while (1) {
        GPIO_WriteReverse(GPIOC, GPIO_PIN_5);
        delay(500);
    }
}

Jenže kompilátor při zpracování souboru main.c narazí na volání funkce delay(500) a netuší, co to je. Nezná její návratový typ, neví, jaké má parametry. Každý soubor se totiž kompiluje samostatně a kompilátor nevidí do jiných souborů.

Musíme mu tedy říct: “Hele, tahle funkce existuje, vypadá takhle, ale její tělo je jinde.” Tomu se říká deklarace (nebo také prototyp) funkce.

Deklarace funkce — prototyp

Deklarace funkce vypadá jako hlavička funkce, ale bez těla — jen se zakončí středníkem:

1
void delay(uint16_t ms);

Tím říkáme: existuje funkce delay, která nic nevrací a přebírá jeden parametr typu uint16_t. Její definice (tělo) je někde jinde.

Náš main.c by tedy vypadal takto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include "stm8s.h"

void delay(uint16_t ms);   // deklarace funkce z delay.c

void setup(void)
{
    GPIO_Init(GPIOC, GPIO_PIN_5, GPIO_MODE_OUT_PP_LOW_SLOW);
}

int main(void)
{
    setup();

    while (1) {
        GPIO_WriteReverse(GPIOC, GPIO_PIN_5);
        delay(500);
    }
}

Teď kompilátor ví, jak funkce delay vypadá, a přeloží main.c bez problémů. Linker pak při linkování spojí oba objektové soubory (main.rel a delay.rel) dohromady a dosadí skutečnou adresu funkce delay.

Tohle ale pořád není úplně ideální. Co když budu chtít funkci delay použít ve třech různých souborech? Musel bych do každého z nich ručně napsat tu deklaraci. A co když se změní parametry funkce? Musel bych to opravit na všech místech.

Hlavičkový soubor — #include

Řešení je dát deklaraci do hlavičkového souboru (header file) s příponou .h. Vytvoříme soubor delay.h:

1
2
3
4
5
6
7
8
#ifndef DELAY_H
#define DELAY_H

#include "stm8s.h"

void delay(uint16_t ms);

#endif

Direktivy #ifndef, #define a #endif jsou tzv. include guard. Zajistí, že se obsah hlavičky vloží jen jednou, i kdyby byl #include "delay.h" uveden vícekrát. To se může stát, když jeden hlavičkový soubor vkládá druhý.

Soubor delay.c si vloží vlastní hlavičku:

1
2
3
4
5
6
7
8
9
#include "delay.h"

void delay(uint16_t ms)
{
    uint16_t i;
    while (ms--) {
        for (i = 0; i < 1000; i++) { }
    }
}

A main.c teď vypadá čistě:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include "stm8s.h"
#include "delay.h"

void setup(void)
{
    GPIO_Init(GPIOC, GPIO_PIN_5, GPIO_MODE_OUT_PP_LOW_SLOW);
}

int main(void)
{
    setup();

    while (1) {
        GPIO_WriteReverse(GPIOC, GPIO_PIN_5);
        delay(500);
    }
}

Máme tedy tři soubory:

soubor co obsahuje
delay.h deklarace (prototyp) funkce
delay.c definice (tělo) funkce
main.c hlavní program, volá delay()

Co do hlavičkového souboru patří a co ne

Do .h souboru patří:

  • deklarace funkcí (prototypy)
  • definice maker (#define)
  • deklarace externích proměnných (extern)
  • definice datových typů (typedef, struct, enum)

Do .h souboru nepatří:

  • těla (definice) funkcí
  • definice proměnných (bez extern)

Pravidlo je prosté: hlavička říká, co existuje; zdrojový soubor říká, jak to funguje.

Externí proměnné

S funkcemi je to jasné — deklaraci dám do hlavičky a tělo do .c souboru. Ale co když potřebuji, aby byla jedna proměnná přístupná z více souborů? Například knihovna milis počítá milisekundy od startu programu. Proměnná miliseconds se inkrementuje v přerušení uvnitř milis.c, ale chci ji číst i z main.c.

Proměnná se definuje (tedy vytvoří v paměti) v jednom .c souboru. Tady žije, tady se pro ni alokuje místo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// milis.c
#include "milis.h"

volatile uint32_t miliseconds = 0;

void init_milis(void)
{
    // nastavení timeru ...
}

uint32_t milis(void)
{
    return miliseconds;
}

V hlavičkovém souboru se proměnná deklaruje pomocí klíčového slova extern. To říká: “Tato proměnná existuje, ale je definována jinde — nerezervuj pro ni paměť, jen věz, že někde je.”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// milis.h
#ifndef MILIS_H
#define MILIS_H

#include "stm8s.h"

extern volatile uint32_t miliseconds;

void init_milis(void);
uint32_t milis(void);

#endif

Teď můžu v main.c proměnnou miliseconds přímo číst:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include "milis.h"

int main(void)
{
    init_milis();

    while (1) {
        if (miliseconds > 5000) {
            // po 5 sekundách udělej něco ...
        }
    }
}

Bez klíčového slova extern by kompilátor vytvořil novou proměnnou miliseconds v každém souboru, který ji definuje. Měli bychom dvě různé proměnné se stejným jménem a linker by zahlásil chybu, nebo (ještě hůř) by se program choval nepředvídatelně.

Takže pravidlo: definice proměnné (bez extern) je právě jednou v .c souboru. Deklarace s extern je v .h souboru a říká ostatním modulům, kde ji hledat.

Možná vás napadne: ale vždyť milis.c si vkládá vlastní hlavičku milis.h — takže po předzpracování preprocesorem tam bude extern deklarace i definice pohromadě. Nevadí to? Ne, nevadí. Deklarací (extern) může být kolik chcete — říkají jen “taková proměnná existuje”. Definice (bez extern) musí být právě jedna a ta skutečně alokuje paměť. Kompilátor si obojí spojí a je spokojený. Je to stejné jako u funkcí, kde taky můžete mít prototyp a tělo ve stejném souboru. A proč si v .c souboru vůbec vkládáme vlastní hlavičku? Je to pojistka — kompilátor zkontroluje, že deklarace v hlavičce odpovídá skutečné definici. Kdybyste třeba v hlavičce omylem napsali uint16_t místo uint32_t, kompilátor by zahlásil chybu. Bez toho #include by si nesrovnalosti nikdo nevšiml.

Jak se to přeloží

Každý .c soubor se nejprve zkompiluje samostatně do objektového souboru .rel. Přepínač -c říká kompilátoru: “Jen zkompiluj, nelinkuj.”

1
2
sdcc -mstm8 -c -o delay.rel delay.c
sdcc -mstm8 -c -o main.rel main.c

Teď máme dva objektové soubory. Ty se v druhém kroku slinkují dohromady do výsledného programu:

1
sdcc -mstm8 -o main.ihx main.rel delay.rel

Výsledkem je soubor main.ihx ve formátu Intel HEX, který se nahraje do mikrokontroléru.

Když máte víc modulů, prostě přidáte další .rel soubory:

1
2
sdcc -mstm8 -c -o milis.rel milis.c
sdcc -mstm8 -o main.ihx main.rel delay.rel milis.rel

A tady je další výhoda oddělené kompilace: když změním jen jeden soubor, nemusím překompilovat všechno. Pokud upravím jen delay.c, stačí překompilovat delay.rel a znovu slinkovat. Soubor main.rel zůstane beze změny. U malého projektu to není poznat, ale u většího projektu s desítkami souborů to ušetří spoustu času.

Automatizace překladu pomocí make

Kdo si má pamatovat, co se změnilo a co ne? Od toho je tu program make. Nástroj make čte soubor Makefile, ve kterém jsou popsané závislosti — tedy co závisí na čem. Když pak spustíte příkaz make, přeloží se jen to, co je potřeba.

Zjednodušeně řečeno může kus Makefile vypadat třeba takto:

1
2
3
4
5
6
7
8
main.ihx: main.rel delay.rel
    sdcc -mstm8 -o main.ihx main.rel delay.rel

main.rel: main.c delay.h
    sdcc -mstm8 -c -o main.rel main.c

delay.rel: delay.c delay.h
    sdcc -mstm8 -c -o delay.rel delay.c

Čte se to takto: main.ihx závisí na main.rel a delay.rel. Pokud se některý z nich změní, spustí se příkaz pod ním. Podobně main.rel závisí na main.c a delay.h — pokud se změní hlavička, překompilují se všechny soubory, které ji vkládají.

V praxi za vás Makefile řeší tohle všechno automaticky, takže nemusíte příkazy psát ručně. Ale je důležité chápat, co se pod kapotou děje — tedy že se každý soubor překládá zvlášť a teprve linker je spojí dohromady.

Shrnutí

  1. Když je program malý, klidně mějte vše v jednom souboru.
  2. Jakmile začne růst, rozdělte ho na moduly — každý modul je dvojice souborů .c a .h.
  3. Do .h souboru dejte deklarace (prototypy) funkcí a extern proměnné.
  4. Do .c souboru dejte definice (těla) funkcí a definice proměnných.
  5. V ostatních souborech použijte #include "modul.h" a máte vše k dispozici.

Celý proces překladu je podrobněji popsán v článku Céčko.


Související posty