11.1 stdio, FILE és pufferelés

Egy szolgáltatás naplója terminálon azonnal megjelenhet, pipe-ba irányítva viszont csak később. Egy sikeres írás után pedig még a fájl lezárása is jelezhet hibát. Mindkét jelenség megértéséhez külön kell választani a C könyvtár streamjét, az operációs rendszer I/O-rétegét és a tartós tárolást.

A fejezet az API-paraméterek, az explicit tulajdonlás és cleanup, valamint a sztringek hosszának ismeretére épít. Itt azonban nem sztringeket, hanem tetszőleges adatot is kezelünk: egy nulla értékű C bájt nem jelenti a bemenet végét.

11.1.1 A FILE* egy állapottal rendelkező stream hozzáférése

ISO C17: a <stdio.h> fejléc FILE típusa a stream vezérléséhez szükséges információkat hordozó objektumtípus. A stream (stream) a program és egy bemeneti vagy kimeneti adatforrás közötti rendezett adatkapcsolat. A FILE belső mezőire nem építünk; a hozzáférést könyvtári függvényeken keresztül végezzük.

POSIX/Linux: a fájlhoz, terminálhoz vagy pipe-hoz kapcsolt stdio stream jellemzően file descriptorra épül. A FILE* azonban nem descriptor: saját pufferelést, EOF-jelzőt és hibajelzőt tart fenn. Egy könyvtári írás ezért nem feltétlenül jelent azonnali kernelhívást.

A program indulásakor három előre megnyitott stream érhető el:

Név Rendeltetés Szokásos tulajdonlási szerződés
stdin Standard bemenet A segédfüggvény kölcsönzi, nem zárja le
stdout Standard kimenet A segédfüggvény kölcsönzi; a program felel a végső hibaellenőrzésért
stderr Standard diagnosztikai kimenet Diagnosztikára használjuk, sikere nem garantált

Ezek nem feltétlenül terminálhoz kapcsolódnak. Az átirányítás megváltoztatja a háttérben elérhető erőforrást, és ezzel a megfigyelhető pufferelést is.

A fejezet példái bájtorientált stdio-műveleteket használnak; ugyanazon streamen nem keverik őket széles karakteres I/O-val.

11.1.2 Megnyitás, módok és lezárás

ISO C17: az fopen(path, mode) siker esetén FILE*, hiba esetén null pointer eredményt ad. A mód nem pusztán jogosultság: a fájl tartalmára gyakorolt hatást is meghatározza.

Alapmód Művelet Nem létező fájl Létező fájl
"r" Olvasás Hiba Tartalma megmarad
"w" Írás Létrehozás Tartalma megnyitáskor elvész
"a" Hozzáfűzés Létrehozás Az írások a mindenkori végére kerülnek
"r+" Olvasás és írás Hiba Tartalma megmarad
"w+" Olvasás és írás Létrehozás Tartalma megnyitáskor elvész
"a+" Olvasás és hozzáfűzés Létrehozás Az írások a végére kerülnek

A b módosító bináris streamet kér: például "rb", "wb" vagy "r+b". ISO C17 szerint a szöveges stream külső reprezentációja átalakulhat. POSIX/Linux alatt a b nem változtatja meg a működést, de hordozható bináris kódban így is indokolt.

A + módú, úgynevezett update stream irányváltásának szabályai fontosak:

Az fclose kiüríti a függő kimenetet, és lezárja a kapcsolatot. Siker esetén nullát, észlelt hiba esetén EOF értéket ad. A hibája ugyanúgy számít, mint az írás hibája.

Erőforrás vagy tároló Tulajdonos Érvényességi határ
Sikeres fopen eredménye A megnyitást vállaló kódrész Az fclose hívásáig
Függvénynek átadott, kölcsönzött FILE* A hívó A hívó lezárása érvényteleníti
Saját, setvbuf-nak átadott puffertömb A tömböt kezelő kódrész Legalább a stream lezárásának befejezéséig éljen

Az fclose hívása után a stream többé nem használható, akkor sem, ha a lezárás hibát jelzett. Ugyanazzal a pointerrel nem próbáljuk újra a lezárást.

11.1.3 Elemszám, rövid művelet és EOF

ISO C17: a fread(ptr, size, count, stream) és fwrite(ptr, size, count, stream) visszatérési értéke a sikeresen feldolgozott teljes elemek száma, nem általában a C bájtok száma.

Ha ss egy elem mérete, nn a kért elemszám, akkor a kért adatmennyiség:

B=s⋅n. B=s\cdot n.

A visszaadott rr elemszámra 0≤r≤n0\leq r\leq n. Ha s=1s=1, akkor rr közvetlenül C bájtszámként értelmezhető. A tárolónak a teljes kért tartományt le kell fednie; a méretszámítás nem csordulhat túl.

Ezért nyers adatmásoláshoz egyszerű szerződés:

fread(buffer, 1, sizeof buffer, input)

Itt buffer tényleges tömb legyen, ne pointer: pointeren a sizeof nem a mögöttes tároló kapacitását adja.

A rövid olvasás jelenthet fájlvéget vagy olvasási hibát. Ezeket külön kell vizsgálni:

Ezek állapotlekérdezések, nem a következő művelet jóslatai. Az utolsó rendelkezésre álló bájt sikeres beolvasása önmagában még nem feltétlenül állítja be az EOF-jelzőt. Ezért hibás a „míg nincs EOF, olvass” logika: előbb olvasunk, utána értékeljük az eredményt.

A rövid fwrite írási hibát jelez. Nem alkalmazunk rá automatikusan descriptor-szintű „írd ki a maradékot” ciklust. A hibajelző törlése sem javítja meg az alapul szolgáló hibát.

11.1.3.1 Miért int az fgetc eredménye?

Az fgetc egy karaktert ad vissza, unsigned char értékként int-re konvertálva, vagy EOF-ot jelez. Az EOF negatív int konstans, de nem szabad feltételezni, hogy pontosan -1.

A könyv szokásos, nyolcbites C bájtú laborprofiljában a karakterértékek 00–255255 közöttiek, ezért az int megkülönbözteti őket az EOF-tól. Ha az eredményt előbb char változóba szűkítjük, ez az információ elveszhet: például egy érvényes bájt összekeveredhet az EOF-fal.

Az EOF eredmény után itt is ferror különíti el az olvasási hibát a rendes végállapottól.

11.1.4 Pufferelés és láthatóság

ISO C17: három pufferelési módot különböztetünk meg:

Teljes pufferelésnél a kimenet gyűlhet a könyvtár pufferében. Sorpufferelésnél az újsor karakter is kiváltja a továbbítást; a puffer megtelése és az explicit kiürítés szintén számít. A pufferelés nélküli működés sem jelent lemezre történő tartós mentést.

Linux/glibc tipikus viselkedés: a terminálhoz kapcsolt stdout sorpufferelt, a fájlhoz vagy pipe-hoz kapcsolt stdout teljesen pufferelt. Ez magyarázza, hogy egy soronként naplózó program pipe mögött késleltetve jelenítheti meg a sorokat.

ISO C17 garancia: a stderr kezdetben nem teljesen pufferelt. Ez nem azonos a minden implementációra érvényes „biztosan pufferelés nélküli” állítással.

A setvbuf módosítja a pufferelést. A stream megnyitása után, de minden más streamművelet előtt kell meghívni; a szabvány a korábbi sikertelen setvbuf hívásra kivételt enged. Nulla a siker, nem nulla a sikertelenség.

Például a setvbuf(stdout, NULL, _IOLBF, BUFSIZ) sorpufferelést kér saját puffertömb átadása nélkül. Ezt még az első kiírás előtt kell ellenőrizni. Saját tömb esetén annak tárolója nem szűnhet meg a stream lezárása előtt.

11.1.4.1 Mit garantál az fflush?

ISO C17: megfelelő kimeneti streamen az fflush a még ki nem írt adatokat a host környezetnek továbbítja, hogy azok a fájlba kerüljenek. Siker esetén nulla, írási hibánál EOF az eredmény. Update streamen akkor alkalmazható így, ha a legutóbbi művelet nem bevitel volt. Az fflush(NULL) az erre alkalmas kimeneti streameket üríti.

Három szintet különítsünk el:

Művelet Lényeges határ
Sikeres fwrite A stdio elfogadta a teljes adatmennyiséget
Sikeres fflush A függő stdio-kimenet továbbítása megtörtént
POSIX fsync Az operációs rendszer fájlszinkronizálási szerződését kéri

Az fflush nem fsync. A tartósság további feltételeit a fájlrendszer-műveletekről szóló rész tárgyalja.

Az fflush(stdin) nem hordozható „bemenettörlés”. ISO C17 szerint ilyen bemeneti streamen a viselkedése nem definiált. A POSIX bizonyos bemeneti streamekre további szabályokat ad, de ebből nem következik általános terminálbemenet-eldobási módszer.

11.1.5 Kidolgozott példa: bináris másolás ellenőrzött lezárással

A program két különböző fájl között másol. Előfeltétel: a két útvonal nem jelölheti ugyanazt a mögöttes fájlt, hard linken keresztül sem; különben a "wb" megnyitás megsemmisítheti a bemenetet. A célfájl hibánál részleges maradhat: ez nem tranzakciós másoló.

Minimum: ISO C17, hosted környezet. Választott laborprofil: Linux, x86-64 System V ABI, LP64, glibc 2.40; GCC 14.2.0 vagy Clang 19.1.7. A példa itt nem fordított vagy futtatott mérési eredmény.

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

int main(int argc, char *argv[])
{
    FILE *input = NULL;
    FILE *output = NULL;
    unsigned char buffer[8192];
    int status = EXIT_FAILURE;

    if (argc != 3) {
        fputs("usage: copy source destination\n", stderr);
        return EXIT_FAILURE;
    }

    input = fopen(argv[1], "rb");
    if (input == NULL) {
        fputs("cannot open source\n", stderr);
        goto cleanup;
    }

    output = fopen(argv[2], "wb");
    if (output == NULL) {
        fputs("cannot open destination\n", stderr);
        goto cleanup;
    }

    for (;;) {
        size_t n = fread(buffer, 1, sizeof buffer, input);

        if (n != 0 && fwrite(buffer, 1, n, output) != n) {
            fputs("write failed\n", stderr);
            goto cleanup;
        }

        if (n < sizeof buffer) {
            if (ferror(input)) {
                fputs("read failed\n", stderr);
                goto cleanup;
            }
            break;
        }
    }

    status = EXIT_SUCCESS;

cleanup:
    if (output != NULL && fclose(output) == EOF) {
        fputs("destination close failed\n", stderr);
        status = EXIT_FAILURE;
    }
    if (input != NULL && fclose(input) == EOF) {
        fputs("source close failed\n", stderr);
        status = EXIT_FAILURE;
    }
    return status;
}

Fordítás a megfelelő verziójú driverekkel:

gcc -std=c17 -Wall -Wextra -Wpedantic -O2 copy.c -o copy-gcc
clang -std=c17 -Wall -Wextra -Wpedantic -O2 copy.c -o copy-clang

A main mindkét stream tulajdonosa. A könyvtár a buffer tömböt csak az adott művelet alatt használja; a tömb a teljes másolás során él. Az fclose minden megszerzett streamre pontosan egyszer fut le.

Helyességi bizonyításvázlat: minden teljes cikluslépés a beolvasott nn C bájtot pontosan egyszer adja át az írásnak. Rövid írásnál a program hibával leáll. Rövid olvasásnál megkülönbözteti az olvasási hibát a fájlvégtől. A kezdetben sikeresnek minősített másolást a késleltetett lezárási hiba még sikertelenné teszi.

Külön fflush nem szükséges: az ellenőrzött fclose elvégzi a kiürítést is. A diagnosztikai fputs hívások szándékosan best-effort jellegűek; hibájukat nem jelentjük újabb diagnosztikával. A hibás kilépési állapot ettől még megmarad.

11.1.6 Feladatok

11.1.6.1 Magyarázó feladat

Egy program minden esemény után újsorral zárt sort ír stdout-ra. Terminálon gyorsan látszik a sor, pipe mögött késik. Miért? Garantálna-e tartósságot egy soronkénti fflush?

11.1.6.2 Kódolási feladat

Írj copy_chars(FILE *input, FILE *output) függvényt fgetc és fputc használatával. Adjon nullát siker, egyet hiba esetén. A streameket kölcsönözze, ne zárja le és ne ürítse explicit módon.

11.1.6.3 Diagnosztikai feladat

Mi hibás az alábbi C17-részletben? A streamek egyébként érvényesek.

/* Hibás tanítási példa; nem futtatási minta. */
while (!feof(input)) {
    char c = fgetc(input);
    fputc(c, output);
}
fflush(stdin);

11.1.7 Megoldások

11.1.7.1 A pufferelés magyarázata

A tipikus Linux/glibc terminálkimenet sorpufferelt, a pipe-kimenet teljesen pufferelt. Az újsor ezért a második esetben nem feltétlenül továbbítja a felgyűlt adatot. Az ellenőrzött fflush(stdout) megszünteti ezt a stdio-szintű várakozást, de tartósságot nem garantál.

Ellenőrzési feltétel: a magyarázat külön kezelje a könyvtári puffert, a kernelnek történő átadást és a tartós tárolást.

11.1.7.2 Karakterenkénti másolás

C17-függvényrészlet, a szükséges fejléccel:

#include <stdio.h>

int copy_chars(FILE *input, FILE *output)
{
    int c;

    while ((c = fgetc(input)) != EOF) {
        if (fputc(c, output) == EOF) {
            return 1;
        }
    }
    return ferror(input) ? 1 : 0;
}

Előfeltétel a két, megfelelő irányban használható, bájtorientált vagy még orientálatlan stream, kezdetben törölt EOF- és hibajelzőkkel. A hívó a tulajdonos; a függvény alatt nem zárhatja le őket. A kimenet hibánál részleges lehet. A késleltetett íráshiba ellenőrzése a hívó későbbi fflush vagy fclose műveletének feladata.

Ellenőrzési feltétel: üres bemenetet, valamint a laborprofilban 0x00 és 0xFF bájtokat tartalmazó bemenetet is helyesen kezeljen.

11.1.7.3 A hibák diagnózisa

A feof túl korai vizsgálat: a ciklus sikertelen olvasás után is megpróbálhat kiírni egy karaktert. A char elveszítheti az EOF és az érvényes karakter közötti különbséget. Az olvasási hiba nem feltétlenül állít EOF-jelzőt, ezért a ciklus végtelenné válhat. Az írás eredményét sem ellenőrzi. Végül az fflush(stdin) ISO C17 alatt nem definiált viselkedésű.

Javítás és ellenőrzési feltétel: az előző megoldás szerint előbb az int eredményt vizsgáljuk, ellenőrizzük az írást, majd a ciklus után az olvasási hibát. EOF-ot soha nem írunk ki adatként.

11.1.8 Rövid összefoglaló

A FILE* állapottal és puffereléssel rendelkező streamhez ad hozzáférést, nem puszta descriptor. A helyes sorrend: művelet, visszatérési érték ellenőrzése, szükség esetén EOF- és hibajelző vizsgálata. A teljes másolás sikeréhez a lezárást is ellenőrizni kell. Az fflush a stdio-kimenet továbbításáról szól, nem tartósságról, és nem hordozható bemenettörlő művelet.