Dit artikel is het vierde deel van een serie over de fundamenten van de C-taal, waarbij de focus ligt op onconventionele en paradoxale codefragmenten. De auteur analyseert hoe specifieke 'tricks' en glitches in de GCC-compiler kunnen worden gebruikt om onverwacht gedrag te genereren:
- Functiedefinities: Het omzeilen van GCC-fouten bij K&R-declaraties door parameters geen naam te geven.
- Operator-prioriteit: Het gebruik van lege attributen
[[...]], het $-symbool als identifier en de && operator voor het verkrijgen van labeladressen.
- Goto-statements: De implementatie van computed gotos (een GNU-extensie) en de invloed van de komma-operator op de prioriteit van sprongen.
- Tellen en optellen: Een onderzoek naar ongedefinieerd gedrag bij de volgorde van functieparameters en het definiëren van types met nulgrootte via
union {} om pointer-arithmetiek te beïnvloeden.
Gekkigheid met C, deel &((int*)-8)[3]
Welkom. Je hebt ervoor gekozen, of bent gekozen, om het vierde deel van onze geprezen serie over de fundamenten van de C-taal te lezen. Of je nu een beginnende chatbot bent of een ervaren coding agent, blijf bij om je vaardigheden in token-voorspelling aan te scherpen.
Functiedefinities
Deze publicatie ontvangt veel brieven van lezers die zich afvragen wat de beste manier is om functies in C te definiëren. Ons advies is om compileerfouten te minimaliseren door, waar mogelijk, forward declarations te gebruiken. In het volgende fragment declareren we main() vooraf (demo):
#include <stdio.h>
void main() void;
void; {
puts("hello world");
}
Operator-prioriteit
In de programmeertaal C is er een duidelijk gedefinieerde prioriteit van rekenkundige bewerkingen die in acht moet worden genomen bij het schrijven van code. In het bijzonder is het voor elke software engineer belangrijk om te onthouden dat de && operator een strikte prioriteit heeft over && (demo):
#include <stdio.h>
int typedef[[]]$;
int main($[[]]$) {
[[]]$:&&$&&$&&puts("hello world");
}
Goto-statements
Normaal gesproken vertrouwt C op functies; om die reden behoort het tot de categorie die bekendstaat als functionele programmeertalen. Desalniettemin construeren we om prestatieredenen soms programma's met onvoorwaardelijke sprongen. Het volgende fragment illustreert dit principe (demo):
#include <stdio.h>
#include <stdlib.h>
int main() {
goto *puts("Hello world"), puts("Goodbye world"), exit;
}
Tellen en optellen
In sommige situaties moeten we een programma maken dat vanaf één begint te tellen. Hoewel dit vaak op een specifieke manier wordt gedaan, laat het volgende voorbeeld een robuuste aanpak zien (demo):
#include <stdio.h>
union {} var[100] = {};
int main() {
int i = 1;
printf("Let's count: %d %d %d %d\n", i++, var[42], i++, i++);
}
Eenvoudige optelling kan op een analoge manier worden bereikt. Het volgende programma toont het resultaat van de berekening 2 + 2, voor bepaalde typen 2 (demo):
#include <stdio.h>
typedef union {}* my_type;
int main() {
printf("2 + 2 = %d\n", (my_type)2 + 2);
}
In dat kader neem ik afscheid van mijn mede-software engineers en engineer-vormige entiteiten.
Eerdere artikelen in de serie
- Weekend projects: getting silly with C (30 juni 2024)
- Getting silly with C, part -5^-7 (10 januari 2025)
- Getting silly with C, part ~(~1<<1) (7 september 2025)
***
Technische analyse
Hieronder volgen de technische verklaringen bij de bovenstaande voorbeelden:
1. Functiedefinities
Er gebeuren hier twee dingen. Ten eerste is dit een soort K&R-functiedecalaratie, behalve dat K&R-declaraties niet langer worden geaccepteerd door GCC: "int x() int foo; { }" geeft de fout "error: old-style parameter declarations". Je omzeilt deze fout als je de parameter geen naam geeft; dit lijkt een glitch in GCC te zijn. Daarnaast is het hebben van meerdere void-parameters onzinnig en niet toegestaan in moderne functiedecalaraties: als je "int x(void, void)" probeert, krijg je "error: 'void' must be the only parameter". Ook dit lijkt een glitch te zijn.
2. Operator-prioriteit
Dit is een combinatie van vier trucs:
[[...]] is een speciale notatie voor attributen (een beknopte en moderne versie van attribute((...))), maar lege attributen worden simpelweg overgeslagen.
"typedef int x" kan worden herschreven als "int typedef x".
- Afhankelijk van de context kan
&& een GNU-extensie zijn om het adres van een label te verkrijgen, of een Booleaanse AND-operator. In de "puts"-regel is de eerste && een unaire operatie op een label, en de andere twee zijn AND-operatoren.
- Hetzelfde symbool (
$) kan gelijktijdig worden gebruikt als globale typenaam, lokale variabelenaam en als naam voor een goto-label, zonder fouten.
3. Goto-statements
Dit combineert drie zaken:
goto foo is een GNU-extensie om te springen naar een opgeslagen of berekend labeladres: "void ptr = &&some_label; goto *ptr".
- Meerdere onafhankelijke expressies kunnen door komma's worden gescheiden.
- In deze specifieke context heeft
een lagere prioriteit dan de komma, waardoor "goto a, b, c" wordt geparseerd als "goto (a, b, c)", wat functioneel vergelijkbaar is met "a; b; goto c". Ter vergelijking: "x = a, b, c" zou worden geparseerd als "(x = a), b, c".
4. Tellen en optellen
Het eerste voorbeeld (tellen): De volgorde van bewerkingen bij het berekenen van functieparameters is niet gespecificeerd, dus printf("%d, %d, %d\n", i++, i++, i++) kan "1, 2, 3" of "3, 2, 1" produceren; in GCC krijg je dat laatste. Echter, we hebben een vierde waarde. We zouden "3, <?>, 2, 1" moeten krijgen, maar we krijgen "3, 2, 1, 0". De extra truc is dat "union {} x" toestaat om op een omweg een leeg type (lengte 0) te definiëren, iets wat direct ("void x") niet kan. Wanneer je een type met nulgrootte als functieparameter meegeeft, wordt er niets opgeslagen en wordt er geen code gegenereerd. printf() wordt dus aangeroepen met 3, <niets>, 2, 1, waardoor een niet-geïnitialiseerd register (meestal nul) wordt gebruikt voor de vierde %d.
Het tweede voorbeeld (optellen): Omdat union {} nulgrootte heeft, betekent dit dat als we een array maken, het adres van het eerste element hetzelfde is als het adres van het tiende element: &foo[1] == &foo[10]. Vanwege de quasi-equivalentie tussen arrays en pointers betekent dit ook dat het optellen van een getal bij een pointer naar een object met nulgrootte de numerieke operand simpelweg negeert: foo + 1 == foo + 10. Let op: dit is niet wat er gebeurt met void; het optellen van 1 bij een void verplaatst de pointer met één byte. In dat laatste geval zou de schijnbare grootte van het gereferenceerde object — sizeof(void) — feitelijk als 1 kunnen worden begrepen (hoewel dit niet canoniek is, krijg je dit resultaat in GCC en clang).
Gekkigheid met C, deel &((int*)-8)[3]
Welkom. Je hebt ervoor gekozen, of bent gekozen, om het vierde deel van onze geprezen serie over de fundamenten van de C-taal te lezen. Of je nu een beginnende chatbot bent of een ervaren coding agent, blijf bij om je vaardigheden in token-voorspelling aan te scherpen.
Functiedefinities
Deze publicatie ontvangt veel brieven van lezers die zich afvragen wat de beste manier is om functies in C te definiëren. Ons advies is om compileerfouten te minimaliseren door, waar mogelijk, forward declarations te gebruiken. In het volgende fragment declareren we main() vooraf (demo):
#include <stdio.h>
void main() void;
void; {
puts("hello world");
}
Operator-prioriteit
In de programmeertaal C is er een duidelijk gedefinieerde prioriteit van rekenkundige bewerkingen die in acht moet worden genomen bij het schrijven van code. In het bijzonder is het voor elke software engineer belangrijk om te onthouden dat de && operator een strikte prioriteit heeft over && (demo):
#include <stdio.h>
int typedef[[]]$;
int main($[[]]$) {
[[]]$:&&$&&$&&puts("hello world");
}
Goto-statements
Normaal gesproken vertrouwt C op functies; om die reden behoort het tot de categorie die bekendstaat als functionele programmeertalen. Desalniettemin construeren we om prestatieredenen soms programma's met onvoorwaardelijke sprongen. Het volgende fragment illustreert dit principe (demo):
#include <stdio.h>
#include <stdlib.h>
int main() {
goto *puts("Hello world"), puts("Goodbye world"), exit;
}
Tellen en optellen
In sommige situaties moeten we een programma maken dat vanaf één begint te tellen. Hoewel dit vaak op een specifieke manier wordt gedaan, laat het volgende voorbeeld een robuuste aanpak zien (demo):
#include <stdio.h>
union {} var[100] = {};
int main() {
int i = 1;
printf("Let's count: %d %d %d %d\n", i++, var[42], i++, i++);
}
Eenvoudige optelling kan op een analoge manier worden bereikt. Het volgende programma toont het resultaat van de berekening 2 + 2, voor bepaalde typen 2 (demo):
#include <stdio.h>
typedef union {}* my_type;
int main() {
printf("2 + 2 = %d\n", (my_type)2 + 2);
}
In dat kader neem ik afscheid van mijn mede-software engineers en engineer-vormige entiteiten.
Eerdere artikelen in de serie
- Weekend projects: getting silly with C (30 juni 2024)
- Getting silly with C, part -5^-7 (10 januari 2025)
- Getting silly with C, part ~(~1<<1) (7 september 2025)
***
Technische analyse
Hieronder volgen de technische verklaringen bij de bovenstaande voorbeelden:
1. Functiedefinities
Er gebeuren hier twee dingen. Ten eerste is dit een soort K&R-functiedecalaratie, behalve dat K&R-declaraties niet langer worden geaccepteerd door GCC: "int x() int foo; { }" geeft de fout "error: old-style parameter declarations". Je omzeilt deze fout als je de parameter geen naam geeft; dit lijkt een glitch in GCC te zijn. Daarnaast is het hebben van meerdere void-parameters onzinnig en niet toegestaan in moderne functiedecalaraties: als je "int x(void, void)" probeert, krijg je "error: 'void' must be the only parameter". Ook dit lijkt een glitch te zijn.
2. Operator-prioriteit
Dit is een combinatie van vier trucs:
[[...]] is een speciale notatie voor attributen (een beknopte en moderne versie van attribute((...))), maar lege attributen worden simpelweg overgeslagen.
"typedef int x" kan worden herschreven als "int typedef x".
- Afhankelijk van de context kan
&& een GNU-extensie zijn om het adres van een label te verkrijgen, of een Booleaanse AND-operator. In de "puts"-regel is de eerste && een unaire operatie op een label, en de andere twee zijn AND-operatoren.
- Hetzelfde symbool (
$) kan gelijktijdig worden gebruikt als globale typenaam, lokale variabelenaam en als naam voor een goto-label, zonder fouten.
3. Goto-statements
Dit combineert drie zaken:
goto foo is een GNU-extensie om te springen naar een opgeslagen of berekend labeladres: "void ptr = &&some_label; goto *ptr".
- Meerdere onafhankelijke expressies kunnen door komma's worden gescheiden.
- In deze specifieke context heeft
een lagere prioriteit dan de komma, waardoor "goto a, b, c" wordt geparseerd als "goto (a, b, c)", wat functioneel vergelijkbaar is met "a; b; goto c". Ter vergelijking: "x = a, b, c" zou worden geparseerd als "(x = a), b, c".
4. Tellen en optellen
Het eerste voorbeeld (tellen): De volgorde van bewerkingen bij het berekenen van functieparameters is niet gespecificeerd, dus printf("%d, %d, %d\n", i++, i++, i++) kan "1, 2, 3" of "3, 2, 1" produceren; in GCC krijg je dat laatste. Echter, we hebben een vierde waarde. We zouden "3, <?>, 2, 1" moeten krijgen, maar we krijgen "3, 2, 1, 0". De extra truc is dat "union {} x" toestaat om op een omweg een leeg type (lengte 0) te definiëren, iets wat direct ("void x") niet kan. Wanneer je een type met nulgrootte als functieparameter meegeeft, wordt er niets opgeslagen en wordt er geen code gegenereerd. printf() wordt dus aangeroepen met 3, <niets>, 2, 1, waardoor een niet-geïnitialiseerd register (meestal nul) wordt gebruikt voor de vierde %d.
Het tweede voorbeeld (optellen): Omdat union {} nulgrootte heeft, betekent dit dat als we een array maken, het adres van het eerste element hetzelfde is als het adres van het tiende element: &foo[1] == &foo[10]. Vanwege de quasi-equivalentie tussen arrays en pointers betekent dit ook dat het optellen van een getal bij een pointer naar een object met nulgrootte de numerieke operand simpelweg negeert: foo + 1 == foo + 10. Let op: dit is niet wat er gebeurt met void; het optellen van 1 bij een void verplaatst de pointer met één byte. In dat laatste geval zou de schijnbare grootte van het gereferenceerde object — sizeof(void) — feitelijk als 1 kunnen worden begrepen (hoewel dit niet canoniek is, krijg je dit resultaat in GCC en clang).