Your first program with UNIT#
This page walks through writing, compiling, and running a simple program
using UNIT. By the end, you’ll have a compiled function that adds two
numbers – first as an object file linked with gcc, then as a
JIT-compiled function called directly from your program.
We’ll use C for this tutorial. If you prefer C++ or Python, see the bindings page for how the same code looks in those languages.
Setup#
Create a file called first.c. Every UNIT program starts with a context
and a procedure:
1#include <unit/unit.h>
2#include <stdio.h>
3
4int main(void)
5{
6 UNIT_Context ctx;
7 if (UNIT_FAILED(UNIT_Context_Init(&ctx))) {
8 fprintf(stderr, "failed to initialize context\n");
9 return 1;
10 }
11
12 UNIT_Procedure proc;
13 if (UNIT_FAILED(UNIT_Procedure_Init(&proc, &ctx, "add"))) {
14 UNIT_PrintError(&ctx, stderr);
15 UNIT_Context_Clear(&ctx);
16 return 1;
17 }
18
19 // We'll add instructions here.
20
21 UNIT_Procedure_Clear(&proc);
22 UNIT_Context_Clear(&ctx);
23 return 0;
24}
The UNIT_Context owns all memory. The UNIT_Procedure
holds the instructions for a single function. The name "add" becomes
the symbol name in the compiled output.
Build and run to make sure everything links:
gcc first.c -lunit -o first
./first
Emitting instructions#
UNIT uses a stack-based instruction set. If you’ve worked with a bytecode interpreter (Python, Java, WebAssembly), this will feel familiar. Values are pushed onto a stack, and instructions consume values from the top.
Our add function takes two arguments and returns their sum. That’s
three instructions:
// int64_t add(int64_t a, int64_t b) { return a + b; }
UNIT_Procedure_AddOperation(&proc, UNIT_OP_LOAD_ARGUMENT, 0); // push a
UNIT_Procedure_AddOperation(&proc, UNIT_OP_LOAD_ARGUMENT, 1); // push b
UNIT_Procedure_AddOperation(&proc, UNIT_OP_ADD, 0); // pop both, push a+b
UNIT_Procedure_AddOperation(&proc, UNIT_OP_RETURN_VALUE, 0); // pop and return
Attention
These functions usually need error handling, via UNIT_FAILED.
For example’s sake, it has been omitted for brevity.
After LOAD_ARGUMENT 0, the stack is [a]. After LOAD_ARGUMENT 1,
it’s [a, b]. ADD pops both and pushes the sum: [a+b].
RETURN_VALUE pops the result and returns it to the caller.
You can verify the instructions look correct by printing them:
UNIT_Procedure_PrintInstructions(&proc, stdout, /*visualize_stack_effect=*/1);
This prints each instruction alongside the stack state, which is helpful for debugging. If UNIT ever gives you an error during compilation complaining about an instruction, try printing all the instructions to visualize the error.
Compiling to an object file#
Now we compile the procedure and write it to an ELF object file:
UNIT_CompiledProcedure *compiled = UNIT_Compile(&proc, UNIT_HOST_PLATFORM);
if (compiled == NULL) {
UNIT_PrintError(&ctx, stderr);
UNIT_Procedure_Clear(&proc);
UNIT_Context_Clear(&ctx);
return 1;
}
UNIT_CompiledProcedure_WriteObjectFile(compiled, "add.o", UNIT_FORMAT_ELF);
UNIT_HOST_PLATFORM auto-detects your machine’s architecture and
ABI. It’s worth noting that UNIT will only work on x86-64 on ELF right now;
support for more architectures (notably AArch64) and other executable formats
(PE/COFF and Mach-O) will be added later.
In the above code, UNIT_Compile() translates the stack IR to register IR, runs
register allocation and optimization, and encodes the result as machine code.
Note
UNIT has two phases of optimization. One of them is done on the stack IR
(see UNIT_Procedure_Optimize()), and then second is done on the
translated IR.
To use the compiled function, write a small driver and link them together:
#include <stdio.h>
#include <stdint.h>
extern int64_t add(int64_t a, int64_t b);
int main(void)
{
printf("%ld\n", add(3, 4));
return 0;
}
$ gcc first.c -lunit -o first
$ ./first
$ gcc driver.c add.o -o driver
$ ./driver
7
JIT compilation#
Instead of writing an object file and linking separately, you can compile and call the function directly in memory:
UNIT_ExecutableBuffer *buf = UNIT_CompiledProcedure_JIT(compiled, NULL);
if (buf == NULL) {
UNIT_PrintError(&ctx, stderr);
UNIT_CompiledProcedure_Free(compiled);
UNIT_Procedure_Clear(&proc);
UNIT_Context_Clear(&ctx);
return 1;
}
// Cast the raw pointer to a function pointer
int64_t (*add)(int64_t, int64_t) = (int64_t (*)(int64_t, int64_t))UNIT_ExecutableBuffer_GetPointer(buf);
printf("%ld\n", add(3, 4)); // prints 7
UNIT_ExecutableBuffer_Free(buf);
The second argument to UNIT_CompiledProcedure_JIT() is a
UNIT_SymbolMap for custom symbol resolution. We pass NULL
here because add doesn’t call any external functions. See
Compilation for details on symbol maps.
Optimization#
UNIT includes optimization passes that can improve the generated code.
Call UNIT_Procedure_Optimize() before compiling:
UNIT_Procedure_Optimize(&proc);
UNIT_CompiledProcedure *compiled = UNIT_Compile(&proc, UNIT_HOST_PLATFORM);
For our simple add function, optimization won’t change anything. But,
for larger programs with constants, redundant loads, or inlineable function
calls, it makes a real difference.
Debugging#
When things go wrong, it helps to see what UNIT is doing. UNIT provides two functions to help with this.
First and foremost, UNIT_Procedure_PrintInstructions() prints all the instructions
in a procedure alongside a simulated stack state after each instruction.
It can be used like this:
UNIT_Procedure_PrintInstructions(&procedure, stdout, /*visualize_stack_effect=*/1);
Output:
procedure "add":
0 LOAD_ARGUMENT 0
[argument_0]
1 LOAD_ARGUMENT 1
[argument_0, argument_1]
2 ADD
[arithmetic_result]
3 RETURN_VALUE
[]
The other function is UNIT_CompiledProcedure_PrintTranslatedIR(),
which prints the translated register IR with allocated registers, which is
helpful for debugging logical errors in your IR.
Usage:
UNIT_CompiledProcedure_PrintTranslatedIR(compiled, stdout);
Output:
translation for "add":
block 0
register_0 = LOAD_ARGUMENT(0)
register_1 = LOAD_ARGUMENT(1)
register_2 = ADD(register_0, register_1)
RETURN_VALUE(register_2)
block 1
Complete program#
1#include <unit/unit.h>
2#include <stdio.h>
3
4int main(void)
5{
6 // Create a context
7 UNIT_Context ctx;
8 if (UNIT_FAILED(UNIT_Context_Init(&ctx))) {
9 fprintf(stderr, "failed to initialize context\n");
10 return 1;
11 }
12
13 // Create a procedure
14 UNIT_Procedure proc;
15 if (UNIT_FAILED(UNIT_Procedure_Init(&proc, &ctx, "add"))) {
16 UNIT_PrintError(&ctx, stderr);
17 UNIT_Context_Clear(&ctx);
18 return 1;
19 }
20
21 // Emit instructions: int64_t add(int64_t a, int64_t b) { return a + b; }
22 UNIT_Procedure_AddOperation(&proc, UNIT_OP_LOAD_ARGUMENT, 0);
23 UNIT_Procedure_AddOperation(&proc, UNIT_OP_LOAD_ARGUMENT, 1);
24 UNIT_Procedure_AddOperation(&proc, UNIT_OP_ADD, 0);
25 UNIT_Procedure_AddOperation(&proc, UNIT_OP_RETURN_VALUE, 0);
26
27 // Optimize
28 UNIT_Procedure_Optimize(&proc);
29
30 // Compile
31 UNIT_CompiledProcedure *compiled = UNIT_Compile(&proc, UNIT_HOST_PLATFORM);
32 if (compiled == NULL) {
33 UNIT_PrintError(&ctx, stderr);
34 UNIT_Procedure_Clear(&proc);
35 UNIT_Context_Clear(&ctx);
36 return 1;
37 }
38
39 // JIT and call
40 UNIT_ExecutableBuffer *buf = UNIT_CompiledProcedure_JIT(compiled, NULL);
41 if (buf == NULL) {
42 UNIT_PrintError(&ctx, stderr);
43 UNIT_CompiledProcedure_Free(compiled);
44 UNIT_Procedure_Clear(&proc);
45 UNIT_Context_Clear(&ctx);
46 return 1;
47 }
48
49 int64_t (*add)(int64_t, int64_t) =
50 (int64_t (*)(int64_t, int64_t))UNIT_ExecutableBuffer_GetPointer(buf);
51
52 printf("%ld\n", add(3, 4)); // 7
53 printf("%ld\n", add(10, 20)); // 30
54
55 // Clean up
56 UNIT_ExecutableBuffer_Free(buf);
57 UNIT_CompiledProcedure_Free(compiled);
58 UNIT_Procedure_Clear(&proc);
59 UNIT_Context_Clear(&ctx);
60 return 0;
61}
$ gcc first.c -lunit -o first
$ ./first
7
30
Next steps#
Now that you know the basics, try these:
Add more instructions. Use
UNIT_OP_MULTIPLYandUNIT_OP_SUBTRACTto build more complex expressions.Add control flow. Use
UNIT_Procedure_CreateJumpLabel(),UNIT_Procedure_UseLabel(), andUNIT_Procedure_AddJump()to implement conditionals and loops.Call external functions. Use
UNIT_Procedure_AddCallName()to callprintf,malloc, or any C function.Use local variables. Use
UNIT_Procedure_CreateLocal()andUNIT_OP_STORE_LOCALandUNIT_OP_LOAD_LOCALto store and read variables.