2.4 Preprocessor-labor és feature detection

Az előfeldolgozó hibáit gyakran nem az eredeti forrás, hanem a fordítónak ténylegesen átadott tokensorozat teszi érthetővé. Egy makró neve helyes műveletet sugallhat, miközben a helyettesítés mást eredményez; egy konfigurációs makró pedig már szerepelhet a végső makrólistában, noha a rá támaszkodó header feldolgozásakor még nem létezett.

A Makrók, feltételes fordítás és veszélyes helyettesítés szabályaira építve most bizonyítékokat gyűjtünk: előfeldolgozott forrást, makrólistát és include-nyomvonalat. Ezekből különítjük el a helyettesítési hibát, az include-sorrend hibáját és a platformképességekre vonatkozó téves feltételezést.

2.4.1 Laborprofil és megfigyelési eszközök

A példák alapja ISO C17. A választott konkrét laborprofil Linux, x86-64 System V ABI, LP64 adatmodell, glibc 2.40; a két fordító GCC 14.2.0 és Clang 19.1.7. Más kiadás is használható, de a tényleges verziót és targetet rögzíteni kell. Az alábbi tokenrészletek levezetett várakozások, nem elvégzett fordítási mérések.

Az előfeldolgozott forrás (preprocessed source) az include-ok, feltételes fordítási ágak és makróhelyettesítések feldolgozása után kapott szöveges reprezentáció. Nem őrzi meg a forrás minden szerkezeti információját, viszont megmutatja, milyen deklarációk és kifejezések jutnak tovább.

A következő kapcsolók GCC/Clang-eszközfunkciók, nem ISO C szintaktikai elemek:

Kapcsoló Mit mutat? Mire nem elegendő?
-E Az előfeldolgozás eredményét Nem végez teljes típusellenőrzést vagy linkelést
-E -dM Az előfeldolgozás végén fennálló makródefiníciókat Nem rekonstruálja a makróállapot történetét
-H A ténylegesen beolvasott headerek beágyazását Nem bizonyítja az interfészek helyességét

A -H nyomvonala a standard hibakimenetre kerül. Formátuma és a rendszerheaderek útvonala verzió- és környezetfüggő.

2.4.2 Kidolgozott labor: két egymástól független hiba

Készítsd el az alábbi három fájlt ugyanabban a könyvtárban. Ez szándékosan hibás, de a megadott kifejezésekkel nem UB-t bemutató C17 tanítási példa.

Az agent_config.h:

#ifndef AGENT_CONFIG_H
#define AGENT_CONFIG_H

#ifndef AGENT_WIDE
#define AGENT_WIDE 1
#endif

#define AGENT_SCALE(x) x * 2

#endif

Az agent_api.h:

#ifndef AGENT_API_H
#define AGENT_API_H

#if defined(AGENT_WIDE) && AGENT_WIDE
enum { AGENT_CAPACITY = 32 };
#else
enum { AGENT_CAPACITY = 16 };
#endif

struct agent_record {
    unsigned char bytes[AGENT_CAPACITY];
};

#endif

A main.c:

#include "agent_api.h"
#include "agent_config.h"
#include <stdio.h>

#define DIAG_INT(value) \
    ((void)fprintf(stderr, "%s:%d:%s: %s=%d\n", \
                   __FILE__, __LINE__, __func__, #value, (value)))

int main(void)
{
    int scaled = AGENT_SCALE(1 + 3);

    DIAG_INT(scaled);
    DIAG_INT(AGENT_CAPACITY);
    return 0;
}

A fejlesztő szándéka szerint az alapértelmezett kapacitás 32, a skálázott érték pedig (1+3)⋅2=8(1+3)\cdot2=8. A program azonban egyik szándékot sem valósítja meg.

2.4.2.1 Bizonyítékgyűjtés

Az alábbi Bash-parancsok külön laborlépések. A CC értékét a rögzített kiadást kiválasztó parancsra állítsd; a teljes sorozatot Clanggal is ismételd meg, külön munkakönyvtárban.

CC=gcc
FLAGS=(-std=c17 -Wall -Wextra -Wpedantic)

"$CC" --version > compiler.txt
"$CC" -dumpmachine >> compiler.txt

"$CC" "${FLAGS[@]}" main.c -o agent
"$CC" "${FLAGS[@]}" -E main.c -o main.i
"$CC" "${FLAGS[@]}" -E -dM main.c > macros.txt
"$CC" "${FLAGS[@]}" -E -H main.c \
    -o /dev/null 2> includes.txt

grep -n -E 'AGENT_CAPACITY|int scaled' main.i

A main.i rendszerheaderekből származó részei hosszúak lehetnek. Elsőként a saját azonosítóidra keress. Az alapértelmezett kimenet forráshelyjelöléseit érdemes megtartani: ezek kapcsolják vissza a tokensorozatot az eredeti fájlokhoz. A GCC/Clang -P kapcsolója olvashatóbb kimenetet adhat, de eltávolítja ezeket a jelöléseket.

2.4.2.2 A hiba okának bizonyítása

A lényeges, kézzel levezetett tokenrészletek:

/* Előfeldolgozott részletek, nem önálló program. */
enum { AGENT_CAPACITY = 16 };

int scaled = 1 + 3 * 2;

Bizonyításvázlat a makróhibához. Az AGENT_SCALE helyettesítési listája x * 2. Az argumentum tokenjei 1 + 3; a helyettesítés nem tesz köréjük automatikusan zárójelet. Ezért:

1+3*2=1+(3*2)=7. 1+3*2 = 1+(3*2)=7.

A hiba már az előfeldolgozott kifejezésben jelen van, tehát nem az optimalizáló változtatta meg a helyes számítást.

Bizonyításvázlat az include-sorrend hibájához. Az agent_api.h feldolgozásakor az AGENT_WIDE még nincs definiálva. A feltétel hamis, ezért a 16-os ág marad meg. A későbbi agent_config.h már nem értékeli újra a korábbi direktívát. Emiatt egyszerre lehet igaz, hogy:

A végső makrólista tehát önmagában félrevezető bizonyíték.

2.4.2.3 Javítás és ellenőrzési feltétel

A makró javított definíciója az agent_config.h fájlban:

#define AGENT_SCALE(x) ((x) * 2)

Ez a precedenciaproblémát javítja. Az aritmetikai művelet helyességéhez továbbra is szükséges, hogy az eredmény ábrázolható legyen a művelet típusában.

Az include-hiba tartós javítása nem pusztán a két include felcserélése. Az agent_api.h saját include guardján belül, a feltételes ág előtt maga olvassa be a függőségét:

#include "agent_config.h"

Így a header önmagában is rendelkezik a saját deklarációihoz szükséges konfigurációval. Az ellenőrzési feltétel: alapértelmezett konfigurációnál az előfeldolgozott deklaráció kapacitása 32, a kifejezés pedig ((1 + 3) * 2) legyen.

2.4.3 Parancssori konfiguráció: -D és -U

A GCC/Clang -DNAME=value kapcsolója a forrás feldolgozása előtt definiálja a makrót. A -DNAME rövid alak értéke 1. A -UNAME megszünteti a definíciót; a -D és -U kapcsolók egymáshoz viszonyított sorrendje számít.

A javított headerrel:

"$CC" "${FLAGS[@]}" -UAGENT_WIDE -DAGENT_WIDE=0 \
    -E main.c -o narrow.i

"$CC" "${FLAGS[@]}" -DAGENT_WIDE=0 -UAGENT_WIDE \
    -E main.c -o default.i

Az első esetben a konfigurációs header már definiált, nulla értékű makrót talál, ezért a kapacitás 16. A másodikban nincs kezdeti definíció, így a header beállítja az alapértelmezett 1-et, és a kapacitás 32.

Két gyakori tévedés következik ebből. A -U nem tiltja meg, hogy a forrás később újradefiniálja a nevet. Továbbá a #ifdef AGENT_WIDE nulla értéknél is igaz: a definíció létezését, nem az értékét vizsgálja.

2.4.4 Forráshely és diagnosztikai helper

ISO C17: a __FILE__ előre definiált makró az aktuális feltételezett forrásfájl nevét adja sztringliterálként, a __LINE__ pedig a forrássor számát egész konstansként. Ezeket a #line direktíva is befolyásolhatja, ezért nem feltétlenül a fizikai fájl változatlan helyazonosítói.

A __func__ ezzel szemben nem makró. Függvényen belüli, előre definiált azonosító; szemantikailag statikus tárolási idejű, konstans karaktertömböt jelöl a függvény nevével. Emiatt nem várható el, hogy a -dM listájában szerepeljen.

A DIAG_INT helper szerződése szűk:

A diagnosztikai sztringek statikus tárolási idejűek; nincs tulajdonátadás. A stderr könyvtár által biztosított stream, amelyet ez a példa nem zár le. Egy korábban lezárt stream használata megsértené a helper előfeltételét.

2.4.5 Platformteszt helyett képességvizsgálat

A platformteszt környezeti kategóriát azonosít. A képességvizsgálat (feature detection) azt ellenőrzi, hogy a programhoz szükséges konkrét nyelvi vagy könyvtári művelet rendelkezésre áll-e.

Vizsgálat Mit jelent? Mit nem bizonyít?
__linux__ A fordító Linux targetet jelez Egy adott libc API elérhetőségét
__clang__ Clang fordítócsaládot jelez Egy adott bővítés támogatását
__GNUC__ GNU-kompatibilitást jelző makró Önmagában nem bizonyítja, hogy GCC fordít
__STDC_VERSION__ Az ISO C nyelvi változat jelzése POSIX API vagy futásidejű szolgáltatás meglétét
Fordítási/linkelési próba A kipróbált használat elfogadhatóságát A futásidejű sikerességet

A libc feature-test makrója más fogalom: gyakran nem képességet jelent be, hanem azt kéri, hogy a headerek mely interfészeket tegyék láthatóvá.

2.4.5.1 POSIX-interfész fordítási próbája

A következő önálló probe_clock.c C17 + POSIX példa, nem tisztán ISO C:

#include <time.h>

int main(void)
{
    struct timespec ts;
    return clock_gettime(CLOCK_MONOTONIC, &ts) == 0 ? 0 : 1;
}

Fordítás és linkelés a választott Linux/glibc profilhoz:

"$CC" "${FLAGS[@]}" -D_POSIX_C_SOURCE=200809L \
    probe_clock.c -o probe_clock

A ts a main automatikus tárolási idejű objektuma. A hívás csak a hívás időtartamára kap kölcsönzött hozzáférést; nincs tulajdonátadás. Élettartama a blokk végén megszűnik. Hibánál a program nem olvassa a tartalmát, hanem 1-et ad vissza.

A _POSIX_C_SOURCE definíciójának minden rendszerheader előtt kell érvényesülnie. Nem elég közvetlenül a <time.h> elé tenni, ha például a <stdio.h> már korábban beolvasta a libc közös konfigurációs headereit. A parancssori definíció ezt az időzítési hibát elkerüli.

A sikeres fordítás és linkelés azt igazolja, hogy ez a használat az adott header-, kapcsoló- és könyvtárkészlettel elfogadható. Nem igazolja, hogy a függvény futás közben sikeres lesz. Régebbi vagy más libc-profilnál a szükséges linkkönyvtárak is eltérhetnek. A csak -fsyntax-only kapcsolóval végzett próba pedig még a linkelhetőséget sem ellenőrzi.

2.4.6 Makrókonfiguráció mint ABI-bemenet

A konfiguráció nem mindig csupán algoritmust választ: nyilvános típusokat is módosíthat.

Az alábbi táblázat a javított laborheaderre vonatkozik, a megadott x86-64 System V/LP64 profilban. A méret a tömbtagé, nem mért teljes struktúraméret.

AGENT_WIDE A bytes tag típusa A tag mérete
0 unsigned char[16] 16 C bájt
1 unsigned char[32] 32 C bájt

Ha egy könyvtár és kliense eltérő értékkel dolgozza fel ezt az interfészt, eltérően értelmezheti ugyanazt a határon átadott objektumot. A linker rendszerint nem végez teljes C-típusellenőrzést; a sikeres linkelés nem zárja ki az inkompatibilis deklarációkat vagy a későbbi hibás memória-hozzáférést.

Hasonló, implementációspecifikus példa a glibc _FILE_OFFSET_BITS beállítása: egyes 32 bites profilokon típusokat és interfészleképezéseket változtat, míg a szokásos 64 bites LP64 környezetben nem ugyanilyen változást okoz.

A reprodukcióhoz ezért a forrásverzió mellett őrizd meg a teljes fordítóparancsot, a kapcsolók sorrendjét, a konfigurációs headereket, az include-útvonalakat, a sysrootot, a fordító- és libc-verziót. A makrólista hasznos pillanatfelvétel, de nem helyettesíti ezeket.

2.4.7 Feladatok

2.4.7.1 Magyarázó feladat

Miért nem cáfolja az include-sorrend hibáját, ha a macros.txt szerint AGENT_WIDE értéke 1? Nevezz meg két további bizonyítékot.

2.4.7.2 Kódolási feladat

Javítsd mindkét hibát. Ezután készíts külön előfeldolgozott fájlt AGENT_WIDE=0 és AGENT_WIDE=1 beállítással. A header működése ne függjön a kliens include-sorrendjétől.

2.4.7.3 Diagnosztikai feladat

Egy projekt Linuxon fordul, ezért automatikusan engedélyezi a clock_gettime használatát. A _POSIX_C_SOURCE definícióját a <stdio.h> után helyezi el. Magyarázd meg a két külön hibát, és tervezz ellenőrzést futtatás nélkül.

2.4.8 Megoldások

2.4.8.1 A végső makróállapot értelmezése

A makrólista az előfeldolgozás végi állapotot mutatja. A korábbi feltételes ág kiválasztását nem módosítja egy későbbi definíció.

Az include-nyomvonal bizonyítja a fájlok feldolgozási sorrendjét, a main.i pedig megmutatja a ténylegesen megmaradt 16-os deklarációt. Az ellenőrzés akkor teljes, ha mindhárom adat ugyanahhoz a fordítóparancshoz tartozik.

2.4.8.2 A javított konfiguráció ellenőrzése

Az agent_api.h maga include-olja az agent_config.h fájlt; a skálázó makró ((x) * 2) alakú.

A két előfeldolgozásban a kapacitás rendre 16 és 32, a skálázott kifejezés mindkettőben ((1 + 3) * 2). További ellenőrzésként egy kizárólag az agent_api.h fájlt include-oló C17 fordítási egységnek is elfogadhatónak kell lennie.

2.4.8.3 A képességpróba javítása

A Linux-target azonosítása nem bizonyítja a szükséges libc-interfész láthatóságát vagy linkelhetőségét. Ettől függetlenül a késői feature-test definíció hatástalan lehet.

A javítás: _POSIX_C_SOURCE=200809L beállítása minden rendszerheader előtt, majd a bemutatott próba fordítása és linkelése a valódi target környezetében. Ellenőrzési feltétel a sikeres build; futásidejű működést ebből nem szabad állítani.

2.4.9 Összefoglaló

A -E a tényleges tokensorozatot, a -dM a végső makróállapotot, a -H az include-feldolgozás nyomvonalát mutatja. Együtt alkalmasak az előfeldolgozási hibák okának bizonyítására.

Az OS azonosítása, a fordító felismerése, az interfészek láthatóságának kérése és a képesség tényleges vizsgálata külön feladat. A konfigurációs makrókat pedig buildbemenetként kell kezelni: némelyikük az ABI-szerződés része.