← Back to DevBytes

Kotlin for System Programming: Hands-On Tutorial:

What is Kotlin for System Programming?

System programming typically involves writing software that interacts closely with the operating system, hardware, or low-level components—tasks like building command-line tools, daemons, device drivers, network services, or embedded applications. Kotlin, through Kotlin/Native, brings the language's modern features—concise syntax, type safety, coroutines, and a rich standard library—directly to native binaries that run without a virtual machine. This tutorial explores how to leverage Kotlin for system-level tasks, from calling C libraries and managing memory manually to building a practical native CLI utility.

Why Kotlin for System Programming Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Using Kotlin for system programming offers several compelling advantages:

Setting Up Your First Kotlin/Native System Project

We'll use Gradle with the Kotlin multiplatform plugin. Create a new directory and add the following build script.

// build.gradle.kts
plugins {
    kotlin("multiplatform") version "1.9.22"
}

kotlin {
    linuxX64("native") {
        binaries {
            executable {
                entryPoint = "main"  // will look for a main() function
            }
        }
    }
    sourceSets {
        val nativeMain by getting {
            dependencies {
                // add any native-specific dependencies here
            }
        }
    }
}

Place your source code under src/nativeMain/kotlin/. For a minimal start, create Main.kt:

// src/nativeMain/kotlin/Main.kt
fun main() {
    println("Hello, system programming from Kotlin/Native!")
}

Build the executable with ./gradlew nativeBinaries. You'll find the binary in build/bin/native/releaseExecutable/. Run it directly: ./build/bin/native/releaseExecutable/your_project.kexe.

Calling C Functions via cinterop

The true power of system programming lies in using OS and C library APIs. Kotlin/Native uses cinterop to generate Kotlin bindings from C headers. Let's call POSIX getpid() and sysconf() to retrieve system information.

Step 1: Create a .def file

Create a file src/nativeInterop/cinterop/posix.def:

// posix.def
headers = unistd.h
package = platform.posix

Step 2: Update Gradle build

Add the cinterop configuration to your build script:

kotlin {
    linuxX64("native") {
        compilations["main"].cinterops {
            val posix by creating {
                defFile("src/nativeInterop/cinterop/posix.def")
            }
        }
        binaries {
            executable {
                entryPoint = "main"
            }
        }
    }
}

Step 3: Use the generated bindings

Now you can call getpid() and other POSIX functions directly from Kotlin:

import platform.posix.getpid
import platform.posix.sysconf
import platform.posix._SC_NPROCESSORS_ONLN
import kotlinx.cinterop.*

fun main() {
    val pid = getpid()
    println("Process ID: $pid")

    val processors = sysconf(_SC_NPROCESSORS_ONLN)
    println("Number of online processors: $processors")
}

Notice the use of kotlinx.cinterop.* for pointer and value conversions. The generated package platform.posix provides Kotlin-friendly function signatures and constants.

Manual Memory Management for Performance-Critical Sections

While Kotlin/Native uses automatic reference counting (and a garbage collector for cyclic references), system programming often demands deterministic control over memory. You can allocate and release native memory manually using kotlinx.cinterop.NativePlacement and the memScoped helper.

import kotlinx.cinterop.*

fun processLargeBuffer() {
    // Allocate a block of 4KB on the native heap
    val buffer = NativeHeap.allocArray<ByteVar>(4096)
    // Use buffer...
    // Free manually when done
    NativeHeap.free(buffer)
}

For short-lived allocations with automatic cleanup, prefer memScoped:

import kotlinx.cinterop.*

fun scopedAllocation() {
    memScoped {
        val tempBuffer = allocArray<ByteVar>(1024)
        // tempBuffer is automatically freed when this block exits
    }
}

This approach gives you predictable, low-overhead memory usage—vital for tight loops, signal handlers, or real-time constraints.

Building a Practical System Utility: A File Watcher Daemon

Let's build a native daemon that monitors a directory for changes using Linux's inotify API. We'll use cinterop to wrap the necessary C calls.

1. Define the cinterop for inotify

Create inotify.def:

headers = sys/inotify.h
package = platform.inotify

Update build.gradle.kts to include the new cinterop:

compilations["main"].cinterops {
    val posix by creating { ... }
    val inotify by creating {
        defFile("src/nativeInterop/cinterop/inotify.def")
    }
}

2. Implement the watcher

We'll write a suspend function that uses coroutines and the inotify file descriptor. Because inotify_read can block, we run it in a separate thread using withContext and newSingleThreadContext.

import kotlinx.cinterop.*
import kotlinx.coroutines.*
import platform.inotify.*
import platform.posix.*

fun main() = runBlocking {
    val watchDir = "/tmp/watched"
    val fd = inotify_init()
    if (fd < 0) {
        println("Failed to initialize inotify: ${strerror(posix_errno)}")
        return@runBlocking
    }

    val watchDescriptor = inotify_add_watch(fd, watchDir,
        IN_CREATE or IN_DELETE or IN_MODIFY or IN_MOVED_FROM or IN_MOVED_TO)
    if (watchDescriptor < 0) {
        println("Failed to add watch: ${strerror(posix_errno)}")
        posix_close(fd)
        return@runBlocking
    }

    println("Watching $watchDir for changes... Press Ctrl+C to stop.")
    // Launch a coroutine that reads events in a dedicated thread
    val watcherContext = newSingleThreadContext("inotify-reader")
    val job = launch(watcherContext) {
        val buffer = memScoped {
            allocArray<ByteVar>(4096)
        }
        while (isActive) {
            val bytesRead = inotify_read(fd, buffer, 4096u)
            if (bytesRead > 0) {
                val eventPtr = buffer.reinterpret<inotify_event>()
                val event = eventPtr.pointed
                val name = event.name?.toKString() ?: "unknown"
                println("Event: mask=${event.mask} name=$name")
            }
        }
    }

    // Keep the program alive until cancelled
    job.join()
    posix_close(fd)
}

This example demonstrates several system programming patterns: calling C initialization, checking error codes with posix_errno, manual buffer allocation, pointer reinterpretation, and structured concurrency for I/O-bound tasks.

Working with Processes and Signals

System utilities often spawn processes or handle OS signals. Kotlin/Native can access fork(), exec(), and signal handling through POSIX bindings.

Spawning a child process

import platform.posix.*

fun runCommand(command: String, args: List<String>) {
    val pid = fork()
    if (pid == 0) {
        // Child process
        val cArgs = arrayOfNulls<CPointer<ByteVar>?>(args.size + 2)
        cArgs[0] = command.cstr
        args.forEachIndexed { i, arg -> cArgs[i+1] = arg.cstr }
        cArgs[args.size + 1] = null
        execvp(command, cArgs)
        // If exec returns, an error occurred
        println("Exec failed: ${strerror(posix_errno)}")
        exit(1)
    } else if (pid > 0) {
        // Parent: wait for child
        var status = 0
        waitpid(pid, status.readValue(), 0)
        println("Child process exited with status: $status")
    } else {
        println("Fork failed: ${strerror(posix_errno)}")
    }
}

Signal handling

Register a handler for SIGINT to gracefully shut down your daemon:

import platform.posix.*
import kotlinx.cinterop.*

fun setupSignalHandler() {
    val action = sigaction()
    // Set handler to a static function (must be @CName external)
    action.sa_handler = staticCFunction { sig: Int ->
        println("Received signal $sig, shutting down...")
        // Set flag or cancel coroutine scope
    }
    sigaction(SIGINT, action, null)
}

Because signal handlers run in an extremely restricted context, keep them minimal—typically just setting an atomic flag or cancelling a coroutine scope.

Linking with External Native Libraries

Real-world system programs often depend on external native libraries like libcurl, libpq, or custom C libraries. You can link them in the Gradle build:

binaries {
    executable {
        entryPoint = "main"
        // Link flags
        linkerOpts("-lcurl", "-lpq")
    }
}

Then, define a .def file that specifies the headers and link dependencies:

headers = curl/curl.h
staticLibraries = libcurl.a
libraryPaths = /usr/local/lib

This integrates seamlessly, allowing you to call library functions with full type safety.

Best Practices for Kotlin System Programming

Conclusion

Kotlin for system programming via Kotlin/Native bridges the gap between modern language design and low-level execution. You gain the expressiveness, safety, and concurrency model of Kotlin while retaining full access to C libraries, manual memory control, and OS APIs. This tutorial walked through project setup, C interop, manual memory management, building a practical file watcher daemon, process and signal handling, and linking external libraries. By following the patterns and best practices outlined here, you can build robust, efficient native tools—from command-line utilities to network daemons and embedded applications—with the same Kotlin you use on the JVM or Android. The ecosystem continues to mature, making this an increasingly viable and productive choice for system-level development.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles