Om C te redden, moeten we de ABI redden
Niet alleen heeft Jason 110% gelijk in zijn visie, maar ik ben het er ook hartgrondig mee eens. Mijn vorige artikel over ABI — spookachtig getiteld "Binary Banshees and Digital Demons" — liet zien hoe implementaties de standaardbibliotheek aanpassen om aan de standaard te voldoen (en niet andersom) wanneer ze daarmee wegkomen. Ook toonde ik aan dat ze soms de existentie van nieuw geïntroduceerde functies bedreigen door de ABI als knuppel te gebruiken. Maar als ik zo'n hevige haat heb voor ABI-stabiliteit en alle implicaties daarvan, waarom zou ik dan beweren dat we het moeten redden?
Zou het niet volledig vernietigd en van deze aarde geveegd moeten worden? Is het niet de anti-menselijke entiteit die ik in mijn vorige artikel noemde? Zou het kunnen dat ik ben geïnfecteerd door Big Business™ en Big MoneyⓇ en nu hier ben om ABI-stabiliteit te promoten? Misschien ben ik stiekem lid geworden van een initiatief voor de standaardbibliotheek en is dit een psychologische operatie om iedereen te conditioneren tot het geloof dat ABI goed is. Of misschien ben ik simpelweg doorgedraaid en kunnen we alles negeren wat ik schrijf!
Gelukkig is niets daarvan gebeurd. Ik ben nog steeds mezelf, ik ben niet omgekocht, en de enige standaardbibliotheek waar ik aan werk is mijn eigen, veilig opgeborgen in een private repository op een git-server in RAID-opslag. Wat ik echter gestaag begin te beseffen, is dat het niet uitmaakt hoeveel ik agiteer en evangeliseer voor een betere standaardbibliotheek; deze systemen zullen hun koers niet wijzigen voor mij, Jason Turner of wie dan ook die klaar is met het verliezen van prestaties en ontwerpruimte door legacy-keuzes uit een tijd dat we simpelweg niet slim genoeg waren om permanente beslissingen zoals deze te nemen.
Dit betekent echter niet dat we moeten opgeven. Er is immers meer dan één manier om een ABI te breken. Maar voordat we verder gaan, is het belangrijk dat iedereen op dezelfde pagina staat over wat een "ABI" nu eigenlijk is. Laten we naar de ABI kijken — ditmaal vanuit de C-kant — en bekijken wat het ons verhindert te repareren.
Het Monster: Application Binary Interface
De Application Binary Interface, waar we voor het gemak naar zullen verwijzen als ABI, is het onzichtbare contract dat je tekent elke keer dat je een structuur maakt of een functie schrijft in C- of C++-code en deze daadwerkelijk gebruikt. In het bijzonder gaat het om de aannames die de compiler maakt over de bit-voor-bit representatie en het gebruik van de hardwarebronnen wanneer er zaken worden afgehandeld buiten een enkele routine.
Dit omvat onder andere:
- De positie, volgorde en lay-out van leden in een
structofclass; - De argumenttypen en het retourtype van een functie (voor C++: inclusief relevante overbelaste functies);
- De "speciale leden" van een klasse (alleen C++);
- De hiërarchie en volgorde van virtuele functies (alleen C++);
- En meer.
Omdat dit artikel zich richt op C, maken we ons geen zorgen over de C++-delen van de ABI. C heeft veel simpelere manieren van werken, waardoor het effectief neerkomt op twee zaken die het meeste belang hebben:
- De positie, volgorde en lay-out van leden in een
struct; - De argumenttypen en het retourtype van een functie.
Omdat C++ de volledige C-standaardbibliotheek vrijwel integraal overneemt met zeer weinig wijzigingen, worden de ABI-problemen van C automatisch ook die van C++. Sterker nog, omdat C de basis vormt voor zoveel software, is het effectief iedereen zijn probleem wat C met zichzelf uitspookt.
Hoe manifesteert ABI zich in C?
Laten we een kort voorbeeld nemen. De ABI van C is "simpel", in die zin dat er effectief een één-op-één correspondentie is tussen een functie die je schrijft en het symbool dat in je binary terechtkomt. Stel dat ik een functie do_stuff declareer die een long long parameter neemt en een long long waarde teruggeeft:
#include <limits.h>
extern long long do_stuff(long long value);
int main () {
long long x = do_stuff(-LLONG_MAX);
/* wow cool stuff with x ! */
return 0;
}
De resulterende assembly voor een x86_64 target zou er ongeveer zo uitzien:
main:
movabs rdi, -9223372036854775807
sub rsp, 8
call do_stuff
xor eax, eax
add rsp, 8
ret
Dit is correct voor een 64-bit getal dat in een enkel register wordt doorgegeven voordat een functieaanroep wordt gedaan. Laten we nu zien wat er gebeurt als we het argument veranderen van long long (een 64-bit getal) naar iets als __int128_t:
#include <limits.h>
extern __int128_t do_stuff(__int128_t value);
int main () {
__int128_t x = do_stuff(-LLONG_MAX);
return 0;
}
Slechts een type-wijziging! Dat zou de assembly niet veel moeten veranderen, toch?
main:
sub rsp, 8
mov rsi, -1
movabs rdi, -9223372036854775807
call do_stuff
xor eax, eax
add rsp, 8
ret
Er zijn toch een paar wijzigingen. Het meest opvallende is dat we niet alleen het rdi-register gebruiken, maar nu ook met rsi bezig zijn. Dit laat zien dat de compiler, zonder zelfs maar de definitie van dostuff te zien, een contract heeft gesmeed tussen zichzelf en de mensen die de definitie van dostuff schrijven. Voor de long long-versie wordt verwacht dat er slechts één register (rdi) wordt gebruikt op x86_64 computers. Voor de __int128_t-versie wordt verwacht dat er twee registers (rsi EN rdi) worden gebruikt om alle 128 bits te bevatten.
Dit is niet een contract op broncode-niveau; het is een contract gesmeed door de compiler, namens jou, met andere compilers en machines. Dit is de Application Binary Interface.
Het probleem dat we hier belichten is zeer specifiek voor C en vergelijkbare ABI's. Ter illustratie: in C++ zou de assembly van do_stuff met __int128t er anders uitzien door name mangling. De functie zou bijvoorbeeld Z8dostuffn heten, waardoor de linker precies weet welk type argument wordt verwacht. C doet dit niet. In C roept de code simpelweg dostuff aan; zolang de linker een symbool met die naam vindt, zal hij dat aanroepen, ongeacht of de types overeenkomen.
ABI van de andere kant
Laten we de functie do_stuff implementeren als een eenvoudige "clamp" voor negatieve getallen:
long long do_stuff (long long value) {
if (value < 0) {
return 0;
}
return value;
}
De assembly hiervoor test de waarde van rdi en geeft het resultaat terug in rax. Dat het retourtype in rax wordt geplaatst, maakt ook deel uit van de ABI. Wanneer we dit aanpassen naar __int128_t:
__int128_t do_stuff (__int128_t value) {
if (value < 0) {
return 0;
}
return value;
}
De assembly verandert drastisch. Er worden nu twee registers (rsi en rdi) gebruikt voor de input, en er wordt gebruik gemaakt van sbb (Subtract with Borrow). Ook het retourresultaat wordt nu verwacht over twee registers verdeeld te zijn. We hebben nu dus twee volledig verschillende verwachtingen voor dezelfde functienaam.
Kan dit echt kapot gaan?
Wat gebeurt er als een applicatie is gecompileerd met de 64-bit versie, maar per ongeluk wordt gekoppeld (linked) aan de 128-bit versie door fouten in de linker of andere trucjes?
Laten we het bewust proberen te breken. We maken twee bestanden: main.c gebruikt de long long declaratie. do_stuff.c gebruikt de __int128_t definitie.
Wanneer we dit bouwen met Clang + MSVC, zal de linker geen enkele klacht hebben. De linker geeft namelijk niets om types; als het symbool do_stuff bestaat, koppelt hij het. Het resultaat is undefined behavior: de applicatie stort waarschijnlijk in of produceert onzin omdat de registers niet overeenkomen.
Problemen door ongelukken
ABI-breuken zijn gevaarlijk omdat ze kunnen gebeuren zonder dat de compiler-frontend of de linker waarschuwt. Dit gebeurt vooral bij het gebruik van bibliotheken (libraries). Omdat C identifiers niet "mangles", moet iedereen die een library gebruikt exact hetzelfde eens zijn over de symbolen in die library.
Dit is pijnlijk duidelijk bij Unix-distributies. Package managers en maintainers moeten ervoor zorgen dat dynamische libraries overeenkomen met de verwachtingen van het hele systeem. Geüpgradede bibliotheken in repositories (zoals apt, yum of pacman) moeten achterwaarts compatibel blijven op binair niveau.
In mijn voorbeeld hierboven had ik geluk omdat ik in debug-modus werkte en een foutmelding kreeg. In productie-software kan dit leiden tot een segmentation fault, of erger: het kan subtiel registers corrumperen of stukken geheugen overschrijven, waardoor de crash pas veel later op een onvoorspelbare plek optreedt.
Dit vertraagt de adoptie van nuttige features vaak met tien tot elf jaar:
- C99 introduceerde
_Complexen Variable Length Arrays. Deze werden pas in C11 optioneel (ca. 12 jaar later). - Ongeveer 10% van de gebruikers gebruikte in 2019 nog steeds Python 2.x, terwijl Python 3 al rond 2008 verscheen (ca. 11 jaar later).
- De ban op copy-on-write voor
std::stringin C++11 werd besloten rond 2008/2010, maar Linux-distributies met GCC/libstdc++ schakelden dit pas definitief in rond 2018/19 (ca. 10 jaar later).
Het "goede" einde en de realiteit van implementaties
Als er geen compatibiliteitsverhaal is, kunnen conservatieve groepen — zoals het C-comité — besluiten om alles simpelweg als "undefined behavior" te markeren. Een voorbeeld is realloc met een grootte van 0. Omdat implementaties divergeerden in hun gedrag en niemand bereid was dit te veranderen na een correctie (DR 400), werd in C23 uiteindelijk besloten dat het gedrag simpelweg ongedefinieerd is.
In werkelijkheid wordt de standaard vaak gedicteerd door de implementaties (compiler-makers, library-ontwikkelaars). Als een grote implementatie aangeeft dat iets te duur is om aan te passen vanwege ABI-stabiliteit, dan gebeurt het niet. Dit is de Catch-22: we kunnen geen verbeteringen doorvoeren omdat we vastzitten aan keuzes uit het verleden.
Een oude oplossing: Assembly labels
Er is echter een uitweg. Robuuste C-libraries (zoals glibc of musl libc) gebruiken al technieken om bijvoorbeeld intmax_t te verhogen zonder bestaande applicaties te breken. Het geheim zijn assembly labels.
Dit stelt een implementatie in staat om het symbool dat in de binary terechtkomt te hernoemen, zonder de broncode aan te passen. Door een laag van indirectie toe te voegen tussen wat je schrijft en wat er daadwerkelijk wordt gecompileerd, kan men versies beheren.
Verschillende compilers hebben hier eigen methoden voor:
- MSVC:
#pragma comment(linker, "/export:NormalName=_BinarySymbolName") - Oracle C:
#pragma redefineextname NormalName BinarySymbolName - GCC/Clang:
attribute((alias("_BinarySymbolName")))
Transparante Aliassen
Ik heb gewerkt aan een voorstel (N2901) om dit officieel in de C-standaard op te nemen via "Transparent Aliases". Het doel is om een symbool te bieden dat officieel bestaat in de binary, maar zonder extra kosten of het introduceren van nieuwe functies.
Zero-Cost
In de C-standaard bestaat de "as-if" regel: zolang het observeerbare gedrag identiek blijft, mag een implementatie doen wat hij wil (Quality of Implementation of QoI). Om te voorkomen dat compilers dit slecht implementeren, is het voorstel om aliassen zo transparant mogelijk te maken, vergelijkbaar met typedefs, maar dan voor functies.
De voorgestelde syntaxis ziet er als volgt uit:
int f(int x) { return x; }
_Alias g = f; // Voorgesteld in N2901
int main () {
return g(1);
}
In de gegenereerde assembly is geen sprake van een functie g; de compiler vervangt dit direct door f. Dit kost niets aan runtime-prestaties of binary-grootte.
De indirectielaag als oplossing voor ABI
Met transparante aliassen kun je de naam in de code scheiden van het symbool in de binary. Je kunt meerdere interne implementaties hebben en op basis van een macro de juiste versie kiezen:
#if VERSION_0
_Alias do_work = __do_work_v0;
#else
_Alias do_work = __do_work_v1;
#endif
Hierdoor kunnen nieuwe applicaties gebruikmaken van verbeterde types en semantiek (bijv. 128-bit integers) via dezelfde functienaam, terwijl oude applicaties gewoon verbonden blijven aan de oude symbolen (_v0) in dezelfde library.
De ABI-test: maxabs
Om dit te bewijzen heb ik een testopzet gemaakt met een shared library (my_libc). Ik creëerde twee versies van een functie maxabs:
- Een oude versie die
long long(64-bit) gebruikt. - Een nieuwe versie die
__int128t(128-bit) gebruikt, gekoppeld via eenAlias.
Ik testte drie scenario's:
- Oude App + Oude Lib: Werkt correct (64-bit).
- Nieuwe App + Nieuwe Lib: Werkt correct (128-bit).
- Oude App + Nieuwe Lib: Werkt correct! De oude applicatie gebruikt de oude symbolen die nog in de nieuwe DLL aanwezig zijn, terwijl de nieuwe app de gealiased versie gebruikt.
Dit bewijst dat we achterwaartse binaire compatibiliteit kunnen garanderen terwijl we tegelijkertijd een upgrade-pad bieden voor nieuwe code.
Geen gratis lunch
Natuurlijk lost dit niet alles op. Afhankelijkheden blijven een probleem: als je library bouwt op een andere library die types wijzigt, moet die hele keten meegeüpgradet worden.
Ook zijn er extreme gevallen zoals Microsoft Windows. De ABI-eisen van Microsoft zijn zo restrictief dat ze vaak volledig nieuwe DLL's verschepen voor minimale wijzigingen om zowel achterwaartse als voorwaartse compatibiliteit te garanderen. Transparante aliassen kunnen dit niet volledig oplossen omdat ze uitgaan van het feit dat je symbolen kunt toevoegen aan een bestaande library.
Toch zou het zelfs voor MSVC een uitweg bieden voor problemen zoals de Annex K conformiteit van bsearch_s. Door een alias te gebruiken, zouden ze een standaard-conforme versie kunnen aanbieden voor nieuwe code, terwijl de oude, niet-conforme versie beschikbaar blijft voor legacy software.
Een groot succes
Dit is geen theoretisch idee meer, maar een existence proof. We kunnen achterwaarts compatibele shared libraries maken op meerdere platforms die naadloze upgrades mogelijk maken.
Het stopt de technische schuld waar we al decennia mee zeulen. Veel van onze huidige beperkingen (zoals intmax_t die vastzit op 64-bit) zijn het gevolg van een angst voor ABI-breuken. Door een standaardmethode voor indirectie te introduceren, hoeven we niet langer vast te zitten aan beslissingen uit 1989.
Ik wil niet dat ik over tien of twintig jaar nog steeds discussies voer over waarom bepaalde types niet kunnen groeien. We controleren de regels van deze digitale wereld; het is tijd dat we ze gebruiken om onze systemen verder te brengen dan ooit tevoren. Ik zal deze oude symbolen omarmen, hun functionaliteit uitbreiden via aliassen voor de nieuwe generatie, en uiteindelijk — wanneer de oude garde met rust is gelegd — de laatste 32-bit en 64-bit kettingen definitief doorbreken.
Groetjes,