Translators Everywhere All at Once
Is CIL an assembly language? Is .NET code still managed after AOT compilation? Translator, compiler, interpreter, assembler, transpiler, JIT, AOT and managed often get mixed up. What does each one actually mean?
The forms between source code and machine code
Between high-level language, assembly language and machine language there are more forms. Follow x = a + b down the chain:
- source code (also high-level code): the text a person writes,
x = a + b - compiler IR, such as LLVM IR, rustc's MIR or GCC's GIMPLE: the compiler's own internal form,
%x = add nsw i32 %a, %b. Most optimization happens here - assembly (asm, file extension .s): one CPU instruction per line,
add eax, ebx. It only works for one CPU family - object code (.o or .obj): what the assembler produces. It is already machine code, but addresses of functions in other files are still placeholders
- machine code (also native code): the bytes after the linker fills in those addresses,
01 d8. The CPU runs these directly
Bytecode is not on that line. Once source is compiled to bytecode, it goes to a virtual machine and never passes through assembly or object code. CPython's BINARY_OP +, the JVM's iadd and CIL's add are all instructions for a VM, not for any CPU. They are stored as .pyc, .class and .dll files; .NET's bytecode is called CIL, also IL or MSIL. An interpreter executes them one at a time, and a JIT compiles the most frequently run parts into machine code. Only at that point does the code rejoin the line above, at its last stop.
Object code only exists in toolchains that have a linker. C, C++, Rust and Go produce one .o per file or package and hand them to the linker. Java's .class and C#'s .dll hold bytecode and have no linker step; the class loader or the CLR resolves names when it loads them. Python and JavaScript have no such layer at all. Machine code from a JIT goes straight into memory and is never written out as a .o.
What counts as IR
IR (intermediate representation) is any form that sits between what a person writes and what the CPU runs:
IR (the category)
├─ MIR rustc's own layer, before LLVM IR. The borrow checker runs here
├─ LLVM IR produced by clang and rustc. Typed, not tied to a CPU, usually never saved to disk
├─ GIMPLE gcc's internal form
├─ Java bytecode .class files from javac, read by the JVM
├─ CIL produced by Roslyn, stored in .dll files, read by the CLR. Also called IL or MSIL
└─ assembly text for the CPU, input to the assembler. Technically IR too, though rarely called that
Only assembly is tied to a CPU: x86 assembly is useless on ARM, while the others run anywhere. Each has a different reader: people and assemblers read assembly, VMs read bytecode and CIL, and LLVM IR exists for the compiler's optimization passes. LLVM IR still has types, and flags like nsw, which marks a signed addition that cannot overflow. JVM bytecode and CIL have types too (CPython's does not), but no flags like nsw for the optimizer. Assembly has no types at all, only registers and instructions. So LLVM IR is the highest level and assembly the lowest.
CIL is not tied to a CPU and is read by a VM, so it belongs with Java bytecode, not with assembly (someone asked this on Stack Overflow).
Which tools count as translators
A translator is any tool that converts code from one of those forms into another:
- preprocessor: text to text, such as C's
#includeand#define - compiler: one language into another, usually higher level into lower
- AOT compiler: gcc, rustc. Finishes before the program runs
- JIT compiler: V8's TurboFan, HotSpot's C2, the CLR's RyuJIT. Compiles while the program runs, and its input is bytecode rather than source
- transpiler: Babel, tsc. One high-level language into another, so the output is still text for people to read
- assembler: assembly into machine code
- interpreter: produces no new form and executes the code directly. Early BASIC and today's bash read source text line by line. CPython and the JVM read bytecode that was compiled beforehand
- linker: joins several .o files into one program without converting anything, so it is not counted among the translators even though every native toolchain has one
C's toolchain is preprocessor, compiler, assembler, linker. Other languages use fewer:
| Language | preprocessor | compiler | assembler | linker | At run time |
|---|---|---|---|---|---|
| C, C++ | yes | gcc, clang, MSVC | gcc calls as; clang uses LLVM's built-in one | yes | machine code runs directly |
| Rust | none; macros expand inside rustc | rustc, via LLVM | built into LLVM | yes | runs directly |
| assembly | - | - | yes | yes | runs directly |
| Java | - | javac → .class | - | - | HotSpot interprets, then JITs |
| C# | - | Roslyn → IL | - | - | CLR JIT, or Native AOT |
| Python | - | built in, source → .pyc | - | - | CPython interprets; JIT optional from 3.13 |
| JavaScript | - | built into the engine, source → bytecode | - | - | V8 interprets, then JITs |
Interpreting, JIT and AOT: when does compilation happen
AOT (ahead of time) means machine code is ready before the program starts. C, Rust and Go work this way. JIT (just in time) means machine code is produced while the program runs. Take this foo.py, which calls a small function ten thousand times:
def add(a, b):
return a + b
total = 0
for i in range(10_000):
total = add(total, i)
print(total)Running python foo.py goes like this:
start
→ compile all of foo.py into bytecode (done once; the script run directly is not saved as .pyc, imported modules are)
→ interpreter reads and executes bytecode one instruction at a time ← "interpreting"
→ (if JIT is on) once a piece of bytecode has run enough times, turn it into micro-ops
→ compile those micro-ops into machine code ← "JIT"
→ from then on, that piece runs the machine code; everything else is still interpreted
end
The first step compiles to bytecode. It happens before execution and covers the whole file at once. Interpreting translates nothing: CPython's interpreter is a large loop written in C and already compiled, and when it reads BINARY_OP + it jumps to the C code that adds two Python objects, then reads the next instruction. The JIT step does translate, into machine code, and only for code that has run many times. Once it sees a + b run thousands of times with integers on both sides every time, it generates a small block of machine code specialised for that type.
That first step also happens "at run time" in a literal sense. Nobody calls it JIT, though, because its output is bytecode the CPU cannot run. It is the same kind of step as javac turning .java into .class. In common usage, JIT always means compiling to machine code.
JIT is a feature of a runtime, not of a language. V8 and HotSpot turn it on by default. CPython has offered it since 3.13, still off by default in 3.15. iOS forbids generating executable code at run time, so .NET on iOS can only use AOT. A JIT also needs repetition before it pays off. The same Node program, measured with JIT on and with --jitless, runs equally fast when run once, and 1.23 times faster with JIT when run 300 times.
Some languages have one mainstream approach, others have both:
| Language | Why | |
|---|---|---|
| AOT only | C, C++, Rust, Go, Swift | Every type is known at compile time, so a JIT would learn nothing new at run time |
| JIT or interpreter only | JavaScript in the browser | The code arrives over the network while the page runs, so there is no "before" to compile in |
| Both | Java, C#, Python, Dart | HotSpot is JIT, GraalVM Native Image is AOT. The CLR is JIT, Native AOT is AOT. CPython interprets, Nuitka compiles AOT. Dart uses JIT during development and AOT for release |
Who frees memory: managed and unmanaged
This has nothing to do with the forms above; it is about who frees memory:
- the runtime, using a GC (garbage collector). The program allocates and never frees. Java, C#, Go and Python work this way
- the programmer, with C's
mallocandfree. Forget to free and memory leaks; free twice or too early and the program crashes - the compiler, as in Rust's ownership model. When a variable goes out of scope, the compiler inserts the code that frees it. There is no GC, and the programmer never writes
free
"Managed code" is a .NET term for code whose execution is managed by a runtime: the runtime compiles it to machine code and runs it, and also frees memory and enforces type safety, such as null and bounds checks. C, C++ and Rust are unmanaged. Python meets the definition too, but nobody calls it managed.
Managed code in .NET and Java happens to have a GC, run on a VM and be JIT compiled, all at once. So the three often get treated as one property, and to assume that compiling C# ahead of time to machine code makes it unmanaged. They are independent:
- managed or not depends on whether a runtime manages execution; GC is one of its services
- AOT or JIT is about when machine code is produced
- whether there is a VM is another matter: Java has one, Go does not
Go has a GC, no VM, and is compiled AOT. Native AOT C# is the same: it becomes machine code and is still managed.
File extensions
| Extension | Contents | Produced by | At run time | Managed |
|---|---|---|---|---|
| .c .cc .rs .go .java .cs .py .js | source code | a person | see below | see below |
| .s .asm | assembly | gcc -S, or written by hand | not run; assembled first | |
| .o .obj | object code | assembler | not run; linked first | unmanaged |
| .so .dylib, C++ .dll | machine code, dynamically linked | linker | loaded at run time, AOT | unmanaged |
| ELF, Mach-O, C .exe | machine code | linker | runs directly, AOT | unmanaged |
| .pyc | Python bytecode | CPython, automatically | interpreted; JIT available since 3.13 | has a GC, not called managed |
| .class .jar | Java bytecode | javac; a .jar is a zip of them | JVM interprets, then JITs | managed |
| .dex | Android bytecode | d8 | ART interprets, then JITs | managed |
| .NET .dll .exe | CIL plus metadata | Roslyn | CLR JIT; a Native AOT .exe is pure machine code | managed |
| .wasm | WebAssembly bytecode | emcc, rustc, Go | JIT in the browser or wasmtime | depends on the language; Rust output has no GC |
A .dll or .exe built by a C++ compiler holds machine code; one built from C# by Roslyn holds CIL. The file command tells them apart. It ignores the extension and reads the first few bytes of the file, then looks them up in a table of known formats:
$ file translators-lite.en.md
translators-lite.en.md: Unicode text, UTF-8 text
$ file /bin/ls
/bin/ls: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64e:Mach-O 64-bit executable arm64e]
$ file UnityPlayer.dll
UnityPlayer.dll: PE32+ executable (DLL) (console) x86-64, for MS Windows
Both kinds of .dll use the PE format and start with the two bytes MZ. A .NET one also carries a CLR header, and file adds Mono/.Net assembly to its output. A C++ one has no such header, so its line ends at x86-64, like UnityPlayer.dll above.
Terms with two meanings
- assembly: assembly language, or the .dll/.exe file in .NET. The .NET kind holds CIL and metadata and has nothing to do with assembly language. The shared name is a coincidence
- binary: in this post, any file that is not plain text. Machine code is binary, and so are .pyc and .class files, which are not machine code. The Java specification calls a .class file the program's binary
- IL, CIL, MSIL: three names for .NET's bytecode. IR is the category; LLVM IR, bytecode and CIL are members of it
- AOT and JIT in frontend frameworks: in Vue and Angular these refer to when templates are compiled into render functions. At build time is AOT, in the browser is JIT. Either way the output is JavaScript, which V8 then interprets and JITs on its own terms.
npm run buildusually transpiles, bundles and minifies; Angular is the exception, whereng buildhas compiled templates AOT by default since Angular 9 - native code: sometimes a synonym for machine code, sometimes a synonym for unmanaged code. The second meaning only comes up when contrasting managed and unmanaged, where C and C++ are the "native" side
The top answer to this Stack Overflow question shows a Visual Studio Disassembly window: a C# hello world after the JIT has compiled it. Each line has three columns:
00000003 E8 30 BE 03 6F call 6F03BE38 ; Console.Out getter
0000000a 8B 15 88 20 BD 02 mov edx, [02BD2088h]
The first column is the address. The second is machine code, the bytes the CPU executes; 8B is MOV. The third is the same instruction as assembly, one line per instruction. The CLR's JIT compiled this from CIL. That makes it machine code, which is native in the first sense. The GC still manages it, which makes it managed and not native in the second sense.
What equals what
- IL = CIL = MSIL, .NET's intermediate form
- LLVM IR, bytecode and assembly are all kinds of IR, but bytecode ≠ assembly. Assembly is tied to a CPU and read by an assembler; bytecode runs on any CPU and is read by a VM
- CIL and Java bytecode belong to the same group; the CLR reads one, the JVM the other
- .NET assembly ≠ assembly language
- assembly ≠ what the assembler produces; the assembler turns assembly into object code
- machine code is binary, and so are bytecode files. Calling something "compiled to a binary" does not mean it is machine code: the Java specification calls a .class file a binary, and it holds bytecode
- machine code = native code in its first sense; the second sense means unmanaged
- managed ≠ JIT. Go and Native AOT C# have a GC and no JIT
- the JVM and the CLR do the same job. Each loads bytecode files, compiles them to machine code and runs them, and leaves memory to a GC. The difference is that the JVM interprets first and sends frequently called methods to a JIT, while the CLR has no interpreter: a method is compiled by the quick JIT on its first call and recompiled by the optimizing JIT once it runs often. The JVM reads Java bytecode from .class files, and the CLR reads CIL from .dll files
Further reading
- 一點都不深入的了解 Compiler、Interpreter 和 VM (in Chinese)
- What is the difference between native code, machine code and assembly code?
- How Unity Supports Cross-Platform Feature
- See What Compiler Optimization Do From LLVM IR
- Just-in-Time (JIT) and Ahead-of-Time (AOT) Compilation in Angular
- Angular AOT vs JIT Comparison
- The .NET Ecosystem: Dive Into Runtime Tools and Languages
- Mono,CLR,.net,Net Framework之间的关系 (in Chinese)