Skip to content
HN On Hacker News ↗

Everyone Says Assembly Is Untyped—Everyone Is Wrong

▲ 156 points 91 comments by adamrezich 3d ago HN discussion ↗

Pangram verdict · v3.3

We believe this text is mainly AI, with some human-written content.

92 %

AI likelihood · overall

AI
6% human-written 94% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,715
PEAK AI % 97% · §1
Analyzed
Aug 22
backend: pangram/v3.3
Segments scanned
1 windows
avg 1715 words each
Distribution
6 / 94%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,715 words · 1 segments analyzed

Human AI-generated
§1 AI · 97%

TL;DR: I believe Odin’s inline assembly is currently the best out of any language. The most important aspects are of this article listed below. I am not aware of any other assembly (GCC/Clang/Rust/Go…) that would combine all of these aspects: Inline assembly is organized into asm “templates”, similar to and callable as procedures. asm templates integrate with rest of the code, through bindings specifying clobbers, pinned, tied, and scratch registers. Assembly syntax is unified across ISAs and consistent with Odin syntax. Assembly is fully type checked, just like rest of Odin code. Understanding that assembly is actually typed. Real semantic diagnostics via core:rexcode encoding tables. It was built in ~7 days. I have been asked why Odin even bothers having its own custom inline assembler at all. Isn’t inline assembly a solved problem? You take a string, you hand it to the assembler, and you let it sort out the rest. Everyone from GCC to Clang to Rust Rust’s inline assembly is a little more sophisticated because of the macro system, but not that much more. does more or less this. The wheel has been invented, right? This is precisely the design I did not want, and precisely the design that most languages have settled for. My goal from the beginning was an inline assembler that actually integrates with the rest of the language rather than feeling bolted on the side. And I honestly believe that what Odin has ended up with is the best inline assembly system in any language right now. I don’t say that lightly, and by the end of this article I hope you’ll at least understand why I believe that to be true. The String-Based Nonsense Let’s start with the thing I was reacting against. Here is what a trivial “add one” looks like in GCC-style extended asm using x86 AT&T/GAS syntax: int dst; asm("movl %1, %0\n\t" "addl $1, %0" : "=r" (dst) // outputs : "r" (src) // inputs : // clobbers ); Look at this and ask yourself: what does the compiler (as opposed to the assembler) understand here? The answer is “almost nothing”. The body is a string. "=r" and "r" are explicit constraint strings, another little stringly-typed DSL glued to the side of the real DSL. The %0 and %1 are positional references into a list you have to count by hand. And if you get any of it wrong, the error you get back is not from the compiler that knows your types and semantics; it is from the assembler, much later on, pointing at generated text that was not written by you. This is the sort of thing that happens when a feature is designed as an escape-hatch first rather than as a part of the language. Nobody seems to have sat down and asked “what would inline assembly look like if it respected the type system, the calling conventions, the constant system, and other things (like multiple-return-value semantics) of the host language?”. Rather, they asked “how do I bodge some assembly into this function with the least amount of compiler work?”, and a string was the answer. These kinds of inline assemblers ignore all of the aspects of the host language, and just bodge it in. I didn’t; I designed one from scratch. A Brief History of Bolting It On Strings are not the only way this has been done, and it is worth looking at what previous languages/compilers have done, because some of these approaches are a heck of a lot better than what GCC/Clang did, and unfortunately this development has stopped in compiler space. MSVC Microsoft’s C compilers had a genuinely different approach. MSVC’s __asm was statement-based, not string-based. You wrote a block of real instructions, and (this is the good part) you referenced your C variables and labels directly by name, and the compiler resolved them for you: int add_one(int x) { __asm { mov eax, x // 'x' is the C parameter, resolved by the compiler inc eax } // For the calling convention, the value in eax is the return value } No constraint strings, no %0, and no counting operands to refer to them. Compared to the GCC contraption this is honestly pleasant to read, and for a long time it was how an enormous amount of Windows systems code got written. So why did it disappear? Firstly, it was x86-only. When Microsoft moved to x64 (and later ARM64) they did not port it. The official guidance became “use compiler intrinsics, or write a separate .asm file and run it through MASM”. One of the stated constraints for the x64 compiler was to have no inline assembler at all. A whole approach was thrown away at the ISA boundary rather than generalized across it. Secondly, even where it existed, the compiler did not really understand the block. It resolved your symbol names, but it carried no explicit clobber information; the optimizer largely treated the region as an opaque fence to be conservative around. It knew what x was. It did not give any feedback to the user as to what the instructions did. Turbo Pascal If you go back further, you’ll find Turbo Pascal, which I have an obvious fondness for, as I do for Pascals in general. For its inline assembly, it had two mechanisms, and together they bracket the entire design space quite nicely. The first mechanism was the inline directive, and it is the purest possible statement of “the compiler understands nothing”. You gave it machine code as a sequence of numeric constants—actual opcodes, as bytes: procedure Cli; inline($FA); { $FA = the CLI instruction } procedure Nops; inline($90/$90); { two NOP bytes } That is not an assembler. This is you being the assembler, by hand, with the compiler faithfully copying your bytes into the stream. It is the ur-escape-hatch Odin keeps this exact capability as the #byte directive, but as one directive among many inside a checked template, not as the entire interface.. The second mechanism, which was added in Turbo Pascal 6.0, was the built-in assembler: the asm ... end block and the assembler procedure directive. This approach is much better as it has real mnemonics, and, like MSVC after it, you could name your Pascal variables and parameters directly: function AddOne(X: Word): Word; assembler; asm mov ax, X { 'X' is the Pascal parameter } inc ax { result returned in AX } end; For 1990, this seems really lovely This is before my time as I was not even born yet., and arguably ahead of where current C compilers eventually landed. But because of its time period, the built-in assembler only ever understood up to 80286 instructions, so the day you wanted a 386 and its 32-bit registers you were sent off to an external assembler anyway. Bolted on, and then bolted shut. MSVC and Turbo Pascal were both better in their instinctual design compared to that of GCC, especially with the dumb constraint strings. However, both of them stopped at exactly the same place: they resolved your identifiers but never modelled the instructions—not the operand types, only limited checking on immediate ranges, no control over what got clobbered or what needed to be pinned. GCC threw away their design and forgot the aspect of letting the assembly speak for itself in its own language. There was no conception that there is actually a type system underneath which could be generalized for the assembly. Which is the whole point of the Odin design, and it is what the rest of this article is about. Assembly Is Not Untyped There is a very common belief that assembly is “untyped”, and that inline assembly is therefore inherently an anything-goes affair. This isn’t true, and getting past it is the single most important idea in the whole design of a universalized inline assembler. I’ve written before about “untyped types” in the context of Odin, but those are actually existential types. Conventionally, “untyped” effectively means everything is “opaque” and very weak (e.g. everything is just an int and you just assume it everywhere). Assembly is usually considered the perfect example of such an “untyped” language. However, every instruction has a set of valid forms. Each form dictates the kind of each operand (register, memory, immediate, label), the class of each register (general-purpose, vector, mask), the width of each operand, the range each immediate may take, and what the instruction clobbers (flags, memory, particular registers). In x86, a mulps wants a 128-bit vector register; a crc32 in one of its forms wants a 32-bit destination and an 8-bit memory source; div reads and writes rdx and rax whether ask to it do or not. That is not the absence of a type system: that is a type system; a rather rich, dependent, per-instruction one. Assembly is effectively a polyadic typed algebra that everyone has agreed to pretend is a soup of bytes. Once you understand this, the design question stops being “how do I smuggle a string past the compiler?” and becomes “how do I express this algebra in the language’s own terms?”. And it turns out Odin already had most of the pieces lying around. One Syntax, Many ISAs The first decision was the surrounding syntax. Not the mnemonics—obviously mov on AMD64 has nothing to say to ldr on arm64—but everything around the mnemonics: how you declare operands, how you reference registers, how you write a memory address, how you spell a label, etc. Here I took the same lesson that Plan 9 (and later Go) took: pick one syntax and keep it consistent across every target. Ken Thompson’s toolchain did this, which Go inherited, and it is genuinely nice to only have to learn the shape of the thing once. Go’s assembly does have its issues (and inconsistencies), but the general idea is brilliant. Odin itself has a context-free grammar, so for Odin’s inline assembly, I wanted it to have a context-free grammar too, with the general form: instruction [operand{, operand}] The same grammar everywhere. The instruction has to be a valid Odin identifier or keyword. Explicit physical registers always take a % sigil (%rax, %xmm0, %al), which keeps them from colliding with your own parameter names and with any global constants from the parent scope. Parameter and scratch names are always