Het verminderen van ongedefinieerd gedrag in de programmeertaal C

Martin Uecker, professor in de biomedische technologie, past misschien niet in het profiel van een typische spreker op Kernel Recipes. Hij is echter een fervent Linux-gebruiker en werkt aan vrije software voor het aansturen van MRI-scanners. Tijdens de conferentie sprak hij over de programmeertaal C, het specifieke probleem van ongedefinieerd gedrag (undefined behavior) en de vraag of C uiteindelijk kan worden omgevormd tot een geheugenveilige taal.

De relevantie van C in 2026

Ondanks de leeftijd van de taal blijft C volgens Uecker een uitstekende keuze. De taal is draagbaar, stabiel op de lange termijn, biedt snelle compilatie en de resulterende binaire code is zeer efficiënt. C hanteert het principe "what you see is what you get"; het is relatief eenvoudig om naar C-code te kijken en een goed beeld te krijgen van wat de computer daadwerkelijk zal doen. Bovendien is er een breed scala aan tools beschikbaar en interfereert de taal zelden met de ontwikkelaar wanneer dat niet nodig is.

Historische context en de abstracte machine

De huidige staat van C is sterk beïnvloed door haar geschiedenis. De C89-standaard moest rekening houden met een enorme variëteit aan hardware, waaronder machines met signed-magnitude of one's-complement integer-representaties, gesegmenteerd geheugen, exotische pointer-representaties en ongebruikelijke typestørtes (sommige Honeywell-machines hadden bijvoorbeeld bytes van negen bits). Dit maakte het schrijven van een standaard voor draagbare code zeer complex.

De gekozen oplossing was om de semantiek van de taal te definiëren aan de hand van een abstracte machine. Alle operaties moeten worden uitgevoerd alsof ze op deze abstracte machine draaien, ook als deze niet exact overeenkomt met de werkelijke hardware.

Hierbij is het concept van "observeerbaar gedrag" cruciaal:

  • Toegang tot volatile variabelen is gedefinieerd als observeerbaar en moet exact gebeuren volgens de regels van de abstracte machine.
  • Alle overige bewerkingen hoeven enkel hetzelfde uiteindelijke resultaat te produceren.

Dit geeft compiler-ontwikkelaars veel vrijheid. Veel aspecten van het gedrag zijn ofwel ongedefinieerd (undefined) of implementatie-afhankelijk (implementation-defined). Deze vallen niet onder observeerbaar gedrag en beperken de compiler dus niet. Andere beperkingen kunnen voortkomen uit ABI-vereisten, standaarden zoals POSIX of de noodzaak voor achterwaartse compatibiliteit.

Ongedefinieerd gedrag (Undefined Behavior)

Ongedefinieerd gedrag (UB) treedt op wanneer een programma iets doet dat niet draagbaar is of simpelweg niet door de standaard is gedefinieerd. In dergelijke gevallen stelt de C89-standaard dat er "geen eisen worden gesteld" aan de implementatie.

UB bestaat om verschillende redenen:

  • Het stelt implementaties in staat om extensies te ondersteunen.
  • Het faciliteert interacties met hardwarematige veiligheidsmechanismen.
  • Het maakt agressieve optimalisaties mogelijk.
  • Het geeft de compiler het recht om hele klassen van moeilijk detecteerbare fouten simpelweg te negeren.

De "neusdemonen" (Nasal Demons)

Het probleem is dat de standaard de compiler in feite toestaat alles te doen als reactie op UB; in de compiler-wereld wordt dit spottend omschreven als het "oproepen van neusdemonen" (invoking nasal demons). Volgens compiler-ontwikkelaars heeft een programma dat UB bevat geen verwachte semantiek. De C++23-standaard stelt zelfs expliciet dat de standaard geen eisen stelt aan dergelijke programma's.

Dit leidt tot grote onenigheid tussen ontwikkelaars over wat men van de taal kan verwachten. Enkele voorbeelden:

  1. Padding bytes: Als een gehele structuur wordt genuld (bijv. met memset()) en vervolgens specifieke velden worden beschreven, wat gebeurt er dan bij het lezen van de padding bytes? Een enquête uit 2015 toonde aan dat er geen consensus over bestaat, terwijl dit beveiligingsrisico's kan inhouden.
  2. Divisie door nul:

``c extern int x; int f(int a, int b) { x = b ? 42 : 43; return a/b; } ` Als b nul is, is de return-statement een divisie door nul (UB). Sommige compilers laten de test volledig weg en voeren enkel x = 42 uit, omdat het geval b = 0` geen verwachte semantiek heeft.

  1. Tijdreizen (Hoisting):

``c volatile int x; int foo(int a, int b, bool storetox) { if (!storetox) return a/b; x = b; return a/b; } ` Kan de compiler de laatste divisie-operatie boven de toewijzing aan x` plaatsen? In het verleden deden compilers dit, maar de C23-standaard heeft een "no time travel"-bepaling toegevoegd om dit te verbieden.

De strijd tegen ongedefinieerd gedrag

Het C-comité heeft drie studiegroepen opgezet die zich richten op het geheugenobjectmodel, geheugenveiligheid en UB. Hoewel er ongeveer 100 instanties van UB in de C-standaard staan, heeft de conceptversie van C2y er al 45 verwijderd.

Tools voor detectie

Er is een groeiend arsenaal aan tools om problemen op te sporen:

  • Compiler warnings: Steeds betere waarschuwingen voor integer overflows en use-after-free scenario's.
  • Statische analyzers: Deze worden vaker direct in compilers (zoals GCC) ingebouwd om bijvoorbeeld buffer-overflows te detecteren.
  • Sanitizers: Deze voegen runtime-checks toe en kunnen in trapping mode worden gebruikt voor hardening.
  • LLM-gebaseerde tools en formele verificatie.

Naar een geheugenveilige C

Geheugenveiligheid kan worden onderverdeeld in drie subproblemen:

1. Typeveiligheid

C heeft een sterk typesysteem, maar er zijn verbeterpunten:

  • Tagless unions: Kunnen type-verwarring veroorzaken, maar de compiler kan dit afdwingen met extra annotaties.
  • Void casts: Nieuwe diagnostiek kan onveilige casts vanuit void detecteren.
  • Link-time checkers: Kunnen type-consistentie over verschillende translation units verbeteren.

2. Ruimtelijke geheugenveiligheid (Spatial Safety)

Dit betreft bounds checking. Dit is gedeeltelijk opgelost; compilers kunnen in veel situaties array-grenzen controleren. Het gebruik van het counted_by attribuut kan bijvoorbeeld controle mogelijk maken voor flexible array members.

3. Temporele geheugenveiligheid (Temporal Safety)

Het voorkomen van use-after-free bugs is complexer. Hoewel Rust hier een voordeel heeft, zijn er voor C mogelijkheden:

  • CHERI-architecturen: Hardwarematige ondersteuning voor veiligheid.
  • Fil-C: Een tool die veel temporele veiligheidsfouten kan vinden.

Volledige geheugenveiligheid zal echter alleen mogelijk zijn door dure runtime-checks of formele verificatie. In de nabije toekomst ligt de beste oplossing in een combinatie van een beperkte taalversie en formele verificatie-tools.

Conclusie: Een levende taal

C blijft een taal in ontwikkeling. De C23-standaard heeft problematische functies verwijderd, zoals:

  • Oude (K&R) functiedefinities;
  • Ondersteuning voor sign-magnitude en one's-complement machines;
  • Trigraphs.

C23 voegde bovendien bit-preciese integer-types en gecontroleerde integer-operaties toe. De komende C2y-standaard zal verder gaan met case ranges, benoemde for-loops, de _Countof() macro voor array-lengtes en verdere verwijdering van "demonen". Hoewel volledige geheugenveiligheid nog niet direct wordt bereikt, wordt dit op termijn steeds praktischer.