IL2CPP 내부: P/래퍼 호출

저는 예전에 네이티브 인터롭 코드에 대한 관리형 코드를 꽤 많이 작성해 보았지만, C#에서 p/invoke 선언을 올바르게 사용하는 것은 여전히 어렵습니다. 런타임이 내 오브젝트를 마샬링하기 위해 무엇을 하는지 이해하는 것은 훨씬 더 미스테리한 일입니다. IL2CPP는 생성된 C++ 코드에서 대부분의 마샬링을 수행하므로 동작을 확인할 수 있고 디버그까지 할 수 있어 문제 해결 및 성능 분석에 훨씬 더 나은 인사이트를 제공합니다.
이 게시물은 마샬링 및 네이티브 인터롭에 대한 일반적인 정보를 제공하는 것이 목적이 아닙니다. 한 개의 게시물로 다루기에는 너무 방대한 주제입니다. 유니티 문서에서는 네이티브 플러그인이 유니티와 상호작용하는 방식에 대해 설명합니다. Mono와 Microsoft 모두 p/invoke 전반에 대한 훌륭한 정보를 많이 제공합니다.
이 시리즈의 모든 게시물과 마찬가지로 변경될 수 있고 실제로 최신 버전의 Unity에서 변경될 가능성이 있는 코드를 살펴볼 것입니다. 그러나 개념은 동일하게 유지되어야 합니다. 이 시리즈에서 설명하는 모든 내용을 구현 세부 사항으로 받아들여 주세요. 하지만 저희는 가능하면 이런 세부 사항을 공개하고 토론하는 것을 좋아합니다!
이 포스팅에서는 OSX에서 Unity 5.0.2p4를 사용하고 있습니다. '아키텍처' 값을 '유니버설'로 설정하여 iOS 플랫폼용으로 빌드하겠습니다. 이 예제의 네이티브 코드는 Xcode 6.3.2에서 ARMv7 및 ARM64용 정적 라이브러리로 빌드했습니다.
네이티브 코드는 다음과 같습니다:
#include <cstring>
#include <cmath>
extern "C" {
int Increment(int i) {
return i + 1;
}
bool StringsMatch(const char* l, const char* r) {
return strcmp(l, r) == 0;
}
struct Vector {
float x;
float y;
float z;
};
float ComputeLength(Vector v) {
return sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}
void SetX(Vector* v, float value) {
v->x = value;
}
struct Boss {
char* name;
int health;
};
bool IsBossDead(Boss b) {
return b.health == 0;
}
int SumArrayElements(int* elements, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += elements[i];
}
return sum;
}
int SumBossHealth(Boss* bosses, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += bosses[i].health;
}
return sum;
}
}Unity의 스크립팅 코드는 다시 HelloWorld.cs 파일에 있습니다. 다음과 같이 진행합니다.
void Start () {
Debug.Log (string.Format ("Using a blittable argument: {0}", Increment (42)));
Debug.Log (string.Format ("Marshaling strings: {0}", StringsMatch ("Hello", "Goodbye")));
var vector = new Vector (1.0f, 2.0f, 3.0f);
Debug.Log (string.Format ("Marshaling a blittable struct: {0}", ComputeLength (vector)));
SetX (ref vector, 42.0f);
Debug.Log (string.Format ("Marshaling a blittable struct by reference: {0}", vector.x));
Debug.Log (string.Format ("Marshaling a non-blittable struct: {0}", IsBossDead (new Boss("Final Boss", 100))));
int[] values = {1, 2, 3, 4};
Debug.Log(string.Format("Marshaling an array: {0}", SumArrayElements(values, values.Length)));
Boss[] bosses = {new Boss("First Boss", 25), new Boss("Second Boss", 45)};
Debug.Log(string.Format("Marshaling an array by reference: {0}", SumBossHealth(bosses, bosses.Length)));
}이 코드의 각 메서드 호출은 위에 표시된 네이티브 코드로 만들어집니다. 이 글의 뒷부분에서 각 메소드에 대한 관리 메소드 선언을 살펴보겠습니다.
IL2CPP가 이미 C++ 코드를 생성하고 있는데, 왜 C#에서 C++ 코드로의 마샬링이 필요할까요? 생성된 C++ 코드는 네이티브 코드이지만 C#의 유형 표현은 C++와 다른 경우가 많으므로 IL2CPP 런타임은 양쪽의 표현을 앞뒤로 변환할 수 있어야 합니다. il2cpp.exe 유틸리티는 유형과 메서드 모두에 대해 이 작업을 수행합니다.
관리 코드에서 모든 유형은 다음 중 하나로 분류할 수 있습니다. 블리터블 또는 비블리터블. 블리터블 유형은 관리 코드와 네이티브 코드(예: 바이트, 정숫값, 부동 소수점)에서 동일한 표현을 가집니다. 블릿할 수 없는 유형은 관리 코드와 네이티브 코드에서 다르게 표현됩니다(예: 부울, 문자열, 배열 유형). 따라서 블리터블 타입은 네이티브 코드로 바로 전달할 수 있지만, 블리터블이 아닌 타입은 네이티브 코드로 전달하기 전에 약간의 변환이 필요합니다. 이러한 변환에는 새로운 메모리 할당이 수반되는 경우가 많습니다.
관리 코드 컴파일러에 특정 메서드가 네이티브 코드로 구현되었음을 알리기 위해 C#에서는 외부 키워드가 사용됩니다. 이 키워드는 DllImport 속성과 함께 관리 코드 런타임이 네이티브 메서드 정의를 찾아 호출할 수 있도록 합니다. il2cpp.exe 유틸리티는 각 외부 메서드에 대한 래퍼 C++ 메서드를 생성합니다. 이 래퍼는 몇 가지 중요한 작업을 수행합니다:
- 함수 포인터를 통해 메서드를 호출하는 데 사용되는 네이티브 메서드에 대한 typedef를 정의합니다.
- 네이티브 메서드를 이름으로 확인하여 해당 메서드에 대한 함수 포인터를 가져옵니다.
- 필요한 경우 인수를 관리되는 표현에서 기본 표현으로 변환합니다(필요한 경우).
- 네이티브 메서드를 호출합니다.
- 메서드의 반환값을 네이티브 표현에서 관리되는 표현으로 변환합니다(필요한 경우).
- In은 필요한 경우 모든 아웃 또는 레퍼런스 인수를 기본 표현에서 관리되는 표현으로 변환합니다(필요한 경우).
다음에는 몇 가지 외부 메서드 선언에 대해 생성된 래퍼 메서드를 살펴보겠습니다.
가장 간단한 종류의 외부 래퍼는 블리터블 유형만 처리합니다.
[DllImport("__Internal")]
private extern static int Increment(int value);
In the Bulk_Assembly-CSharp_0.cpp file, search for the string “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:
extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
extern "C" int32_t HelloWorld_Increment_m3 (Object_t * __this /* static, unused */, int32_t ___value, const MethodInfo* method)
{
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
static PInvokeFunc _il2cpp_pinvoke_func;
if (!_il2cpp_pinvoke_func)
{
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
if (_il2cpp_pinvoke_func == NULL)
{
il2cpp_codegen_raise_exception(il2cpp_codegen_get_not_supported_exception("Unable to find method for p/invoke: 'Increment'"));
}
}
int32_t _return_value = _il2cpp_pinvoke_func(___value);
return _return_value;
}
먼저 네이티브 함수 시그니처의 typedef에 주목하세요:
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);각 래퍼 함수에도 비슷한 내용이 표시됩니다. 이 네이티브 함수는 단일 int32_t를 받아 int32_t를 반환합니다.
다음으로 래퍼는 적절한 함수 포인터를 찾아 정적 변수에 저장합니다:
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;여기서 증분 함수는 실제로 (C++ 코드의) 외부 문에서 가져온 것입니다:
extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}iOS에서 네이티브 메서드는 단일 바이너리에 정적으로 링크되어 있으므로(DllImport 속성의 "__Internal" 문자열로 표시) IL2CPP 런타임은 함수 포인터를 조회하기 위해 아무것도 하지 않습니다. 대신 이 외부 문은 링크 시점에 적절한 함수를 찾도록 링커에 알려줍니다. 다른 플랫폼에서는 IL2CPP 런타임이 이 함수 포인터를 얻기 위해 플랫폼별 API 메서드를 사용하여 조회를 수행할 수 있습니다(필요한 경우).
실제로 이는 iOS에서 관리 코드의 잘못된 p/invoke 서명이 생성된 코드에 링커 오류로 표시된다는 것을 의미합니다. 이 오류는 런타임에 발생하지 않습니다. 따라서 런타임에 사용되지 않더라도 모든 p/invoke 서명은 정확해야 합니다.
마지막으로 함수 포인터를 통해 네이티브 메서드를 호출하고 반환값을 반환합니다. 인수는 네이티브 함수에 값으로 전달되므로 예상대로 네이티브 코드에서 해당 값을 변경하면 관리 코드에서는 사용할 수 없습니다.
문자열과 같이 잘 지워지지 않는 유형을 사용하면 상황이 좀 더 흥미로워집니다. 이전 게시물에서 IL2CPP의 문자열은 UTF-16을 통해 인코딩된 2바이트 문자 배열로 표시되며, 접두사에는 4바이트 길이 값이 붙는다는 점을 기억하세요. 이 표현은 iOS의 C에서 문자열의 char* 또는 wchar_t* 표현과 일치하지 않으므로 약간의 변환을 수행해야 합니다. 스트링스매치 메서드(생성된 코드의 헬로월드_스트링스매치_m4)를 살펴보면 다음과 같습니다:
DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);각 문자열 인수가 (UnmangedType.LPStr 지시문으로 인해) char*로 변환되는 것을 볼 수 있습니다.
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);변환은 다음과 같습니다(첫 번째 인수의 경우):
char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);적절한 길이의 새 문자 버퍼가 할당되고 문자열의 내용이 새 버퍼에 복사됩니다. 물론 네이티브 메서드가 호출된 후에는 할당된 버퍼를 정리해야 합니다:
il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;따라서 문자열과 같은 비블리트블 타입을 마샬링하는 데는 많은 비용이 들 수 있습니다.
int나 문자열과 같은 단순한 유형도 좋지만, 좀 더 복잡한 사용자 정의 유형은 어떨까요? 세 개의 부동 소수점 값이 포함된 위의 벡터 구조를 마샬링한다고 가정해 보겠습니다. 사용자 정의 유형은 모든 필드가 블리트 가능한 경우에만 블리트할 수 있는 것으로 나타났습니다. 따라서 인수를 변환할 필요 없이 ComputeLength(생성된 코드의 HelloWorld_ComputeLength_m5)를 호출할 수 있습니다:
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );
// I’ve omitted the function pointer code.
float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;인자 유형이 int였던 초기 예제에서와 마찬가지로 인자가 값으로 전달되는 것을 확인할 수 있습니다. 벡터의 인스턴스를 수정하고 관리 코드에서 해당 변경 사항을 확인하려면 SetX 메서드(HelloWorld_SetX_m6)에서와 같이 참조로 전달해야 합니다:
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 *, float);
Vector_t1 * ____v_marshaled = { 0 };
Vector_t1 ____v_marshaled_dereferenced = { 0 };
____v_marshaled_dereferenced = *___v;
____v_marshaled = &____v_marshaled_dereferenced;
float _return_value = _il2cpp_pinvoke_func(____v_marshaled, ___value);
Vector_t1 ____v_result_dereferenced = { 0 };
Vector_t1 * ____v_result = &____v_result_dereferenced;
*____v_result = *____v_marshaled;
*___v = *____v_result;
return _return_value;
여기서 벡터 인수는 네이티브 코드에 대한 포인터로 전달됩니다. 생성된 코드는 약간 복잡한 과정을 거치지만 기본적으로 동일한 유형의 로컬 변수를 생성하고 인수의 값을 로컬에 복사한 다음 해당 로컬 변수에 대한 포인터로 네이티브 메서드를 호출하는 방식입니다. 네이티브 함수가 반환된 후 로컬 변수의 값이 다시 인수로 복사되고 관리 코드에서 해당 값을 사용할 수 있습니다.
위에서 정의한 보스 유형과 같이 블릿할 수 없는 사용자 정의 유형도 마샬링할 수 있지만 조금 더 많은 작업이 필요합니다. 이 유형의 각 필드는 기본 표현으로 마샬링되어야 합니다. 또한 생성된 C++ 코드에는 네이티브 코드의 표현과 일치하는 관리되는 유형의 표현이 필요합니다.
IsBossDead 외부 선언을 살펴보겠습니다:
[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);이 메서드의 래퍼 이름은 HelloWorld_IsBossDead_m7입니다:
extern "C" bool HelloWorld_IsBossDead_m7 (Object_t * __this /* static, unused */, Boss_t2 ___b, const MethodInfo* method)
{
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (Boss_t2_marshaled);
Boss_t2_marshaled ____b_marshaled = { 0 };
Boss_t2_marshal(___b, ____b_marshaled);
uint8_t _return_value = _il2cpp_pinvoke_func(____b_marshaled);
Boss_t2_marshal_cleanup(____b_marshaled);
return _return_value;
}인수는 Boss 구조체에 대해 생성된 유형인 Boss_t2 유형으로 래퍼 함수에 전달됩니다. 다른 유형으로 네이티브 함수에 전달되는 것을 확인할 수 있습니다: Boss_t2_marshaled. 이 유형의 정의로 넘어가면 C++ 정적 라이브러리 코드의 Boss 구조체 정의와 일치하는 것을 볼 수 있습니다:
struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};다시 C#에서 UnmanagedType.LPStr 지시문을 사용하여 문자열 필드를 char*로 마샬링해야 함을 표시했습니다. 블리트할 수 없는 사용자 정의 유형으로 문제를 디버깅하는 경우 다음을 살펴보는 것이 매우 유용합니다. _marshaled 구조체를 살펴보는 것이 매우 유용합니다. 필드 레이아웃이 네이티브 측과 일치하지 않으면 관리 코드의 마샬링 지시어가 올바르지 않을 수 있습니다.
Boss_t2_marshal 함수는 각 필드를 마샬링하는 생성된 함수이며, Boss_t2_marshal_cleanup은 해당 마샬링 프로세스 중에 할당된 모든 메모리를 해제합니다.
마지막으로 블리터블 및 비블리터블 타입의 배열이 어떻게 마샬링되는지 살펴보겠습니다. SumArrayElements 메서드에는 정수 배열이 전달됩니다:
[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);이 배열은 마샬링되지만 배열의 요소 유형(int)이 블리트 가능하므로 마샬링 비용이 매우 적습니다:
int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((Il2CppCodeGenArray*)___elements);il2cpp_codegen_marshal_array 함수는 기존 관리 배열 메모리에 대한 포인터를 반환하기만 하면 됩니다!
그러나 블릿할 수 없는 유형의 배열을 마샬링하는 데는 훨씬 더 많은 비용이 듭니다. SumBossHealth 메서드는 보스 인스턴스 배열을 전달합니다:
[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);래퍼는 새 배열을 할당하고 각 요소를 개별적으로 마샬링해야 합니다:
Boss_t2_marshaled* ____bosses_marshaled = { 0 };
size_t ____bosses_Length = 0;
if (___bosses != NULL)
{
____bosses_Length = ((Il2CppCodeGenArray*)___bosses)->max_length;
____bosses_marshaled = il2cpp_codegen_marshal_allocate_array<Boss_t2_marshaled>(____bosses_Length);
}
for (int i = 0; i < ____bosses_Length; i++)
{
Boss_t2 const& item = *reinterpret_cast<Boss_t2 *>(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));
Boss_t2_marshal(item, (____bosses_marshaled)[i]);
}물론 이러한 모든 할당은 네이티브 메서드 호출이 완료된 후에도 정리됩니다.
IL2CPP 스크립팅 백엔드는 Mono 스크립팅 백엔드와 동일한 마샬링 동작을 지원합니다. IL2CPP는 외부 메서드 및 유형에 대해 생성된 래퍼를 생성하므로 네이티브 인터롭 호출에 대한 관리 비용을 확인할 수 있습니다. 블리터블 타입의 경우 이 비용은 그리 나쁘지 않지만, 블리터블이 아닌 타입은 인터롭 비용이 매우 높아질 수 있습니다. 늘 그렇듯이 이 게시물에서는 마샬링에 대한 표면적인 내용만 다루었습니다. 생성된 코드를 자세히 살펴보고 반환값과 아웃 파라미터, 네이티브 함수 포인터와 관리 델리게이트, 사용자 정의 참조 유형에 대한 마샬링이 어떻게 수행되는지 확인하세요.
다음 시간에는 IL2CPP가 가비지 컬렉터와 어떻게 통합되는지 살펴보겠습니다.
