Iedereen zegt dat assembly niet getypeerd is—iedereen heeft het fout
- Inline assembly is georganiseerd in asm "templates", die vergelijkbaar zijn met en aanroepbaar zijn als procedures.
- Asm-templates integreren met de rest van de code via bindings die clobbers, pinned, tied en scratch registers specificeren.
- De assembly-syntax is uniform over verschillende ISA's (Instruction Set Architectures) en consistent met de Odin-syntax.
- Assembly is volledig type-gecontroleerd, net als de rest van de Odin-code.
- Inzicht in het feit dat assembly feitelijk getypeerd is.
- Echte semantische diagnostiek via
core:rexcodeencoding-tabellen. - Het is gebouwd in ongeveer 7 dagen.
Er is mij gevraagd waarom Odin überhaupt een eigen custom inline assembler heeft. Is inline assembly geen opgelost probleem? Je neemt een string, geeft deze aan de assembler en laat die de rest uitzoeken. Vrijwel elke taal, van GCC tot Clang en Rust, doet dit ongeveer zo. De wiel is al uitgevonden, toch?
Dit is precies het ontwerp waar ik tegen was, en precies het ontwerp waar de meeste talen genoegen mee hebben genomen. Mijn doel was vanaf het begin een inline assembler die daadwerkelijk integreert met de rest van de taal, in plaats van dat het aanvoelt als iets dat er later tegenaan is geplakt. Ik geloof oprecht dat wat Odin heeft bereikt het beste inline assembly-systeem in elke huidige taal is. Dat zeg ik niet lichtzinnig, en aan het einde van dit artikel hoop ik dat je in ieder geval begrijpt waarom ik dat geloof.
De onzin van string-gebaseerde assembly
Laten we beginnen met waar ik tegen reageerde. Dit is hoe een triviale "voeg één toe" eruitziet in GCC-stijl extended asm met x86 AT&T/GAS-syntax:
int dst;
asm("movl %1, %0\n\t"
"addl $1, %0"
: "=r" (dst) // outputs
: "r" (src) // inputs
: // clobbers
);
Kijk hiernaar en vraag jezelf af: wat begrijpt de compiler (in tegenstelling tot de assembler) hier eigenlijk? Het antwoord is: "bijna niets". De body is een string. "=r" en "r" zijn expliciete constraint-strings, een andere kleine "stringly-typed" DSL die aan de zijlijn van de echte DSL is geplakt. De %0 en %1 zijn positionele referenties naar een lijst die je handmatig moet tellen. En als je iets fout doet, komt de foutmelding niet van de compiler die je types en semantiek kent; hij komt veel later van de assembler, wijzend naar gegenereerde tekst die niet door jou is geschreven.
Dit is wat er gebeurt wanneer een functie primair is ontworpen als een "ontsnappingsluik" (escape-hatch) in plaats van als een onderdeel van de taal. Niemand lijkt zich af te hebben gevraagd: "Hoe zou inline assembly eruitzien als het het typesysteem, de calling conventions, het constantsysteem en andere zaken (zoals multiple-return-value semantiek) van de gasttaal respecteerde?". In plaats daarvan vroegen ze: "Hoe prop ik wat assembly in deze functie met de minste hoeveelheid compilerwerk?", en een string was het antwoord.
Dit soort inline assemblers negeren alle aspecten van de gasttaal. Ik deed dat niet; ik heb er een vanaf nul ontworpen.
Een korte geschiedenis van het "aanplakken"
Strings zijn niet de enige manier waarop dit is gedaan. Het is de moeite waard om te kijken naar wat eerdere talen/compilers hebben gedaan, omdat sommige van deze benaderingen veel beter waren dan die van GCC/Clang, hoewel deze ontwikkeling in de compiler-wereld helaas is gestopt.
MSVC
De C-compilers van Microsoft hadden een wezenlijk andere aanpak. De __asm van MSVC was statement-gebaseerd, niet string-gebaseerd. Je schreef een blok echte instructies en je kon je C-variabelen en labels direct bij naam refereren, waarna de compiler deze voor je oploste:
int add_one(int x) {
__asm {
mov eax, x // 'x' is de C-parameter, opgelost door de compiler
inc eax
}
// Voor de calling convention is de waarde in eax de returnwaarde
}
Geen constraint-strings, geen %0, en geen operands tellen om ze te refereren. Vergeleken met het GCC-mechanisme is dit prettig leesbaar. Waarom is dit verdwenen?
Ten eerste was het x86-only. Toen Microsoft overging op x64 (en later ARM64), hebben ze het niet geporteerd. Het officiële advies werd: "gebruik compiler intrinsics, of schrijf een apart .asm-bestand en run dit via MASM". Een van de gestelde beperkingen voor de x64-compiler was dat er helemaal geen inline assembler mocht zijn. Een hele aanpak werd weggegooid bij de ISA-grens in plaats van gegeneraliseerd over die grens heen.
Ten tweede begreep de compiler, zelfs waar het bestond, het blok niet echt. Hij loste je symboolnamen op, maar had geen expliciete clobber-informatie; de optimizer behandelde de regio grotendeels als een ondoorzichtig hek (opaque fence) waar hij conservatief omheen moest werken. Hij wist wat x was, maar gaf de gebruiker geen feedback over wat de instructies deden.
Turbo Pascal
Als je verder teruggaat, kom je bij Turbo Pascal. Voor inline assembly had het twee mechanismen die het ontwerpveld mooi inkaderen.
Het eerste mechanisme was de inline-directieve, de puurste vorm van "de compiler begrijpt niets". Je gaf machinecode mee als een reeks numerieke constanten—werkelijke opcodes, als bytes:
procedure Cli; inline($FA); { $FA = de CLI instructie }
procedure Nops; inline($90/$90); { twee NOP bytes }
Dat is geen assembler. Hier ben jíj de assembler, handmatig, waarbij de compiler je bytes trouw in de stream kopieert. Het is het ultieme ontsnappingsluik. Odin behoudt deze exacte functionaliteit via de #byte directieve, maar dan als één directieve onder een gecontroleerde template, niet als de gehele interface.
Het tweede mechanisme, toegevoegd in Turbo Pascal 6.0, was de ingebouwde assembler: het asm ... end blok en de assembler-procedure directieve. Deze aanpak was veel beter omdat hij echte mnemonics had en, net als bij MSVC, Pascal-variabelen en parameters direct konden worden benoemd:
function AddOne(X: Word): Word; assembler;
asm
mov ax, X { 'X' is de Pascal parameter }
inc ax { resultaat geretourneerd in AX }
end;
Voor 1990 was dit fantastisch, en waarschijnlijk verder gevorderd dan waar huidige C-compilers uiteindelijk zijn beland. Maar vanwege de tijd waarin het ontstond, begreep de ingebouwde assembler alleen instructies tot en met de 80286. Zodra je een 386 en diens 32-bit registers wilde, werd je toch naar een externe assembler gestuurd.
Vastgeplakt en daarna dichtgetimmerd
Zowel MSVC als Turbo Pascal waren in hun instinctieve ontwerp beter dan GCC. Echter, beiden stopten op precies hetzelfde punt: ze losten je identifiers op, maar modelleerden de instructies nooit—niet de operand-types, slechts beperkte controle op immediate-ranges, en geen controle over wat er geclobberd werd of wat gepinned moest worden. GCC gooide hun ontwerp weg en vergat het aspect waarbij de assembly voor zichzelf spreekt in zijn eigen taal.
Er was geen besef dat er eigenlijk een typesysteem onder ligt dat gegeneraliseerd kan worden voor assembly. Dat is precies het punt van het Odin-ontwerp.
Assembly is niet getypeerd
Er bestaat een wijdverbreid geloof dat assembly "niet getypeerd" is, en dat inline assembly daarom inherent een kwestie van "alles mag" is. Dit is niet waar. Dit inzien is het belangrijkste idee in het ontwerp van een universaliseerde inline assembler.
Conventioneel betekent "niet getypeerd" effectief dat alles "opaak" en zwak is (bijv. alles is gewoon een int en je gaat daar overal vanuit). Assembly wordt meestal gezien als het perfecte voorbeeld van zo'n niet-getypeerde taal.
Echter, elke instructie heeft een set geldige vormen. Elke vorm dicteert het soort van elke operand (register, geheugen, immediate, label), de klasse van elk register (general-purpose, vector, mask), de breedte van elke operand, het bereik dat elke immediate kan aannemen, en wat de instructie clobbert (flags, geheugen, specifieke registers). In x86 wil een mulps een 128-bit vector register; een crc32 wil in een van zijn vormen een 32-bit bestemming en een 8-bit geheugenbron; div leest en schrijft naar rdx en rax, of je er nu om vraagt of niet.
Dat is niet de afwezigheid van een typesysteem: dat is een typesysteem; een vrij rijk, dependent typesysteem per instructie. Assembly is effectief een polyadische getypeerde algebra die iedereen is overeengekomen te behandelen als een soep van bytes. Zodra je dit begrijpt, is de ontwerpvraag niet meer "hoe smokkel ik een string langs de compiler?", maar "hoe druk ik deze algebra uit in de termen van de taal zelf?". En het blijkt dat Odin de meeste stukjes daarvoor al had liggen.
Eén syntax, vele ISA's
De eerste beslissing was de omringende syntax. Niet de mnemonics—duidelijk heeft mov op AMD64 niets te maken met ldr op arm64—maar alles rondom de mnemonics: hoe je operanden declareert, hoe je registers referentieert, hoe je een geheugenadres schrijft, hoe je een label spelt, enz.
Hier heb ik dezelfde les geleerd als Plan 9 (en later Go): kies één syntax en houd deze consistent over elk target. De toolchain van Ken Thompson deed dit, wat Go heeft overgenomen, en het is echt prettig om de vorm van het geheel slechts één keer te hoeven leren.
Odin heeft zelf een context-vrije grammatica, dus ik wilde dat Odin's inline assembly dat ook had, met de algemene vorm:
instructie [operand{, operand}]
Dezelfde grammatica overal. De instructie moet een geldige Odin-identifier of keyword zijn. Expliciete fysieke registers krijgen altijd een % sigil (%rax, %xmm0, %al), wat voorkomt dat ze botsen met je eigen parameternamen en met globale constanten uit de parent scope. Parameter- en scratch-namen zijn altijd "kaal" (omdat de compiler de semantiek begrijpt). Geheugen-operanden zijn altijd Intel-stijl effectieve adressen ([base + index*scale + disp]). Labels zijn altijd .name. Je leert deze vorm één keer en het is van toepassing op elke ISA die we toevoegen, ook al zijn de onderliggende instructies volledig verschillend. Het gebruikt dezelfde set tokens als Odin: getal-literalen, commentaren, en zelfs de semicolon-insertie regels.
Dit is hetzelfde principe waar ik steeds naar terugkeer: coherentie boven consistentie. Odin is coherent met zichzelf, niet met GAS, NASM of de traditionele assembler van een platform. Dit is Odin's inline assembler.
Intel-volgorde, niet AT&T
Er is één syntactische beslissing waar mensen het meeste over discussiëren: Intel of AT&T/GAS. Voor Odin's inline asm-bodies wordt de Intel-operandvolgorde gebruikt—bestemming eerst, dst, src—samen met Intel-stijl (maar enigszins andere) geheugenadressering.
Hier ben ik afgeweken van Plan 9 en Go. Plan 9 en Go schrijven operanden bron-eerst, van links naar rechts in de datastroom-volgorde. In Go betekent MOVQ $0, AX dat AX wordt geleegd, met de bestemming aan de rechterkant. Dat is dezelfde volgorde als AT&T, en het tegenovergestelde van Intel.
Ik heb de filosofie van één-grammatica-voor-elke-ISA overgenomen, maar niet hun operandvolgorde. Dit is geen arbitraire keuze:
- Coherentie met Odin:
mov dst, srcleest alsdst = src. De bestemming staat links, precies waar het toewijzingsdoel leeft in elke andere regel Odin die je schrijft:x = y,x := y,name: type = value. AT&T'smovl %src, %dstlaat de datastroom achteruit lopen ten opzichte van elke toewijzing in de omringende taal. - Universele conventie: Bestemming-eerst is geen Intel-eigenaardigheid, het is de meerderheidsconventie over ISA's. ARM schrijft
add r0, r1, r2(bestemming eerst). RISC-V schrijftadd rd, rs1, rs2(bestemming eerst). MIPS documentatie doet hetzelfde. De bron-eerst volgorde is juist de parochial (lokale) variant, geërfd van de DEC en PDP-11 lineage.
De rest van de AT&T-bagage valt om soortgelijke redenen af:
Geheugen-operanden
AT&T schrijft disp(base, index, scale), positionele slots die je simpelweg uit je hoofd moet leren. Intel schrijft [base + index*scale + disp], wat leest als de adres-arithmetiek die het daadwerkelijk is. Odin gebruikt dat laatste, en dit strekt zich schoon uit naar vormen die andere targets nodig hebben, zoals [base + index<<scale] op arm64.
Operand-grootte
AT&T bakt de breedte in de mnemonic (movb, movw, movl, movq). Odin hoeft dat niet, want de operanden zijn getypeerd; de grootte komt voort uit het type van de parameter, en waar geen register dit vastpint, uit een expliciete [%rax]:u8 annotatie.
Sigils
AT&T versiert elk register met % en elke immediate met $, onvoorwaardelijk. Odin's % ziet er oppervlakkig hetzelfde uit, maar doet iets heel anders: het verschijnt alleen op expliciete fysieke registers om botsingen met gebruikersparameters en scratch-namen te voorkomen. In een idiomatische template schrijf je kale namen en grijp je alleen naar %rax wanneer je echt een register moet pinnen.
Vergelijking AT&T/GAS vs Odin:
AT&T/GAS:
movl %eax, %ebx # ebx = eax (bron is LINKS)
addl $1, %ebx # ebx += 1
movl 8(%rdi,%rsi,4), %ecx # ecx = *(rdi + rsi*4 + 8)
Odin (Intel-volgorde):
mov %ebx, %eax // ebx = eax (bestemming is LINKS)
add %ebx, 1 // ebx += 1
mov %ecx, [%rdi + %rsi*4 + 8]
Een waarschijnlijker voorbeeld met benoemde parameters zou zijn:
mov x, y
add x, 1
mov z, [base + index*4 + disp]
De Template Syntax
Dit is de algemene vorm van een asm-template:
naam :: asm(params) -> (resultaten) [bindings] {
body
}
De params zijn je inputs, als kale namen met Odin-types. De resultaten zijn je outputs, eveneens kale namen met types, gebruikmakend van dezelfde signatuur-syntax als een Odin-procedure. Het [bindings] blok bevat alles wat geen eenvoudige input of output is: de ties, pins, scratch-registers, width-views, clobbers en effects. De body is de instructiestroom.
De parametertypes zijn echte Odin-types: integers, floats, booleans, pointers, multi-pointers of #simd[N]T. De compiler gebruikt het type om de registerklasse, de operandbreedte en de validiteit van een instructie te bepalen.
Je kunt ook parametrische polymorfe constante parameters declareren. Een $name parameter is een compile-time immediate ($ctrl: u8), die wordt gecontroleerd op het moment van instantiatie.
Voorbeeld van eenvoudige asm-syntax:
add_one :: asm(x: u64) -> (r: u64) [
x -> r,
] {
inc r
}
In het bindings-blok betekent x -> r dat de input x en de output r aan hetzelfde register worden gekoppeld, wat resulteert in een read-write operand. Geen %0, geen "+r", geen handmatig tellen.
Multiple Return Values
Assembly-instructies zijn van nature polyadisch. rdtsc produceert twee resultaten in edx en eax. cpuid produceert er vier. div produceert gelijktijdig een quotiënt en een restwaarde. In Odin returneer je deze simpelweg.
rdtsc :: asm() -> (lo, hi: u32) [
lo = %eax,
hi = %edx,
] {
rdtsc
}
cpuid :: asm(leaf: u32) -> (a, b, c, d: u32) [
leaf -> a = %eax,
b = %ebx,
c = %ecx,
d = %edx,
] {
cpuid
}
divmod_u64 :: asm(n: u64, d: u64) -> (quo, rem: u64) [
n -> quo = %rax,
rem = %rdx,
#clobber flags,
] {
xor %rdx, %rdx // clear de hoge helft van de dividend
div d // rax = rdx:rax / d ; rdx = remainder
}
Op de aanroeplocatie worden deze gedestructureerd zoals bij elke andere Odin-procedure:
lo, hi := rdtsc()
quo, rem := divmod_u64(100, 7)
ea, eb, ec, ed := cpuid(0)
Ties, Pins, Scratch en Width-Views
Het binding-blok is waar veel expliciete register-pinning plaatsvindt. Ik wil dat zoveel mogelijk clobbering wordt afgeleid (inferred), maar waar specificiteit nodig is, moet dit leesbaar en benoemd zijn.
De volgende elementen bevinden zich in het binding-blok:
- Clobbering: Expliciet registers overschrijven via
#clobber %rax, flags via#clobber flags, of geheugen via#clobber memory. - Effects: Specificaties zoals
#volatileof#align_stack. - Tie:
in -> outbindt een input en output aan één register. - Pin:
name = %regdwingt een specifiek fysiek register af. - Scratch Register/Parameter:
name: Tis een werkregister waarvan de klasse wordt bepaald door het type (i64geeft een general-purpose register,#simd[4]f32een vector register). - Width-View:
view: T = srcis een tweede naam voor het register vansrc, maar gezien op een smallere breedte (bijv. de lage 8 bits van een 64-bit register).
Voorbeeld van een vector-kernel:
dot_f32x4 :: asm(a, b: [^]f32, n: i64) -> (result: f32) [
acc: #simd[4]f32,
tmp: #simd[4]f32,
i: i64,
#clobber flags,
#clobber memory,
] {
xorps acc, acc
xor i, i
.loop:
movups tmp, [a + i*4]
mulps tmp, [b + i*4]
addps acc, tmp
add i, 4
cmp i, n
jl .loop
haddps acc, acc
haddps acc, acc
movss result, acc
}
Labels zoals .loop zijn lokaal voor de template en worden per instantiatie mangled, waardoor symbol-collisions worden voorkomen.
Prefixes en andere syntactische eigenaardigheden
De assembly-body wordt getokeniseerd en geparsed door dezelfde machine als de rest van Odin.
Prefixes krijgen hun eigen regel
Instructie-prefixes zoals lock, rep en repne in x86 staan niet vóór de mnemonic, maar op hun eigen regel:
atomic_fetch_add :: asm(p: ^i64, delta: i64) -> (old: i64) [
delta -> old,
] {
lock
xadd [p], old
}
Dit voorkomt dat er speciale uitzonderingen in de tokenizer nodig zijn. Een prefix is simpelweg een instructie die geen operanden neemt en op zijn eigen regel staat. De semantic checker controleert of de prefix legaal is voor de volgende instructie (bijv. lock vereist een memory destination).
Overige regels:
- Commentaren: Gebruiken
//en/**/, niet;of#. De;is in Odin de statement-separator. - Getal-literalen: Gebruiken de Odin-stijl (
0xFAvoor hex,0b1010voor binair) inclusief cijfer-separatoren. - Directives: Gebruiken
#(bijv.#byte,#skip,#nop,#align), consistent met Odin's directive-sigil. - Labels: Beginnen met een punt (
.name:) om aan te geven dat ze template-lokaal zijn.
De compiler begrijpt het daadwerkelijk
Het belangrijkste onderdeel is de semantische controle. Templates worden niet verbatim doorgegeven aan de assembler; de frontend controleert elke instructie tegen de encoding-tabellen van het target.
Dit is mogelijk dankzij core:rexcode, een high-performance multi-architectuur instructie encoder/decoder/printer library geschreven in Odin.
Voor elke instructie controleert de compiler:
- De mnemonic.
- Het aantal operanden.
- Het soort operand (register vs geheugen vs immediate vs label).
- De operand-grootte en klasse.
- Immediate ranges.
- De volledige validiteit van geheugen-operanden.
Als er een fout wordt gemaakt, rapporteert de compiler dit direct bij het betreffende token in de broncode, in plaats van later een ondoorzichtige assembler-fout te geven.
Hoe de compiler het begrijpt: rexcode
core:rexcode is ontworpen door Brendan Punsky (dotbmp). Het is tabel-gestuurd vanuit één bron van waarheid. Elke architectuur heeft één handgeschreven tabel, die door een metaprogram wordt platgeslagen tot binary blobs die bij compile-time in @(rodata) worden geladen. Lookups zijn $O(1)$.
rexcode ondersteunt reeds:
- x86 (x86-64 en i386, incl. SSE/AVX/AVX-512/BMI/FMA/AES-NI).
- ARM (arm32 en arm64).
- MIPS, RISC-V, PPC (incl. Power ISA 3.1).
- Embedded/Retro (ppc_vle, mos6502, mos65816, rsp).
- IR-laag (WASM en SPIR-V).
Waarom bestond dit decennia geleden niet?
De encoding van x86 of arm64 is een vaste, kenbare, eindige zaak. Toch heruitvindt elke assembler, JIT of emulator deze kennis opnieuw. Er was nooit een schone, geverifieerde, importeerbare bibliotheek die elke instructievorm voor een dozijn architecturen bood.
Ik zou beweren dat de afwezigheid van zo'n bibliotheek de reden is waarom inline assemblers zo slecht zijn. Semantische controle is geen moeilijk idee; maar zonder een machine-leesbaar model van de instructieset kun je het niet controleren.
Odin is naar mijn weten een van de eerste talen die zo'n bibliotheek in de standaarddistributie verscheept. Hierdoor duurde de implementatie van de templates slechts ongeveer 7 dagen.
Templates, geen Intrinsics
Sommigen zullen vragen of deze asm-templates niet gewoon hygiënische macro's zijn. Mijn bezwaar tegen macro's was altijd een algemeen-doel systeem; een beperkte hygiënische macro voor een specifiek domein is iets anders. Een asm-template kan maar één ding: een getypeerde, gecontroleerde instructiestroom expanderen.
Omdat ze templates zijn, vervallen veel dedicated compiler intrinsics. Je kunt ze nu zelf bouwen:
mfence :: asm() [ #volatile ] { mfence }
atomic_fetch_add :: asm(p: ^i64, delta: i64) -> (old: i64) [
delta -> old,
] {
lock
xadd [p], old
}
tzcnt :: asm(x: u64) -> (count: u64, was_zero: bool) [
was_zero = %flags.z,
] {
tzcnt count, x
}
Opmerking: was_zero = %flags.z geeft toegang tot een flag uit het pseudo-register %flags. De zero-flag wordt zo een getypeerd boolean resultaat.
De beste inline assembler
Ik herhaal: ik geloof dat dit het beste inline assembly-systeem is. Het integreert met het typesysteem, spreekt native polyadische return-waarden, biedt expliciete controle over ties/pins/scratch/width-views, gebruikt één coherente syntax en geeft echte semantische diagnostiek.
Dit komt niet voort uit een "grand type theory", maar uit dezelfde plek als al het ontwerp van Odin: doen wat mensen daadwerkelijk willen, in plaats van iets te doen wat men als een noodzakelijk kwaad beschouwt.
Assembly was al die tijd getypeerd. We moesten alleen stoppen met doen alsof dat niet zo was.
Groetjes,