Scheme Compiler in Incremental Steps: Compiling Integers

Sasank Chilamkurthy | | 6 minutes to read.

I somehow came to love compilers. May be it was my experience with PyTorch - after all it is some sort of interpreter/compiler combo which executed python code on GPUs. I figured that compilers are an underappreciated part of what makes computers tick. Compilers enabled first human-computer interface: humans can now talk to computers in a more intelligible language than 0/1s.

Compilers are considered arcane pieces of software. They are purely symbolic pieces of software which translate programs from one language to another. Usually translation happens from higher level languages like C++ to lower level assembly language but this doesn’t have to be the case. What makes compilers so hard is that you have to preserve the semantics of the program between source and target languages.

Compilers are usually written in passes. What this means is that each pass of the compiler transforms programs into a representation that is input to next pass. It looks something like this:

; passes are functions that take in a representation
; and return another representations
(define compiler-passes
  (list lexical-analysis syntax-analysis 
        semantic-analysis dead-code-removal code-generation))

(define (compile-program program)
    (compile-with-passes program compiler-passes))

(define (compile-with-passes program-representation passes)
    (if (null? passes)
        program-representation
        (compile-with-passes
            ((car passes) program-representation)
            (cdr passes))))

These are common passes but there can be a lot of passes or very few passes. Nano pass framework takes above to extreme by creating a lot of ultra small passes (in the order of 50 passes).

However modular this makes the compiler construction, such frameworks are a terrible way to teach how compilers work. This is because you never know see the working compiler until you wrote all the passes. Alternative approach to teach compilers is presented in “An Incremental Approach to Compiler Construction” by Abdulaziz Ghuloum. In this approach, we incrementally compile features of the language starting from the simplest expressions to more complicated ones. We get sense of achievement at every step!

In this tutorial, we follow this approach to compile a subset of scheme. We will also use scheme as our implementation language.

Compiling Integers

Let’s start with integers or fixnums. We’ll take help from gcc to find the relevant assembly code.

// test.c
int scheme_entry(){
    return 42;
}
$ gcc -O3 --omit-frame-pointer -S test.c
$ cat test.s
	.file	"test.c"
	.text
	.p2align 4
	.globl	scheme_entry
	.type	scheme_entry, @function
scheme_entry:
.LFB0:
	.cfi_startproc
	movl	$42, %eax
	ret
	.cfi_endproc
.LFE0:
	.size	scheme_entry, .-scheme_entry
	.ident	"GCC: (Debian 12.2.0-14) 12.2.0"
	.section	.note.GNU-stack,"",@progbits

In the assembly code, you can see scheme_entry is compiled to following two instructions:

movl	$42, %eax
ret

Rest of the assembly code is boilerplate. The register %eax serves as the return value register. Simple to emit this in scheme:

(define (compile-program x)
    (emit "movl $~a, %eax" x)
    (emit "ret"))

Linker and Runtime

To execute this we need to create linker or runtime that takes above instructions and runs it. To do that let’s create a runtime.c that takes in a scheme_entry function and runs it:

//runtime.c
#include <stdio.h>

int main(int argc, char** argv){
    printf("%d\n", scheme_entry());
    return 0;
}

Now we need to create assembly file containing scheme_entry function. Then that assembly file needs to be linked with above runtime. We’ll then get executable which can be run. Following scheme script does the job:

; linker.scm
(load "./compiler.scm")
(use-modules (ice-9 textual-ports))

(define output-file-param (make-parameter #f))

(define (emit format-string . args)
  ; Format the assembly code using format
  (apply format (output-file-param) format-string args)
  (newline (output-file-param)))

(define (build x)
    (define output-file (open-output-file "/tmp/scheme_entry.s"))
    (output-file-param output-file)
    (display (string-append 
        ".text\n"
        ".p2align 4\n"
        ".globl	scheme_entry\n"        
        ".type	scheme_entry, @function\n"
        "scheme_entry:\n")
        output-file)

    (compile-program x)

    (display (string-append 
        ".LFE0:\n"
        ".size	scheme_entry, .-scheme_entry\n"
        ".section	.note.GNU-stack,\"\",@progbits\n")
        output-file)
    
    (close-output-port output-file))

(define (run x)
    (build x)
    (system "gcc -w runtime.c /tmp/scheme_entry.s -o /tmp/scheme_entry")
    (system "/tmp/scheme_entry > /tmp/scheme_entry.out")
    (call-with-input-file "/tmp/scheme_entry.out"
        get-string-all))

To run this:

$ guile 
GNU Guile 3.0.8
scheme@(guile-user)> (load "linker.scm")
scheme@(guile-user)> (run 2)
$1 = "2\n"

Testing

Since we’re adding features incrementally, we want to write tests to ensure that later features don’t break the already written code. run function from linker.scm returns the output of the linked executable. We just have to compare this to expected result for a set of known cases. Here’s a simple test driver:

; tests.scm

(load "linker.scm")

(define (run-test expr expected-result)
    (let ((result (run expr)))
        (if (string=? result expected-result)
            (begin (display expr)
                (display ": passed\n"))
            (error 'failed expr))))

; integers
(run-test 0 "0\n")
(run-test 1 "1\n")
(run-test -1 "-1\n")
(run-test 10 "10\n")
(run-test -10 "-10\n")
(run-test 2736 "2736\n")
(run-test -2736 "-2736\n")
(run-test 536870911 "536870911\n")
(run-test -536870912 "-536870912\n")

Execute these tests with

$ guile tests.scm 
0: passed
1: passed
-1: passed
10: passed
-10: passed
2736: passed
-2736: passed
536870911: passed
-536870912: passed

And that’s all for today folks. We have generated assembly instructions for compiling a integer, linked to a simple runtime and ran it. In the next part, we’ll continue with compiling constants and other features.

Working code at the end of this step can be found at my Github repo with tag step_1_integers.