Backend Development 12 min read

Compiling Kotlin to Jack Bytecode: Adding a Jack Target to the Kotlin Compiler

The article shows how to extend the Kotlin compiler with a new Jack target by reusing its front‑end, adding a CLI module and a backend that transforms Kotlin IR into Jack bytecode, then running the generated .jack files on the Nand2Tetris virtual machine.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Compiling Kotlin to Jack Bytecode: Adding a Jack Target to the Kotlin Compiler

This article explains how to extend the Kotlin compiler with a new target that generates Jack bytecode, using the Jack language and virtual machine introduced in the Nand2Tetris project.

Jack Language and Bytecode

Jack is a simple, object‑oriented, weakly typed language whose programs start from a main function inside a Main class. A minimal example is:

class Main {

function void main() {
  do Output.printInt(1 + (2 * 3));
  return;
}
}

The corresponding Jack VM commands (stack‑based) are:

function Main.main 0
push constant 1
push constant 2
push constant 3
call Math.multiply 2
add
call Output.printInt 1
pop temp 0
push constant 0
return

The VM supports four command categories: arithmetic, memory access, program flow, and function call.

Kotlin → Jack Compilation Flow

To add a Jack target, the Kotlin compiler’s front‑end can be reused; the main work is converting the intermediate representation (IR) to Jack bytecode. The required steps are:

Add a Kotlin/Jack standard library (e.g., an Output class).

Create a cli-jack module that parses command‑line arguments and invokes the backend.

Create a backend-jack module that transforms IR into Jack bytecode.

cli‑jack Module

The entry point is a class extending CLICompiler . When kotlinc is invoked, the org.jetbrains.kotlin.preloading.Preloader loads the appropriate target class.

class K2JackCompiler : CLICompiler
() {
    override val defaultPerformanceManager = K2JSCompilerPerformanceManager()
    override fun createMetadataVersion(versionArray: IntArray): BinaryVersion = KlibMetadataVersion(*versionArray)
    override fun createArguments(): K2JSCompilerArguments = K2JSCompilerArguments()
    override fun executableScriptFileName(): String = "kotlinc-jack"
    override fun MutableList
.addPlatformOptions(arguments: K2JSCompilerArguments) { }
    override fun doExecute(
        arguments: K2JSCompilerArguments,
        configuration: CompilerConfiguration,
        rootDisposable: Disposable,
        paths: KotlinPaths?
    ): ExitCode {
        // 1. parse CLI args
        val outputDirPath = arguments.outputDir ?: return COMPILATION_ERROR
        val outputName = arguments.moduleName ?: return COMPILATION_ERROR
        // 2. generate FIR
        val firOutput = compileModulesToAnalyzedFirWithLightTree(...)
        // 3. FIR → IR
        val fir2IrActualizedResult = transformFirToIr(...)
        // 4. IR → Jack bytecode
        IrModuleToJackTransformer().generateCode(fir2IrActualizedResult.irModuleFragment, outputDirPath, outputName)
        return OK
    }
}

backend‑jack Module

The backend implements the IR‑to‑Jack transformation using a visitor pattern. Key classes include:

class IrModuleToJackTransformer {
    fun generateCode(irModule: IrModuleFragment, outputDirPath: String, outputName: String) {
        irModule.files.forEach { file ->
            val context = JackGenerationContext(outputDirPath, outputName)
            file.accept(IrFileToJackTransformer(), data = context)
        }
    }
}

class IrFileToJackTransformer : BaseIrElementToJackTransformer() {
    override fun visitFile(declaration: IrFile, context: JackGenerationContext) {
        super.visitFile(declaration, context)
        declaration.declarations.forEach { it.accept(IrDeclarationToJackTransformer(), context) }
    }
}
// ... other visitors for expressions, statements, etc.

Running the Compiler

After building the cli-jack and backend-jack modules, the compiler can be executed via the preloader:

package org.jetbrains.kotlin.preloading;

@SuppressWarnings("UseOfSystemOutOrSystemErr")
public class Preloader {
    public static void main(String[] args) throws Exception {
        try {
            String[] testArgs = {
                "-cp", "./dist/kotlinc/lib/kotlin-compiler.jar",
                "org.jetbrains.kotlin.cli.jack.K2JackCompiler",
                "-libraries", "./dist/kotlinc/lib/kotlin-stdlib-js.klib",
                "-ir-output-dir", "/path/to/out",
                "-ir-output-name", "FibRecursive",
                "/path/to/FibRecursive.kt"
            };
            run(testArgs);
        } catch (PreloaderException e) {
            System.err.println("error: " + e.toString());
        }
    }
}

The generated .jack files can be executed on the online Nand2Tetris VM ( https://nand2tetris.github.io/web-ide/vm ) to verify correctness.

Conclusion

The article demonstrates that, thanks to Kotlin’s layered architecture, adding a new compilation target is straightforward: implement a backend module that translates IR to the desired bytecode and wire it through a CLI module. The example shows a complete flow from Kotlin source to Jack bytecode, including language basics, VM command set, and practical compiler code.

KotlinVirtual Machinecompiler backendIR transformationJack bytecodemultiplatform target
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.