9.5 Layout- és protokolllabor két architektúrán

Két gépen azonos sizeof értéket mérni még nem bizonyítja, hogy egy struktúra bájtjai hordozható hálózati üzenetet alkotnak. A natív memóriaelrendezést a nyelvi szabályok, a target ABI-ja és a fordítási beállítások együtt határozzák meg. A hálózati reprezentációt ezzel szemben a protokoll szerződésének kell rögzítenie.

Ebben a laborban ugyanazt a C17 programot vizsgáljuk x86-64 és AArch64 környezetben. Külön dokumentáljuk a layoutot, a generált memória-hozzáféréseket és a protokolltesztek eredményét. A kiindulópont a Padding, alignment és offsetof, a Byte order és biztonságos szerializálás, valamint a Reprodukálható fordítási és diagnosztikai labor.

9.5.1 A labor profilja és bizonyítékai

A példák minimumkövetelménye ISO C17. A két összehasonlítandó referencia-profil:

Tulajdonság x86-64 profil AArch64 profil
Operációs környezet Linux, hosted Linux, hosted
ABI x86-64 System V, LP64 AAPCS64, LP64
Bájt CHAR_BIT == 8 CHAR_BIT == 8
Byte order little-endian little-endian
Fordító GCC 14.2.0 GCC 14.2.0
C könyvtár glibc 2.40 glibc 2.40
Nyelvi mód -std=c17 -std=c17

Ezek választott laborfeltételek, nem egy már elvégzett mérés környezeti adatai. Más kiadás használható, de külön profilként dokumentálandó. Rögzítsd a tényleges fordítóverziót, target triple-t, libc-verziót, fordítási kapcsolókat, kernelverziót, továbbá azt, hogy valódi hardveren vagy emulátorban futott-e a program.

Háromféle bizonyítékot különíts el:

Az alábbi konkrét layoutértékek előrejelzések; a kódhoz nem tartozik itt közölt futtatási eredmény.

9.5.2 Az üzenet és a wire format szerződése

Az üzenet három mezője: egy műveletazonosító, egy sorszám és egy számláló. Az érvényes azonosítók 1 és 2; mindkettőhöz a mezőtípusok teljes értéktartománya megengedett.

A wire format, vagyis a továbbított bájtsorozat formátuma pontosan hét oktett:

Mező Natív x86-64: várható offset/méret Natív AArch64: várható offset/méret Wire: offset/méret
tag 0 / 1 0 / 1 0 / 1
belső padding 1 / 3 1 / 3 nincs
sequence 4 / 4 4 / 4 1 / 4
count 8 / 2 8 / 2 5 / 2
záró padding 10 / 2 10 / 2 nincs
teljes méret 12 12 7
igazítás 4 4 oktettenként feldolgozható

A natív oszlopok az előbbi profilokra, szokásos ABI-layouttal, packingot módosító kapcsolók nélkül értendők. A wire formátumban a többoktettes mezők big-endian sorrendűek.

A dekóder pontosan egy teljes rekordot fogad:

Hiba esetén a kimeneti objektum változatlan marad. A függvény nem stream-framingot végez: több összefűzött rekordból a hívó választja ki a feldolgozandó hétoktettes tartományt.

9.5.3 Teljes mérő- és protokollteszt

A következő lab.c önálló C17 program. Az exact-width típusokat és a nyolcbites C bájtot fordításkor ellenőrzi. Ezek a labor követelményei, nem minden C implementációra igaz állítások.

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

#if !defined(UINT16_MAX) || !defined(UINT32_MAX)
#error "This lab requires uint16_t and uint32_t"
#endif

#ifdef NDEBUG
#error "This test program requires enabled assertions"
#endif

_Static_assert(CHAR_BIT == 8, "This protocol lab requires octets");

enum { WIRE_SIZE = 7 };

enum status {
    P_OK,
    P_ARGUMENT,
    P_SIZE,
    P_TAG
};

struct message {
    unsigned char tag;
    uint32_t sequence;
    uint16_t count;
};

static int valid_tag(unsigned char tag)
{
    return tag == 1 || tag == 2;
}

static int same(struct message a, struct message b)
{
    return a.tag == b.tag &&
           a.sequence == b.sequence &&
           a.count == b.count;
}

static enum status encode(unsigned char *dst, size_t n,
                          const struct message *src)
{
    if (dst == NULL || src == NULL)
        return P_ARGUMENT;
    if (n != WIRE_SIZE)
        return P_SIZE;
    if (!valid_tag(src->tag))
        return P_TAG;

    dst[0] = src->tag;
    dst[1] = (unsigned char)(src->sequence >> 24);
    dst[2] = (unsigned char)(src->sequence >> 16);
    dst[3] = (unsigned char)(src->sequence >> 8);
    dst[4] = (unsigned char)src->sequence;
    dst[5] = (unsigned char)(src->count >> 8);
    dst[6] = (unsigned char)src->count;
    return P_OK;
}

static enum status decode(const unsigned char *src, size_t n,
                          struct message *dst)
{
    if (src == NULL || dst == NULL)
        return P_ARGUMENT;
    if (n != WIRE_SIZE)
        return P_SIZE;
    if (!valid_tag(src[0]))
        return P_TAG;

    struct message tmp = {
        .tag = src[0],
        .sequence = ((uint32_t)src[1] << 24) |
                    ((uint32_t)src[2] << 16) |
                    ((uint32_t)src[3] << 8) |
                    (uint32_t)src[4],
        .count = (uint16_t)(((uint32_t)src[5] << 8) |
                           (uint32_t)src[6])
    };

    *dst = tmp;
    return P_OK;
}

/* Külön vizsgálható, natív reprezentációt kezelő függvények. */
uint32_t load_aligned(const uint32_t *p)
{
    return *p;
}

void store_aligned(uint32_t *p, uint32_t value)
{
    *p = value;
}

uint32_t load_native_bytes(const unsigned char *p)
{
    uint32_t value;
    memcpy(&value, p, sizeof value);
    return value;
}

void store_native_bytes(unsigned char *p, uint32_t value)
{
    memcpy(p, &value, sizeof value);
}

static void expect_decode_error(const unsigned char *src, size_t n,
                                enum status expected)
{
    const struct message before = {2, UINT32_MAX, UINT16_MAX};
    struct message after = before;

    assert(decode(src, n, &after) == expected);
    assert(same(after, before));
}

int main(void)
{
    printf("message: size=%zu align=%zu\n",
           sizeof(struct message), _Alignof(struct message));
    printf("offsets: tag=%zu sequence=%zu count=%zu\n",
           offsetof(struct message, tag),
           offsetof(struct message, sequence),
           offsetof(struct message, count));
    printf("uint32_t: size=%zu align=%zu\n",
           sizeof(uint32_t), _Alignof(uint32_t));

    const unsigned char golden[WIRE_SIZE] = {
        0x01, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab
    };
    const struct message expected = {
        1, UINT32_C(0x01234567), UINT16_C(0x89ab)
    };
    unsigned char bytes[WIRE_SIZE];
    struct message decoded = {0};

    assert(encode(bytes, sizeof bytes, &expected) == P_OK);
    assert(memcmp(bytes, golden, sizeof golden) == 0);
    assert(decode(golden, sizeof golden, &decoded) == P_OK);
    assert(same(decoded, expected));

    const uint32_t sequences[] = {
        0, 1, UINT32_C(0x80000000), UINT32_MAX
    };
    const uint16_t counts[] = {0, 1, UINT16_MAX};

    for (unsigned char tag = 1; tag <= 2; ++tag) {
        for (size_t i = 0;
             i < sizeof sequences / sizeof sequences[0]; ++i) {
            for (size_t j = 0;
                 j < sizeof counts / sizeof counts[0]; ++j) {
                struct message input = {tag, sequences[i], counts[j]};
                assert(encode(bytes, sizeof bytes, &input) == P_OK);
                assert(decode(bytes, sizeof bytes, &decoded) == P_OK);
                assert(same(input, decoded));
            }
        }
    }

    for (size_t n = 0; n < WIRE_SIZE; ++n)
        expect_decode_error(golden, n, P_SIZE);

    const unsigned char too_long[WIRE_SIZE + 1] = {1};
    expect_decode_error(too_long, sizeof too_long, P_SIZE);

    unsigned char bad_tag[WIRE_SIZE];
    memcpy(bad_tag, golden, sizeof bad_tag);
    bad_tag[0] = 3;
    expect_decode_error(bad_tag, sizeof bad_tag, P_TAG);
    expect_decode_error(NULL, WIRE_SIZE, P_ARGUMENT);

    assert(decode(golden, sizeof golden, NULL) == P_ARGUMENT);

    struct message invalid = expected;
    invalid.tag = 0;
    memset(bytes, 0xa5, sizeof bytes);
    assert(encode(bytes, sizeof bytes, &invalid) == P_TAG);
    for (size_t i = 0; i < sizeof bytes; ++i)
        assert(bytes[i] == 0xa5);

    puts("All protocol assertions passed.");
    return 0;
}

Fordítás mindkét target saját környezetében:

gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -Wconversion \
    lab.c -o lab

A tesztet külön laborlépésként indítsd el; a jegyzőkönyvbe a tényleges kimenetet és kilépési státuszt másold. Az utolsó üzenet a program várt sikerjelzése, nem itt igazolt teszteredmény.

9.5.3.1 Tulajdonlás és érvényesség

Hozzáférés Tulajdonos és élettartam Előfeltétel, invalidálás és hibakimenet
encode, decode pointerei a hívó objektumai; kölcsönzött hozzáférés a hívás végéig a nem null pointer megfelelő élő objektumra/tartományra mutat; a forrás és cél nem fed át
load_aligned, store_aligned a hívó élő uint32_t objektuma megfelelő igazítás és olvashatóság/írhatóság; megsértésük UB
load_native_bytes, store_native_bytes a hívó bájttartománya legalább sizeof(uint32_t) elérhető C bájt; külön egész típusú igazítás nem kell
tesztadatok main automatikus objektumai élettartamuk a blokk végén ér véget; a függvények nem tárolnak el pointert

Az élettartam vége vagy a tároló felszabadítása érvénytelenítené a kölcsönzött hozzáférést. A hosszparaméter nem bizonyítja a tároló tényleges méretét: a hazudott hossz bounds-hibához vezethet. Az API hibakódjai nem helyettesítik ezeket az előfeltételeket.

9.5.4 A golden adat és a round-trip értelmezése

A golden byte vector a specifikációból, az encoder működésétől függetlenül előállított referencia-bájtsorozat. A példában:

sequence=1⋅224+35⋅216+69⋅28+103=𝟶𝚡𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽. \text{sequence} = 1\cdot 2^{24} +35\cdot 2^{16} +69\cdot 2^8 +103 = \mathtt{0x01234567}.

Ezért a négy továbbított oktett 01 23 45 67. A count hasonlóan 89 ab.

A round-trip tulajdonság:

D(E(m))=m, D(E(m))=m,

ahol mm érvényes üzenet, EE az encoder, DD a decoder. Az egyenlőség itt mezőértékek egyenlőségét jelenti, nem a struktúra teljes objektumreprezentációjának azonosságát.

Bizonyításvázlat. Az encoder a sorszámot négy, egymást nem átfedő nyolcbites részre bontja. A decoder ezeket eredeti helyükre tolja, majd bitenkénti VAGY művelettel egyesíti. A számlálónál ugyanez két résszel történik. A tag változatlanul másolódik, ezért mindhárom mező helyreáll.

A teszt 24 határérték-kombinációt jár be, nem a teljes értéktartományt. A golden teszt azért is szükséges, mert két azonosan hibás, például little-endian encoder és decoder sikeres round-tripet adhatna.

A struktúrák összehasonlítására használt memcmp tipikus hiba lenne: a padding nem logikai mező. A wire bájttömbök összehasonlítására viszont itt megfelelő.

9.5.5 Load/store és unaligned hozzáférés assemblyben

A már definiált négy hozzáférési függvény assemblyjét mindkét gépen állítsd elő:

gcc -std=c17 -O2 -S lab.c -o lab.s

AArch64-en készíts külön változatot:

gcc -std=c17 -O2 -mstrict-align -S lab.c -o lab-strict.s

GCC-specifikus kapcsoló: a -mstrict-align AArch64 targeten használható; nem ISO C tulajdonság.

A következőket keresd, ne egy előre feltételezett teljes utasítássorozatot:

Függvény x86-64-en lehetséges alak AArch64-en lehetséges alak
igazított betöltés/tárolás movl memóriaoperandussal ldr / str W regiszterrel
bájtforrásból memcpy szintén egyetlen gépi betöltés konfigurációtól függően ldr vagy kisebb betöltések
bájtcélba memcpy szintén egyetlen gépi tárolás konfigurációtól függően str vagy kisebb tárolások

Ezek keresési támpontok, nem mért kimenetek. A memcpy nem kötelezően függvényhívás: a fordító a kisméretű másolást beépítheti.

A lényegi különbség a forrásprogram szerződése. A uint32_t * dereferálásához megfelelően igazított uint32_t objektum szükséges. A bájtforrásos memcpy nem követel ilyen igazítást a forrástól. A fordító ettől még használhat az adott környezetben megengedett unaligned gépi betöltést.

Az AArch64 sem jelent általános „unaligned hozzáféréskor mindig összeomlik” szabályt. Az utasítás, memóriatípus és rendszerbeállítás is számít; a hardver toleranciája pedig nem teszi definiálttá a hibás C kódot.

9.5.6 Sanitizer-lelet és helyes javítás

Az alábbi bad.c szándékosan hibás, UB-t tartalmazó diagnosztikai példa. Nincs garantált normál kimenete.

#include <limits.h>
#include <stdint.h>

#ifndef UINT32_MAX
#error "This example requires uint32_t"
#endif

_Static_assert(CHAR_BIT == 8, "Octet-based lab");
_Static_assert(_Alignof(uint32_t) > 1,
               "This experiment requires nontrivial alignment");

int main(void)
{
    _Alignas(uint32_t)
    unsigned char storage[sizeof(uint32_t) + 1] = {0};

    const unsigned char *borrowed = storage + 1;
    return (int)*(const uint32_t *)(const void *)borrowed;
}

A storage a tároló tulajdonosa; a borrowed a blokk végéig élő, kölcsönzött bájthozzáférés. A tartomány elég hosszú, de az egybájtos eltolás miatt a cím nem megfelelően igazított. Ráadásul a deklarált bájttömbön keresztül így nem kapunk szabályos uint32_t objektumhozzáférést sem.

gcc -std=c17 -O1 -g -Wall -Wextra \
    -fsanitize=alignment -fno-sanitize-recover=alignment \
    bad.c -o bad

A sanitizer várhatóan hibás igazítású betöltést jelez; a pontos szöveg és forráshely a fordítókiadástól függ. A jegyzőkönyvben az elvárt igazítást és az érintett műveletet azonosítsd. Egyetlen lelet nem a program összes hibájának felsorolása.

A helyes natív javítás a korábbi load_native_bytes: megfelelően igazított helyi uint32_t objektumba másolunk. A labor exact-width unsigned típusánál minden bitminta érvényes értéket képvisel.

Ez azonban nem byte-order konverzió. Wire adatokhoz a decode explicit mezőösszeállítása kell. A cast eltüntetése javíthatja az objektumhozzáférést, de önmagában nem valósítja meg a protokollt.

9.5.7 Feladatok

9.5.7.1 Layout dokumentálása

Futtasd a mérőprogramot mindkét profilon. Készíts táblázatot a tényleges méretről, igazításról és offsetekről. Indokold, miért nem bizonyít hordozható wire formátumot az eredmények egyezése.

9.5.7.2 Protokollteszt bővítése

Egészítsd ki a tesztet úgy, hogy az összes lehetséges oktettértéket ellenőrizze tag mezőként. Érvénytelen tag esetén ellenőrizd a kimenet változatlanságát is.

9.5.7.3 Diagnosztika és javítás

Dokumentáld a hibás program sanitizer-leletét. Készíts javított változatot memcpy-vel, majd hasonlítsd össze a négy hozzáférési függvény assemblyjét mindkét targeten. AArch64-en a -mstrict-align változatot is vizsgáld.

9.5.8 Megoldások

9.5.8.1 Layout dokumentálása

A referencia-profilokon a várakozás: sizeof == 12, _Alignof == 4, az offsetek pedig 0, 4, 8. Eltéréskor először a tényleges ABI-t, kapcsolókat és típusokat ellenőrizd.

Az egyezés csak két konkrét profilra vonatkozó megfigyelés. A natív reprezentáció paddingot tartalmaz, mezősorrendjének változtatása módosíthatja a layoutot, és a little-endian mezők nem a protokoll big-endian reprezentációját adják. Ellenőrzési feltétel: a mérési táblázat mellé teljes környezeti profil tartozik.

9.5.8.2 Protokollteszt bővítése

A következő C17 részlet a main függvénybe illeszthető, a golden deklarációja után:

for (unsigned int t = 0; t < 256; ++t) {
    unsigned char candidate[WIRE_SIZE];
    memcpy(candidate, golden, sizeof candidate);
    candidate[0] = (unsigned char)t;

    if (t == 1 || t == 2) {
        struct message result = {0};
        assert(decode(candidate, sizeof candidate, &result) == P_OK);
        assert(result.tag == t);
    } else {
        expect_decode_error(candidate, sizeof candidate, P_TAG);
    }
}

A ciklusváltozó szándékosan nem unsigned char: annak körbefordulása könnyen végtelen ciklust okozna. Ellenőrzési feltétel: két elfogadott és 254 elutasított tag, hibánál változatlan kimenet.

9.5.8.3 Diagnosztika és javítás

A javított bad.c tartalmazza a <string.h> headert, és a hibás castos olvasás helyett ezt használja:

uint32_t value;
memcpy(&value, borrowed, sizeof value);
return (int)value;

Ez a megadott, nullákkal feltöltött bemenetre vonatkozó részlet. Ellenőrzési feltétel: ezen a hozzáférésen nincs alignment-lelet, és a bounds továbbra is helyes.

Az assemblyben a memcpy akár ugyanarra az utasításra egyszerűsödhet, mint a hibás castos olvasás. Ettől a két C program helyessége nem lesz azonos: az utasításazonosság nem bizonyítja a nyelvi előfeltételek teljesülését.

9.5.9 Összefoglaló

A layoutmérés egy konkrét build tulajdonságait mutatja meg; a wire formátumot külön szerződés rögzíti. A golden teszt a reprezentációt, a round-trip az oda-vissza értékmegőrzést, a negatív tesztek a hibaszerződést ellenőrzik. Az assembly megmagyarázza a végrehajtást, a sanitizer pedig hibákat tárhat fel, de egyik sem helyettesíti az igazítási, objektum-, bounds- és élettartam-előfeltételek indoklását.