7.3 Nullterminált sztring és a hossz szerződése

Egy hálózati üzenet bájtsorozat, egy C-sztring viszont olyan adat, amelynek végét egy meghatározott érték jelzi. A kettő összekeverése nem pusztán szövegkezelési hiba: határon túli olvasást, csonkolást vagy protokollértelmezési eltérést okozhat.

A Tömbobjektum, decay és függvényparaméter rész alapján a függvénynek átadott pointerből nem állapítható meg a mögöttes tömb mérete. A const pointerek, több szintű indirection és void* rész pedig megmutatta, hogy a const char * korlátozza az adott hozzáférésen keresztüli módosítást, de önmagában nem igazolja sem a tároló élettartamát, sem a terminátor jelenlétét. Most ezekből építünk explicit sztringszerződést.

7.3.1 Sztring, hossz és kapacitás

ISO C17: a sztring (string) karakterek összefüggő sorozata, amelyet az első nullkarakter lezár; a sztring ezt a lezáró karaktert is magában foglalja. A nullkarakter értéke nulla, szokásos jelölése '\0'. A NUL terminátor nem azonos a '0' számjeggyel, és nem azonos a null pointer jelölésére használt NULL makróval.

Három mennyiséget kell külön követni:

Mivel sizeof(char) == 1, egy char tömb elemszáma egyben a C bájtokban mért mérete. Ez nem jelenti általánosan azt, hogy egy C bájt nyolc bit: ezt a CHAR_BIT értéke határozza meg.

Egy CC kapacitású tárolóban elhelyezett sztring alapinvariánsa:

$$ 0 \le L < C, \qquad \text{\texttt{data[L]} = \texttt{'\backslash 0'}}. $$

A terminátor előtti elemek egyike sem lehet nulla, különben a sztring korábban véget érne. Az üres sztring hossza nulla, de tárolásához akkor is legalább egy C bájt szükséges.

A karakter szó itt különösen veszélyes. A bájtok száma nem feltétlenül egyezik meg sem a Unicode-kódpontok, sem a felhasználó által egyetlen jelnek érzékelt egységek számával. A strlen nem megjelenített karaktereket számol; ezt a különbséget a Szövegkódolás, locale és UTF-8 C-ben rész bontja ki.

7.3.2 char tömb és string literal

Az alábbi, függvénytörzsbe illeszthető részlet ISO C17-kód, külön platformfeltétel nélkül:

char name[] = "api";
const char *label = "api";
char field[3] = "api";

A három deklaráció eltérő szerződést jelent.

Deklaráció Tároló és kezelés Élettartam Módosíthatóság és sztringállapot
name Önálló, automatikus tömb; a blokk kezeli A blokk végrehajtásának elhagyásáig Elemei írhatók; kezdetben NUL-lal lezárt sztring
label Nem tulajdonló pointer egy literál statikus tárolójára A literál a program teljes futása alatt él A literál módosítása UB
field Önálló, automatikus tömb; a blokk kezeli A blokk végrehajtásának elhagyásáig Elemei írhatók, de nincs bennük terminátor

A name inicializálásakor a tömb megkapja az 'a', 'p', 'i', '\0' értékeket. A name[0] = 'A' megengedett; a name[3] = '!' szintén érvényes elemírás, de megszünteti a sztring lezárását.

ISO C17: a char field[3] = "api"; megengedett inicializálás. Ha nincs hely a terminátornak, az kimarad. A tömb ezért érvényes adat, de nem sztring.

C-ben a char *p = "api"; deklaráció is megengedett, a literál módosítása azonban nem definiált viselkedés. A const char * jobb interfész, mert az ilyen írási kísérleteket fordításkor észlelhetővé teszi.

A tömbre mutató kölcsönzött pointert a tömb élettartamának vége érvényteleníti. A terminátor felülírása másfajta hiba: a tároló és a pointer még érvényes lehet, miközben a sztringszerződés már nem teljesül.

7.3.3 Mit tud, és mit nem tud a strlen?

A <string.h> fejlécben deklarált strlen egy érvényes sztring hasznos hosszát adja vissza size_t típussal. A terminátort nem számolja bele.

A függvény előfeltételezi, hogy a pointertől indulva elérhető egy olvasható, NUL-lal lezárt sztring. Nem kap kapacitást, ezért nem alkalmas annak ellenőrzésére, hogy egy tetszőleges buffer sztring-e. A fenti strlen(field) hívás nem definiált viselkedéshez vezet: a függvény a tömbön belül nem talál terminátort.

A strlen(name) kezdetben három, a sizeof name négy. Ezzel szemben a sizeof label a pointer mérete, nem a literál hossza vagy kapacitása.

A terminátor megkeresése általánosan O(L)O(L) idejű bejárás; nem igényel allokációt, és nem módosítja a tárolót. A pontos gépi megvalósítás könyvtár- és targetfüggő. Ha minden hozzáfűzés előtt újra megkeressük az egyre hosszabb sztring végét, sok kis művelet összköltsége négyzetessé válhat. Gyakran ezért érdemes a hasznos hosszt külön állapotként nyilvántartani.

7.3.4 Másolás és hozzáfűzés: a korlát nem automatikus

ISO C17: a strcpy(dst, src) átmásolja a forrássztringet a terminátorral együtt. Legyen a forrás hossza SS, a cél kapacitása CC. A szükséges méretfeltétel:

S<C. S < C.

Emellett a forrásnak érvényes sztringnek, a célnak megfelelően nagy, írható tárolónak kell lennie, és a másolt tartományok nem fedhetik át egymást. A függvény nem kap kapacitást, nem végez szerződés szerinti bounds-ellenőrzést, és nem jelez „kevés hely” hibát.

A strcat(dst, src) a cél meglévő terminátorának helyétől másolja be a forrást. Ha a cél jelenlegi hossza DD, akkor:

D+S+1≤C. D + S + 1 \le C.

Ezt ellenőrző kódban célszerű kivonással megfogalmazni:

D<CésS<C−D. D < C \quad\text{és}\quad S < C-D.

Bizonyításvázlat. A D<CD<C feltétel mellett a C−DC-D kivonás nem csordul alul. Ez a cél jelenlegi végétől rendelkezésre álló hely, amelybe a forrás SS bájtjának és egy terminátornak kell beférnie. Így elkerülhető a D + S + 1 esetleges size_t túlcsordulása.

Ez csak akkor valódi védelem, ha DD és SS már megbízhatóan ismert. Egy lezáratlan bufferre előbb meghívott strlen hibáját az utólagos méretellenőrzés nem javítja meg.

7.3.4.1 Miért nem általánosan biztonságos a strncpy?

A strncpy(dst, src, n) nem egyszerűen „kapacitásbiztos strcpy”.

A célban tehát legalább n írható elem szükséges. A forrásban vagy elérhető terminátornak, vagy legalább n olvasható karakternek kell lennie; az átfedés itt sem megengedett.

Ez például nullákkal kitöltött, rögzített szélességű mezők előállítására használható. Általános sztringmásolásnál viszont könnyen lezáratlan eredményt ad, és rövid forrás esetén is n célbájtot ír. A végső NUL kézi beírása lezárhatja az eredményt, de önmagában nem teszi észlelhetővé az adatvesztést.

7.3.5 snprintf: a visszatérési érték is része a szerződésnek

ISO C17: a snprintf(dst, cap, format, ...) legfeljebb cap C bájtot ír, a lezáró NUL-t is beleértve. Pozitív kapacitás és nemnegatív visszatérési érték esetén a tárolt eredmény lezárt.

A visszatérési érték int, és kétféle jelentése van:

Pozitív kapacitásnál tehát:

R<0⇒hiba,0≤R<C⇒teljes eredmény,R≥C⇒csonkolás. \begin{aligned} R < 0 &\Rightarrow \text{hiba},\\ 0 \le R < C &\Rightarrow \text{teljes eredmény},\\ R \ge C &\Rightarrow \text{csonkolás}. \end{aligned}

A negatív értéket a size_t konverzió előtt kell kezelni. A cap == 0 speciális eset: nincs írás, és a célpointer lehet null pointer; a hívás méretigényt számíthat, de nem hoz létre tárolt sztringet.

A korlát kizárólag a célra vonatkozik. A %s argumentumának továbbra is érvényes sztringnek kell lennie. A forrás és a cél átfedését sem teszi biztonságossá; a snprintf(buf, cap, "%s!", buf) hibás megoldás. Külső adatot pedig %s argumentumként adjunk át, ne formátumsztringként.

7.3.5.1 Kidolgozott példa: teljes eredmény vagy üres cél

A következő függvény hiba esetén nem ad tovább részleges konfigurációs értéket: pozitív kapacitásnál üres sztringre állítja a célt.

A hívó birtokolja a forrás és a cél tárolóját; a függvény csak a hívás idejére kölcsönzi őket, pointert nem őriz meg. A name érvényes sztring, a dst pozitív cap esetén legalább ennyi írható elemet jelöl, és a két tartomány nem fed át.

Profil: ISO C17, GNU bővítések nélkül. Választott, itt nem futtatással ellenőrzött laborprofil: Linux x86-64 System V, GCC 14.2.0 vagy Clang 19.1.7, glibc 2.40. A kód nem igényel nyolcbites C bájtot.

#include <assert.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>

enum text_status {
    TEXT_OK,
    TEXT_NO_SPACE,
    TEXT_ENCODING_ERROR
};

static enum text_status
format_endpoint(char *dst, size_t cap,
                const char *name, unsigned port)
{
    if (cap == 0) {
        return TEXT_NO_SPACE;
    }

    int result = snprintf(dst, cap, "node=%s;port=%u",
                          name, port);

    if (result < 0) {
        dst[0] = '\0';
        return TEXT_ENCODING_ERROR;
    }

    if ((size_t)result >= cap) {
        dst[0] = '\0';
        return TEXT_NO_SPACE;
    }

    return TEXT_OK;
}

int main(void)
{
    const char name[] = "api";
    char output[32];
    char small[8];

    enum text_status status =
        format_endpoint(output, sizeof output, name, 443u);

    assert(status == TEXT_OK);
    assert(strcmp(output, "node=api;port=443") == 0);
    assert(strlen(output) == 17);

    status = format_endpoint(small, sizeof small, name, 443u);
    assert(status == TEXT_NO_SPACE);
    assert(small[0] == '\0');

    return 0;
}

Fordítás a külön laborban, a megfelelő verziójú eszközökkel:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion \
    -O0 -g endpoint.c -o endpoint-gcc

clang -std=c17 -Wall -Wextra -Wpedantic -Wconversion \
    -O0 -g endpoint.c -o endpoint-clang

A várt teljes eredmény 17 hasznos C bájt, ezért legalább 18 elem kell hozzá. Nyolcelemű célban a snprintf legfeljebb hét hasznos bájtot tárol, de továbbra is 17-et ad vissza. A wrapper ezt csonkolásként kezeli, majd kiüríti a célt. Ezek levezetett elvárások, nem közölt futtatási eredmények.

A művelet felülírja a cél korábbi tartalmát; annak korábbi hosszadata többé nem használható. A célra mutató pointerek ettől még nem válnak dangling pointerré.

7.3.6 Beágyazott NUL és bináris protokolladat

Tekintsük az alábbi ISO C17-adatdeklarációt:

char packet[] = {'O', 'K', '\0', 'X'};

A tömb négy C bájtot tartalmaz, de a strlen(packet) eredménye kettő. A kezdőcímen az "OK" sztring található; az utána következő 'X' már nem része annak.

Egy protokoll szempontjából azonban mind a négy bájt lehet jelentős. Ha az egyik komponens explicit hosszal, a másik sztringként kezeli ugyanazt az adatot, eltérő üzenetet értelmezhetnek. Bináris adathoz ezért pointer és hossz együtt szükséges; a NUL ott közönséges adatérték lehet.

Hasonlóképpen a snprintf visszatérési értéke sem mindig egyezik meg a cél strlen-értékével: például a %c formázás nulla értékkel belső NUL-t írhat. A fenti endpoint-formátum ilyen karaktert nem állít elő. Bináris másoláshoz a Memóriamásolás, összehasonlítás és byte view rész ad megfelelő eszközöket.

7.3.7 Feladatok

7.3.7.1 Magyarázó feladat: melyik mennyiség mit jelent?

A char data[] = {'a', '\0', 'b', '\0'}; deklarációnál add meg a kapacitást, a strlen(data) eredményét és a kezdőcímen található sztring tárolóigényét. Megőrzi-e a teljes tömb tartalmát a strcpy?

7.3.7.2 Kódolási feladat: elutasító másolás

Írj bool copy_string(char *dst, size_t cap, const char *src) függvényt. Kevés hely esetén adjon false értéket, és ne módosítsa a célt. A forrás érvényes sztring; pozitív kapacitásnál a cél megfelelően nagy, írható tároló. Átfedés nincs.

7.3.7.3 Diagnosztikai feladat: határeset

Az alábbi ISO C17-részlet nem UB-példa, hanem hibás sikerellenőrzés:

char buf[8];
int n = snprintf(buf, sizeof buf, "%s", "12345678");

if (n <= (int)sizeof buf) {
    puts(buf);  /* Tévesen teljes eredménynek minősíti. */
}

Mi a visszatérési érték és a tárolt sztring? Javítsd az ellenőrzést úgy, hogy a negatív eredményt is kezelje.

7.3.8 Megoldások

7.3.8.1 A hossz értelmezése

A kapacitás négy, a strlen eredménye egy, a kezdőcímen található sztring tárolóigénye kettő C bájt. A strcpy csak az 'a', '\0' értékeket másolja át; a teljes bináris tartalmat nem.

Ellenőrzési feltétel: a tömb méretét, az első sztring hosszát és annak terminátoros méretét külön mennyiségként kell megadni.

7.3.8.2 Elutasító másolás megvalósítása

ISO C17-függvényrészlet, a szükséges headerekkel; külön tesztprogramba illeszthető:

#include <stdbool.h>
#include <stddef.h>
#include <string.h>

bool copy_string(char *dst, size_t cap, const char *src)
{
    if (cap == 0) {
        return false;
    }

    size_t len = strlen(src);
    if (len >= cap) {
        return false;
    }

    for (size_t i = 0; i < len; ++i) {
        dst[i] = src[i];
    }
    dst[len] = '\0';
    return true;
}

A len < cap feltétel biztosítja a terminátor helyét anélkül, hogy len + 1 összeadást kellene ellenőrizni. A függvény nem allokál, lineáris költségű, és nem őriz meg kölcsönzött pointert.

Ellenőrizendő az üres forrás egy- és nullaelemű céllal, a pontosan elegendő kapacitás, valamint az egy bájttal kisebb cél változatlansága.

7.3.8.3 A csonkolás felismerése

A visszatérési érték nyolc, a tárolt sztring "1234567". Az egyenlőség már csonkolást jelent.

Helyes ISO C17-ellenőrzési részlet:

if (n < 0) {
    /* Kódolási hiba. */
} else if ((size_t)n >= sizeof buf) {
    /* Csonkolás. */
} else {
    puts(buf);
}

A teljes eredmény ágába csak nemnegatív, a kapacitásnál szigorúan kisebb érték kerülhet.

7.3.9 Összefoglaló

A C-sztring szerződés: élő, olvasható tárolóban elérhető NUL terminátort feltételez. A kapacitás, a hasznos bájthossz és a szöveges karakterek száma külön fogalom. A strlen nem validátor, a strcpy és strcat nem kapacitásellenőrző, a strncpy pedig nem garantál általánosan lezárt eredményt. A snprintf korlátozza az írást, de sikerét és csonkolását külön ellenőrizni kell. Bináris adatnál mindig explicit hosszra építs, ne terminátorra.