wandres.dev
KOTLIN/NATIVE · sin máquina virtual

Interoperar con C: cinterop, punteros y memoria manual

La interoperabilidad con C es la razón última por la que existe Kotlin/Native, y también el punto donde el lenguaje suspende casi todas sus garantías. Esta lección explica cómo la herramienta cinterop convierte encabezados en una biblioteca de Kotlin, cómo se tipan los punteros y por qué el sistema de tipos distingue lo apuntado de la variable que lo contiene, qué hace exactamente memScoped con la memoria que reserva y cuándo no basta, y cómo cruzan la frontera las cadenas, las estructuras y las funciones de retorno.

⏱ 26 min

Todo lo que Kotlin promete —nulabilidad en el tipo, inmutabilidad por defecto, memoria administrada— deja de aplicarse en cuanto una llamada cruza hacia C. No porque el diseño sea descuidado, sino porque no hay forma de saber si un puntero devuelto por una biblioteca ajena es válido, si el llamante debe liberarlo o si apunta a memoria de la pila que dejará de existir al retornar. Kotlin resuelve esa asimetría de la única manera honesta posible: modela la frontera con un sistema de tipos deliberadamente incómodo, marca toda la API como experimental para que nadie la use por accidente, y deja el resto en tus manos. Esta lección va sobre esa incomodidad, que es información y no burocracia.

🎯 Al terminar esta lección sabrás
  • Generar enlaces a una biblioteca C desde un fichero de definición y entender qué produce cinterop.
  • Leer y escribir la jerarquía de tipos de punteros distinguiendo CPointer, CPointed y los tipos terminados en Var.
  • Gestionar el ciclo de vida de la memoria nativa con memScoped y saber cuándo hay que salir de él.
  • Convertir cadenas, estructuras y funciones de retorno entre los dos lados sin fugas ni punteros colgantes.

De un encabezado C a un klib

La unidad de trabajo es un fichero de definición que describe qué encabezados leer, contra qué enlazar y bajo qué paquete colocar el resultado. La herramienta cinterop lo procesa, analiza los encabezados con un frontend de C real y emite un klib con declaraciones de Kotlin que se corresponden una a una con las de C.

// zstd.def no es C, pero describe C. Se muestra su contenido tipico.
// headers = zstd.h
// package = zstd
// compilerOpts = -I/usr/local/include
// linkerOpts = -L/usr/local/lib -lzstd
// headerFilter = zstd*.h
kotlin {
    linuxX64 {
        compilations.getByName("main").cinterops {
            val zstd by creating {
                defFile(project.file("src/nativeInterop/cinterop/zstd.def"))
                packageName("zstd")
            }
        }
    }
}

Dos parámetros deciden la calidad del resultado. El filtro de encabezados delimita qué declaraciones se consideran parte de la biblioteca y cuáles son arrastre de las cabeceras del sistema; sin él acabas exponiendo media biblioteca estándar de C en tu paquete y multiplicando el tiempo de compilación. Y las opciones del enlazador son lo que decide si el símbolo existirá en el binario final: cinterop genera declaraciones aunque la biblioteca no esté instalada, y el error aparecerá mucho después, al enlazar.

Toda la API generada exige una activación explícita. En Kotlin moderno, cualquier uso de tipos de interoperabilidad requiere aceptar @OptIn(ExperimentalForeignApi::class), y esa fricción es intencionada: marca en el código exactamente qué funciones renuncian a las garantías del lenguaje.

flowchart TD
A[Encabezados C] --> B[Fichero def]
B --> C[Herramienta cinterop]
C --> D[klib de enlaces]
D --> E[Compilador de Kotlin Native]
F[Codigo Kotlin] --> E
E --> G[Ficheros objeto]
G --> H[Enlazador]
I[Biblioteca estatica o dinamica] --> H
H --> J[Binario final]

Punteros y su tipado

El punto que más resistencia genera es que Kotlin distingue dos conceptos que en C se escriben igual. CPointed representa lo que hay en esa dirección; CPointer representa la dirección misma. Los tipos terminados en VarIntVar, ByteVar, CPointerVar— son variables de C vistas como celdas de memoria escribibles: tienen una propiedad value que lee y escribe la posición apuntada.

De ahí salen las tres operaciones que se usan todo el tiempo. La propiedad ptr sobre algo apuntado devuelve su dirección; la propiedad pointed sobre una dirección devuelve la celda; y el operador de índice sobre un puntero accede a un elemento con aritmética de punteros implícita. La nulabilidad se modela con el tipo: un puntero que puede ser nulo es CPointer<T>? y el puntero nulo de C es exactamente null en Kotlin, de modo que el operador de aserción y las comprobaciones habituales funcionan sin conversiones.

@OptIn(ExperimentalForeignApi::class)
fun ejemploPunteros() = memScoped {
    val n = alloc<IntVar>()            // reserva un int en memoria nativa
    n.value = 42
    val p: CPointer<IntVar> = n.ptr
    println(p.pointed.value)           // 42

    val buffer = allocArray<ByteVar>(256)
    buffer[0] = 'K'.code.toByte()
    val opaco: COpaquePointer = buffer  // se pierde el tipo, no la direccion
    val recuperado = opaco.reinterpret<ByteVar>()
    println(recuperado[0])
}

La reinterpretación merece una advertencia explícita. reinterpret no comprueba nada: es la contrapartida de un molde de puntero en C y su corrección depende enteramente de que sepas qué hay realmente en esa dirección. Un puntero opaco reinterpretado al tipo equivocado no produce una excepción, produce lecturas de basura o una violación de segmento en un punto arbitrario del programa.

⚠️
La correspondencia de anchos no es negociable

Int es de 32 bits siempre; long en C mide 32 o 64 según la plataforma. Por eso cinterop genera alias como platform.posix.size_t en lugar de tipos fijos, y por eso conviene usar esos alias en las firmas propias en vez de sustituirlos por el tipo concreto que resulta correcto en la máquina de desarrollo. El error solo aparece al compilar para el otro target, y a veces solo en ejecución.

memScoped y el ciclo de vida

Ninguna memoria reservada para C la administra el recolector. memScoped establece un ámbito que actúa como colocador: todo lo que se reserva dentro se libera de golpe al salir del bloque, también si sale por una excepción. Es un arena, no un recolector, y por eso es barato y predecible.

Su límite es el que cabe esperar de un arena: los punteros obtenidos dentro dejan de ser válidos al salir. Devolver un puntero desde dentro de un memScoped es el error más frecuente de esta parte del lenguaje y el compilador no lo detecta, porque el tipo del puntero no dice nada sobre cuánto vive lo que apunta.

@OptIn(ExperimentalForeignApi::class)
fun leerLinea(ruta: String): String? = memScoped {
    val f = fopen(ruta, "r") ?: return null
    try {
        val buf = allocArray<ByteVar>(4096)
        if (fgets(buf, 4096, f) == null) null else buf.toKString()
    } finally {
        fclose(f)                       // el arena no sabe nada de descriptores
    }
}

// Cuando el dato debe sobrevivir al bloque, la reserva es manual y el
// emparejamiento con la liberacion pasa a ser responsabilidad tuya.
@OptIn(ExperimentalForeignApi::class)
class Buffer(tam: Int) {
    private val datos = nativeHeap.allocArray<ByteVar>(tam)
    fun cerrar() = nativeHeap.free(datos)
}

Obsérvese la doble contabilidad del primer ejemplo: el arena libera el búfer, pero el descriptor de fichero abierto con fopen no es memoria y debe cerrarse a mano. memScoped administra reservas, no recursos; confundir ambas cosas produce programas que no fugan memoria y sí agotan descriptores.

Cruzar valores: cadenas, estructuras y retrollamadas

Las cadenas son el caso más frecuente y el que más copias silenciosas esconde. Kotlin guarda cadenas en su propio montículo con su propia codificación, así que pasar una a C exige materializar una copia terminada en cero, y recibir una de C exige copiarla al montículo de Kotlin. La propiedad cstr produce esa copia dentro del ámbito actual; el método toKString hace el viaje inverso y asume codificación UTF-8.

Las estructuras se modelan con dos tipos según dónde vivan. Un valor de estructura por copia es CValue, que es inmutable y se pasa por valor; una estructura reservada en memoria y accesible por referencia se manipula a través de su CPointer y sus campos son propiedades normales. La función cValue construye la primera y useContents permite leerla sin colocarla en ningún sitio.

Las retrollamadas imponen la restricción más severa. Un puntero a función de C no puede llevar estado asociado, de modo que staticCFunction solo acepta lambdas que no capturen nada. El contexto se transporta por el propio protocolo de C, normalmente con un puntero opaco de usuario que la biblioteca devuelve intacto a la retrollamada.

@OptIn(ExperimentalForeignApi::class)
fun registrar(cb: CPointer<CFunction<(Int, COpaquePointer?) -> Unit>>) { /* ... */ }

@OptIn(ExperimentalForeignApi::class)
fun instalar(estado: Estado) {
    val ref = StableRef.create(estado)          // ancla el objeto Kotlin
    registrar(staticCFunction { codigo, datos ->
        val e = datos!!.asStableRef<Estado>().get()
        e.recibir(codigo)
    })
    // ref.dispose() cuando la biblioteca garantice que no habra mas llamadas
}

StableRef es la pieza que faltaba: fija un objeto Kotlin para que el recolector no lo mueva ni lo libere mientras C conserve su dirección, y devuelve un puntero opaco transportable. Es una fuga deliberada hasta que alguien la deshaga, y ese alguien eres tú.

El tipado de punteros de Kotlin no describe la memoria, describe lo poco que el compilador puede saber de ella, y confundir ambas cosas es el origen de casi todo el código de interoperabilidad roto

Cuesta muy poco leer la jerarquía de tipos de interoperabilidad como una traducción mecánica de C a Kotlin y concluir que es más verbosa sin ser más segura, y esa lectura es la que produce el patrón que arruina proyectos: envolver la biblioteca en una capa fina que devuelve punteros desnudos, declararla resuelta y dejar que el resto del equipo la use como si fuera código Kotlin normal. Lo que esa lectura pierde es que la distinción entre lo apuntado y la dirección, o la existencia de tipos separados para las estructuras por valor y por referencia, no está describiendo la memoria de C —C no tiene esas categorías y no las necesita— sino describiendo con precisión la frontera del conocimiento del compilador. Un tipo de puntero en Kotlin te dice qué hay en esa dirección si es que hay algo, y guarda un silencio absoluto sobre las tres preguntas que de verdad deciden si tu programa es correcto: cuánto tiempo será válida esa dirección, quién tiene la obligación de liberarla y si alguien más puede escribir ahí mientras tú lees. Ningún sistema de tipos que se limite a leer encabezados de C puede responderlas, porque las respuestas no están en el encabezado sino en la documentación, y a menudo ni siquiera ahí. De esa observación se sigue la única disciplina que funciona a escala, y no es técnica sino organizativa: la capa de interoperabilidad no debe exponer nunca hacia arriba un tipo de puntero, ni siquiera envuelto, porque hacerlo traslada a cada punto de uso una obligación de propiedad que el tipo no comunica y que el revisor no verá. La frontera correcta convierte punteros en tipos de Kotlin con propietario explícito —una clase que reserva y libera, un valor copiado al montículo administrado, un ámbito que fuerza el uso dentro de sus límites— y absorbe dentro de sí toda la aritmética, todos los moldes y todas las reservas manuales. Escrita así, la capa es tediosa y desagradecida, ocupa mucho más de lo que parecía necesario y es el único sitio del proyecto donde hay que revisar con lupa; escrita de la otra forma, el proyecto entero se convierte en ese sitio.

⚔️ Una envoltura que no deje escapar un puntero
  1. Elige una biblioteca C pequeña instalada en tu sistema, escribe su fichero de definición con filtro de encabezados y comprueba cuántas declaraciones aparecen con filtro y sin él.
  2. Reserva un entero con memScoped, devuelve su puntero fuera del bloque y úsalo. Documenta qué ocurre y por qué el compilador no te avisó.
  3. Escribe la misma función dos veces: una que reciba una cadena de C y la copie al montículo de Kotlin, y otra que pase una cadena de Kotlin a C. Cuenta las copias de memoria de cada dirección.
  4. Envuelve un recurso que exija apertura y cierre en una clase que implemente AutoCloseable y verifica con una prueba que no fuga aunque el bloque lance una excepción.
  5. Registra una retrollamada con staticCFunction transportando estado mediante StableRef, y demuestra con una prueba que liberar la referencia demasiado pronto produce un fallo.