3.1 Szimbólumok, linkage és relocation

A linker nem C-forráskódot olvas: objektumfájlokban tárolt szimbólumokat és hivatkozásokat kapcsol össze. Emiatt a sikeres fordítás még nem jelenti azt, hogy a teljes program összeállítható. Egy függvény deklarációja elegendő lehet a hívás lefordításához, miközben a megvalósítása hiányzik; egy hibás header pedig több objektumfájlba is eljuttathatja ugyanannak az objektumnak a definícióját.

A Headerek, deklarációk és include-higiénia részben megismert deklaráció–definíció különbséget most bináris szinten vizsgáljuk. A parancsok a Warningok, optimalizálási profilok és link-driver rész gyakorlatát követik: a végső linkelést is a fordító driverével végezzük.

3.1.1 Nyelvi kapcsolás és definíció

ISO C17. A nyelvi kapcsolás (linkage) azt határozza meg, hogy különböző deklarációk azonosítója ugyanazt az objektumot vagy függvényt jelöli-e.

A kapcsolás nem azonos a hatókörrel vagy az élettartammal. Egy blokkon belüli static int count; azonosítónak nincs kapcsolása, noha objektuma nem szűnik meg a blokk elhagyásakor. Ezeket a különbségeket a Scope, linkage, storage duration és lifetime rész tárgyalja részletesen.

Az alábbi deklarációk egymástól független példák, fájlhatókörben, korábbi deklaráció nélkül:

Deklaráció Szerep Kapcsolás
extern int limit; Nem definiáló deklaráció Külső
int limit = 3; Objektumdefiníció Külső
extern int limit = 3; Objektumdefiníció az inicializáló miatt Külső
int limit; Tentative definition Külső
static int limit; Tentative definition Belső
int budget(void); Függvénydeklaráció Külső
static int budget(void); Függvénydeklaráció Belső

Az extern tehát nem egyszerűen azt jelenti, hogy „máshol van definiálva”. Inicializálóval definíciót is adhat. Korábbi, látható deklaráció esetén pedig annak kapcsolását is örökölheti: egy fájlhatókörű static int limit; után álló extern int limit; ugyanazt a belső kapcsolású objektumot deklarálja.

3.1.1.1 Mit jelent a tentative definition?

ISO C17. A tentative definition olyan fájlhatókörű objektumdeklaráció, amelynek nincs inicializálója, és tárolásiosztály-meghatározója nincs, vagy static.

Az alábbi teljes fordítási egység C17-ben érvényes:

int requests;
int requests;

Ha a fordítási egység végéig nem jelenik meg hozzá inicializálóval ellátott definíció, a nyelvi hatás olyan, mintha egy nullaértékkel inicializált definíció szerepelne benne. Ha később int requests = 5; is következik, az adja meg az objektum kezdeti értékét.

Ez fordítási egységen belüli szabály. Nem engedély arra, hogy minden .c fájl külön definíciót kapjon ugyanahhoz a külső kapcsolású objektumhoz. A példáinkban használt külső objektumokhoz és függvényekhez a teljes programban egyetlen definíció szükséges.

3.1.2 Több fordítási egység vizsgálata

A következő példa minimumszabványa ISO C17. A megfigyelésekhez választott laborprofil: Linux, x86-64 System V ABI, LP64, CHAR_BIT == 8; GCC 14.2.0 vagy Clang 19.1.7, GNU binutils 2.43 és glibc 2.40. Ez reprodukciós célprofil, nem dokumentált futtatási eredmény. A szimbólumokra vonatkozó táblázat alább várt értelmezés, nem bemásolt eszközkimenet.

Az retry.h tartalma:

#ifndef RETRY_H
#define RETRY_H

extern int retry_limit;
extern int calls;

int retry_budget(void);

#endif

A retry.c tartalma:

#include "retry.h"

int retry_limit = 3;
int calls;

static int reserve = 1;

int retry_budget(void)
{
    calls = 1;
    return retry_limit - reserve;
}

A main.c tartalma:

#include "retry.h"

int main(void)
{
    int budget = retry_budget();
    return budget == 2 && calls == 1 ? 0 : 1;
}

A program nem módosítja a retry_limit és reserve értékét; így a kivonás eredménye biztosan ábrázolható int típusban. A calls név itt csak a függvény meghívását jelzi, nem általános számláló.

A tanulói laborban használható fordítási és vizsgálati parancsok:

gcc -std=c17 -Wall -Wextra -Wpedantic -O0 \
    -fno-lto -fno-common -fno-pie -c retry.c main.c

nm -A retry.o main.o
readelf -Ws retry.o
readelf -Ws main.o
readelf -rW retry.o
readelf -rW main.o

gcc -no-pie retry.o main.o -o retry-demo

Clang használatakor a fordítási és linkelési sorban a gcc helyére clang kerül. Az explicit opciók kizárják a vizsgálatból az LTO-t, a common-kezelést és a PIE alapértelmezésének eltéréseit. Éles programok hardeningprofilját ebből nem szabad levezetni.

GNU binutils / ELF. A lényeges szimbólumok várható értelmezése:

Fájl, név nm jelölés ELF-kötés, szekció Jelentés
retry.o: retry_limit D GLOBAL, .data Inicializált objektumdefiníció
retry.o: calls B GLOBAL, .bss Nullaértékkel inicializált definíció
retry.o: reserve d LOCAL, .data Belső kapcsolású objektum
retry.o: retry_budget T GLOBAL, .text Függvénydefiníció
main.o: retry_budget U GLOBAL, UND Feloldásra váró hivatkozás
main.o: calls U GLOBAL, UND Feloldásra váró hivatkozás

Az nm betűi eszközkonvenciók: a nagybetű gyakran globális, a kisbetű lokális szimbólumot jelöl, de vannak kivételek. A readelf -Ws külön oszlopokban mutatja a típust, kötést (binding), láthatóságot (visibility) és szekcióindexet.

Egy puszta extern deklaráció nem feltétlenül eredményez szimbólumtábla-bejegyzést. A main.c például nem használja a retry_limit objektumot, ezért annak deklarációja önmagában nem igényel feloldandó gépi hivatkozást.

3.1.2.1 A linkage nem ELF-láthatóság

ELF / fordítóbővítés. A C kapcsolása és az ELF láthatósága külön szerződés.

Tulajdonság Milyen kérdésre válaszol?
C linkage Mely deklarációk jelölik ugyanazt az entitást?
ELF binding, például LOCAL, GLOBAL, WEAK Hogyan vesz részt a szimbólum a linker feloldásában?
ELF visibility, például DEFAULT, HIDDEN Milyen elérhetőségi és helyettesíthetőségi szabályok vonatkoznak rá az ELF-modellben?

Egy külső kapcsolású C függvény ELF-szimbóluma lehet GLOBAL HIDDEN. Ettől nem válik C szerinti belső kapcsolású függvénnyé: más objektumfájlból továbbra is hivatkozható ugyanazon végső linkelt komponensen belül. A -fvisibility=hidden GCC/Clang opció ezért nem helyettesíti a fájlhatókörű static használatát.

Egy lokális automatikus változó pedig rendszerint nem linker által feloldandó szimbólum. A debuggerben látható neve nem bizonyítja az ellenkezőjét: a hibakeresési információ külön adatszerkezet.

3.1.3 Relocation: a hiányzó cím kiszámítása

A main.c fordításakor a fordító ismeri a retry_budget típusát, de végleges címét még nem. A gépi kód megfelelő mezőjéhez ezért relokációs rekordot (relocation record) állít elő.

ELF / x86-64 ABI. A rekord megadja:

Az x86-64 ELF-ben gyakori RELA-formátum külön mezőben tárolja az addendet. A readelf -rW ezeket a rekordokat mutatja.

A linker először összegyűjti a bemeneti szekciókat, kiosztja azok helyét, majd feloldja a szimbólumokat és elvégzi az előírt javításokat. Az undefined szimbólum tehát egy .o fájlban rendszerint normális köztes állapot.

3.1.3.1 Egy PC-relatív javítás levezetése

Az x86-64 R_X86_64_PC32 relokáció számítása:

D=S+A−P, D = S + A - P,

ahol SS a szimbólum címe, AA az addend, PP pedig a javítandó mező címe. Az eredménynek előjeles 32 bites értékként ábrázolhatónak kell lennie.

A következő címek illusztrációk a megadott x86-64 laborprofilhoz, nem a példaprogram mért címei:

Mennyiség Illusztratív érték
SS 0x402000
PP 0x401004
AA -4

Ekkor:

D=𝟶𝚡𝟺𝟶𝟸𝟶𝟶𝟶−4−𝟶𝚡𝟺𝟶𝟷𝟶𝟶𝟺=𝟶𝚡𝙵𝙵𝟾. D = \mathtt{0x402000} - 4 - \mathtt{0x401004} = \mathtt{0xFF8}.

Bizonyításvázlat. Olyan utasításnál, amelynek négy oktettes eltolásmezője után közvetlenül a következő utasítás kezdődik, annak címe P+4P+4. A processzor innen számol:

(P+4)+D=P+4+S−4−P=S. (P+4)+D = P+4+S-4-P = S.

A -4 tehát összehangolja a relokáció mezőkezdethez kötött számítását a processzor következő utasításhoz viszonyított címzésével.

Nem minden hivatkozás PC32: függvényhívásnál például R_X86_64_PLT32 is megjelenhet. A konkrét típust vizsgálni kell, nem kikövetkeztetni pusztán a C-kifejezésből. A dinamikus feloldást az ELF-betöltés, PLT/GOT és dinamikus szimbólumok rész folytatja.

3.1.4 Erős, gyenge és common szimbólumok

ELF / GCC–Clang-bővítés. Az erős és gyenge szimbólum nem ISO C nyelvi kategória. Szokásos terminológiával egy közönséges globális definíció erős, míg a STB_WEAK kötésű szimbólum gyenge.

Szokásos ELF-linkelésben egy erős definíció elsőbbséget élvez egy azonos nevű gyenge definícióval szemben. Két erős definíció általában multiple-definition hibát okoz. Több gyenge definíció közötti választásra nem szabad hordozható alkalmazáslogikát építeni.

GCC/Clang-bővítéssel például az alábbi különálló fordítási egység adhat gyenge alapértelmezést:

/* ISO C17 szintaxisra épülő GCC/Clang ELF-bővítés. */
__attribute__((weak))
int site_limit(void)
{
    return 3;
}
gcc -std=c17 -Wall -Wextra -c fallback.c -o fallback.o
nm fallback.o
readelf -Ws fallback.o

A várt besorolás függvénynél W, illetve WEAK. Ez platformhoz kötött felülírási mechanizmus, nem általános C-modularizálási eszköz.

3.1.4.1 Miért fontos a -fno-common?

Régebbi toolchainprofilokban a fájlhatókörű int total; gyakran common szimbólumként jelent meg. A common-bejegyzés még nem közönséges, szekcióhoz rendelt definíció: helyigényt és igazítási követelményt közöl. Az nm jellemző jelölése C, a readelf szekcióindexe COM.

A linker több ilyen bejegyzést hagyományosan összevonhatott. Ez elfedte a headerbe írt tentative definition hibáját.

Modern GCC- és Clang-profilokban gyakori alapértelmezés a -fno-common; a reprodukálható parancs mégis írja ki explicit módon. Ekkor a tentative definition tipikusan rendes .bss-definícióvá válik, és a több fordítási egységben elhelyezett példányok ütköznek.

A -fcommon visszakapcsolása nem helyettesíti a javítást:

3.1.5 Tipikus diagnosztikai tévedések

Undefined reference esetén újabb deklaráció hozzáadása. A deklaráció már elegendő volt a fordításhoz. A hiányzó definíciót tartalmazó objektumfájlt kell bevonni, vagy a tévesen belső kapcsolású megvalósítást kell javítani.

Multiple definition esetén include guard hozzáadása. A guard egy fordítási egységen belül működik; külön .c fájlok között nem akadályozza meg a definíció megismétlődését.

Sikeres linkelésből típushelyességre következtetés. A szokásos ELF-linker nem teljes C-típusellenőrző. Össze nem illő deklarációk is azonos szimbólumnévre mutathatnak. A közös header és annak megvalósításoldali beillesztése ezért helyességi követelmény.

3.1.6 Feladatok

  1. Magyarázat. Miért d a reserve, de D a retry_limit? Külső kapcsolású marad-e egy HIDDEN láthatóságú függvény?

  2. Kódolás. Egészítsd ki a példát extern int last_budget; deklarációval. Egyetlen definíció legyen, a retry_budget tárolja bele az eredményt, a main pedig ellenőrizze azt. Milyen szimbólumokat vársz?

  3. Diagnosztika. Két külön fájl, a.c és b.c, egyaránt kizárólag az alábbi teljes tartalmat kapja:

    int total;

    Vizsgáld meg őket -fno-common, majd -fcommon mellett. Magyarázd meg a részleges linkelés eltérését. Mi történne az eredeti példában, ha csak a main.o kerülne a végső linkparancsba?

3.1.7 Megoldások

3.1.7.1 Kapcsolás és láthatóság

A reserve fájlhatókörű static objektum: belső kapcsolású, a laborprofilban lokális ELF-szimbólummal. A retry_limit külső kapcsolású, globális szimbólum. Mindkettő inicializált adat, innen a d és D.

A HIDDEN nem változtatja meg a C kapcsolását. Ellenőrzési feltétel: a readelf -Ws kimenetében a Bind és Vis oszlopot külön értelmezzük.

3.1.7.2 Egyetlen új definíció

A headerbe kerüljön extern int last_budget;, a retry.c fájlba pedig egyszer int last_budget;.

A függvény módosított részlete:

int retry_budget(void)
{
    calls = 1;
    last_budget = retry_limit - reserve;
    return last_budget;
}

A main visszatérési kifejezése legyen:

return budget == 2 && calls == 1 && last_budget == budget ? 0 : 1;

Az eredeti buildparancsok változatlanok. Ellenőrzési feltétel: a retry.o egy B definíciót, a main.o egy U hivatkozást tartalmazzon last_budget néven; a teljes program sikeres ellenőrzésének várt kilépési állapota nulla.

3.1.7.3 Common és hiányzó objektumfájl

gcc -std=c17 -fno-common -c a.c b.c
nm -A a.o b.o
gcc -r a.o b.o -o combined.o

Mindkét objektumban rendes definíció várható; a részleges linkelés várhatóan multiple-definition hibával leáll. A -r újabb relokálható objektumot készítene, ezért itt nem szükséges main.

gcc -std=c17 -fcommon -c a.c b.c
nm -A a.o b.o
gcc -r a.o b.o -o combined.o

Itt C bejegyzések és sikeres összevonás várható. Ez a toolchain viselkedése, nem az ISO C többdefiníciós engedménye.

Az eredeti példából a retry.o kihagyása feloldatlan retry_budget és calls hivatkozást hagy. Ellenőrzési feltétel: a hiányzó neveket össze kell vetni a main.o U bejegyzéseivel.

3.1.8 Összefoglaló

A C kapcsolása deklarációk azonosságát, az ELF szimbólumtáblája a bináris összekapcsolás adatait írja le. A relokáció a még ismeretlen címek javítási receptje. Az nm és a readelf segítségével elkülöníthető a hiányzó definíció, a többszörös definíció és a common-kezelés. A megbízható alapminta változatlan: közös deklaráció a headerben, egyetlen megvalósítás, explicit fordítási profil.