Prerequisites and assumptions
You are using the Kotlin Gradle DSL and the Kotlin Multiplatform plugin. You have a working C toolchain per OS. On macOS install Xcode command line tools or Homebrew. On Linux install GCC or Clang and the development headers for the libraries you plan to use. On Windows install MSYS2 with MinGW and ensure gcc and pkg config are in PATH.
Good reads and references
- Kotlin/Native overview and C interop guides
- Mapping primitive data types, strings, [structs and unions…
Prerequisites and assumptions
You are using the Kotlin Gradle DSL and the Kotlin Multiplatform plugin. You have a working C toolchain per OS. On macOS install Xcode command line tools or Homebrew. On Linux install GCC or Clang and the development headers for the libraries you plan to use. On Windows install MSYS2 with MinGW and ensure gcc and pkg config are in PATH.
Good reads and references
- Kotlin/Native overview and C interop guides
- Mapping primitive data types, strings, structs and unions, function pointers from C
- Kotlin/Native libraries and Gradle cinterops
- Exploring Kotlin (native) compilation - deep dive into compilation process
- POSIX.1-2024 specifications for Linux and macOS and Win32 API reference for Windows
What you get with Kotlin Native (desktop)
Kotlin Native compiles Kotlin to native machine code. On desktop that means you can ship fast binaries that talk to existing C libraries and to the system APIs on macOS Windows and Linux.
Why teams pick this
- Performance and small footprint for command line tools daemons services and GUI backends
- Direct access to POSIX on Linux and macOS and to Win32 on Windows which are exposed primarily in C
- Seamless reuse of proven C code without a rewrite
Note on platform libraries: Kotlin/Native provides prebuilt platform libraries like platform.posix
for POSIX systems and platform.windows
for Windows APIs. Before creating custom interop, check if the functionality you need is already available in these platform libraries.
The interop workflow at a glance
Interop has two phases. Phase one translates C headers into Kotlin facing APIs. Phase two compiles and links your Kotlin code with those bindings into a platform binary.
flowchart TD
A[C library\n headers and .so or .dylib or .dll ] --> B[Definition file .def]
B --> C[Gradle cinterops configuration]
C --> D[cinterop parses headers and emits .klib]
D --> E[Your Kotlin code imports generated API]
E --> F[Kotlin Native compiler lowers to LLVM]
F --> G[Platform toolchain links]
G --> H[Final native artifact\n .kexe or .so or .dylib or .dll ]
Steps you perform
- Write a definition file with extension .def that tells interop which headers to import and how to parse them
- Configure Gradle so cinterop runs for each desktop target you build
- Write Kotlin that calls the generated bindings
Anatomy of the definition file
The .def file is more than a list of headers. It configures the Clang parser and it carries link flags that are used at the final link step.
Example libcurl.def
# What to import
headers = curl/curl.h
headerFilter = curl/**
# Where the generated symbols live
package = com.example.curl
# Platform specific flags
# You can use general platform flags (osx, linux, windows)
compilerOpts.linux = -I/usr/include -I/usr/include/x86_64-linux-gnu
linkerOpts.linux = -L/usr/lib/x86_64-linux-gnu -lcurl
compilerOpts.osx = -I/opt/homebrew/opt/curl/include -I/usr/local/opt/curl/include
linkerOpts.osx = -L/opt/homebrew/opt/curl/lib -L/usr/local/opt/curl/lib -lcurl -framework Security -framework SystemConfiguration
compilerOpts.windows = -IC:/msys64/mingw64/include
linkerOpts.windows = -LC:/msys64/mingw64/lib -lcurl -lws2_32
# Or use more specific target flags for fine-grained control
# compilerOpts.macos_x64 = -I/usr/local/opt/curl/include
# compilerOpts.macos_arm64 = -I/opt/homebrew/opt/curl/include
# Optional quality of life knobs
# staticLibraries = libcurl.a
# libraryPaths = /opt/homebrew/opt/curl/lib
Definition file properties
Property | Description | Example |
---|---|---|
headers | A space separated list of C header files to process. This is the core input for the tool | headers = my_lib.h another_header.h |
package | Specifies the Kotlin package where the generated bindings will be placed | package = com.mycompany.clib |
headerFilter | A glob pattern to selectively include declarations only from specific headers. Useful for reducing binary size and avoiding conflicts with system headers | headerFilter = my_lib/** |
compilerOpts | Passes flags directly to the underlying C compiler (Clang) used for parsing headers. Essential for defining macros or adding include paths. Can be platform specific | compilerOpts = -I/usr/local/include``compilerOpts.linux = -DDEBUG=1 |
linkerOpts | Passes flags directly to the linker. Used to link against the actual C library (.so, .dylib, .a). Can be platform-specific | linkerOpts = -L/usr/local/lib -lmy_lib |
staticLibraries | Specifies static libraries (.a) to be bundled directly into the resulting .klib file (Experimental) | staticLibraries = libfoo.a |
libraryPaths | A space separated list of directories to search for the static libraries specified in staticLibraries (Experimental) | libraryPaths = /path/to/libs |
Gradle configuration for desktop targets
Use the Kotlin Multiplatform plugin and enable desktop targets. Configure cinterops on the main compilation so a .klib is generated per target.
kotlin {
macosArm64()
macosX64()
linuxX64()
mingwX64()
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
compilations["main"].cinterops {
val curl by creating {
definitionFile.set(project.layout.projectDirectory.file("src/nativeInterop/cinterop/libcurl.def"))
packageName("com.example.curl") // Gradle side setting not a .def key
}
}
}
}
Tips
- Interop runs per target so you get tasks like cinteropCurlMacosArm64 and cinteropCurlLinuxX64
- The generated .klib is added to the compilation dependencies so your Kotlin code can import the package you chose in the .def file
What cinterop generates and what it does not
cinterop uses Clang to parse the headers and generates a Kotlin Native library file with extension .klib that contains declarations and metadata the compiler consumes. The entire process is orchestrated by the Kotlin/Native compiler (internally called Konan), which uses an LLVM backend to produce optimized machine code for the target platform. If staticLibraries is set a static archive can be embedded so that consumers link it automatically just by depending on the .klib.
What is imported
- Functions global variables enums structs and typedefs
- Simple macros that can be turned into constants
What is not imported or has caveats
- Complex C macros and inline functions may not become callable Kotlin and often require a small C shim library
- Variadic functions and bit fields can have limitations
- Nullability and enum mapping are heuristic based so verify the generated API
The Rosetta stone for types
All interop types live under package kotlinx.cinterop
C type | Kotlin Native type | Notes |
---|---|---|
int long short | Int Long Short | direct mapping of signed integrals |
char | Byte | C char is 8 bit and is not Kotlin Char |
unsigned int unsigned long | UInt ULong | use unsigned types to match semantics |
float double | Float Double | direct mapping |
T* | CPointer<TVar>? | nullable to represent null |
T* parameter | CValuesRef<TVar>? | accepts a pointer or a contiguous sequence |
char* | CPointer<ByteVar>? | use toKString to read and string.cstr to pass, wide APIs on Windows use wcstr |
struct S by value | CValue<S> | build with cValue block and read with useContents |
struct S* | allocate with alloc<S>() inside memScoped | pass via .ptr |
function pointer int (*f)(int) | CPointer<CFunction<(Int) -> Int>>? | non-capturing callbacks via staticCFunction , state via StableRef |
Size and numeric conversion
- Use
convert<size_t>()
when passing sizes and lengths to C - Prefer explicit
convert
calls at boundaries so the same code works on 32 bit and 64 bit size platforms
Memory management that keeps you safe
Important: The C interop functionality is currently in Beta status. The cinterop API is marked as experimental, and you need to opt-in by adding @OptIn(ExperimentalForeignApi::class)
to your functions that use cinterop types. This annotation acknowledges that the API may change in future Kotlin versions, though the core concepts remain stable.
memScoped block
- Allocations done with
alloc
andallocArray
insidememScoped
are freed when the block exits including error paths
nativeHeap
- Manual lifetime for long lived native objects using
nativeHeap.alloc
andnativeHeap.free
use with care and pair allocations with frees
Pinning managed memory
- Use
usePinned
on a Kotlin array or string to get a stable native pointer that stays valid while the C function runs - Alternatively, use
refTo()
for simpler cases where you need to pass a reference to a single element
CValues
helpers to avoid manual allocation
// Array literal without manual alloc
foo(cValuesOf(1, 2, 3), 3)
// Struct literal
val p: CValue<Point> = cValue { x = 10; y = 20 }
usePoint(p)
// Inspect contents
p.useContents { println("$x,$y") }
// Create a modified copy (CValue is immutable)
val movedPoint = p.copy {
x = 15 // Change x while keeping y the same
}
Passing a struct by pointer
import kotlinx.cinterop.*
@OptIn(ExperimentalForeignApi::class)
fun processStruct() {
memScoped {
val s = alloc<MyStruct>()
s.field1 = 100
s.field2 = 3.14
process_struct_by_pointer(s.ptr)
}
}
Reading into a pinned buffer
import kotlinx.cinterop.*
import platform.posix.*
@OptIn(ExperimentalForeignApi::class)
fun readFile(fd: Int) {
val buffer = ByteArray(1024)
buffer.usePinned { pinned ->
val n = read(fd, pinned.addressOf(0), buffer.size.convert<size_t>())
// process buffer
}
}
Callbacks from C back into Kotlin
Non capturing callbacks
- Use
staticCFunction
with a top level or static Kotlin function. The function must not capture Kotlin state
Passing state through user data
- Allocate StableRef of a Kotlin object and pass its pointer with
asCPointer
as the user data parameter of the C API. Later recover it withasStableRef
get and dispose it when done
Thread safety note: With Kotlin/Native’s modern memory manager, StableRef
can be accessed from different threads. However, if your callback may be invoked from multiple threads concurrently, you still need to ensure proper synchronization for any shared mutable state within the referenced object. Consider using thread safe data structures or explicit locking mechanisms for concurrent access.
Signature example
val cb = staticCFunction { code: Int ->
println("code is $code")
}
Stable user data example
class Context(val message: String)
val ref = StableRef.create(Context("hello"))
val user = ref.asCPointer()
// pass user to C API
// inside callback recover it
val ctx = user.asStableRef<Context>().get()
println(ctx.message)
ref.dispose()
Strings and encoding across platforms
C strings are sequences of bytes terminated by zero. Use toKString
to read a C string into Kotlin and use string.cstr
to pass a Kotlin string to C. On Windows many APIs use wide strings. Use wcstr
for those.
The noStringConversion
property in your .def
file allows you to specify functions that should not have automatic string conversion. List the function names where you need explicit control:
noStringConversion = LoadCursorA LoadCursorW processRawData
Use this when:
- You need explicit control over memory allocation for strings
- Working with binary data that shouldn’t be interpreted as UTF-8
- Performance is critical and you want to avoid conversion overhead
Desktop specifics
macOS
- Targets
macosArm64
andmacosX64
- Headers from Xcode SDK or Homebrew
- Link frameworks with
-framework
flags inlinkerOpts.osx
for exampleSecurity
orSystemConfiguration
- Use
headerFilter
to keep imports focused when working with large SDKs
Linux
- Target
linuxX64
- Headers and libraries come from system paths such as
/usr/include
and/usr/lib
on Debian based or frominclude
andlib
under/usr
on other distros - When you vendor a library install headers to a known prefix and pass explicit include and library paths in
compilerOpts
andlinkerOpts
Windows with MinGW
- Target
mingwX64
(Note: This is a Tier 3 target with limited official support) - Install the library via MSYS2 packages headers under
mingw64/include
and libs undermingw64/lib
- Link system libraries such as
ws2_32
when needed - Many Win32 APIs have ANSI and Wide variants. For wide functions pass
string.wcstr
Packaging and distribution notes
Static versus dynamic
- With
staticLibraries
you can embed a static archive in the.klib
so dependents link it automatically. Dynamic linking requires that the target machine can find the.so
or.dylib
or.dll
at runtime. Setrpath
or the appropriate environment variable when you use shared libraries
Binary kinds
- Kotlin Native produces executables (.kexe), shared libraries and static libraries. Choose the kind that matches your integration story
Runtime search paths
- On Linux adjust
LD_LIBRARY_PATH
or embedrpath
. On macOS adjustrpath
and codesign when necessary. On Windows ensure the.dll
is reachable viaPATH
or next to the executable
Error handling patterns
C libraries typically signal errors through return codes or by setting errno
. Here are idiomatic patterns for handling these in Kotlin:
Checking return codes
import kotlinx.cinterop.*
@OptIn(ExperimentalForeignApi::class)
fun safeOperation(): Result<String> {
val result = c_function_that_may_fail()
return if (result < 0) {
Result.failure(Exception("Operation failed with code: $result"))
} else {
Result.success("Success")
}
}
Working with errno
import kotlinx.cinterop.*
import platform.posix.*
@OptIn(ExperimentalForeignApi::class)
fun readWithErrorCheck(fd: Int, buffer: ByteArray): Int {
errno = 0 // Clear errno before the call
val bytesRead = buffer.usePinned { pinned ->
read(fd, pinned.addressOf(0), buffer.size.convert())
}
if (bytesRead < 0) {
val errorCode = errno
val errorMessage = strerror(errorCode)?.toKString() ?: "Unknown error"
throw IOException("Read failed: $errorMessage (errno: $errorCode)")
}
return bytesRead.toInt()
}
Performance considerations
Crossing the interop boundary has overhead. Keep these points in mind:
- Callback overhead: Callbacks through
staticCFunction
have additional indirection cost - Memory allocation: Prefer stack allocation with
memScoped
over heap allocation for temporary data - Large data transfers: Consider using direct memory buffers for bulk data operations
For performance critical code, profile to identify bottlenecks and consider writing performance sensitive loops in C if the interop overhead becomes significant.
Troubleshooting quick wins
Header not found
- Check include paths in
compilerOpts
and run Gradle with--info
to see the Clang command line
Undefined symbol at link time
- Verify your
linkerOpts
and that the library actually exports the symbol you call
Type does not match
- Inspect the generated stubs under build and confirm the mapping. Use a C shim if you need to bridge a tricky signature
Callback crashes
- Ensure your callback is non capturing and that any
StableRef
is disposed when no longer needed
Different behavior across platforms
- Use
convert
calls for sizes and numeric conversions and audit any platform specific macros or calling conventions
Debugging tips
When things go wrong, these techniques help diagnose issues:
Gradle build debugging
- Run with
--info
or--debug
flags to see detailed cinterop invocation:./gradlew cinteropCurlMacosArm64 --info
- Inspect generated bindings via IDE navigation or explore the build directory for generated stubs
Runtime debugging
- Enable Kotlin Native GC logging to monitor memory behavior:
-Xruntime-logs=gc=info
- Use platform debuggers:
lldb
on macOS/Linux, Visual Studio debugger on Windows - Add C side logging in a shim library to trace execution flow
- Check for memory leaks using
kotlin.native.internal.GC.lastGCInfo()
in tests
Common cinterop flags for debugging
# In your .def file
compilerOpts = -v # Verbose Clang output
excludedFunctions = problematic_function # Skip specific functions
strictEnums = CURLcode MyErrorCode # Generate these C enums as Kotlin enums (space separated list)
Inspecting generated bindings
- Generated Kotlin stubs show the actual API surface
- Use IDE navigation to jump to generated declarations
- Use the
klib
tool to inspect library contents if needed
Quick checklist for new desktop interop
- Tight
headerFilter
in the.def
and correct package name cinterops
configured on the desktop targets you support- Use
memScoped
andCValues
helpers as the default allocation pattern - Use
convert<size_t>()
for sizes and lengths - On Windows identify ANSI versus Wide APIs early
- Treat the
.klib
as the interop boundary and avoid depending on undocumented internals - Test on each target during development not only at the end
Glossary
Clang
A C/C++ compiler frontend for the LLVM project, used by Kotlin/Native to parse C headers and understand C code structure.
.def file
A definition file that configures how cinterop should process C headers, including compiler flags, linker options, and package names.
GCC
GNU Compiler Collection, a popular open source compiler system that supports C, C++, and other languages.
.kexe
Kotlin/Native executable file extension, the final compiled binary that can run directly on the target platform.
.klib
Kotlin/Native library format that contains compiled code and metadata, can be shared between Kotlin/Native projects.
Konan
The internal name for the Kotlin/Native compiler that transforms Kotlin code into native binaries using LLVM.
LLVM
Low Level Virtual Machine, a compiler infrastructure that Kotlin/Native uses to generate optimized machine code for different platforms.
memScoped
A Kotlin/Native memory management scope that automatically frees allocated native memory when the scope exits.
MinGW
Minimalist GNU for Windows, provides GCC compiler and GNU tools for Windows development.
MSYS2
A software distribution and building platform for Windows that provides a Unix like environment and package management.
POSIX
Portable Operating System Interface, a family of standards for maintaining compatibility between operating systems like Linux and macOS.
StableRef
A Kotlin/Native mechanism to create a stable reference to a Kotlin object that can be passed to C code as a pointer.
Static library
A library (.a, .lib) that is compiled directly into the final executable at link time.
Dynamic library
A library (.so, .dylib, .dll) that is loaded at runtime and shared between multiple programs.
Toolchain
A set of programming tools (compiler, linker, debugger) used together to build software for a specific platform.
Closing note
Describe the C surface in a clear .def
file, generate bindings per desktop target, and let Kotlin Native produce fast and predictable binaries that interoperate with your C libraries and system APIs. Keep the surface tight rely on safe allocation patterns and verify each platform along the way. This results in an approachable workflow for teams that want the convenience of Kotlin and the reach of native code on macOS Windows and Linux.