r/ProgrammingLanguages • u/naharashu • 14d ago
Help How to make a compiler backend?
Hell everyone, i have an question. Im for long trying to make a cool, powerful "kinda" low level language similar to zig and rust, but im struggling to choice llvm as backend, sure i can generate C, but its makes compiler dependent on gcc or clang or other c compiler. LLVM seems hard to me, sure project like QBE exist, but QBE doesnt have C/C++ api like llvm's IRbuilder. So are there other ways? I tried thinking about using GCC infrastructure but GCC has poor api and not very documented api. Maybe just stick to generating C?
27
u/A1oso 14d ago
Transpiling to C has significant downsides:
- when debugging your program, you see the transpiled code rather than the actual source code
- C's semantics (e.g. undefined behavior) might leak into your language by accident
- some language features (e.g. garbage collection and tail calls) are very difficult to represent in C, but are easily supported in LLVM
- generating C code is error prone in subtle ways. Concatenating strings might seem easier at first, but at the cost of type safety and LLVM's many affordances.
I'm currently writing a compiler using LLVM, it isn't as difficult as you think. Once you understand the basic principles, it is pretty straightforward. And when you're stuck, you can ask an AI how to proceed, which I found very helpful.
I'm writing my compiler in Rust, using the wonderful inkwell bindings for LLVM. By the way, I can highly recommend writing the compiler in a language with sum types and pattern matching. It makes it easy to create an AST, transform it into another IR and implement different compiler passes.
10
u/TheChief275 14d ago
#line directives can be used to direct line source information towards another file.
A lot of C's undefined behavior can actually be defined (at the cost of performance obviously). One common source of undefined behavior is from signed integers overflowing. One thing you will probably want to do is to actually insert overflow checks, but when users disable that you probably still want this to be defined. This can be done by converting the integers to unsigned first and then performing the unsigned version of the operation, casting back to signed. This works because the operations are identical and unsigned overflow is actually defined in C to be wrapping. When signed overflow occurs, obviously the resulting number will still depend on whether the number is one's complement or two's complement or whatever other result, but the overflow checks should prevent most users from relying on any specific behavior.
If you would've depended on LLVM anyways, you can probably also depend specifically on Clang. Clang has the attribute "musttail" that forces a function to be tail recursive. Regarding GC, C isn't a necessarily more complex language to implement it in, granted that you will not be writing the C code yourself, only generating. C GCs are often implemented through scanning the stack, which can become fairly complicated under optimizations and force tons of usage of volatile to work correctly, or even worse a stack may not exist at all. The better behavior is just to insert the things that a language like Java does to get the GC to track roots. One thing is to just gather all pointers in a scope inside a single array in that scope, and then just push these artificial scopes. That way your GC will know all roots without relying on tricky, unreliable mechanisms.
This is too vague of a point to even tackle, but the most important part is to just treat C code as a backend like you would LLVM IR; generate SSA-like C code, no fancy expressions where any type of unintended conversion can happen, and all around just try to be as specific with types as you can possibly be.
4
u/SweetBabyAlaska 14d ago
the problem with LLVM is that it is massive and very slow. Ive been using Zig and it has its own backend for debug builds using the Aro C-compiler (as a library in Zig) to parse C code alongside Zig code and generate asm
9
u/koflerdavid 14d ago edited 13d ago
For a production grade compiler it's probably best to use LLVM or GCC as a backend. But writing a backend targeting assembly [edit:] is very educational. Best target a simpler architecture like RISC-V instead of x86, unless you are already familiar with x86 assembly.
Targeting C is also fine. There are C compilers for just about any architecture. You can also target Tiny C Compiler, which can be embedded into your compiler.
2
u/naharashu 13d ago
I wanted to embed libtcc into compiler but tcc is targeting C99, i need some of C11 features like _Thread_local
1
u/koflerdavid 13d ago
Ah, too bad. It's not a total roadblock though; you'd simply have to call native APIs then. That's surely more performant and less buggy than implementing your own thread locals.
1
u/naharashu 13d ago
What about libgccjit
1
u/koflerdavid 13d ago
That could be a solid option as well. But I know only that GNU Emacs uses it for JIT compilation.
3
u/GunpowderGuy 14d ago
What do You want to do with your low level language
1
u/Routine_Working_9754 10d ago
uhh...write low level code?
0
u/GunpowderGuy 9d ago
but what do you want to do with it, that you cant do with Zig, or F* or rust or C++
etc
2
u/Falcon731 14d ago
As long as you don't really care deeply about optimisation - then getting to assembly isn't that hard (especially if you are targeting RISC).
2
u/mamcx 14d ago
You question is more about which backend to use, and I think in 2026:
- C, C++, LLVM are beast, introduce a massive surface, direct and indirect complications and if you are not very well versed on them it will be hard.
But today are many alternatives worth considering:
Web Assembly: Very simple-ish, good tooling, decent perf
Any modern replacement for C/C++: Rust, Zig, Odin, Nim, Go, etc
This allow you to use something with much better ergonomics, tooling and more protection against complexity or weirdness of C/C++. The trick is to pick the one that is closer in semantics AND hopefully, more "powerful" that you need.
And alternative is to introduce an intermediate IR that will generate the final backend IR/Code so you have control and leisure to do whatever and even do suboptimal things until you finish later.
2
u/Express-Guest-1061 14d ago
I am also making a language. For me, it was easiest to first target WebAssembly binary, from scratch. Then I can add a pass from wasm-format to native whenever I want, but WebAssembly provides a good target for me now in the start. But it also depends on what you want to do.
1
u/hopeless__programmer 13d ago
Same, but I targeted WASM text format instead. You can also run it (WASM) in browser out of the box.
2
u/L8_4_Dinner (Ⓧ Ecstasy/XVM) 14d ago
There are a bunch of back end projects going on, in addition to the (literal) elephant in the room -- LLVM.
Cliff Click is doing Simple, for example: https://github.com/SeaOfNodes/Simple
1
u/quasar_tree 14d ago
If you're just doing this for fun/learning, generating asm is certainly very easy and straightforward, depending on how much low-level wizardry you want to expose in your language. If this is the case, don't overthink it!
If you're seriously trying to make a useful language, it depends on what exactly you want your language to do and we'd need more information to give you good advice
1
u/naharashu 14d ago
I want my language be like some kind of hybrid of zig, rust and betterC(D). I want to add something like exceptions, pointers, references, cffi, etc.
1
u/initial-algebra 14d ago edited 14d ago
Generating C code and invoking the C compiler as a separate process is the same way you use QBE, and LLVM can optionally be used that way as well.
1
u/zweiler1 🔥 Flint 14d ago edited 14d ago
I think it depends on the complexity of your language, how much time you want to invest and how much control over the runtime you want to have. If you land on the higher side for those questions, I would really recommend LLVM IR with the C API to you. If you land on the lower side, then C is maybe good enough for your needs.
I had no idea about LLVM IR or compilers in general when starting out with my language, and I must say after the initial few weeks I got the hang of it. The most complicated thing for me to understand were probably PHINodes but they really aren't hard at all, unlike I originally thought (but use select where possible).
If you decide to use LLVM IR, here are some recommendations from my side which would've helped me when starting out:
- If you want to statically link LLVM you need to build LLVM yourself and it will be a pain to manage all flags, but once you got it up and running it works fine. So I definitely would recommend to start out by linking LLVM dynamically.
- Only use one translation unit (
Module). While LLVM supports the ability to link multiple modules, it broke type names and other stuff on my side occasionally (assertions which should not have thrown, like in this issue) and those errors were nasty to track down, keep it simple - Make sure to have LLVM assertions turned on. I worked without them for a long time and debugging and finding out why something broke was a pain (it's a breeze once they are turned on)
- While debugging failing code, you can always call
dump()on basically any LLVMValue, it makes your life just that much easier (it prints the single IR code line of the SSA value which is very useful) - Make sure to verify the Module before compiling it, the verifyer is your friend
- Don't forget to set up the
TargetMachine. I basically just got lucky that it compiled and ran fine for a long time and only noticed I havent set it up brcause of weid bugs - It's not a shame to ask AI questions about LLVM API or IR code in general, just don't let it generate any code for you
I can recommend LLVM quite a lot. But if I would start over again I probably would not have chosen C++ for my compiler. The lack of pattern matching and first-class sum types (std::variant just doesn't hit the same) makes me crazy sometimes.
1
u/stumpychubbins 14d ago
Generating C is fine as a quick stop-gap solution but you should really be using llvm (via inkwell). Maybe cranelift, if you don’t mind worse-performing code, in my experience it’s easier to use.
1
u/Honest_Medium_2872 14d ago
The basic concept of a compiler backend is to abstract away the code generation phases so they can support many platforms / architectures.
You can use this to your advantage.
Model the backend like you would any compiler: Graph based structure and operations.
I say this because you do something like: AST -> CFG (Control Flow Graph) which is what a normal compiler would do.
Where, the overall CFG for your program is broken down into basic blocks, then those blocks are just sequences of instruction nodes that fork and join over the execution of your program.
You dont need complete SSA and dominance frontiers, postorder/reverse post order, etc.
What you do need is DAG (directed acyclic graph) that represents your parsed program.
When you get to creating instructions for your DAG, you can target C code generation as a proof of concept.
Effectively, converting the basic blocks to generated C code and each instruction or set of instruction converting to C statements.
Then you effectively have a transpiler from your source to C. This helps you build the foundations for a future code generation pass that allows you to compile directly to machine code.
It has the added benefit of being able to physically see how graph mutations and optimizations directly impact your code generation while you model it.
Here is a functional language i am working on for compiling UI descriptions to machine code using LLVM: https://github.com/s0cks/kura
Should help you ideate what a backend looks like.
Here is a clojure inspired language I am working on for embedding that compiles to its own bytecode (for now):
Here is a scala clone I worked on as a teen that emitted x86_64 machine code without LLVM that I abandoned because of life getting in the way:
1
u/Public_Grade_2145 13d ago
If you don't care optimisations, then emit assembly text is actually easier but still depending on external assembler. Mine emit assembly text directly from ANF IR, if I suuport x86, amd64, aarch64 and riscv64, then I will have four backends but of very thin one. Though it might felt repetitive.
1
1
u/SamG101_ 12d ago
I guess C is easier to compile too because you only have to know the general structure of C where-as with LLVM its a gigantic API to learn, but definitely worth it if you have the time. I'm currently writing a compiler to target LLVM and I spent a fair bit of time understanding why to do what examples do, but you'd get the hang of it after some time.
1
u/TownNew7018 8d ago
We're emitting to Zig (and JS). Zig is a really good compiler target for a number of reasons, including the build toolchain that it gives you access to. Our compiler toolchain is also implemented with Zig.
Pros of Zig is that you can do local imports, so you can organize your code into structs that import what they need without there being crashes between them. Things like that are really nice.
A potential con would be that Zig doesn't allow for variable shadowing, so you will probably have to mangle variables in some cases, and it is a little strict on actually using variables that are declared, but there is a nice pattern for codegen where you can "discard" a variable but still have it usable the rest of the scope; `_ = &variable_name`. If you're writing Zig directly you could argue this is a benefit. If you're using it as a compiler target, it makes some things more complex in general. Another potential con is that it is comparatively slow to compile.
1
u/Rechenplaner 8d ago
One generally has to accept that every solution brings disadvantages.
I have chosen WASM as my target language and will use WAMR to compile AOT into efficient binaries. That is not quite as efficient as a program written in C or translated directly with LLVM, but with appropriate optimization it reaches ninety percent of that performance, which is absolutely sufficient for almost all use cases.
After long consideration, I excluded LLVM because first, I do not feel like dealing with C++ and second, integrating LLVM is a life's work in itself. All professional compilers sooner or later end up statically linking LLVM directly themselves, which is an absolute no-go for me. Therefore, I had initially set C as the output of my compiler, but after hearing about WAMR and the possibilities of WASM, I have now pursued this target.
0
29
u/Aaxper 14d ago
Better than generating C is generating LLVM IR