[Unity] C++과 C#의 소통 방법
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!
Transform, MeshRenderer, AudioSource 등 유니티의 컴포넌트와 Monobehaviour를 상속받은 우리의 컴포넌트는, C#과 Cpp에 동시에 존재한다.
이들의 실체는 Cpp에 있다. C#은 그 Native Object에 대한 참조를 Managed Object로써 들고 있을 뿐이다. 따라서, transform.position 등 그 객체의 멤버에 접근하려 한다면 그것은 결국 C# 참조를 통해 최종적으로 Native쪽 객체의 멤버에 접근하는 것이다.
또 Monobehaviour를 상속받은 클래스의 Start()나 Update()같은 콜백은 Cpp에서 직접 호출하는 것이다. 지금까지는 ‘Unity가 호출해준다’라고만 알고 있었다면, 이 장에서는 두 구분된 세계(C#과 Cpp)가 어떻게 소통하는지 다룬다.
C# 내에서 의존성을 주입하고 함수를 호출하는 과정과는 다르게, 우리는 C#에서 Cpp의 함수를 호출하기 위해 특별한 방법을 사용한다. 그 이유는 크게 셋 있다.
C#과 Cpp의 근본적인 차이 중 하나로 런타임의 유무를 꼽을 수 있겠다. 우리가 단순히 C#에서 Cpp함수를 호출한다고 해보자. 인자로 참조를 넘겼을때, 그 함수가 실행되는 동안 moving GC가 그 참조를 이동한다면 어떤 일이 생길까?
GC는 Managed 영역 바깥은 추적할 수 없다. 따라서 Managed는 아무런 문제가 없으나, Native측은 참조의 주소 이동 사실을 알 수 없어 무의미한 주소를 여전히 참조하게 될 것이다.
따라서 Native에 참조를 넘길 때, 그 객체를 Pin하여 GC에게 ‘이 것은 이동하면 안 됨’을 알리는 등으로 동작한다.
이처럼 두 세계의 객체 생명주기를 동기화시키기 위한 일련의 과정이 필요하다.
위에서 언급한 stack map과 GC safe point삽입 문제에 더하여, 스택에 삽입하고 빼고 정리하는 일련의 과정은 그 표준조차 통일되어있지 않는 경우가 있다. Native쪽의 cdecl과 CLR의 managed calling convention은 인자를 스택에 넣는 방식부터가 다르다(x86 기준)
이외에도 반환값의 위치, 스택의 정리 주체(caller, callee)도 다를 수 있다. 결국 이 둘을 중계할 것이 필요하다.
int, float, double, boolean 등의 원시타입은 해당이 없으나, string이나 구조체는 C#과 Cpp의 구현 방식이 다르다. 따라서 서로의 함수에 인자로 전달할때, 이를 대상 환경에 맞게 변환해줄 중계자가 필요해진다.
예시로, C#의 string은 UTF-16 인코딩을 쓰고 객체 앞에 GC를 위한 헤더가 붙는다. Cpp는 헤더도 없고 char* 일 수도 있고 std::string 일 수도 있다.
구조체의 필드정렬과 패딩에 대해서도 보장할 수 없다. 구조체의 멤버는 메모리상의 접근 효율을 위해 재정렬되거나 더미 바이트의 패딩을 삽입하는데, 그 기준을 결정하는 주체에서부터 양쪽에서 다르다.
따라서 ‘이 자료를 재배치하지 마라’고 명시할 필요가 있다.
결국 두 세계의 호환을 위해 자료의 변환이 필요하다.
위와 같은 문제를 해결하며 두 세계를 소통시키는 것을 Interop라고 부른다.
먼저 CoreCLR의 Interop은 P/Invoke로 이루어진다. 그 형태는 아래와 같다.
[DllImport("native_lib")]
static extern int MyNativeFunc(int a, float b);
이는 native_lib라는 dll에서 int MyNativeFunc(int, float) 를 ‘찾아’ 실행하라는 뜻이다. 런타임이 이를 수행하며, 이 과정에서 interop도 함께 진행된다. 자료구조 호환을 위한 변환(Marshaling), 호출 규약 일치 등이 이루어진다.
Native쪽의 함수를 호출할 때 전달하는 인자들에 대해서, int나 float같은 원시타입은 그냥 넘기면 된다. 그런데 string, 배열, 복잡한 구조체 등은 위에서 언급했듯이 변환이 필요하다. 전자를 Blittable타입이라 부르고, 후자는 Non-Blittable타입이라 부른다.
대부분의 마샬링은 꽤나 오버헤드를 수반한다. 단순히 C#과 Cpp의 경계를 넘나드는 것도 물론 비용이 있으나, 더 중요한 것은 이 Non-Blittable 타입들의 변환 비용이다.
Unity는 대부분의 API를 Internal Call로 구현했다. 이는 P/Invoke와는 사뭇 다르다. 우리는 IDE에서 UnityEngine의 클래스의 ‘정의로 이동’을 통해 아래와 같은 코드를 볼 수 있다.
[MethodImpl(MethodImplOptions.InternalCall)]
extern void SetPosition(Vector3 pos);
위 함수의 어트리뷰트에는 찾아야할 라이브러리가 적혀있지 않다. 이는 그 구체 Native 함수 구현이 엔진 내에 있고, 런타임(Mono나 IL2CPP)이 그 테이블을 들고 있기 때문이다.
C# 메소드 해시 - Cpp 함수 포인터 쌍의 테이블을 구성한다.구조적으로 Unity의 게임 프로세스와 엔진의 Cpp 구현들은 같은 프로세스로 링크되어있다. 즉, 외부 라이브러리가 아니다. 그리고 Unity는 런타임을 포함한다.
참고로 IL2CPP는 빌드시점에 IL → Cpp 변환을 거친다. 이때 icall 메소드 대신 직접 cpp함수포인터를 삽입해두면 되므로 테이블은 필요없다.
이러한 구조에서 Unity는 굳이 외부 라이브러리까지 가지 않고, icall을 통해 더 간결하게 두 분리된 세계를 연결할 수 있다.
지금까지는 Managed → Native 방향의 interop을 다뤘다. 그 반대 방향도 연결이 필요하다. Monobehavior를 상속한 클래스의 Start() , Update() 같은 콜백이 바로 그 Cpp에서 C#으로의 호출이다.
Mono에서는 mono_runtime_invoke 등의 임베딩 API(런타임이 라이브러리로 쓰일 때의 API)를 쓴다. 이렇게 실행되는 C# 함수는 IL로 존재할 것이고, Mono는 이를 JIT컴파일하여 실행한 뒤 그 결과를 cpp측에 반환한다.
IL2CPP도 마찬가지로 동작하지만, AOT컴파일 과정에서 cpp를 거칠 때 cpp → c# 호출을 위한 함수 포인터 테이블 생성을 마친다.
참고로, 두 런타임 모두 Cpp에서 mono_runtime_invoke나 il2cpp_runtime_invoke를 호출할 때 메타데이터 테이블을 뒤지고, il2cpp는 곧바로 함수포인터 실행, mono는 jit컴파일 후 캐싱 및 실행한다는 점에서 차이가 있다.
Interop을 통해, 이제 서로가 서로의 함수를 호출할 수 있게 되었다. 그러나 Managed 에서 Native Object로의 접근은 여전히 미구현이다. 이 문제를 해결하기위해 Wrapper를 사용한다.
래퍼는 native 객체에 대한 포인터를 갖는다. 그리고 각 메소드에 대해 interop한다. 그 구현 형태는 아래와 같다.
class Character {
IntPtr nativePtr; // Native Object의 주소를 정수로 보관
public Character(int hp)
{
nativePtr = Character_Create(hp); // C++에 생성 요청
}
public int HP {
get { return Character_GetHP(nativePtr); }
set { Character_SetHP(nativePtr, value); }
}
public void Destroy()
{
Character_Destroy(nativePtr); // C++에 해제 요청
nativePtr = IntPtr.Zero;
}
// Internal Call 선언
[MethodImpl(MethodImplOptions.InternalCall)]
static extern IntPtr Character_Create(int hp);
[MethodImpl(MethodImplOptions.InternalCall)]
static extern int Character_GetHP(IntPtr ptr);
[MethodImpl(MethodImplOptions.InternalCall)]
static extern void Character_SetHP(IntPtr ptr, int value);
[MethodImpl(MethodImplOptions.InternalCall)]
static extern void Character_Destroy(IntPtr ptr);
}
위 예제는 hp필드만 있는 간단한 래퍼 클래스의 예시이다. 그리고 아래는 이에 대응하는 Cpp측 Native 객체의 구현이다.
// Native Object — Unmanaged Memory에 존재
class NativeCharacter {
int hp;
public:
NativeCharacter(int hp) : hp(hp) {}
int GetHP() { return hp; }
void SetHP(int value) { hp = value; }
};
// C#에서 호출될 C++ 함수들
NativeCharacter* Character_Create(int hp) {
return new NativeCharacter(hp);
}
int Character_GetHP(NativeCharacter* ptr) {
return ptr->GetHP();
}
void Character_SetHP(NativeCharacter* ptr, int value) {
ptr->SetHP(value);
}
void Character_Destroy(NativeCharacter* ptr) {
delete ptr;
}
이제 Character라는 C# 래퍼 클래스를 사용하는 것으로, 우리는 Native 객체에 접근할 수 있다.
우리는 그다지 이런 래퍼를 구현한 적이 없었다. 그럼에도 게임은 곧잘 돌아간다. 그 이유는 무엇일까? 바로 우리의 모든 Monobehavior 클래스가 UnityEngine.Object를 상속받기 때문이고, 이 base class에 IntPtr가 들어있기 때문이다.
이것은 Cpp로 구현된 UnityEngine과의 여러가지 상호작용을 가능하게 한다. 그 중에어도 가장 체감할 수 있는 것이 Inspector일 것이다.
Unity엔진에는 수많은 컴포넌트가 있고, 이들은 모두 내부적으로 Cpp와 연결되어있다. 당연히, 이 모든 컴포넌트에 래퍼 객체가 있다. 즉 개발자는 기능 하나를 위해 Cpp와 C# 모두에 해당 기능을 하는 메소드를 추가해야한다. 이는 여간 귀찮은 일이 될 것이다.
바인딩 생성기는 기 구현된 Cpp의 객체에 대해 C# 래퍼를 자동으로 생성해준다. 구체적으로는, Cpp의 필드들에 대해 특정 어트리뷰트를 달아주면 자동 생성해준다. 이제 좀 덜 귀찮겠죠.
