Lua is an example of a programming language that still uses an one-pass compiler. It keeps the implementation small, which is useful in embedded contexts. Additionally, not constructing an AST helps when parsing very large files. Lua files are sometimes used to store data, sort of like JSON.
If anyone's interested in a simple single-pass compiler for a full language, you can also check out munificent's Crafting Interpreters. It walks you through a Java based AST parser and C based bytecode compiler.
The real difference between the compiler in the example, and mine, is that I handle floating-point operations and also explicitly use printf to show the output. (Because otherwise your return value is limited to an 8bit number.)
This is nice. The -debug flag is a cool feature. But if you first build a representation of the whole program in memory, then traverse that representation to generate code, it's not really single-pass.
I remember some of the old DOS compilers would generate similar code, using the stack as an actual expression evaluation stack when registers weren't sufficient. It's not a bad idea even now, since x86 has an internal cache of the top of the stack so operations on it are specifically optimised, and the opcodes are very small: push/pop reg is 1 byte, push s8 is 2.
I'm currently working on a 3-part blog series on writing a one-pass compiler in Python that emits C. It is quite a bit longer though because we make the lexer and parser from scratch. I started it because my students were having trouble with other tutorials because of all the jargon, theory, and libraries required.
That's very well done, thanks for sharing. I really like the straight forward writing style, I've never attempted to write a compiler, and have only done basic parsers for day to day tasks, so like the clean, simple approach.
I’ve been writing a one-pass optimising compiler for Wasm. It’s not perfect, but the code it outputs currently outperforms Cranelift's by a factor of 2-10x for many of the files in the Wasm specification testsuite.
Older versions of C had the restriction that variables could only be declared at the start of a block. Supposedly this was due to the original C compiler being one-pass. It's easier to keep track of the size of the stack frame if you see all declarations in one place. Though I think with a frame pointer you could make it work anyway. You have essentially the same complications if you allow programmers to open a block anywhere and declare variables there. I don't know if the very first versions of C allowed this.
Maybe more exotically, here is a legal Haskell program:
compute = f x y
x = 3
y = 4
f = (+)
Using typed identifiers before they are declared would not be possible in a single-pass compiler. You wouldn't know what code to emit for compute since you wouldn't even know its type until you have seen the definitions of x, y, and f.
Interestingly, this restriction does not apply to goto labels, which you can use first and define later: The compiler can just emit the label as written into the assembly code and let the assembler worry about patching up the jump target.
> Interestingly, this restriction does not apply to goto labels, which you can use first and define later: The compiler can just emit the label as written into the assembly code and let the assembler worry about patching up the jump target.
A one-pass compiler can manage this by maintaining a mapping of undefined labels to a list of goto statements that refer to them. Once the definition is located, unwind the list and fill in the jump values. Types are trickier because the size is unknown.
There is no way to "fill in the jump values" if you have already emitted the goto to a place you can't modify later. Like the featured article does, emitting its code with printf. I agree that you could emit code to an intermediate buffer, fix it up later, and still call this (a slightly more relaxed version of) one-pass compilation.
>Older versions of C had the restriction that variables could only be declared at the start of a block.
Pascal also has that restriction
Although Delphi does not have it anymore. But FreePascal still has it, even in Delphi compatible mode. The FreePascal developers have also said, they will keep that restriction to improve readability. The code could not be read anymore if variables were placed willy-nilly everywhere
I'm actually working on a Pascal compiler right now. Register management has been a really tricky part for me, but I didn't even thing of treating an x86 processor as a stack based machine. Very clever, especially when you just want a simple compiler an not anything super optimized.
For anyone wondering, the NOPs are so that jumps don't get messed up. Assembly level GOTOs generally work on byte/word offsets, so if something intended to jump back before the calculation but was expecting two PUSH_INTs and an OP_PLUS (5 words: 3 opcodes, 2 arguments) but there was only a single PUSH_INT (2 words), it would jump back 3 words too far.
Later I do walk back over the bytecode and remove the nops. But I have to update all JMP/CALL instructions to cope with the changed destination offsets. Not a hard job in my case, as there are only a couple of instructions which refer to byte-offsets.
(If I allowed "LD A,[BC]", or similar permuations I'd have a lot more work to do.)
https://en.wikipedia.org/wiki/Constant_folding: "Constant folding is the process of recognizing and evaluating constant expressions at compile time rather than computing them at runtime. Terms in constant expressions are typically simple literals, such as the integer literal 2, but they may also be variables whose values are known at compile time. Consider the statement:
i = 320 * 200 * 32;
Most compilers would not actually generate two multiply instructions and a store for this statement. Instead, they identify constructs such as these and substitute the computed values at compile time (in this case, 2,048,000)."
Say you have a simple expression within your code: 4+3*2. Rather than computing the result at run time, you can reduce (fold) that expression into 10, because it's a simple constant that only needs to be computed once.