Compiling a simple program#
Compiling a procedure#
Now that we have a working UNIT_Procedure type, let’s compile it to machine
code!
There is a single function to compile a procedure, called UNIT_Compile().
It takes two arguments:
A pointer to the procedure we want to compile.
The target platform – a combination of the architecture and ABI.
The current platform is accessible via the UNIT_HOST_PLATFORM macro.
Currently, UNIT only supports the AMD64 architecture, so let’s pass
UNIT_HOST_PLATFORM.
Note
AMD64 has many different names. You might be used to reading it as “x86-64”, “x64”, or “Intel 64”.
UNIT_Compile() returns a heap-allocated UNIT_CompiledProcedure,
or NULL on failure. When we’re done with it, we need to free it with
UNIT_CompiledProcedure_Free().
Now, our code looks like this:
1 #include <unit/unit.h>
2 #include <stdio.h>
3
4 int main(void)
5 {
6 UNIT_Context context;
7 if (UNIT_FAILED(UNIT_Context_Init(&context))) {
8 return 1;
9 }
10
11 UNIT_Procedure procedure;
12 if (UNIT_FAILED(UNIT_Procedure_Init(&procedure, &context, "main"))) {
13 UNIT_PrintError(&context, stderr);
14 UNIT_Context_Clear(&context);
15 return 1;
16 }
17
18 #define ADDOP_INT(op, value) \
19 if (UNIT_FAILED(UNIT_Procedure_AddOperation(&procedure, op, value))) { \
20 UNIT_PrintError(&context, stderr); \
21 UNIT_Procedure_Clear(&procedure); \
22 UNIT_Context_Clear(&context); \
23 return 1; \
24 }
25
26 #define ADDOP(op) ADDOP_INT(op, 0)
27
28 ADDOP_INT(UNIT_OP_LOAD_INTEGER, 0);
29 ADDOP(UNIT_OP_RETURN_VALUE);
30
31 #undef ADDOP_INT
32 #undef ADDOP
33
34 UNIT_CompiledProcedure *compiled = UNIT_Compile(&procedure, UNIT_HOST_PLATFORM);
35 if (compiled == NULL) {
36 UNIT_PrintError(&context, stderr);
37 UNIT_Procedure_Clear(&procedure);
38 UNIT_Context_Clear(&context);
39 return 1;
40 }
41
42 // We will use the compiled procedure in a moment.
43
44 UNIT_CompiledProcedure_Free(compiled);
45 UNIT_Procedure_Clear(&procedure);
46 UNIT_Context_Clear(&context);
47 return 0;
48 }
Writing an object file#
The most straightforward way to use the compiled procedure is to write it
to an object file. For this, we can use
UNIT_CompiledProcedure_WriteObjectFile(). We need to pass the format
that the object file will be stored in. For Linux, this is ELF, so we pass
UNIT_FORMAT_ELF.
Note
Windows uses the Portable Executable (PE) format (UNIT_FORMAT_PE),
and macOS uses the Mach Object (Mach-O) format (UNIT_FORMAT_MACHO).
UNIT does not support either of these at the moment; trying to pass them to
UNIT_CompiledProcedure_WriteObjectFile() will result in an error
being set at runtime.
if (UNIT_FAILED(UNIT_CompiledProcedure_WriteObjectFile(compiled, "output.o",
UNIT_FORMAT_ELF))) {
UNIT_PrintError(&context, stderr);
UNIT_CompiledProcedure_Free(compiled);
UNIT_Procedure_Clear(&procedure);
UNIT_Context_Clear(&context);
return 1;
}
Build and run the compiler, then link the output:
gcc main.c -lunit -o guessing_game
./guessing_game
gcc output.o -o output
./output
echo $?
The last command prints 0 – our procedure returned successfully!
Now, before we start building things, let’s go over some other important information about compilation.
JIT compilation#
Writing an object file and linking it is useful for ahead-of-time compilation, but sometimes you want to compile and run code immediately in the same process. This is called JIT (Just-In-Time) compilation.
UNIT_CompiledProcedure_JIT() maps the compiled machine code into
executable memory in a type known as UNIT_ExecutableBuffer.
We can get a pointer to the executable memory through
UNIT_ExecutableBuffer_GetPointer().
Like with UNIT_CompiledProcedure, a UNIT_ExecutableBuffer is
heap-allocated memory and must be freed later (via UNIT_ExecutableBuffer_Free()).
UNIT_ExecutableBuffer *buf = UNIT_CompiledProcedure_JIT(compiled, NULL /* more on this in a moment */);
if (buf == NULL) {
UNIT_PrintError(&context, stderr);
UNIT_CompiledProcedure_Free(compiled);
UNIT_Procedure_Clear(&procedure);
UNIT_Context_Clear(&context);
return 1;
}
// Cast the raw pointer to a function pointer and call it
int64_t (*my_main)(void) = (int64_t (*)(void))UNIT_ExecutableBuffer_GetPointer(buf);
int64_t result = my_main();
printf("returned: %ld\n", result); // returned: 0
UNIT_ExecutableBuffer_Free(buf);
The second argument to UNIT_CompiledProcedure_JIT() is a
UNIT_SymbolMap for resolving external function names. We pass
NULL here because our procedure doesn’t call any external functions yet.
We will need it later when we call printf and scanf.
Optimization#
Before compiling, you can also run optimization passes on the procedure with
UNIT_Procedure_Optimize():
if (UNIT_FAILED(UNIT_Procedure_Optimize(&procedure))) {
/* ... */
}
UNIT_CompiledProcedure *compiled = UNIT_Compile(&procedure, UNIT_HOST_PLATFORM);
This will modify the procedure’s instructions to generally make it more efficient. Optimization is a one-way street; an optimized procedure cannot be “unoptimized”.
Optimization is optional but recommended. Note that UNIT runs two optimization passes.
This one is only on the stack IR, but during compilation, there’s a second, more simple
pass on the translated register IR. This second optimization pass is enabled by default;
it can be disabled by setting UNIT_FLAG_NO_OPTIMIZE_TRANSLATION on the procedure.
For our simple “return 0” procedure, optimization has no effect, but as we add more instructions, it will make a noticeable difference.
Debugging stack errors#
When using UNIT, you’ll likely run into an error like this at some point:
[INVALID USAGE] stack underflow at SOME_INSTRUCTION
Or this:
[INVALID USAGE] procedure did not consume entire stack
This means that there is a stack-effect error somewhere in your IR.
To debug this, we can use a function called UNIT_Procedure_PrintInstructions(),
which prints all the instructions in a procedure alongside a simulated stack
state after each instruction. This is very helpful for visualizing what your
IR is doing at translation time, and often makes it very easy to determine
what is wrong with your IR.
It can be used like this:
UNIT_Procedure_PrintInstructions(&procedure, stdout, /*visualize_stack_effect=*/1);
Output:
procedure "main":
0 LOAD_INTEGER 0
[0]
1 RETURN_VALUE
[]
Debugging logical errors#
For debugging logical errors in your IR, another helpful function is
UNIT_CompiledProcedure_PrintTranslatedIR(), which prints the translated
register machine IR. For many people, this can be easier to read than the stack
machine IR, because it resembles an actual programming language and also because
it’s closer to what your CPU actually executes.
It can be used like this:
UNIT_CompiledProcedure_PrintTranslatedIR(compiled, stdout);
Sample output:
translation for "main":
block 0
RETURN_VALUE(0)
block 1
Hint
During translation, UNIT splits up your code into blocks of linear control flow (also known as a basic block) for the sake of optimization and register allocation. Blocks will be split at jumps and at returns.
If the stack IR looks wrong, your instructions are wrong. If the stack IR looks right but the register IR looks wrong, you may have found a bug in UNIT – please file an issue!
Putting it together#
Here is the complete program so far. We use the object file approach for
the guessing game since it needs to be the real main function:
1 #include <unit/unit.h>
2 #include <stdio.h>
3
4 int main(void)
5 {
6 UNIT_Context context;
7 if (UNIT_FAILED(UNIT_Context_Init(&context))) {
8 fprintf(stderr, "failed to initialize context\n");
9 return 1;
10 }
11
12 UNIT_Procedure procedure;
13 if (UNIT_FAILED(UNIT_Procedure_Init(&procedure, &context, "main"))) {
14 UNIT_PrintError(&context, stderr);
15 UNIT_Context_Clear(&context);
16 return 1;
17 }
18
19 #define ADDOP_INT(op, value) \
20 if (UNIT_FAILED(UNIT_Procedure_AddOperation(&procedure, op, value))) { \
21 goto error; \
22 }
23
24 #define ADDOP(op) ADDOP_INT(op, 0)
25
26 ADDOP_INT(UNIT_OP_LOAD_INTEGER, 0);
27 ADDOP(UNIT_OP_RETURN_VALUE);
28
29 if (UNIT_FAILED(UNIT_Procedure_Optimize(&procedure))) {
30 goto error;
31 }
32
33 UNIT_CompiledProcedure *compiled = UNIT_Compile(&procedure, UNIT_HOST_PLATFORM);
34 if (compiled == NULL) {
35 goto error;
36 }
37
38 if (UNIT_FAILED(UNIT_CompiledProcedure_WriteObjectFile(compiled, "output.o",
39 UNIT_FORMAT_ELF))) {
40 UNIT_CompiledProcedure_Free(compiled);
41 goto error;
42 }
43
44 printf("Wrote output.o\n");
45
46 UNIT_CompiledProcedure_Free(compiled);
47 UNIT_Procedure_Clear(&procedure);
48 UNIT_Context_Clear(&context);
49 return 0;
50 error:
51 UNIT_PrintError(&context, stderr);
52 UNIT_Procedure_Clear(&procedure);
53 UNIT_Context_Clear(&context);
54 return 1;
55 }
gcc main.c -lunit -o guessing_game
./guessing_game
Wrote output.o
gcc output.o -o output
./output
echo $?
0
We now have a working compiler pipeline. In the next section, we will start building the actual guessing game by adding arithmetic, external function calls, and control flow.