Writing a C Compiler, Part 1
norasandler.com
norasandler.com
Which is why I've been writing a mini-LLVM in haskell as a tutorial to explain optimising compiler construction: [Tiny optimising compiler](https://github.com/bollu/tiny-optimising-compiler)
What we need is that the source code for the smaller number of optimizing compilers can be compiled by the many, many different inefficient C compilers.
We need to be sure that the binary of the optimizing compiler is not compromised by the compiler which compiled the optimizing compiler.
You could compile compilers. Including compiling various compilers written in C. At some point you compile the wanted optimizing compiler's source using various different compilers generated through various chains of compilation. Those binaries of the optimizing compiler may all differ, but the code generated by those multiple binary versions of the optimizing compiler should be identical.
Finally use the uncompromised optimizing compiler to compile itself. In fact, using the multiple binaries of the optimizing compiler to compile itself and be sure they all produced the same final binary.
Cross compilation doesn't hurt either. For example a Raspbery Pi compiles the optimizing compiler for x86-64. The binary of that compiler should still match the same compiler compiled from other C compilers.
Now we not only have to be paranoid about the binary of a compiler being compromised by another compiler, but by the Intel Management Engine. Compromise baked right into the hardware.
Every paranoid thing I thought ten years ago turned out to be true.
http://wiki.c2.com/?TheKenThompsonHack
http://vxer.org/lib/pdf/Reflections%20on%20Trusting%20Trust....
Is this even possible?
And wouldn't it be easier to just verify the binary manually?
Also doing cross-compilation on different platforms should produce identical binaries, are at least identical behaving binaries.
That seems like confirmation bias. But yeah, good idea about compiler integrity.
Having had the pleasure of writing a couple emulators and a couple compilers at this point: the emulator is easier than the compiler, by a lot. Relative to parsing and emitting code, emulating a straightforward architecture is easy.
This is a great post!
Writing an emulator is almost as much fun as writing a compiler. And when you are done, you will have another level of appreciation of how computers work.
http://schierlm.github.io/OberonEmulator/
I sent a bunch of patches to rework the in-browser emulator a couple weeks ago. If you don't know C, I recommend reading through the JS emulator's source. (View source should suffice—it's all unminified vanilla JS; there's no opinionated JS framework involved.)
With it running in the browser, the emulator frontend treats the web platform as its widget toolkit. The code to interface with that is in webdriver.js[2] and takes about 1000 lines of code. The CPU and memory operations themselves are implemented in risc.js[3] and only take about 1/3 that.
To follow along with instruction fetching/decoding/execution, you'll need to understand the ISA. There's a good 3-page overview linked from projectoberon.com under the title "RISC Architecture"[4]. A more in-depth description of the design is also available[5].
I have some tentative work for a machine-code level debugger online[6]. It's unfinished, however, so it comes with no documentation and the toolbar icons are missing. (There are tooltips, however.) So you can play with it if you feel like watching the registers and flags change while stepping through machine instructions.
1. https://github.com/pdewacht/oberon-risc-emu/
2. https://github.com/schierlm/OberonEmulator/blob/master/JS/we...
3. https://github.com/schierlm/OberonEmulator/blob/master/JS/ri...
4. https://www.inf.ethz.ch/personal/wirth/FPGA-relatedWork/RISC...
5. https://www.inf.ethz.ch/personal/wirth/FPGA-relatedWork/RISC...
6. https://www.colbyrussell.com/staging/aubergine/emu.html?imag...
What exactly do you mean by this? Direct object code emission?
> the emulator is easier than the compiler, by a lot
Yeah, definite +1 there. For most simple machines it really is not much more than a loop with a switch on the opcode where the cases update the CPU state.
These approaches aren't just for toy languages. Java/C#/Python standard implementations compile to byte code that runs on virtual machines. Glasgow Haskell Compiler compiles to C--, and a bunch of languages compile to JavaScript.
Do the generation to some kind of byte code as you are suggesting, but using an instruction format that be used as macro calls in macro assemblers.
Then just write the macros for each bytecode, doesn't matter matter if the register usage is bad, goal is just to have a plain native executable that runs.
That approach sounds like the same one in Jack Crenshaw's very lucid tutorial:
https://compilers.iecc.com/crenshaw/
He uses Pascal to write a Pascal compiler, but the basic steps seem to be the same --- start with a very simple lexer, add pieces to it incrementally, then write a recursive-descent parser on top of that and gradually extend it to handle more of the language.
Another simplification that really helps, once you're used to recursive descent and have noticed that the functions for each operator/precedence level are very similar, is to refactor them into a lookup table and an even simpler function that uses it, creating "precedence climbing":
https://www.engr.mun.ca/~theo/Misc/exp_parsing.htm
That makes it very easy to add/change/extend operators, or even have them be user-defined(!) and dynamically modifiable.
As a demonstration of how absolutely tiny a compiler can be, this one is worth inspecting carefully --- it also uses a handwritten lexer and recursive-descent parser, but doesn't make an AST and just emits code for a stack VM: https://news.ycombinator.com/item?id=8558822
...and someone modified that one to make an x86 JIT: https://news.ycombinator.com/item?id=8746054
I then followed this on-line course where you actually write a compiler for the COOL language. It has been one of the best educative things I've ever done in my life and I can't help recommending it.
https://lagunita.stanford.edu/courses/Engineering/Compilers/...
I'd love to do something like that in my professional career but unfortunately 90% of the time I ended up carrying out way less interesting work (writing application for end users).
Why Study Compiler Construction?
A compiler is a large, complex program. Compilers often include hundreds of thousands, if not millions, of lines of code, organized into multiple subsystems and components. The various parts of a compiler interact in complex ways. Design decisions made for one part of the compiler have important ramifications for other parts. Thus, the design and implementation of a compiler is a substantial exercise in software engineering.
A good compiler contains a microcosm of computer science. It makes practical use of greedy algorithms (register allocation), heuristic search techniques (list scheduling), graph algorithms (dead-code elimination), dynamic programming (instruction selection), finite automata and push-down automata (scanning and parsing), and fixed-point algorithms (data-flow analysis). It deals with problems such as dynamic allocation, synchronization, naming, locality, memory hierarchy management, and pipeline scheduling. Few software systems bring together as many complex and diverse components. Working inside a compiler provides practical experience in software engineering that is hard to obtain with smaller, less intricate systems.
Writing a compiler isn’t much harder than writing an interpreter, if at all.
Assuming your language can be parsed without running it (Perl, for instance, cannot, in general. See https://www.perlmonks.org/?node_id=663393), it is just a matter of doing that and emitting code for every statement, plus some bookkeeping in order to fix up branches. If the language you are compiling is simple, that’s not hard to do.
The result will not be the fastest possible, but it will be compiled code, and it will be faster (sometimes significantly so) than interpreting the program.
We don’t say “Web applications are large and complex”, either.
Just as you can start simple with a web application and, if needed, grow it, you can start with a simple compiler and, if needed, grow it.
(And, on current hardware, I think the complexity of the language matters less than it used to. Single pass languages such as pascal still are easier targets, but computers have plenty of RAM, so fixing up things is easier than it used to be.
Having written a production compiler in a previous lifetime, and talked with developers over the years since then, it is clear the difference that knowledge makes in their day-to-day programming.
I think this incremental approach must be the way to do it.
I was inspired by 8cc to write my own C compiler; but I ended up burnt out on that project. (The standard document is just infuriating. There's so much ambiguity and disorganization in the document, and the actual technical specifications are like an afterthought to the novel of text.)
May be time to get back into it!
Optimizing e.g. C to create much more optimal Assembly (especially using some more non-standard instructions) seems like a much more daunting task
The hard stuff is everything related to the code generation – register allocation, instruction selection and scheduling, dealing with any peculiarities of the target ISA and microarchitecture, etc. Your goal here is to preserve the semantics of the HLL constructs while emitting the optimal sequence of instructions for the CPU to execute.
The back end is mostly about transforms that preserve meaning, with a long tail of attempts to prove things about the code to make things faster in certain cases.
You can also have languages with complex front ends that need lots of work, whether it's preserving runtime type information (a big serialisation job that's not strictly back end work), interesting type systems that also are trying to prove things about the code, features that require increasingly elaborate transformations before they can be handed off to any common back end (like closures, iterators, async, continuation passing style).
Even parsing C and elaborating it to a correctly typed AST is not quite as simple as others are making it out to be, though. Getting all the implicit type conversions correct is not completely trivial, and is a popular source of bugs (see some examples in http://www.cs.utah.edu/~regehr/papers/pldi11-preprint.pdf for instance).
There are also some annoying ambiguities in the grammar and in name resolution rules that mean that sometimes it's not easy to tell whether something is supposed to be a type name or a variable name: https://jhjourdan.mketjh.fr/pdf/jourdan2017simple.pdf
C is a "simple" language in many senses of the word, but it has a lot of complex details. It's fine to gloss over them when writing a compiler for a language very similar to C for learning purposes, but getting everything just right for actual C is tough.
All of these design warts of C show up clearly when attempting to write a compiler and are not very obvious to most users of the language.
C++, on the other hand, is definitely far harder to parse, especially if you include things like templates.
Neither have the concept of booleans, but use the idea that a computation can succeed and return a result or fail and return no result. This leads to making certain kinds of expressions that normally require booleans and the associated logical operators to be simpler and more clear, as below:
if a < b && b < c && c < d then ...
compared with
if a < b < c < d then ....
Which is easier to mentally parse?
if a := read() then write(a)
which would translate into something like the following Go code: a, err := read()
if err == nil {
write(a)
}write(a := read())
where write will not be executed if read fails, nor will a have any value assigned to it. Failure is an option and not an error.
So to copy standard input to standard output, we do
every write(read())
At the end of file, the read fails, the write fails and then the every fails and on you go.
"A Retargetable C Compiler: Design and Implementation", from David R. Hanson and Christopher W. Fraser
"Compiler Design in C", from by Allen I. Holub
A bit old, but there are not significant changes to C since they were written, other than optimizers taking advantage of UB.
Really? There are much better choices available in 2017. ANTRL 4 leaps to mind which has been in development since 1989.
I don't know of any "real" language written in C or C++ that uses ANTLR.
The state of the art for production languages in C/C++ is either hand-written parsers or yacc/bison. I've never even seen flex used, and I've looked at over 20 lexer/parser implementations for "real" languages.
You may be thinking of ANTRL 3. I really didn't much care for the ANTLR 3 C target but I use ANTRL 4 and its C target. It is really much much better than ANTRL 3. TParr says ANTLR 4 is the parser generator he always wanted to make.
However, I'm not still pitching ANTRL as a parser for a real compiler. ANTRL rules the world for little languages, domain specific languages but a production language would almost certainly would use a hand crafted recursive decent compiler.
This is a learning project. So I'd strongly recommend anyone learn modern tools and methods and learn those. bison is C++ native with experimental Java support and nothing else. flex is C and C++ only. Not a fan.
I still enjoyed reading it though.
EDIT:
I've the recursive descent parsing article on wikipedia, and I'm amazed how the C code example is missing parts... Kinda sad.
As long as I can compile a language in C, it removes a lot of work.
Also C compilers can do a lot of micro optimizations that are not trivial, so compiling something to C or maybe LLIR seems like a better choice.
The first issue is the impedance mismatch between the original language semantics and what is possible to represent in C.
Two examples are tail calls and continuations, that are hard to get right at C level, either requiring compiler extensions, longjmp/setjmp tricks, or a little bit of assembly.
Then there is the whole issue with undefined behaviour in C, introducing bugs that didn't exist on the original code.
Even if the generated code is clean today regarding UB, you cannot ensure that a new C compiler version won't be introducing such issues.
Having said this, there are languages like Eiffel and Nim, whose main backend relies on C compilers, instead of compiling directly to pure native code.
Skipping Objective-C and C++ here, because although they started as C pre-processors, their code was to be an extension to C and so their semantics also imply C semantics.
Compiling to some kind of IR, like LLIR is more sane.
Then you can do whatever you want with it, generate code in another programming language, machine code, or a plain interpreter for bootstrapping purposes.
> Even if the generated code is clean today regarding UB, you cannot ensure that a new C compiler version won't be introducing such issues.
This is not correct. Undefined behaviour is specified in the standard, and a good compiler-to-C will always generate UB-free code.
If your code avoids UB behavior as specified by the standard it will run in the same way when compiled with any standard-compliant compiler.
What gets documented on the standard is what compiler vendors that bother to seat at ANSI C meetings agree to document as such.
By no means does the standard forbid compiler vendors to take advantage of situations not yet documented as such.
Eventually new cases of UB might get added to the standard.
The current set of about 200 scenarios of UB in C11 weren't all there in C89.
Another example, is that you can have code that is perfectly valid, UB free, but thanks to PGO and code re-writting rules on the optimizer stage, gets rewritten with UB side effects at a later stage in the optimizer pipeline.