9.4 Byte order és biztonságos szerializálás

A hálózaton nem C-objektumok, hanem egy protokoll szerint értelmezhető oktettek haladnak. A szerializálás (serialization) egy programbeli érték átalakítása meghatározott külső reprezentációvá; a deszerializálás ennek ellenőrzött visszaalakítása. Egy hordozható formátum az értékeket, a mezők szélességét, sorrendjét és érvényességi feltételeit rögzíti, nem pedig egy fordító pillanatnyi memóriaelrendezését.

Az előfeltételek közül a memóriamásolás és byte view adja a reprezentáció elérésének eszközeit, a promóciók és túlcsordulás az aritmetikai biztonságot. Az union, enum és bit-field tárgyalásából pedig azt visszük tovább, hogy egy natív típus kényelmes belső modell lehet anélkül, hogy külső adatformátumnak alkalmas volna.

9.4.1 Host order és wire order

A host byte order a több C bájtot elfoglaló skaláris értékek adott implementációban használt bájtsorrendje. A wire order a protokoll által előírt sorrend. A kettő egyezhet, de a protokoll helyessége nem függhet ettől.

ISO C17: a C bájt, azaz a sizeof egysége CHAR_BIT bitet tartalmaz; nem általánosan nyolcat. A hálózati oktett viszont definíció szerint nyolc bit. E fejezet codecje ezért explicit feltételként követeli meg a CHAR_BIT == 8 tulajdonságot és a használt exact-width egész típusokat.

A 0x12345678 érték négyoktettes reprezentációja:

Sorrend Oktettek növekvő offset szerint
Big-endian 12 34 56 78
Little-endian 78 56 34 12

Big-endian esetén a legnagyobb helyi értékű oktett kerül előre. Ha egy kk oktettes, előjel nélküli mező oktettjei b0,…,bk−1b_0,\ldots,b_{k-1}, akkor:

x=∑i=0k−1bi256k−1−i,0≤bi<256. x=\sum_{i=0}^{k-1}b_i\,256^{k-1-i}, \qquad 0\le b_i<256.

Az explicit codec ezt az összefüggést valósítja meg. Nem szükséges előbb felismernie a host bájtsorrendjét.

9.4.1.1 Miért nem protokoll a natív struktúra?

Egy struktúra sizeof hosszúságú objektumreprezentációjának közvetlen kiírása több különböző szerződést mos össze:

Natív tulajdonság Külső formátumbeli probléma
Tagok közötti és végi padding Nem jelent adatmezőt; tartalma nem megbízható, és információt szivárogtathat
Több-bájtos egészek reprezentációja A fogadó más bájtsorrendet használhat
long, size_t, enum szélessége Eltérhet a platformok vagy fordítási profilok között
Pointertag Címet tartalmaz, nem a hivatkozott adatot; más folyamatban nem használható
Bit-field elrendezése Nem helyettesít explicit bitpozíciókat meghatározó protokollt

A padding kikapcsolása fordítóbővítéssel legfeljebb az egyik problémát módosítja. Nem definiál bájtsorrendet, pointerjelentést vagy verziózást. A helyes megoldás a szemantikai mezők egyenkénti kódolása.

9.4.2 Egy kis telemetriaüzenet szerződése

A példában egy verziót, egy sorszámot, egy hőmérsékletet és egy átlátszatlan payloadot továbbítunk. A teljes wire formátum:

Offset oktettben Hossz oktettben Mező Szabály
0 1 Verzió Pontosan 1
1 2 Payloadhossz Előjel nélküli, big-endian
3 8 Sorszám Előjel nélküli, big-endian
11 2 Hőmérséklet 16 bites kettes komplemens kód, big-endian
13 LL Payload Pontosan LL oktett

Ez protokollelrendezés, nem ABI-layout; a táblázat nem függ a host struktúraoffsetjeitől.

A hőmérséklet skálázott egész szám:

T=q100∘C,−32768≤q≤32767. T=\frac{q}{100}\;{}^\circ\mathrm{C}, \qquad -32768\le q\le32767.

Így például q=−125q=-125 jelentése −1,25∘C-1{,}25\,^\circ\mathrm{C}. A protokollnak a mértékegységet és a skálát is rögzítenie kell: a „16 bites signed érték” önmagában kevés. Ha az alkalmazás lebegőpontos mérésből állítja elő qq-t, a kerekítési és tartományhibapolitikát még a codec meghívása előtt kell alkalmaznia.

Az előjeles érték wire kódja:

u={q,q≥0,q+65536,q<0. u= \begin{cases} q, & q\ge0,\\ q+65536, & q<0. \end{cases}

Visszafelé:

q={u,u<32768,u−65536,u≥32768. q= \begin{cases} u, & u<32768,\\ u-65536, & u\ge32768. \end{cases}

Ez matematikai átalakítás, nem a host előjeles reprezentációjának másolása. A belső típus ezért int32_t: a dekódolás közbülső értékei biztonságosan elférnek benne. Nem végzünk tartományon kívüli unsigned–signed konverziót.

9.4.3 Igazítatlan buffer és POSIX konverziók

Egy fogadott puffer adott offsetje nem feltétlenül megfelelően igazított egy egész típushoz. A uint64_t *-ra alakított cím dereferálása nem biztonságos dekódolási módszer: sérülhet az igazítási követelmény, és külön vizsgálatot igényelnének az objektum- és aliasing-szabályok is. Az, hogy a hardver bizonyos igazítatlan betöltéseket támogat, nem ad nyelvi felmentést.

Két bevált módszer van:

POSIX API: a <arpa/inet.h> fejléc htons és htonl függvénye a 16, illetve 32 bites hostértéket alakítja hálózati sorrendű reprezentációhoz. Az ellenkező irány a ntohs, illetve ntohl. Ezek nem ISO C függvények. A névben szereplő long nem a host unsigned long tetszőleges szélességét jelenti.

Az alábbi C17 + POSIX kódrészlet négy olvasható oktettet igényel. A tároló tulajdonosa a hívó; a kölcsönzött src hozzáférésnek a hívás végéig érvényesnek kell maradnia. A függvény nem ellenőrzi a hosszt, és nem ad hibakódot: az előfeltételt a hívónak kell biztosítania.

#include <arpa/inet.h>
#include <stdint.h>
#include <string.h>

uint32_t read_u32_be_posix(const unsigned char *src)
{
    uint32_t network_value;
    memcpy(&network_value, src, sizeof network_value);
    return ntohl(network_value);
}

A helyi network_value igazítása megfelelő. A memcpy viszont önmagában nem cserél bájtsorrendet; ezért szükséges utána a konverzió.

A POSIX htonl nem nyolcoktettes codec. A következő példában a 64 bites mezőt is explicit műveletekkel kódoljuk, így nincs szükség platformspecifikus htonll változatra.

9.4.4 Bounds-ellenőrzött encoder és decoder

Az encoder a hívó bufferébe ír. A decoder nem másolja a payloadot, hanem kölcsönzött nézetet ad rá.

Objektum vagy hozzáférés Tulajdonos és élettartam Érvénytelenítés
Encoder bemeneti üzenete és payloadja A hívó; a hívás végéig érvényes Megszüntetésük a hívás alatt szerződéssértés
Encoder célbuffere A hívó; legalább cap írható C bájt Felszabadítás, sikeres realloc
Decoder bemeneti buffere A hívó; legalább n olvasható C bájt Felszabadítás, sikeres realloc
Dekódolt payloadnézet Nem tulajdonol tárolót A bemenet élettartamának vége; újrafelhasználása megszünteti a nézet üzenetjelentését
Dekódolt metaadatobjektum A hívó Megszűnése nem szabadítja fel a payloadot

A bemenet nem módosulhat konkurensen az olvasással. Az encoder célbuffere nem fedheti át a forrásadatokat vagy a metaadatobjektumokat; a decoder kimeneti objektuma nem fedheti át a bemenetet. A pointerek által jelzett tényleges tárolóméret helyessége hívói előfeltétel: ezt egy C-függvény a számszerű hosszparaméterből nem tudja ellenőrizni.

A visszatérési hibák esetén az encoder célbuffere és written kimenete, illetve a decoder kimeneti objektuma változatlan marad.

9.4.4.1 Teljes C17 példa

A codec.c ISO C17-et, nyolcbites C bájtot, valamint uint16_t, uint32_t, uint64_t és int32_t támogatást igényel. Nem használ POSIX függvényt.

#include <assert.h>
#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>

#if CHAR_BIT != 8
#error "This codec requires 8-bit C bytes"
#endif

#if !defined(UINT16_MAX) || !defined(UINT32_MAX) || \
    !defined(UINT64_MAX) || !defined(INT32_MAX)
#error "Required exact-width integer types are unavailable"
#endif

enum { HEADER_SIZE = 13 };

enum codec_status {
    CODEC_OK,
    CODEC_INVALID,
    CODEC_SHORT,
    CODEC_FORMAT
};

struct message {
    uint64_t sequence;
    int32_t temperature_centi;
    const unsigned char *payload;
    size_t payload_size;
};

/* Belső előfeltétel: 1 <= n <= 8, p legalább n bájtot jelöl. */
static void put_be(unsigned char *p, size_t n, uint64_t x)
{
    while (n != 0) {
        --n;
        p[n] = (unsigned char)(x & UINT64_C(255));
        x >>= 8;
    }
}

static uint64_t get_be(const unsigned char *p, size_t n)
{
    uint64_t x = 0;

    for (size_t i = 0; i < n; ++i) {
        x = (x << 8) | (uint64_t)p[i];
    }
    return x;
}

static enum codec_status encode_message(
    unsigned char *dst, size_t cap,
    const struct message *m, size_t *written)
{
    if (dst == NULL || m == NULL || written == NULL) {
        return CODEC_INVALID;
    }

    if ((m->payload_size != 0 && m->payload == NULL) ||
        m->payload_size > UINT16_MAX ||
        m->temperature_centi < -INT32_C(32768) ||
        m->temperature_centi > INT32_C(32767)) {
        return CODEC_INVALID;
    }

    if (m->payload_size > SIZE_MAX - HEADER_SIZE) {
        return CODEC_INVALID;
    }

    const size_t total = HEADER_SIZE + m->payload_size;
    if (cap < total) {
        return CODEC_SHORT;
    }

    const int32_t q = m->temperature_centi;
    const uint64_t raw = q < 0
        ? (uint64_t)(q + INT32_C(65536))
        : (uint64_t)q;

    dst[0] = 1;
    put_be(dst + 1, 2, (uint64_t)m->payload_size);
    put_be(dst + 3, 8, m->sequence);
    put_be(dst + 11, 2, raw);

    if (m->payload_size != 0) {
        memcpy(dst + HEADER_SIZE, m->payload, m->payload_size);
    }

    *written = total;
    return CODEC_OK;
}

static enum codec_status decode_message(
    const unsigned char *src, size_t n, struct message *out)
{
    if (src == NULL || out == NULL) {
        return CODEC_INVALID;
    }
    if (n < HEADER_SIZE) {
        return CODEC_SHORT;
    }
    if (src[0] != 1) {
        return CODEC_FORMAT;
    }

    const size_t length = (size_t)get_be(src + 1, 2);
    const size_t available = n - HEADER_SIZE;

    if (length > available) {
        return CODEC_SHORT;
    }
    if (length < available) {
        return CODEC_FORMAT;
    }

    const uint32_t raw = (uint32_t)get_be(src + 11, 2);
    const int32_t q = raw < UINT32_C(32768)
        ? (int32_t)raw
        : (int32_t)raw - INT32_C(65536);

    const struct message result = {
        .sequence = get_be(src + 3, 8),
        .temperature_centi = q,
        .payload = src + HEADER_SIZE,
        .payload_size = length
    };

    *out = result;
    return CODEC_OK;
}

int main(void)
{
    const unsigned char payload[] = { 0x41, 0x00, 0xff };
    const struct message input = {
        .sequence = UINT64_C(0x0102030405060708),
        .temperature_centi = -125,
        .payload = payload,
        .payload_size = sizeof payload
    };

    const unsigned char expected[] = {
        0x01, 0x00, 0x03,
        0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
        0xff, 0x83,
        0x41, 0x00, 0xff
    };

    unsigned char storage[1 + sizeof expected];
    unsigned char *wire = storage + 1;
    size_t written = 0;
    struct message output = {0};

    assert(encode_message(wire, sizeof expected,
                          &input, &written) == CODEC_OK);
    assert(written == sizeof expected);
    assert(memcmp(wire, expected, written) == 0);

    assert(decode_message(wire, written, &output) == CODEC_OK);
    assert(output.sequence == input.sequence);
    assert(output.temperature_centi == input.temperature_centi);
    assert(output.payload_size == sizeof payload);
    assert(memcmp(output.payload, payload, sizeof payload) == 0);

    assert(decode_message(wire, written - 1, &output) == CODEC_SHORT);
    return 0;
}

Az eltolt wire cím demonstrálja, hogy a codec nem igényel egész típushoz megfelelően igazított bemenetet. Az eltolás önmagában nem bizonyítja, hogy az adott futásban a cím ténylegesen igazítatlan.

A külön elvégzendő labor választott profilja: Linux x86-64 System V ABI, LP64, glibc 2.40; GCC 14.2.0 és Clang 19.1.7, alapértelmezett ABI-opciókkal. A parancsok e konkrét fordítókiadásokra mutató gcc, illetve clang programot feltételeznek. A példához itt nem tartozik elvégzett fordítás vagy mérés.

gcc -std=c17 -O2 -Wall -Wextra -Wconversion -Wpedantic \
    -pedantic-errors codec.c -o codec-gcc

clang -std=c17 -O2 -Wall -Wextra -Wconversion -Wpedantic \
    -pedantic-errors codec.c -o codec-clang

9.4.5 A méretellenőrzés és az értékmegőrzés indoklása

Az encoderben nem elegendő előbb kiszámítani a teljes méretet, és utána összevetni a kapacitással. Ha HH a fejlécméret és LL a payloadhossz, először ezt ellenőrizzük:

L≤SIZE_MAX−H. L\le \mathrm{SIZE\_MAX}-H.

Csak ezután képezzük N=H+LN=H+L értékét. Így az összeadás nem fordul körbe. A 16 bites hosszmező külön korlát: az aritmetikai reprezentálhatóság és a protokoll érvényessége két eltérő feltétel.

A decoder előbb megköveteli az n≥Hn\ge H feltételt. Emiatt az n−Hn-H kivonás biztonságos, és a payloadhossz közvetlenül összevethető a rendelkezésre álló területtel. Ebben az API-ban egy buffer pontosan egy teljes üzenet: hiányzó payload esetén CODEC_SHORT, többletbájt esetén CODEC_FORMAT az eredmény. TCP-streamhez külön framingréteg szükséges; azt a socket API és framing tárgyalja.

9.4.5.1 Helyességi bizonyításvázlat

A get_be első jj iterációja után az akkumulátor az első jj oktett big-endian értékét tartalmazza. A következő lépés x′=256x+bjx'=256x+b_j, pontosan a következő oktett hozzáfűzése. Legfeljebb nyolc oktett esetén az eredmény elfér uint64_t-ban. A put_be fordítva, az alsó nyolc bit leválasztásával és jobbra léptetéssel állítja elő ugyanezt a sorozatot.

Minden fejlécolvasást megelőz a teljes fejléc méretellenőrzése. Az encoder minden lehetséges hibavisszatérése az első írás előtt található; a decoder csak a teljes ellenőrzés után rendeli hozzá a helyi eredményt a kimenethez.

A mintában:

65536−125=65411=𝟶𝚡𝙵𝙵𝟾𝟹. 65536-125=65411=\mathtt{0xFF83}.

A payloadhossz 00 03, a hőmérséklet FF 83, a teljes üzenet 16 oktett. Ezek levezetett elvárt értékek, nem mérési eredmények. A round-trip megőrzi a mezőértékeket és a payload tartalmát, de nem a payload pointerének címét.

Az encoder költsége O(H+L)O(H+L), allokáció nélkül, folytonos írással és payloadmásolással. A decoder O(H)O(H) munkát végez, nem allokál, és nem járja be a payloadot; ennek ára a kölcsönzött nézet élettartam-szerződése.

9.4.6 Gyakori hibák és javításuk

Csak memcpy használata. Az igazítás problémáját kezeli, a bájtsorrendet nem. Utána konverzió kell, vagy eleve explicit oktettkiolvasás.

Előjeles nyers mező közvetlen castolása. C17-ben egy nem reprezentálható unsigned érték signed típussá alakítása nem általános dekódolási recept. A példában a kivonás még a tágabb, biztonságos tartományban történik.

A payloadhossz ellenőrzése a másolás után. Ekkor a bounds-sértés már megtörtént. Minden ellenőrzést az első érintett hozzáférés elé kell helyezni.

A dekódolt pointer megtartása a fogadópuffer újrafelhasználása után. A nézet nem saját másolat. Hosszabb megőrzéshez külön tulajdonolt tároló szükséges. Nulla hosszúságnál a visszaadott pointer lehet one-past; nem dereferálható.

Csak encoder–decoder round-trip tesztelése. Két azonosan hibás függvény egymást elfedheti. Ezért kell a protokollból kézzel levezetett oktettsorozat is.

9.4.7 Feladatok

  1. Magyarázó feladat. Mi a sorszám wire reprezentációja, ha értéke 0x0102030405060708? Miért nem változhat ez a host architektúrájával?

  2. Kódolási feladat. Bővítsd a tesztet a -32768, 32767 és 32768 hőmérsékletértékekkel. Ellenőrizd a wire oktetteket és a hibakimenet változatlanságát.

  3. Diagnosztikai feladat. Egy fejlesztő így ellenőrzi a méretet: total = HEADER_SIZE + length; if (total > cap) .... A decoderből pedig visszaadott payloadpointert eltárolja, majd új fogadáshoz felhasználja ugyanazt a buffert. Mi a két egymástól független hiba?

9.4.8 Megoldások

9.4.8.1 A bájtsorrend értelmezése

A nyolc oktett 01 02 03 04 05 06 07 08. A wire order a protokoll tulajdonsága; a host order csak a programbeli egész objektumreprezentációját befolyásolja. Ellenőrzési feltételként a kódolt buffer megfelelő nyolc oktettjét hasonlítsd ehhez a rögzített sorozathoz.

9.4.8.2 Határértékteszt

Az alábbi C17 részlet a teljes példa main függvényében, a meglévő változókkal használható:

struct message edge = input;

edge.temperature_centi = -32768;
assert(encode_message(wire, sizeof expected,
                      &edge, &written) == CODEC_OK);
assert(wire[11] == 0x80 && wire[12] == 0x00);
assert(decode_message(wire, written, &output) == CODEC_OK);
assert(output.temperature_centi == -32768);

edge.temperature_centi = 32767;
assert(encode_message(wire, sizeof expected,
                      &edge, &written) == CODEC_OK);
assert(wire[11] == 0x7f && wire[12] == 0xff);

unsigned char saved[sizeof expected];
memcpy(saved, wire, sizeof saved);
written = 999;

edge.temperature_centi = 32768;
assert(encode_message(wire, sizeof expected,
                      &edge, &written) == CODEC_INVALID);
assert(written == 999);
assert(memcmp(wire, saved, sizeof saved) == 0);

A hibás érték nem csonkolható csendben 16 bitre. Az ellenőrzés egyszerre vizsgálja az érvényességi korlátot és a módosítás nélküli hibavisszatérést.

9.4.8.3 Overflow és nézetélettartam

Az összeadás a kapacitásvizsgálat előtt körbefordulhat. A javítás az előzetes length > SIZE_MAX - HEADER_SIZE ellenőrzés, majd a kapacitásvizsgálat.

A második hiba ettől független: a pointer címe megmaradhat, miközben már más üzenet adataira mutat. Ez tároló-újrafelhasználáskor logikai érvénytelenítés; felszabadításkor dangling hozzáférés lenne. Ellenőrzési feltétel, hogy minden nézethasználat megelőzze a buffer újrafelhasználását, vagy előtte saját másolat készüljön.

9.4.9 Összefoglaló

A hordozható protokoll értékeket és oktettsorrendet definiál, nem natív struktúrákat másol. A biztonságos codec külön ellenőrzi a formátumot, a méretaritmetikát és a hozzáférési határokat. Az explicit oktettkezelés megszünteti az igazítás- és host-bájtsorrend-függést; a másolásmentes dekódolás viszont explicit kölcsönzési és élettartam-szerződést igényel.