Cvičení 2 – Volání funkcí v jazyce „C“


Poslední změna: pondělí 9. března 2009

1. Proč se učit jazyk „C“

Jazyk „C“ se svojí koncepcí nachází přibližně na polovině cesty od assemblerem k moderním objektově orientovaným jazykům s automatickou správou paměti. Jedná se o jazyk relativně starý (vyvinut mezi roky 1970 a 1978) velmi těsně spjatý s operačním systémem Unix. Vyvstává tedy otázka, proč se tímto jazykem zabývat. Důvodů je více a množství z nich je velmi podstatných:

  • jazyk a jeho knihovny byly vyvíjeny s koncepcí a jádrem Unixu a je tedy nejpřirozenějším prostředím pro přímou komunikaci se systémovými voláními takovéhoto systému, zároveň koncept základních volání Unixového/Posixového jádra (open, read, write, close, ioctl, atd.) je natolik univerzální, že byl v podstatě přejat do všech systémů (s malými odchylkami i Windows NT a výše).

  • protože je knihovna standardních funkcí jazyka již dlouhodobě zavedená a i s jazykem standardizovaná, je přenositelnost programů mezi systémy ve formě zdrojových kódů relativně bezproblémová

  • jedná se o jazyk kompilovaný a kompilátor jazyka „C“ je v podstatě vždy první, který je potřeba při portaci na nový procesor vytvořit. Většinou se tedy na tvorbě nebo alespoň optimalizaci kompilátoru přímo podílí výrobce procesoru a výsledkem je velmi rychlý kód. Většina architektur a instrukčních sad procesorů je již při vývoji ovlivněna požadavky a filozofií jazyka „C“

  • další vyšší jazyky (C++, Java, C#, Python) v podstatě vždy staví na knihovnách vytvořených v jazyce „C“ a existující prostředí s tímto jazykem tedy na cílové platformě vyžadují. Většina interpretrů vyšších jazyků je také v jazyce „C“ napsaných (Java, Python, Perl, Lisp, atd.).

  • velké množství dalších jazyků do určité míry vychází ze zvyklostí a klíčových slov jazyka „C“

  • základní datové typy jazyka jsou definovány pružně tak, aby co nejlépe odpovídaly vlastnostem cílového procesoru. Někdy to může vyžadovat více přemýšlení při psaní přenositelného kódu, ale odměnou je kvalitní a dobře optimalizovatelný kód. Jazyk umožňuje psát kód přenositelný mezi 8, 16, 32 i 64-bitovými procesory i procesory DSP, které někdy používají i různé specifické číselné reprezentace

  • i přes výrazný boom vyšších programovacích jazyků v současné době je stále množství kódu napsaného v jazyce „C“ největší a knihovny v tomto jazyce se dobře začleňují i do prostředí vyšších jazyků.

  • Jedná se o jazyk, ve kterém je napsaná naprostá většina jader operačních systémů a ve kterém se píše většina ovladačů pro tyto systémy. Existují i výjimky, např. L4 Fiasco je psáno v C++, jeho předchůdce byl psán v assembleru, existuje například i miniaturní systém napsaný v Javě a s využitím kompilátoru GNU GCJ z části zkompilovaný do binární podoby, interpretr pro zbytek je však opět v jazyce „C“.

  • jedná se o jazyk vhodný pro psaní kódu řídicích a vnořených (embedded) aplikací. I komfortní grafické vývojové nástroje jako je Real Time Workshop (RTW) pro Matlab:Simulink v několika krocích převedou blokovou reprezentaci na zdrojový kód v jazyce „C“ a ten je pak na cílový DSP procesor zkompilován kompilátorem jazyka „C“. Pokud je potřeba vytvořit vlastní bloky pro obsluhu periferií cílového procesoru, je opět nutné využít jazyka „C“

  • jazyk umožňuje i velmi nízkoúrovňový zápis kódu natolik přizpůsobený zpracování instrukcí procesorem, že v moderních systémech je pouze 1% nebo i méně kódu napsáno v jazyce symbolických adres (assembleru). Jedná se většinou pouze o funkce pro zprovoznění prostředí pro jazyk „C“, nejnižší část obsloužení přerušení, přepínání procesů a zprávu virtuální paměti

  • psaní větších celků v assembleru je nevýhodné, protože přenos na jinou architekturu se stává velmi náročným a je potřeba v podstatě veškerý kód přepisovat. Opravdu jen ty nejčastěji volané vnitřní části cyklů může někdy být výhodné pro zrychlení běhu programu přepsat ručně do assembleru. Je to však velmi náročné a kromě kodeků pro video a audio aplikace to přichází v úvahu pouze u specializovaných zařízení a aplikací

2. Hlavní rozdíly a omezení proti jazyku Java

Jazyk Java je braný za základ proto, že právě on je základem výuky programování v prvním ročníku. Konstrukce pro řízení běhu programu (if, then, else, while, switch,...) jsou v jazyce „C“ v podstatě stejné.

Jazyk „C“ nedefinuje koncept objektů. Jedná se o čistě procedurální jazyk založený na psaní sekvencí příkazů tvořících těla funkcí. To je rozdíl od objektově orientovaných jazyků, které zavádí metody pracující nad objekty. Metody však při určitém zjednodušení nejsou nic jiného než funkce s implicitním prvním parametrem this/self. V jazyce „C“ dále chybí zabudovaná možnost odvozování/rozšiřování typů a ukazatel na tabulku virtuálních metod objektu (VMT položka uložená v objektu), systém zachytávání výjimek a je tedy nutné vystačit s ukazateli do paměti a na struktury. Veškerou správu paměti si musí také zajistit program sám. Není ovšem problém programovat objektovým stylem i v jazyce „C“ a je-li to nutné, tak lze použít i některou z knihoven pro automatickou správu paměti (např. Hans Boehm garbage collector).

Následuje seznam konstrukcí a vlastností, na které je potřeba si dát obzvláště pozor a ve kterých se velmi často chybuje:

  • hlavičkové soubory (*.h) nejsou zakompilované do objektových souborů a knihoven, knihovny jsou pouze archivy objektových souborů (*.o) a ke svému použití vyžadují dostupnost a distribuci původních hlavičkových souborů

  • pokud je hlavičkovým souborem mezi více zdrojovými kódy zpřístupněna globální proměnná, měla by v hlavičkovém souboru být deklarována s modifikátorem extern a pouze v jednom ze zdrojových souborů definována bez tohoto modifikátoru

  • hlavičkový soubor, který jiným modulům zpřístupňuje určitou funkci nebo i proměnnou by měl být použit/zatažen (#include) i do zdrojových textů s implementací. Pouze za této podmínky kompilátor při rozdílné definici typu mezi hlavičkovým souborem a implementací vyhlásí chybu

  • kuriózně trochu netradiční, přesto však někdy výhodný, systém masek struct/enum/union, které nejsou totéž co nový typ, který se definuje s využitím klíčového slova typedef. Otázkou zde ovšem zůstává, co je to tradice, když „C“ existovalo mnohem dříve než většina ostatních jazyků

  • systém základních typů, které nemají pevně dané bitové délky a reprezentace (char - adresovatelná jednotka, int pro daný procesor nejpřirozenější celočíselný/ordinal typ). Pro reprezentace základních typů jazyka je pouze dané, že počet bitů reprezentace typu char <= short int <= int <= long int <= long long int. char musí reprezentovat alespoň osm bitů (byte). Novější normy definují i minimum pro int a typy pro plovoucí řádovou čárku.

  • je potřeba dát si pozor na asynchronní změny proměnných (z přerušení, z jiného vlákna, atd.). U takto měněných proměnný je potřeba uvést modifikátor typu volatile.

3. Příklad jednoduchého programu (Fibonacciho posloupnost)

Fibonacciho posloupnost je číselná řada daná předpisem x0=0, x1=1, xn=xn-2+xn-1. Jednoduchý program, který tuto funkci implementuje přímo podle této definice naleznete v souboru fibo1.c. Soubor obsahuje vlastní funkci fibofnc a funkci main. Funkce fibofnc přijímá jeden vstupní argument typu celé číslo (int n), který reprezentuje pořadové číslo požadovaného prvku. Návratová hodnota funkce je též celé číslo (int) a představuje vypočítanou hodnoty požadovaného prvku.

int
fibofnc (int n)
{
    if (n <= 0)
        return 0;

    if (n == 1)
        return 1;

    return fibofnc (n - 1) + fibofnc (n - 2);
}

Druhá funkce (main) je standardem jazyka „C“ definovaný vstupní bod do programu. Funkce vrací celé číslo, které je po skončení programu/procesu systémem předáno jako návratový kód tomu procesu, který právě končící proces spustil (při cvičeních se jedná o shell běžící v terminálovém okně). Zvykem je, že nulová návratová hodnota představuje normální ukončení programu, hodnoty z rozsahu 1 až 255 jsou vráceny, pokud program sám sebe ukončí předčasně z důvodu nekorektních vstupních dat či jiné detekované chyby. Při volání programu lze na příkazové řádce za jméno programu (v uvažovaném případě ./fibo1 ) uvést další textové parametry. Zjednodušeně lze říct, že každé samostatné slovo oddělené od ostatních mezerami se stává samostatným řetězcem. Funkci main jsou pak tyto parametry programu předány jako pole ukazatelů argv[] na jednotlivé řetězce (slova). Parametr argc funkce main pak značí počet položek v tomto poli. Položka na první pozici (pozice s indexem 0) obsahuje ukazatel na jméno, pod kterým byl program spuštěn. Další položka (index 1) obsahuje první slovo za jménem programu. Pokud je tedy toto slovo uvedeno, slouží v uvažovaném příkladu jako vstup pořadového čísla počítaného prvku. O převod řetězce na celočíselnou hodnotu se stará funkce strtol. Pokud parametr uveden není, je programu předáno pouze jméno, pod kterým byl spuštěn, argc je rovné 1 a program se zeptá na vstupní hodnotu na terminálu. Po volání vlastní funkce fibofnc jsou zadaná hodnota i vypočítaný výsledek vypsány na terminál funkcí formátovaného výstupu printf.

int
main (int argc, char *argv[])
{
    int n;
    int res;
    char *p;

    if (argc >= 2) {
        n = strtol (argv[1], &p, 0);
        if ((p == argv[1]) || *p) {
            fprintf (stderr, "The \"%s\" string is not a number\n", argv[1]);
            return 1;
        }
    } else {
        printf ("Input number: ");
        scanf ("%d", &n);
    }

    res = fibofnc (n);

    printf ("fibofnc(%d)=%d\n", n, res);

    return 0;
}

Další sada souborů pak slouží ke kompilaci programu fibo2, který již v prvním kroku napočítá a do pole uloží napočítané prvky Fibonacciho posloupnosti až do zadaného pořadí posledního prvku. O alokaci pole a jeho naplnění hodnotami prvků se stará funkce fiboser. Tato funkce pak vrací do výstupního parametru předaného funkci referencí (adresou cílového umístění) hodnotu ukazatele na začátek pole s prvky posloupnosti. Dále je z hlavního programu (funkce main) zavolána funkce fiboprint, která posloupnost vytiskne. Nakonec je vypsán počet volání funkce fibofnc.

int
fiboseries (int **series, int n)
{
    int *p;
    int i;

    if (n < 0)
        return 0;

    p = malloc (sizeof (int) * (n + 1));
    *series = p;

    if (!p)
        return 0;

    for (i = 0; i <= n; i++) {
        p[i] = fibofnc (i);
    }

    return 1;
}

V tomto úseku kódu je již použita práce s poli a je nutné pochopit, jakým způsobem se v jazyce „C“ pracuje s alokací paměti a poli.

4. Práce s poli a ukazateli v jazyce „C“

V tomto odstavci je ukázáno na příkladech, jakým způsobem se v jazyce „C“ pracuje s pamětí. Z pohledu procesoru i jazyka „C“ si můžeme paměťový prostor libovolného procesu představit jako jedno velké pole bajtů (nebo lépe adresovatelných jednotek char). Proto lze pro každou proměnnou umístěnou v paměti určit její adresu, to je index na místo, kde je hodnota uložena v tohoto vše obsahujícím poli.

Pole, to je vlastně prostor, pro uložení více prvků shodného typu za sebou můžeme pro konstantní délku automaticky vytvořit na zásobníku či definovat v globální paměti následující konstrukcí

int pole[30];

Takto byl vytvořen prostor pro uložení třiceti prvků typu int. K prvkům můžeme přistupovat jednoduše indexováním od indexu 0 do indexu 29. Například pole[0], pole[1], pole[29]. I když je proveden pokus o přístup za konec pole, tak tento pokus není kompilátorem detekován. Dojde-li k tomuto pokusu za běhu programu, tak je chování programu nedefinovatelné. Při zápisu může dojít i k poškození důležitých dat a následnému pádu programu. I pouhý pokus o čtení z oblasti, pro kterou není mapování paměti nadefinováno nebo pro která není procesu přístupná, způsobí na Unixových systémech vyvolání signálu SIGBUS či SIGSEGV. Tyto signály pak vedou k ukončení programu.

Následující ukázky ilustrují práci s ukazatelem na celočíselnou hodnotu. Ukazatel není nic jiného, než proměnná, jejíž hodnota představuje adresu umístění dat v paměti. Při představě paměťového prostoru procesu jako pole znaků (char) se tedy jedná o index znaku/byte, ve kterém je umístěn první znak/byte uložené hodnoty. Pro ilustraci práce s ukazateli je nadefinována proměnná celočíselného typu int pojmenovaná i. Dále je nadefinován ukazatel p na paměťovou lokaci tohoto typu.

int i;
int *p;

Ukazatel lze naplnit adresou, na které se proměnná i nachází následovně

p=&i;

Každá z pěti následujících řádek poté provede stejnou operaci, přičtení čísla 1 k hodnotě proměnné i

i=i+1;
*p=*p+1;
i++;
(*p)++;
p[0]++;

K prvku, na který právě ukazatel ukazuje tedy můžeme přistupovat jak přes referenci *p, tak přes index zápisem p[0].

Pokud je požadováno naplnění ukazatele adresou prvního prvku pole pole, tak toto přiřazení můžeme provést následujícími dvěma způsoby

p=pole;
p=&pole[0];

Zvýšení všech prvků pole při využití přístupu přes indexování lze provést například následovně

for(i=0;i<30;i++)
  pole[i]++;

Stejného efektu lze však docílit i s využitím ukazatele, který postupně ukazuje na jednotlivé prvky pole

p=pole;
for(i=0;i<30;i++){
  (*(p++))++;
}

Operaci *(p++)++; lze rozepsat do dvou jednodušších operací *p=*p+1; p=p+1; . Přičtení jedničky k ukazateli představuje vlastně posun ukazatele na následující položku. Protože v případě procesoru i386 v 32-bitovém režimu zabírá celočíselná hodnota int čtyři byte, jedná se o přičtení hodnoty 4 k adrese uložené v proměnné/ukazateli p. Operace p++ je tedy ekvivalentní příkazu

p=(int*) ((char*)p + sizeof(int));

Výrazy (int*) a (char*) představují tzv. přetypování. Překladač je přetypováním informován o tom, že má vypočítanou hodnotu části výrazu za operátorem přetypování převést na požadovaný typ. V tomto případě se pouze mění předpokládaný typ hodnoty, na kterou ukazatel bude ukazovat. Přetypování na ukazatel na znak vede k tomu, že přičtení čísla odpovídá přičtení tohoto čísla k hodnotě adresy obsažené v ukazateli. Adresu uloženou v ukazateli lze převést na číselnou hodnotu přetypováním této hodnoty na typ long nebo správněji na typ uintptr_t definovaný normou C99.

Dalším možným příkladem, kde lze využít ukazatelů, je, pokud je požadováno, aby nějaká funkce měnila více výstupních hodnot naráz. Funkce může jako návratovou hodnotou předat jen jedno číslo, adresu a dokonce i strukturu. Pokud má však funkce posunovat ukazatel do znakového řetězce za funkcí zpracovanou část vstupu (například funkce strtol), či vrací více hodnot, tak je potřeba předat tyto parametry odkazem (referencí). Pro názornost je předveden normální způsob návratu hodnoty spočítané ve funkci

int vysledek;

int
f1(int a)
{
    return a*2;
}

int
main()
{
    vysledek=f1(10);
    ...

Na začátku je nadefinována globální proměnná vysledek a dále pak funkce f1. Nakonec je tato funkce zavolána a výsledek uložen do deklarované proměnné. Stejného efektu lze však docílit i s využitím předání výstupního parametru referencí

void
f2(int *y, int a)
{
    *y=a*2;
}

int
main()
{
    f2(&vysledek,10);
    ...

V tomto případě je funkci předána adresa, na kterou má výsledek výpočtu uložit.

5. Zadání samostatného úkolu

  1. Pochopit předložené příklady programů

  2. Upravit druhou verzi tak, aby výpočet probíhal výrazně rychleji. Je podstatné, aby byl zachován koncept, kdy je nejdříve celé pole naplněno a pak vypsáno. Předložené řešení je však z hlediska exponenciálního růstu algoritmické náročnosti s počtem prvků krajně nevýhodné. Vaším úkolem je program zrychlit. Dobu provádění programu si můžete změřit jeho voláním přes utilitu time (zápis například time ./fibo2).

7. Další literatura s popisem jazyka „C“

Všechny připomínky k předmětu, obsahu stránek, objevené chyby v ukázkových programech apod. adresujte na autory: