C 구조체와 공용체 타입 매핑 – 튜토리얼

C 구조체와 공용체 타입 매핑 – 튜토리얼

C 라이브러리 import는 Beta 단계에 있어요. cinterop 도구가 C 라이브러리에서 생성한 모든 Kotlin 선언에는 @ExperimentalForeignApi 애노테이션을 붙여야 해요.

Kotlin/Native와 함께 제공되는 네이티브 플랫폼 라이브러리(Foundation, UIKit, POSIX 등)는 일부 API에 대해서만 opt-in(동의)이 필요해요.

어떤 C 구조체(struct)와 공용체(union) 선언이 Kotlin에서 보이는지 살펴보고, Kotlin/Native와 멀티플랫폼 Gradle 빌드의 고급 C interop 관련 사용 사례도 알아볼게요.

이 튜토리얼에서 배우는 내용:

  • 구조체와 공용체 타입이 어떻게 매핑되는지
  • Kotlin에서 구조체와 공용체 타입을 사용하는 법

출처: Mapping struct and union types from C – tutorial

본문

C 구조체와 공용체 타입 매핑

Kotlin이 구조체와 공용체 타입을 어떻게 매핑하는지 이해하려면, C로 선언해 보고 그것들이 Kotlin에서 어떻게 표현되는지 확인해 볼게요.

이전 튜토리얼에서 이미 필요한 파일들을 갖춘 C 라이브러리를 만들었죠. 이번 단계에서는 interop.def 파일의 --- 구분자 뒤쪽 선언을 업데이트해요:

---

typedef struct {
  int a;
  double b;
} MyStruct;

void struct_by_value(MyStruct s) {}
void struct_by_pointer(MyStruct* s) {}

typedef union {
  int a;
  MyStruct b;
  float c;
} MyUnion;

void union_by_value(MyUnion u) {}
void union_by_pointer(MyUnion* u) {}

interop.def 파일은 애플리케이션을 컴파일·실행하거나 IDE에서 여는 데 필요한 모든 것을 제공해요.

C 라이브러리에 대해 생성된 Kotlin API 살펴보기

C 구조체와 공용체 타입이 Kotlin/Native에 어떻게 매핑되는지 보고 프로젝트를 업데이트해 볼게요:

  1. 이전 튜토리얼src/nativeMain/kotlin에 있는 hello.kt 파일을 다음 내용으로 업데이트해요:
import interop.*
import kotlinx.cinterop.ExperimentalForeignApi

@OptIn(ExperimentalForeignApi::class)
fun main() {
    println("Hello Kotlin/Native!")

    struct_by_value(/* fix me*/)
    struct_by_pointer(/* fix me*/)
    union_by_value(/* fix me*/)
    union_by_pointer(/* fix me*/)
}
  1. 컴파일러 오류를 피하려면 빌드 과정에 상호 운용을 추가해요. 이를 위해 build.gradle(.kts) 빌드 파일을 다음 내용으로 업데이트해요:
kotlin {
    macosArm64()    // macOS on Apple Silicon
    // linuxArm64() // Linux on ARM64 platforms
    // linuxX64()   // Linux on x86_64 platforms
    // mingwX64()   // on Windows

    targets.withType<KotlinNativeTarget>().configureEach {
        val main by compilations.getting
        val interop by main.cinterops.creating {
            definitionFile.set(project.file("src/nativeInterop/cinterop/interop.def"))
        }

        binaries {
            executable()
        }
    }
}
kotlin {
    macosArm64()    // Apple Silicon macOS
    // linuxArm64() // Linux on ARM64 platforms
    // linuxX64()   // Linux on x86_64 platforms
    // mingwX64()   // Windows

    targets.withType(KotlinNativeTarget).configureEach {
        compilations.main.cinterops {
            interop {
                definitionFile = project.file('src/nativeInterop/cinterop/interop.def')
            }
        }

        binaries {
            executable()
        }
    }
}
  1. IntelliJ IDEA의 선언으로 이동(Go to declaration) 명령(Cmd + B/Ctrl + B)을 사용해서 C 함수, 구조체, 공용체에 대해 생성된 다음 API로 이동해요:
fun struct_by_value(s: kotlinx.cinterop.CValue<interop.MyStruct>)
fun struct_by_pointer(s: kotlinx.cinterop.CValuesRef<interop.MyStruct>?)

fun union_by_value(u: kotlinx.cinterop.CValue<interop.MyUnion>)
fun union_by_pointer(u: kotlinx.cinterop.CValuesRef<interop.MyUnion>?)

기술적으로 Kotlin 쪽에서는 구조체와 공용체 타입 사이에 차이가 없어요. cinterop 도구는 C 구조체 선언과 공용체 선언 모두에 대해 Kotlin 타입을 생성해요.

생성된 API에는 CValue<T>CValuesRef<T>에 대한 정규화된 패키지 이름이 포함되어 있어, 그것들이 kotlinx.cinterop에 있음을 반영해요. CValue<T>는 값으로 전달되는 구조체 매개변수를 나타내고, CValuesRef<T>?는 구조체나 공용체에 대한 포인터를 전달할 때 사용돼요.

Kotlin에서 구조체와 공용체 타입 사용하기

생성된 API 덕분에 Kotlin에서 C 구조체와 공용체 타입을 사용하는 건 간단해요. 유일한 질문은 이 타입들의 새 인스턴스를 어떻게 만드느냐예요.

MyStructMyUnion을 매개변수로 받는 생성된 함수를 살펴볼게요. 값 전달 매개변수는 kotlinx.cinterop.CValue<T>로, 포인터 타입 매개변수는 kotlinx.cinterop.CValuesRef<T>?로 표현돼요.

Kotlin은 이런 타입들을 만들고 다루기 위한 편리한 API를 제공해요. 실제로 어떻게 사용하는지 살펴볼게요.

CValue 만들기

CValue<T> 타입은 C 함수 호출에 값 전달 매개변수를 넘길 때 사용해요. cValue 함수로 CValue<T> 인스턴스를 만들어요. 이 함수는 기본 C 타입을 제자리에서 초기화하기 위해 수신자 있는 람다 함수를 요구해요. 함수는 다음과 같이 선언돼요:

fun <reified T : CStructVar> cValue(initialize: T.() -> Unit): CValue<T>

cValue를 사용해서 값 전달 매개변수를 넘기는 방법은 다음과 같아요:

import interop.*
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.cValue

@OptIn(ExperimentalForeignApi::class)
fun callValue() {

    val cStruct = cValue<MyStruct> {
        a = 42
        b = 3.14
    }
    struct_by_value(cStruct)

    val cUnion = cValue<MyUnion> {
        b.a = 5
        b.b = 2.7182
    }

    union_by_value(cUnion)
}

구조체와 공용체를 CValuesRef로 만들기

CValuesRef<T> 타입은 Kotlin에서 C 함수의 포인터 타입 매개변수를 전달할 때 사용해요. MyStructMyUnion을 네이티브 메모리에 할당하려면 kotlinx.cinterop.NativePlacement 타입의 다음 확장 함수를 사용해요:

fun <reified T : kotlinx.cinterop.CVariable> alloc(): T

NativePlacementmallocfree와 비슷한 함수를 가진 네이티브 메모리를 나타내요. NativePlacement에는 여러 구현이 있어요:

  • 전역 구현은 kotlinx.cinterop.nativeHeap이에요. 다만 사용 후 메모리를 해제하려면 nativeHeap.free()를 호출해야 해요.
  • 더 안전한 대안은 memScoped()예요. 이것은 수명이 짧은 메모리 스코프를 만드는데, 블록이 끝나면 모든 할당이 자동으로 해제돼요:
fun <R> memScoped(block: kotlinx.cinterop.MemScope.() -> R): R

memScoped()를 사용하면 포인터로 함수를 호출하는 코드는 이렇게 생겼어요:

import interop.*
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ptr

@OptIn(ExperimentalForeignApi::class)
fun callRef() {
    memScoped {
        val cStruct = alloc<MyStruct>()
        cStruct.a = 42
        cStruct.b = 3.14

        struct_by_pointer(cStruct.ptr)

        val cUnion = alloc<MyUnion>()
        cUnion.b.a = 5
        cUnion.b.b = 2.7182

        union_by_pointer(cUnion.ptr)
    }
}

여기서 memScoped {} 블록 안에서 사용할 수 있는 ptr 확장 프로퍼티가 MyStructMyUnion 인스턴스를 네이티브 포인터로 변환해요.

메모리는 memScoped {} 블록 안에서 관리되므로 블록이 끝나면 자동으로 해제돼요. 이 스코프 밖에서는 포인터를 사용하지 않는 게 좋아요. 해제된 메모리에 접근하는 일을 막으려는 거예요. 더 오래 살아야 하는 할당(C 라이브러리에서 캐싱할 때 등)이 필요하다면 Arena()nativeHeap을 고려해 보세요.

CValue와 CValuesRef 사이의 변환

한 함수 호출에서 구조체를 값으로 전달한 뒤, 다른 호출에서 같은 구조체를 참조로 전달해야 하는 경우가 있어요.

그러려면 NativePlacement가 필요한데, 먼저 CValue<T>를 어떻게 포인터로 바꾸는지 살펴볼게요:

import interop.*
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.cValue
import kotlinx.cinterop.memScoped

@OptIn(ExperimentalForeignApi::class)
fun callMix_ref() {
    val cStruct = cValue<MyStruct> {
        a = 42
        b = 3.14
    }

    memScoped {
        struct_by_pointer(cStruct.ptr)
    }
}

여기서도 memScoped {}ptr 확장 프로퍼티가 MyStruct 인스턴스를 네이티브 포인터로 바꿔요. 이 포인터들은 memScoped {} 블록 안에서만 유효해요.

포인터를 다시 값 전달 변수로 바꾸려면 .readValue() 확장 함수를 호출해요:

import interop.*
import kotlinx.cinterop.alloc
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.readValue

@OptIn(ExperimentalForeignApi::class)
fun callMix_value() {
    memScoped {
        val cStruct = alloc<MyStruct>()
        cStruct.a = 42
        cStruct.b = 3.14

        struct_by_value(cStruct.readValue())
    }
}

Kotlin 코드 업데이트하기

이제 Kotlin 코드에서 C 선언을 사용하는 법을 배웠으니 프로젝트에서 직접 사용해 볼게요. hello.kt 파일의 최종 코드는 다음과 같을 수 있어요:

import interop.*
import kotlinx.cinterop.alloc
import kotlinx.cinterop.cValue
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.readValue
import kotlinx.cinterop.ExperimentalForeignApi

@OptIn(ExperimentalForeignApi::class)
fun main() {
    println("Hello Kotlin/Native!")

    val cUnion = cValue<MyUnion> {
        b.a = 5
        b.b = 2.7182
    }

    memScoped {
        union_by_value(cUnion)
        union_by_pointer(cUnion.ptr)
    }

    memScoped {
        val cStruct = alloc<MyStruct> {
            a = 42
            b = 3.14
        }

        struct_by_value(cStruct.readValue())
        struct_by_pointer(cStruct.ptr)
    }
}

모든 것이 예상대로 동작하는지 확인하려면 IDE에서 runDebugExecutable<YourTargetName> Gradle 태스크를 실행하거나 터미널에서 콘솔 명령을 사용해요. 예를 들어:

./gradlew runDebugExecutableMacosArm64

더 알아보기

다음 단계에서는 함수 포인터가 Kotlin과 C 사이에서 어떻게 매핑되는지 배워요. C와의 상호 운용(Interoperability with C) 문서에서 더 고급 시나리오를 다루는 내용도 확인해 보세요.