1.1 Absztrakt gép, forrásprogram és natív folyamat

Egy magas szintű nyelvben gyakran elegendő úgy gondolkodni, hogy a változó értéket nevez meg, a futtatókörnyezet pedig gondoskodik annak tárolásáról és kezeléséről. C-ben ez a kép nem elég: külön kell választanunk az értéket, az azt tároló objektumot, az objektum típusát és a gépi megvalósítást. Ugyanakkor a másik véglet is félrevezető: a C-forrás nem processzorutasítások hordozható jelölésrendszere.

A helyes kiindulópont a nyelvi absztrakt gép (abstract machine): a program jelentését meghatározó szabályrendszer. Ezt kapcsoljuk össze a már ismert natív folyamatmodellel. A nyelvi állításokat itt az ISO C17, a konkrét rendszerpéldákat a Linux-labor szintjén kezeljük. A szabványos követelmény, az implementáció választása és egy futás megfigyelése különböző bizonyíték.

1.1.1 Hogyan használd ezt a könyvet

Célcsoport. A könyv olyan DevOps-, SRE- és rendszerüzemeltetési szakembereknek szól, akik a Linuxot és legalább egy szkript- vagy magas szintű nyelvet napi szinten használják, és natív (C, C++) komponenseket kell építeniük, hibát keresniük bennük, vagy felelősséggel üzemeltetniük őket. Nem kezdő programozási tankönyv: a vezérlési szerkezeteket és az alapvető algoritmusokat ismertnek tekinti.

Előismeretek. A következőket feltételezzük:

Rövid szintfelmérő. Ha az alábbiak közül bármelyikre nem tudsz biztosan válaszolni, előbb az adott előismeretet érdemes pótolni, mert a későbbi elakadás nagy eséllyel ebből fakad, nem a C-ből vagy a C++-ból.

  1. Mit ír ki az echo $? egy sikertelen grep után, és miért?
  2. Mi a különbség egy folyamat és egy fájlleíró között? Mi történik egy fájlleíróval fork után?
  3. Mi a költsége egy elem beszúrásának egy tömb elejére, illetve egy láncolt lista elejére?
  4. Mit jelent, hogy egy függvény „hibakóddal” jelez hibát, és ki felel a hibakód ellenőrzéséért?
  5. Hogyan futtatsz egy parancsot úgy, hogy a standard kimenete egy fájlba, a hibakimenete egy másikba kerüljön?

Tanulási útvonalak. A könyv négy részből áll. Az alapútvonal kötelező; a haladó útvonal választható, és az alapútvonalra épül.

Útvonal Fejezetek Cél
C alapútvonal 1–17 C17 és Linux/POSIX rendszerprogramozás, a 17. fejezet záróprojektjével
C/C++ híd 18–19 a C23 és a C/C++ határ, átmenet a modern C++-ra
C++ alapútvonal 20–39 C++20 erőforráskezelés, generikus programozás, könyvtár, konkurencia, build; a 39. fejezet portjával
Haladó útvonal 40–46 C++23 elmélyítés, C++26 kitekintés, referencia-függelék

Az alapútvonal kötelező laborjai, ebben a sorrendben: 1.3 (reprodukálható fordítási labor, ez a következő kötelező labor e fejezet után), 2.4, 5.5, 9.5, 14.6, 17.2–17.3 (C záróprojekt), 23.5, 25.5, 26.6, 28.6, 30.6, 35.5, 36.5, 37.6 és 39 (a C rendszer C++20 portja). A haladó útvonal laborjai: 40.1, 42.4 és 44.4. A laborok profilját és tesztelt forráscsomagjait az 1.3 alfejezet írja le.

Az önellenőrzéshez és a visszatéréshez a Hibaminták, döntési táblák és önellenőrző térkép (46.5) ad tünetből kiinduló térképet: ha egy későbbi fejezetben elakadsz, ott keresd meg a tünetet, és az a releváns fejezetre és előismeretre mutat.

1.1.2 A forrástól a folyamatig

A forrásprogram ember által szerkesztett szövegekből áll. A fordítási egység (translation unit) nem egyszerűen egy .c fájl: a preprocessor által feldolgozott forrás, a beillesztett headerek tartalmával, a feltételes részek kiválasztása és a makróhelyettesítések után.

A végrehajtható állomány a fordítási és linkelési folyamat egyik lehetséges eredménye. A folyamat ezzel szemben futásidejű rendszerpéldány: saját virtuális címtérrel, végrehajtási állapottal és OS-erőforrásokkal. Ugyanabból az állományból több folyamat indulhat; az állomány nem tartalmazza előre valamennyi futás konkrét állapotát.

A Linux-labor szokásos eszközlánca:

Szereplő Feladata Fontos megkülönböztetés
Preprocessor Include-ok, makrók és feltételes fordítás feldolgozása Szövegszintű előkészítés, nem programfuttatás
Fordító (compiler) Nyelvi elemzés, ellenőrzés, optimalizálás, célkód előállítása A nyelvi jelentésből készít implementációt
Assembler Assemblyből gépi kódot tartalmazó objektumfájl készítése Integrálva is lehet a fordítóba
Linker Objektumfájlok és könyvtárak összekapcsolása, szimbólumhivatkozások feloldása Egy objektumfájl rendszerint még nem önálló program
Betöltő (loader) A program futásának előkészítése Linuxon a kernel és dinamikus programnál a dinamikus linker is részt vesz benne
Runtime Indulási, leállási és egyéb futásidejű támogatás Nem szükségképpen értelmező vagy virtuális gép
libc A C könyvtári szolgáltatásainak és gyakran rendszerinterfészeknek a megvalósítása Nem azonos sem a kernellel, sem a teljes runtime-mal

Ezek logikai szerepek, nem feltétlenül külön programok. A gcc vagy clang parancs rendszerint fordítódriverként több lépést vezérel. Az objektumfájl nevében szereplő „objektum” pedig nem azonos a nyelvi memóriamodell objektumával.

A fordítási fázisok részleteit a Fordítási fázisok és fordítási egységek tárgyalja. Itt az a fontos, hogy az ISO C nem követel ELF-formátumot, Linux-folyamatot vagy külön assemblerprogramot.

1.1.3 Objektum, érték, típus, cím és bájt

ISO C17: az objektum (object) a végrehajtási környezet olyan adattárolási tartománya, amely értékeket tartalmazhat. Nem feltétlenül objektumorientált osztálypéldány: egy int változó által megnevezett tárolási tartomány is objektum.

Az érték az adat jelentése, például a negyvenkettő egész szám. A típus meghatározza többek között az értékkészletet, a megengedett műveleteket és az értékek tárolási értelmezését. Egy kifejezésnek is lehet értéke anélkül, hogy külön, címezhető objektum tartozna hozzá.

Az objektum címe pointerértékkel jelölhető, amikor a nyelv megengedi a címképzést. A pointer nem általánosan „egy egész számba írt fizikai cím”. A Linux felhasználói folyamatában megfigyelt szokásos cím virtuális cím; a fizikai leképezést az OS és a hardver kezeli.

Nyelvi fogalom Lehetséges gépi megvalósítás Ami nem következik belőle
Lokális egész objektum Memóriahely, regiszter vagy optimalizálással elhagyott tárolás Nem szükségképpen kap stackhelyet
Objektum értéke Bitekkel kódolt reprezentáció vagy előre kiszámított konstans Nem szükségképpen történik memóriából olvasás
Objektum címe A laborban rendszerint virtuális címként megvalósított pointer Nem garantált, hogy futások között változatlan
Objektumméret sizeof által megadott C bájtszám Nem azonos a hasznos értékbitek számával

A könyvben C_byte jelöli a sizeof egységét. Egy C bájt CHAR_BIT bitből áll; ezt a <limits.h> adja meg. ISO C17 szerint:

sizeof(𝚌𝚑𝚊𝚛)=1,𝙲𝙷𝙰𝚁_𝙱𝙸𝚃≥8. \operatorname{sizeof}(\texttt{char})=1, \qquad \texttt{CHAR\_BIT}\geq 8.

A bájt ezért nem minden C-implementációban azonos a nyolcbites oktettel. A kijelölt x86-64 laborban nyolcbites bájtokkal dolgozunk, de ez a laborprofil tulajdonsága.

Ha x olyan objektum, amelyre a sizeof x megengedett, reprezentációjának mérete bitekben:

Bx=(sizeofx)⋅𝙲𝙷𝙰𝚁_𝙱𝙸𝚃. B_x=(\operatorname{sizeof}x)\cdot\texttt{CHAR\_BIT}.

Ez a teljes reprezentáció mérete. Nem állítja, hogy minden bit önálló értékinformációt hordoz: a típusnak lehetnek kitöltőbitjei is.

1.1.4 Az absztrakt gép és az as-if elv

Az absztrakt gép nem egy háttérben futó szoftver. Azt írja le, hogyan értelmezzük a program műveleteit és azok megfigyelhető következményeit.

Az as-if elv előképe így foglalható össze: a megvalósítás eltérhet a forrás lépésenkénti, naiv végrehajtásától, amennyiben megőrzi a definiált programvégrehajtás szabvány által megkövetelt megfigyelhető viselkedését. Az I/O és a volatile hozzáférések például külön követelményekhez kötöttek; ezek részletei később következnek.

Ebből nem következik, hogy minden forrásbeli változónak külön memóriahelye lesz, vagy minden aritmetikai kifejezéshez futásidejű utasítás tartozik. A debugger által mutatható lokális változók megőrzése sem általános nyelvi követelmény.

A nem definiált viselkedés (undefined behavior, UB) esetén a szabvány nem ír elő viselkedési követelményeket. Ez nem ugyanaz, mint egy dokumentálandó implementációs választás. Egy sikeres próbafutás önmagában egyik nyelvi előfeltétel teljesülését sem bizonyítja.

1.1.5 Hosted, freestanding, C és C++

ISO C17: a hosted környezet a szabvány hosted végrehajtásra előírt indulási és könyvtári szolgáltatásait biztosítja. A szokásos Linux-parancssori C-program ilyen környezetben fut; a program indulásakor meghívott függvény neve main.

Freestanding környezetben az indulás módja és az induló függvény neve implementáció által meghatározott, a kötelező könyvtári készlet pedig szűkebb. Kernel, bootloader vagy mikrokontrolleres program gyakran ilyen környezethez tartozik. A „freestanding” azonban nem egyszerűen az „OS nélküli” szinonimája: a nyelvi és könyvtári szerződésről szól.

A C és a C++ önálló nyelvek. Hasonló szintaxisuk nem jelent azonos konverziós, inicializálási vagy élettartam-szabályokat. A könyv C-alapja C17, C++-alapja C++20. Egy C-program C++-fordítóval való elfogadtatása nem általános helyességi ellenőrzés; a különbségeket a C és C++: azonos szintaxis, eltérő szemantika rendszerezi.

1.1.6 Kidolgozott példa: a változó nem kötelező memóriahely

Az alábbi teljes program minimumkövetelménye a könyvben C17 hosted környezet. A GCC-buildparancsok profilja a központi profiltábla lab-gcc profilja, a clang sor a lab-clang profilhoz tartozik. Buildjegyzőkönyv: a két GCC-parancs (-O0, -O2) a lab-gcc profilban figyelmeztetés nélkül fordított, a program mindkét esetben value=42; size=4 C_byte; CHAR_BIT=8 kimenettel futott; a Clang-sort nem ellenőriztük.

#include <limits.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int count = 21;
    int total = count * 2;

    if (printf("value=%d; size=%zu C_byte; CHAR_BIT=%d\n",
               total, sizeof total, CHAR_BIT) < 0) {
        return EXIT_FAILURE;
    }

    if (fflush(stdout) == EOF) {
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

Mentés model.c néven, majd külön laborlépésként fordítás:

gcc -std=c17 -Wall -Wextra -Wpedantic -O0 -g model.c -o model-o0
gcc -std=c17 -Wall -Wextra -Wpedantic -O2 -g model.c -o model-o2
clang -std=c17 -Wall -Wextra -Wpedantic -O2 -g model.c -o model-clang

A parancsokhoz a kijelölt verziójú eszközöknek már telepítve kell lenniük. A környezet rögzítését a Reprodukálható fordítási és diagnosztikai labor részletezi.

A count és total nyelvi objektumok. A sizeof total a total típusának tárolási méretét adja, nem a 42 leírásához szükséges minimális bitszámot. A %zu a sizeof eredménytípusának megfelelő formátum.

A stdout standard streamet a futtatási környezet biztosítja; a program használja, de nem zárja le. A lezárás érvénytelenítené ezt a használati lehetőséget. A példa a kiíráskor és a puffer ürítésekor jelzett hibát sikertelen kilépési állapottal kezeli.

Bizonyításvázlat az előre kiszámítás megengedhetőségére:

  1. A count inicializálás után 21.
  2. A szorzás eredménye 21⋅2=4221\cdot2=42; mindkét érték minden C17-es int típusban ábrázolható.
  3. A két objektum címét nem kérjük le, és nincs közbülső módosítás.
  4. A kiírásnak a 42 értéket kell megkapnia, de ehhez nem szükséges két külön memóriahely vagy futásidejű szorzás.

Sikeres I/O mellett tehát a value mező elvárt értéke 42. A méretmezők az implementációt jellemzik. A tényleges utasítássor és a debuggerben látható változók köre csak vizsgálattal állapítható meg; az -O0 sem garantál szó szerinti forrás–utasítás megfelelést.

1.1.7 Gyakori hibák

A „lokális változó a stacken van” hasznos kezdeti gépi kép lehet, de nem nyelvi garancia. Javítása: előbb az objektum típusát és élettartamát azonosítsuk, utána vizsgáljuk a megvalósítást.

A „lefordult, tehát helyes” következtetés szintén hibás. A fordító elfogadása nem bizonyítja minden lehetséges végrehajtás szabályosságát.

A „nálam négy bájt, tehát mindig négy bájt” általánosítás összekeveri a target tulajdonságát a nyelvi követelménnyel. A megfigyelés mellé mindig kell környezeti profil.

1.1.8 Feladatok

1.1.8.1 Magyarázó feladat

Miért nem azonos a model.c, a fordítási egység, a model-o2 és az abból indított folyamat? Melyikből lehet egyszerre több futó példány?

1.1.8.2 Kódolási feladat

Egészítsd ki a programot a sizeof(char) és a sizeof total * CHAR_BIT matematikai szorzatának kiírásával. Melyik eredmény garantált, és melyik implementációfüggő? Tartsd meg az I/O-hibakezelést.

1.1.8.3 Diagnosztikai feladat

Az optimalizált program debuggerében a count értékére „optimized out” jelzés jelenik meg, miközben sikeres I/O mellett a program 42-t ír ki. Ez önmagában fordítóhibát bizonyít? Mit ellenőriznél?

1.1.9 Megoldások

1.1.9.1 A program különböző alakjai

A model.c forrásfájl; a fordítási egység a preprocesszálás utáni nyelvi bemenet; a model-o2 linkelt végrehajtható állomány; a folyamat annak futásidejű példánya. Ugyanabból az állományból több folyamat indulhat.

Ellenőrzési feltétel: a magyarázat különítse el a szerkesztett szöveget, a fordítás bemenetét, a buildterméket és a futási állapotot.

1.1.9.2 A méretek kiírása

A meglévő printf helyére illeszthető részlet, az eredeti hibakezelő blokkal:

if (printf("char=%zu C_byte; total=%zu bits\n",
           sizeof(char),
           sizeof total * (size_t)CHAR_BIT) < 0) {
    return EXIT_FAILURE;
}

A szorzás eredménytípusa így size_t, tehát a %zu megfelelő. Előfeltétel, hogy a matematikai szorzat beleférjen ebbe a típusba; a kijelölt laborprofilban ez teljesül.

A char mérete garantáltan 1 C_byte. A második eredmény a teljes objektumreprezentáció bitmérete, nem az értékbitek száma.

Ellenőrzési feltétel: a char mező 1, a második mező pedig a külön megfigyelt méret és CHAR_BIT szorzata legyen.

1.1.9.3 Az optimalizált változó értelmezése

Nem bizonyít fordítóhibát. A fordító elhagyhatta a külön tárolást, a debuginformáció pedig nem feltétlenül teszi rekonstruálhatóvá a változót.

Ellenőrizni kell a forrás és a bináris összetartozását, a fordítási kapcsolókat, a fordítóverziót és szükség esetén a gépi kódot.

Ellenőrzési feltétel: a következtetés a megfigyelhető programviselkedésre támaszkodjon, ne egy feltételezett stackhely hiányára.

1.1.10 Összefoglaló

A C-program jelentését az absztrakt gép szabályai adják; a natív folyamat ezek egyik megvalósítása. Az objektum nem fizikai memóriahely, a C bájt nem szükségképpen oktett, a változó pedig nem garantált stackrekesz. A helyes rendszerszemlélet minden állításnál megkülönbözteti a nyelvi követelményt, az implementáció szerződését és a konkrét labor megfigyelését.