C 문자열 매핑 – 튜토리얼

C 문자열 매핑 – 튜토리얼

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

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

이 시리즈의 마지막 파트로서, Kotlin/Native에서 C 문자열을 다루는 방법을 살펴볼게요.

이 튜토리얼에서는 다음을 배워요:

  • Kotlin 문자열을 C로 전달하기
  • Kotlin에서 C 문자열 읽기
  • C 문자열 바이트를 Kotlin 문자열로 받아오기

출처: Mapping strings from C – tutorial

본문

C 문자열 다루기

C에는 전용 문자열 타입이 없어요. 특정 맥락에서 주어진 char *가 C 문자열을 나타내는지는 메서드 시그니처나 문서를 보고 판단하면 돼요.

C 언어의 문자열은 null로 끝나는(null-terminated) 형태라서, 문자열의 끝을 표시하기 위해 바이트 시퀀스 끝에 0인 \0 문자를 붙여요. 보통은 UTF-8로 인코딩된 문자열을 사용해요. UTF-8 인코딩은 가변 폭(variable-width) 문자를 사용하며 ASCII와 하위 호환돼요. Kotlin/Native는 기본적으로 UTF-8 문자 인코딩을 사용해요.

Kotlin과 C 사이에서 문자열이 어떻게 매핑되는지 이해하려면 먼저 라이브러리 헤더를 만들어 볼게요. 시리즈의 첫 번째 파트에서 이미 필요한 파일들을 갖춘 C 라이브러리를 만들었죠. 이번 단계에서는:

  1. C 문자열을 다루는 다음 함수 선언으로 lib.h 파일을 업데이트해요:
#ifndef LIB2_H_INCLUDED
#define LIB2_H_INCLUDED

void pass_string(char* str);
char* return_string();
int copy_string(char* str, int size);

#endif

이 예시는 C 언어에서 문자열을 전달하거나 받는 일반적인 방법들을 보여줘요. return_string() 함수의 반환값은 조심해서 다뤄야 해요. 반환된 char*을 해제할 때는 올바른 free() 함수를 사용해야 해요.

  1. interop.def 파일의 --- 구분자 뒤쪽 선언을 업데이트해요:
---

void pass_string(char* str) {
}

char* return_string() {
  return "C string";
}

int copy_string(char* str, int size) {
    *str++ = 'C';
    *str++ = ' ';
    *str++ = 'K';
    *str++ = '/';
    *str++ = 'N';
    *str++ = 0;
    return 0;
}

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!")

    pass_string(/*fix me*/)
    val useMe = return_string()
    val useMe2 = copy_string(/*fix me*/)
}
  1. IntelliJ IDEA의 선언으로 이동(Go to declaration) 명령(Cmd + B/Ctrl + B)을 사용해서 C 함수에 대해 생성된 다음 API로 이동해요:
fun pass_string(str: kotlinx.cinterop.CValuesRef<kotlinx.cinterop.ByteVarOf<kotlin.Byte> /* from: kotlinx.cinterop.ByteVar */>?)
fun return_string(): kotlinx.cinterop.CPointer<kotlinx.cinterop.ByteVarOf<kotlin.Byte> /* from: kotlinx.cinterop.ByteVar */>?
fun copy_string(str: kotlinx.cinterop.CValuesRef<kotlinx.cinterop.ByteVarOf<kotlin.Byte> /* from: kotlinx.cinterop.ByteVar */>?, size: kotlin.Int): kotlin.Int

이 선언들은 단순해요. Kotlin에서 C char * 포인터는 매개변수로는 str: CValuesRef<ByteVarOf>?에, 반환 타입으로는 CPointer<ByteVarOf>?에 매핑돼요. Kotlin은 char 타입을 보통 8비트 부호 있는 값인 kotlin.Byte로 표현해요.

생성된 Kotlin 선언에서 strCValuesRef<ByteVarOf<Byte>>?로 정의돼요. 이 타입은 nullable이므로 인자 값으로 null을 전달할 수 있어요.

Kotlin 문자열을 C로 전달하기

Kotlin에서 이 API를 사용해 볼게요. 먼저 pass_string() 함수를 호출해요:

import interop.*
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.cstr

@OptIn(ExperimentalForeignApi::class)
fun passStringToC() {
    val str = "This is a Kotlin string"
    pass_string(str.cstr)
}

String.cstr 확장 프로퍼티 덕분에 Kotlin 문자열을 C로 전달하는 건 간단해요. UTF-16 문자를 다루는 경우를 위한 String.wcstr 프로퍼티도 있어요.

Kotlin에서 C 문자열 읽기

이제 return_string() 함수가 반환한 char *를 받아 Kotlin 문자열로 바꿔 볼게요:

import interop.*
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString

@OptIn(ExperimentalForeignApi::class)
fun passStringToC() {
    val stringFromC = return_string()?.toKString()

    println("Returned from C: $stringFromC")
}

여기서 .toKString() 확장 함수가 return_string() 함수에서 반환된 C 문자열을 Kotlin 문자열로 변환해요.

Kotlin은 인코딩에 따라 C char * 문자열을 Kotlin 문자열로 변환하는 여러 확장 함수를 제공해요:

fun CPointer<ByteVarOf<Byte>>.toKString(): String // Standard function for UTF-8 strings
fun CPointer<ByteVarOf<Byte>>.toKStringFromUtf8(): String // Explicitly converts UTF-8 strings
fun CPointer<ShortVarOf<Short>>.toKStringFromUtf16(): String // Converts UTF-16 encoded strings
fun CPointer<IntVarOf<Int>>.toKStringFromUtf32(): String // Converts UTF-32 encoded strings

Kotlin에서 C 문자열 바이트 받아오기

이번에는 copy_string() C 함수를 사용해서 주어진 버퍼에 C 문자열을 써 볼게요. 이 함수는 문자열을 쓸 메모리 위치를 가리키는 포인터와 허용되는 버퍼 크기, 두 인자를 받아요.

이 함수는 성공했는지 실패했는지를 알려주는 값을 반환해야 해요. 0이면 성공했고 제공된 버퍼가 충분히 컸다고 가정해 볼게요:

import interop.*
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned

@OptIn(ExperimentalForeignApi::class)
fun sendString() {
    val buf = ByteArray(255)
    buf.usePinned { pinned ->
        if (copy_string(pinned.addressOf(0), buf.size - 1) != 0) {
            throw Error("Failed to read string from C")
        }
    }

    val copiedStringFromC = buf.decodeToString()
    println("Message from C: $copiedStringFromC")
}

여기서는 먼저 네이티브 포인터를 C 함수에 전달해요. .usePinned() 확장 함수가 바이트 배열의 네이티브 메모리 주소를 일시적으로 고정(pin)해요. C 함수가 바이트 배열에 데이터를 채워 넣어요. 또 다른 확장 함수인 ByteArray.decodeToString()은 UTF-8 인코딩을 가정하고 바이트 배열을 Kotlin 문자열로 바꿔요.

Kotlin 코드 업데이트하기

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

import interop.*
import kotlinx.cinterop.*

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

    val str = "This is a Kotlin string"
    pass_string(str.cstr)

    val useMe = return_string()?.toKString() ?: error("null pointer returned")
    println(useMe)

    val copyFromC = ByteArray(255).usePinned { pinned ->
        val useMe2 = copy_string(pinned.addressOf(0), pinned.get().size - 1)
        if (useMe2 != 0) throw Error("Failed to read a string from C")
        pinned.get().decodeToString()
    }

    println(copyFromC)
}

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

./gradlew runDebugExecutableMacosArm64

더 알아보기

C와의 상호 운용(Interoperability with C) 문서에서 더 고급 시나리오를 다루는 내용을 확인해 보세요.