ABI vs API: DLL-hell en package managers

Door H!GHGuY op donderdag 26 februari 2015 21:30 - Reacties (5)
Categorie: Technologie, Views: 1.446

Ik speelde al een tijdje met het idee om blogposts te maken om wat achtergrond informatie te verschaffen bij artikels van T.net. Naar aanleiding van mijn reacties op dit artikel heb ik nu dan ook een goed excuus.

Veel developers kunnen zich bij een stabiele API nog wel wat voorstellen, maar als het over ABI gaat dan is het wel weer iets anders. Nochtans heeft dit voor veel users wel een belangrijke impact.

Stabiele API

Laat ons vooral beginnen met wat een API is, voor de tweaker met andere voorkeuren. API staat voor Application Programming Interface. Of lichtjes vertaald: het is de programmeer-interface tussen 2 stukken applicatiecode.

Een schoolboekvoorbeeldje:

C++:
1
2
3
4
5
6
7
8
9
class IBasicCalculator
{
  public:
    virtual int Add(int a, int b) const = 0;
    virtual int Multiply(int a, int b) const = 0;
    virtual int Sum(int a[], size_t count) const = 0;
    virtual ~IBasicCalculator();
};
IBasicCalculator* GetCalculator();


Hier zien we de API van een eenvoudige rekenmachine waar je mee getallen kunt optellen en vermenigvuldigen of de som van een reeks getallen kunt berekenen.

Een stukje code die dit gebruikt zou er zo kunnen uitzien:

C++:
1
2
3
4
5
6
7
8
9
10
11
int main(int argc, char** argv)
{
  int numbers[] = { 1, 2 ,3 ,4 ,5, 6 };

  /* Maak een calculator aan */
  IBasicCalculator* calc = GetCalculator();
  /* bereken de som van de getallan [1->6]
  int sum = calc->Sum(numbers, 6);
  printf("Sum = %d\n", sum);
  return 0;
}


Deze code berekent de som van de getallen 1 tot 6 en print die af. Allemaal weinig interessant...

Een mogelijke implementatie in de eerste versie van Sum() zou deze kunnen geweest zijn:

C++:
1
2
3
4
5
6
7
int CBasicCalculator::Sum(int a[], size_t count) const
{
  int sum = a[0];
  for (size_t i = 1; i < count; ++i)
    sum += a[i];
  return sum;
}


De oplettende programmeur heeft al gezien dat de code hierboven een kleine optimalisatie bevat door de eerste waarde te gebruiken om 'sum' mee te initialiseren. De nog oplettendere programmeur heeft gezien dat dit een bug is wanneer "count" eigenlijk 0 is.

Dus, we schrijven en verspreiden een nieuwe versie van de code met daarin:

C++:
1
2
3
4
5
6
7
int CBasicCalculator::Sum(int a[], size_t count) const
{
  int sum = 0;
  for (size_t i = 0; i < count; ++i)
    sum += a[i];
  return sum;
}


Het interessante hier is dat de functie signatuur (lees: regel 1) ongewijzigd is bij het doorvoeren van de bugfix. Deze update heeft dus een stabiele API, want de kleine applicatie hierboven zal zonder een letter code te wijzigen opnieuw gecompileerd kunnen worden en werken met de nieuwe versie.

Een stabiele API houdt bijvoorbeeld ook in dat ik methodes mag toevoegen:

C++:
1
2
3
4
5
6
...
    virtual int Sum(int a[], size_t count) const;
    virtual int Divide(int a, int b) const;
    virtual int Power(int a, int b) const;
    virtual ~IBasicCalculator();
    ...


Ze kunnen immers nog nooit zijn gebruikt door applicaties, dus je breekt er niets mee.
Updates die een stabiele API hebben ten opzichte van de vorige release noemt men ook source-compatible. Je kan dus bvb de library een update geven, de applicatie die ze gebruikt zal ertegen compileren en linken zonder probleem.

Stabiele ABI

We gaan opnieuw beginnen met wat een ABI is: een Application Binary Interface. Dit is de interface tussen 2 binaire modules.
Om te weten wat dit betekent, moeten we even dieper gaan in hoe CPU's in elkaar zitten.

Laten we beginnen bij het geheugen. Het grootste stuk rechstreeks aanspreekbaar geheugen is typisch de RAM. In vele gigabytes, verschillende snelheden, met of zonder heatsinks.
Daarna komen van groot naar klein de verschillende caches. Van megabytes L3 tot enkele kilobytes L1 cache.
Wanneer een CPU echter berekeningen uitvoert, dan dat hij dit met waardes die in de registers van de CPU zelf zitten. Een CPU heeft slechts een beperkt aantal registers, X86_64 heeft 16 general purpose registers, terwijl een PowerPC of MIPS CPU er typisch een 32-tal heeft.

Waarom is dit belangrijk? Omdat deze registers ook gebruikt worden om waarden door te geven aan functies. Zie bijvoorbeeld hier wat informatie over de MIPS ABI.
Subroutine calls
----------------

Parameter registers:
general-purpose r4-r11
floating point f12-f19

Register usage:
fixed 0 value r0
volatile r1-r15, r24, r25
non-volatile r16-r23, r30
kernel reserved r26, r27
gp (SDA base) r28
stack pointer r29
frame pointer r30 (if needed)
return address r31
Hier staat het duidelijk: de eerste 8 parameters van een functie worden waar mogelijk in register 4 tot register 11 opgeslaan. De rest wordt via de stack doorgegeven.
Ook de return value(s) worden via parameters doorgegeven. Deze zaken maken eigenlijk deel uit van de "calling convention", de afspraken rond het maken van functie-oproepen.

Dit is echter niet het hele plaatje. Wanneer je een structuur doorgeeft via een pointer/referentie dan moeten beide partijen het eens zijn over de layout van de structuur:

C++:
1
2
3
4
5
struct version
{
  char version_type;
  unsigned long major, minor, build;
};



Een doorwinterde programmeur weet dat er meerdere mogelijke layouts zijn van bovenstaande code:
- unsigned long zal op huidige platformen typisch 32 of 64 bit zijn afhankelijk van de doelmachine (of een compiler optie)
- tussen de "version_type" en "major" velden zal typisch padding (ongebruikt geheugen) zitten, aangezien een unsigned long altijd op een adres moet starten welke deelbaar is door (typisch) 4 of 8. Deze padding zal dus in de meeste gevallen 3 of 7 bytes zijn.

Een voorbeeld zou dus zijn:
| version (1 byte) | padding (3 bytes) | major (4bytes) |
| minor (4 bytes) | build (4 bytes) |


Het is dus belangrijk dat iedereen het eens is, zowel de oproepende code als de opgeroepen code. Deze eensgezindheid krijg je door de ABI te volgen.
De term ABI-compatibility is enigzins verwarrend. De ABI (het document met de beschrijving van bovenstaande zaken) wijzigt niet echt. Met die term wordt eerder bedoeld of 2 versies van bijvoorbeeld dezelfde library op elk vlak compatibel zijn wanneer aangesproken door een externe applicatie.

Laten we even terugkomen op de code uit het vorige stukje. De bugfix die we daar doorvoerden had de API niet gebroken, en ook de ABI was ongewijzigd. We hadden immers geen veranderingen doorgevoerd aan hoe de data tussen de 2 stukken code 'beweegt'.
Het toevoegen van de nieuwe functie was echter wel een ABI-break, maar om die uit te leggen moeten we tot onze knieŽn in het C++-water; Net iets te diep voor nu.
Dan gaan we maar een nieuw feature ontwikkelen!

Onze developer had gehoord van instructies die in 1 keer kunnen vermenigvuldigen en optellen, de zogeheten multiply-add (MAD) instructies en wou deze wel eens gebruiken door de Multiply functie aan te passen tot:

C++:
1
virtual int Multiply(int a, int b, int c = 0);


De nieuwe parameter is een default parameter, dus wanneer aanroepende code ze niet specifieert vult de compiler ze in met 0. De auteur is blij: zijn gebruikers krijgen een compatibele API, want bijvoorbeeld:

C++:
1
int mad = calc->Multiply(3,2);


geeft nog altijd 6 als antwoord zonder enige compilatie-fout.

Jammer genoeg zijn de gebruikers niet blij. De ABI is echter gebroken door deze aanpassing. Tweaker Henk had in zijn systeem de library een update gegeven en had geen zin om zijn applicatie te hercompileren. Resultaat: de applicatie doet niet echt wat ie daarvoor deed.
Henk zijn code gaf 2 parameters door (bvb via regiser r4-r5), terwijl de library er 3 verwachtte (bvb via register r4-r6). Als voor eender welke reden uit het verleden een getal verschillend van 0 in r6 zat, dan geeft de functie een fout resultaat terug.
Oplossing: letterlijk elke applicatie die ooit gebouwd is tegen de calculator code moet je opnieuw bouwen. We kunnen dus zeggen dat die aanpassing niet ABI-compatible is met de vorige versie.

We hebben gezien dat je een verandering kan maken die niet ABI-stabiel is, maar wel API-stabiel. Kan het ook omgekeerd? Ja. Zeer eenvoudig:

C++:
1
2
3
4
5
struct version
{
  char version_name; // was version_type
  unsigned long major, minor, build;
};


Elk stukje code dat "version_type" gebruikte zal vanaf nu niet meer compileren. Als dit echter de enige verandering is, dan zal een update van een library met deze code nog steeds even goed werken. De layout in het geheugen is immers niet veranderd.

Er is nog wat interessant leesvoer. De eerste is een lijst met zaken die je in C++ wel en niet mag doen om de ABI stabiel te houden bij updates. De 2de kwam ik tijdens het schrijven tegen en had ook een mooi overzicht met nog enkele interessante voorbeeldjes.
https://techbase.kde.org/...+#The_Do.27s_and_Don.27ts
http://wezfurlong.org/blo...in-an-evolving-code-base/

DLL-hell en package managers

Wat betekent dit nu voor gewone gebruikers? In een ideale wereld met perfecte programmeurs zou dit heel weinig betekenen. Developers zouden bij kleine updates en (security) fixes waar mogelijk de ABI stabiel houden (API is in deze context eigenlijk irrelevant) en er zou wereldvrede zijn.

Je kan het de developers jammer genoeg niet altijd kwalijk nemen. Voor wie de KDE-link hierboven heeft gevolgd: het is niet eenvoudig om de ABI stabiel te houden. Ik ken persoonlijk zelf de regeltjes ook niet vanbuiten. C++ is echter een moeilijke taal om de ABI van stabiel te houden. Zijn neefje, C, is daar veel makkelijker in.

We weten nu al dat ABI compatibility veel gebroken wordt en dat ABI breaks het noodzakelijk maken dat alle programma's die communiceren met de ABI-incompatible module volledig vanaf de broncode opnieuw gebouwd worden. Wat is dan het effect voor de gebruikers?

…ťn van de mogelijkheden is dat er naast ontwikkelaar en gebruiker een 3de partij in het spel komt, zoals je op Linux ziet: de package maintainer. 1 van de belangrijke taken van een distributie is het integreren van software tot een geheel dat ten allen tijde correct werkt. Dit betekent ook dat als er een update van 1 stukje software is, ze mogelijk een hele hoop andere ook mogen opnieuw bouwen. Om dit te vermijden, gebeurt het veel dat die maintainer kleine updates van een latere versie op een ABI-compatibele manier in hun specifieke versie 'backport'.
Een 2de nadeel van dit model is dat software voor de ene Linux distributie meestal niet zomaar op een andere zal draaien, als er externe code nodig is.
Er bestaan echter initiatieven om ABI incompatibilities op te volgen, zie bijvoorbeeld http://upstream.rosalinux.ru/

Een andere mogelijkheid is dat applicatie-bouwers geen zin hebben om afhankelijk te zijn van derden voor het verspreiden van hun software. Op dat moment kunnen ze kiezen om alle externe software waar ze van afhankelijk zijn zelf mee te bundelen. Zo krijg je dat 10 applicaties misschien 10 keer hetzelfde stukje code zitten hebben. Je kan al raden wat het gevolg is: van de 10 stukjes zal het bij een security update al sterk zijn mocht er 1 van zijn die die update tijdig krijgt.
Dit ligt iets dichter bij de situatie die je op Windows vaker tegenkomt.

Bij eerdere versies van Windows was het bovendien ook de gewoonte om zo'n libraries/DLL's in globale folders zoals c:\windows\system of system32 te gooien. Zo kon het zijn dat 2 software pakketten ofwel 2 verschillende versies naast elkaar installeerden of, erger nog, een bestaande versie overschrijven met een ABI-incompatibele versie. Een regelrechte DLL-hell dus want bestaande pakketen houden plots op met werken als je andere software installeert.

Om af te sluiten nog een mooi samenspel van ABI compatibility en ABI compatibility op Linux. Daar is het namelijk zo dat de kernel van het besturingssysteem er een zeer strikte ABI etiquette op na houdt. Wil je niet een tirade van ome Linus Torvalds op je dak krijgen, dan hou je je er best aan.
Libraries doen dit helaas niet zo strikt. Recent kwam er dus een stukje nieuwe software, Docker, welke het toelaat om op relatief eenvoudige manier applicaties en libraries te bundelen in een 'container', zodat je ze op eender welke Linux distributie kan draaien (want de kernel ABI is wel stabiel). Gelukkig is dit niet het enige selling point van Docker ;)

En zo zie je maar dat Linux met zo'n stap een stukje dichter bij het software distributie model van Windows komt. Gelukkig hebben ze volgens mij met die techniek de mogelijkheid om op termijn het beste van beide werelden te combineren: distributie van applicaties via containers die op elke Linux distro draaien en het eenvoudig updaten van zulke containers met security en andere fixes via het package management systeem. Uiteraard, mits ABI-stability.