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:

first.c#
 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:

bash#
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:

first.c#
// 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:

first.c#
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:

first.c#
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:

driver.c#
#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;
}
bash#
$ 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:

first.c#
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#

first.c#
 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}
bash#
$ gcc first.c -lunit -o first
$ ./first
7
30

Next steps#

Now that you know the basics, try these: