IL2CPP 내부: 생성된 코드 둘러보기

이 글은 IL2CPP 내부 시리즈의 두 번째 블로그 게시물입니다. 이 글에서는 il2cpp.exe에 의해 생성된 C++ 코드를 살펴보겠습니다. 이 과정에서 네이티브 코드에서 관리형 유형이 어떻게 표현되는지 살펴보고, .NET 가상 머신을 지원하는 데 사용되는 런타임 검사를 살펴보고, 루프가 생성되는 방법 등을 살펴봅니다!
이후 버전의 Unity에서 변경될 버전별 코드에 대해 알아보겠습니다. 하지만 개념은 동일하게 유지됩니다.
프로젝트 예시
이 예제에서는 사용 가능한 최신 버전인 5.0.1p1을 사용하겠습니다. 이 시리즈의 첫 번째 글에서와 마찬가지로 빈 프로젝트로 시작하여 스크립트 파일 하나를 추가하겠습니다. 이번에는 다음과 같은 내용을 담고 있습니다:
using UnityEngine;
public class HelloWorld : MonoBehaviour {
private class Important {
public static int ClassIdentifier = 42;
public int InstanceIdentifier;
}
void Start () {
Debug.Log("Hello, IL2CPP!");
Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);
var importantData = new [] {
new Important { InstanceIdentifier = 0 },
new Important { InstanceIdentifier = 1 } };
Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);
Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);
try {
throw new InvalidOperationException("Don't panic");
}
catch (InvalidOperationException e) {
Debug.Log(e.Message);
}
for (var i = 0; i < 3; ++i) {
Debug.LogFormat("Loop iteration: {0}", i);
}
}
}Windows에서 Unity 에디터를 실행하여 WebGL용 프로젝트를 빌드하겠습니다. 빌드 설정에서 개발 플레이어 옵션을 선택했더니 생성된 C++ 코드에서 비교적 멋진 이름을 얻을 수 있었습니다. 또한 WebGL 플레이어 설정에서 예외 활성화 옵션을 전체로 설정했습니다.
생성된 코드 개요
WebGL 빌드가 완료되면 생성된 C++ 코드는 내 프로젝트 디렉터리의 Temp\StagingArea\Data\il2cppOutput 디렉터리에서 확인할 수 있습니다. 편집기를 닫으면 이 디렉터리는 삭제됩니다. 하지만 에디터가 열려 있는 한 이 디렉터리는 변경되지 않은 상태로 유지되므로 검사할 수 있습니다.
이 작은 프로젝트에서도 il2cpp.exe 유틸리티는 많은 파일을 생성했습니다. 4625개의 헤더 파일과 89개의 C++ 소스 코드 파일이 표시됩니다. 이 모든 코드를 처리하기 위해 저는 Exuberant CTag와 함께 작동하는 텍스트 편집기를 사용합니다. CTags는 일반적으로 이 코드에 대한 태그 파일을 빠르게 생성하므로 탐색하기가 더 쉽습니다.
처음에는 생성된 C++ 파일 중 상당수가 간단한 스크립트 코드가 아니라 mscorlib.dll과 같은 표준 라이브러리에서 변환된 버전의 코드임을 알 수 있습니다. 이 시리즈의 첫 번째 글에서 언급했듯이 IL2CPP 스크립팅 백엔드는 Mono 스크립팅 백엔드와 동일한 표준 라이브러리 코드를 사용합니다. il2cpp.exe가 실행될 때마다 mscorlib.dll 및 기타 표준 라이브러리 어셈블리의 코드를 변환한다는 점에 유의하세요. 코드가 변경되지 않으므로 불필요한 작업으로 보일 수 있습니다.
그러나 IL2CPP 스크립팅 백엔드는 항상 바이트 코드 스트리핑을 사용하여 실행 파일 크기를 줄입니다. 따라서 스크립트 코드를 조금만 변경해도 상황에 따라 표준 라이브러리 코드의 여러 부분이 사용되거나 사용되지 않을 수 있습니다. 따라서 매번 mscorlib.dll 어셈블리를 변환해야 합니다. 점진적 빌드를 수행하는 더 나은 방법을 연구하고 있지만 아직 좋은 해결책은 없습니다.
관리 코드가 생성된 C++ 코드에 매핑되는 방법
관리 코드의 각 유형에 대해 il2cpp.exe는 해당 유형의 C++ 정의를 위한 헤더 파일과 해당 유형의 메서드 선언을 위한 헤더 파일을 하나씩 생성합니다. 예를 들어 변환된 UnityEngine.Vector3 타입의 내용을 살펴봅시다. 해당 유형의 헤더 파일 이름은 UnityEngine_UnityEngine_Vector3.h입니다. 이름은 어셈블리 이름인 UnityEngine.dll과 네임스페이스 및 유형 이름을 기반으로 만들어집니다. 코드는 다음과 같습니다:
// UnityEngine.Vector3
struct Vector3_t78
{
// System.Single UnityEngine.Vector3::x
float ___x_1;
// System.Single UnityEngine.Vector3::y
float ___y_2;
// System.Single UnityEngine.Vector3::z
float ___z_3;
};il2cpp.exe 유틸리티는 세 개의 인스턴스 필드 각각을 변환하고 충돌과 예약어를 피하기 위해 약간의 이름 변경을 수행했습니다. 선행 밑줄을 사용하여 C++에서 일부 예약 이름을 사용하고 있지만, 지금까지 C++ 표준 라이브러리 코드와 충돌하는 사례는 발견되지 않았습니다.
UnityEngine_UnityEngine_Vector3MethodDeclarations.h 파일에는 Vector3의 모든 메서드에 대한 메서드 선언이 포함되어 있습니다. 예를 들어, Vector3는 Object.ToString 메서드를 재정의합니다:
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR이 네이티브 선언이 나타내는 관리 메서드를 나타내는 주석에 주목하세요. 저는 출력 파일에서 이 형식으로 관리되는 메서드의 이름을 검색하는 것이 유용하다는 것을 종종 발견하는데, 특히 ToString과 같이 일반적인 이름을 가진 메서드의 경우 더욱 그렇습니다.
il2cpp.exe에 의해 변환된 모든 메소드에 대해 몇 가지 흥미로운 점을 주목하세요:
- 이들은 C++의 멤버 함수가 아닙니다. 모든 메서드는 자유 함수이며, 첫 번째 인수는 "this" 포인터입니다. 관리 코드의 정적 함수의 경우 IL2CPP는 이 첫 번째 인수에 항상 NULL 값을 전달합니다. 항상 "this" 포인터를 첫 번째 인수로 사용하여 메서드를 선언함으로써 il2cpp.exe의 메서드 생성 코드를 단순화하고 생성된 코드에서 다른 메서드(예: 델리게이트)를 통한 메서드 호출을 더 간단하게 만들 수 있습니다.
- 모든 메서드에는 가상 메서드 호출과 같은 작업에 사용되는 메서드에 대한 메타데이터를 포함하는 MethodInfo* 유형의 추가 인수가 있습니다. 모노 스크립팅 백엔드는 플랫폼별 트램폴린을 사용하여 이 메타데이터를 전달합니다. IL2CPP의 경우 휴대성을 높이기 위해 트램폴린을 사용하지 않기로 결정했습니다.
- 모든 메서드는 외부 "C"로 선언되어 있으므로 il2cpp.exe가 때때로 C++ 컴파일러에 거짓말을 하고 모든 메서드가 동일한 유형인 것처럼 취급할 수 있습니다.
- 유형에는 "_t" 접미사가 붙습니다. 메서드 이름에는 "_m" 접미사가 붙습니다. 이름 충돌은 각 이름에 고유 번호를 추가하여 해결합니다. 이 숫자는 사용자 스크립트 코드가 변경되면 변경되므로 빌드마다 이 숫자에 의존해서는 안 됩니다.
처음 두 점은 모든 메서드에 적어도 두 개의 매개변수, 즉 "this" 포인터와 MethodInfo 포인터가 있다는 것을 의미합니다. 이러한 추가 매개변수로 인해 불필요한 오버헤드가 발생하나요? 오버헤드가 추가되는 것은 분명하지만, 이러한 추가 인수가 성능 문제를 일으킨다는 것을 시사하는 증거는 아직까지 발견되지 않았습니다. 그럴 것 같지만, 프로파일링 결과 성능의 차이는 측정할 수 없는 것으로 나타났습니다.
Ctags를 사용하여 이 ToString 메서드의 정의로 넘어갈 수 있습니다. Bulk_UnityEngine_0.cpp 파일에 있습니다. 해당 메서드 정의의 코드는 Vector3::ToString() 메서드의 C# 코드와 크게 닮지 않았습니다. 그러나 ILSpy와 같은 도구를 사용하여 Vector3::ToString() 메서드의 코드를 반영하면 생성된 C++ 코드가 IL 코드와 매우 유사하다는 것을 알 수 있습니다.
il2cpp.exe가 메서드 선언과 마찬가지로 각 유형에 대한 메서드 정의에 대해 별도의 C++ 파일을 생성하지 않는 이유는 무엇인가요? 이 벌크_유니티엔진_0.cpp 파일은 실제로 20,481줄에 달하는 꽤 큰 파일입니다! 사용하던 C++ 컴파일러가 많은 수의 소스 파일에 문제가 있다는 사실을 알게 되었습니다. 동일한 소스 코드를 80개의 .cpp 파일로 컴파일하는 것보다 4천 개의 .cpp 파일을 컴파일하는 데 훨씬 더 오랜 시간이 걸렸습니다. 따라서 il2cpp.exe는 유형에 대한 메서드 정의를 그룹으로 일괄 처리하고 그룹당 하나의 C++ 파일을 생성합니다.
이제 메서드 선언 헤더 파일로 돌아가서 파일 맨 위에 있는 이 줄을 확인합니다:
#include "codegen/il2cpp-codegen.h"il2cpp-codegen.h 파일에는 생성된 코드가 libil2cpp 런타임 서비스에 액세스하는 데 사용하는 인터페이스가 포함되어 있습니다. 나중에 생성된 코드에서 런타임을 사용하는 몇 가지 방법에 대해 설명하겠습니다.
메서드 프롤로그
Vector3::ToString() 메서드의 정의를 살펴보겠습니다. 특히, 모든 메소드에서 il2cpp.exe에 의해 방출되는 공통 프롤로그가 있습니다.
StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);
static bool Vector3_ToString_m2315_init;
if (!Vector3_ToString_m2315_init)
{
ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);
Vector3_ToString_m2315_init = true;
}이 프롤로그의 첫 줄은 StackTraceSentry 유형의 로컬 변수를 생성합니다. 이 변수는 관리되는 호출 스택을 추적하는 데 사용되므로 IL2CPP가 Environment.StackTrace와 같은 호출에서 이를 보고할 수 있습니다. 이 항목의 코드 생성은 실제로 선택 사항이며, 이 경우 il2cpp.exe에 전달된 --enable-stacktrace 옵션에 의해 활성화됩니다(WebGL 플레이어 설정에서 예외 활성화 옵션을 전체로 설정했기 때문에). 작은 함수의 경우 이 변수의 오버헤드가 성능에 부정적인 영향을 미치는 것으로 나타났습니다. 따라서 플랫폼별 스택 추적 정보를 사용할 수 있는 iOS 및 기타 플랫폼의 경우 이 줄을 생성된 코드에 포함하지 않습니다. WebGL의 경우 플랫폼별 스택 추적을 지원하지 않으므로 관리 코드 예외가 제대로 작동하도록 허용해야 합니다.
프롤로그의 두 번째 부분에서는 메서드 본문에 사용된 배열 또는 일반 유형에 대한 유형 메타데이터의 지연 초기화를 수행합니다. 따라서 ObjectU5BU5D_t4라는 이름은 System.Object[] 유형의 이름입니다. 프롤로그의 이 부분은 한 번만 실행되며 유형이 이미 다른 곳에서 초기화된 경우 아무 일도 하지 않는 경우가 많으므로 이 생성된 코드가 성능에 미치는 부정적인 영향은 발견되지 않았습니다.
이 코드 스레드는 안전한가요? 두 개의 스레드가 동시에 Vector3::ToString()을 호출하면 어떻게 될까요? 실제로 이 코드는 유형 초기화에 사용되는 libil2cpp 런타임의 모든 코드가 여러 스레드에서 호출해도 안전하기 때문에 문제가 되지 않습니다. il2cpp_codegen_class_from_type 함수가 두 번 이상 호출될 수 있지만 실제 작업은 하나의 스레드에서 한 번만 수행됩니다(어쩌면 그럴 가능성도 있습니다). 메서드 실행은 해당 초기화가 완료될 때까지 계속되지 않습니다. 따라서 이 메서드 프롤로그는 스레드에 안전합니다.
런타임 검사
메서드의 다음 부분에서는 객체 배열을 생성하고 Vector3의 x 필드 값을 로컬에 저장한 다음 로컬을 박스 처리하고 인덱스 0에 배열에 추가합니다. 다음은 생성된 C++ 코드(일부 주석 포함)입니다:
// Create a new single-dimension, zero-based object array
ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));
// Store the Vector3::x field in a local
float L_1 = (__this->___x_1);
float L_2 = L_1;
// Box the float instance, since it is a value type.
Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);
// Here are three important runtime checks
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);
ArrayElementTypeCheck (L_0, L_3);
// Store the boxed value in the array at index 0
*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;세 가지 런타임 검사는 IL 코드에 존재하지 않고 대신 il2cpp.exe에 의해 주입됩니다.
- 배열의 값이 null인 경우 NullCheck 코드는 NullReferenceException을 던집니다.
- 배열 인덱스가 올바르지 않은 경우 IL2CPP_ARRAY_BOUNDS_CHECK 코드가 IndexOutOfRangeException을 던집니다.
- 배열에 추가되는 요소의 유형이 올바르지 않은 경우 ArrayElementTypeCheck 코드는 ArrayTypeMismatchException을 던집니다.
이 세 가지 런타임 검사는 모두 .NET 가상 머신에서 제공하는 보증입니다. 모노 스크립팅 백엔드는 코드를 삽입하는 대신 플랫폼별 시그널링 메커니즘을 사용하여 이러한 동일한 런타임 검사를 처리합니다. IL2CPP의 경우 플랫폼에 구애받지 않고 플랫폼별 시그널링 메커니즘이 없는 WebGL과 같은 플랫폼을 지원하고자 했기 때문에 il2cpp.exe에 이러한 검사를 삽입했습니다.
하지만 이러한 런타임 검사로 인해 성능 문제가 발생하나요? 대부분의 경우 성능에 부정적인 영향을 미치지 않았으며 .NET 가상 머신에 필요한 이점과 안전성을 제공합니다. 하지만 몇 가지 특정 사례에서 이러한 점검이 특히 타이트한 루프에서 성능 저하로 이어지는 것을 목격하고 있습니다. 현재 관리 코드에 주석을 달아 il2cpp.exe가 C++ 코드를 생성할 때 이러한 런타임 검사를 제거할 수 있도록 하는 방법을 개발 중입니다. 계속 지켜봐 주세요.
스태틱 필드
이제 인스턴스 필드(Vector3 유형)가 어떻게 보이는지 살펴봤으니, 정적 필드가 어떻게 변환되고 액세스되는지 살펴보겠습니다. 내 빌드의 Bulk_Assembly-CSharp_0.cpp 파일에 있는 HelloWorld_Start_m3 메서드의 정의를 찾습니다. 거기에서 중요_t1 유형(theAssemblyU2DCSharp_HelloWorld_중요.h 파일에 있음)으로 이동합니다:
struct Important_t1 : public Object_t
{
// System.Int32 HelloWorld/Important::InstanceIdentifier
int32_t ___InstanceIdentifier_1;
};
struct Important_t1_StaticFields
{
// System.Int32 HelloWorld/Important::ClassIdentifier
int32_t ___ClassIdentifier_0;
};이 유형의 모든 인스턴스 간에 정적 필드가 공유되므로 il2cpp.exe는 이 유형의 정적 필드를 보관하기 위해 별도의 C++ 구조체를 생성했습니다. 따라서 런타임에 중요_t1_StaticFields 유형의 인스턴스가 하나 생성되고 중요_t1 유형의 모든 인스턴스가 해당 정적 필드 유형의 인스턴스를 공유하게 됩니다. 생성된 코드에서 정적 필드는 다음과 같이 액세스됩니다:
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);중요_t1에 대한 유형 메타데이터는 중요_t1_StaticFields 유형의 단일 인스턴스에 대한 포인터를 보유하며, 해당 인스턴스는 정적 필드의 값을 가져오는 데 사용됩니다.
예외
관리되는 예외는 il2cpp.exe에 의해 C++ 예외로 변환됩니다. 플랫폼별 솔루션을 다시 한 번 피하기 위해 이 방법을 선택했습니다. il2cpp.exe가 관리되는 예외를 발생시키기 위해 코드를 방출해야 하는 경우, il2cpp_codegen_raise_exception 함수를 호출합니다.
관리되는 예외를 던지고 잡는 HelloWorld_Start_m3 메서드의 코드는 다음과 같습니다:
try
{ // begin try (depth: 1)
InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));
InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);
il2cpp_codegen_raise_exception(L_17);
// IL_0092: leave IL_00a8
goto IL_00a8;
} // end try (depth: 1)
catch(Il2CppExceptionWrapper& e)
{
__exception_local = (Exception_t8 *)e.ex;
if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))
goto IL_0097;
throw e;
}
IL_0097:
{ // begin catch(System.InvalidOperationException)
V_1 = ((InvalidOperationException_t7 *)__exception_local);
NullCheck(V_1);
String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);
Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);
// IL_00a3: leave IL_00a8
goto IL_00a8;
} // end catch (depth: 1)모든 관리되는 예외는 C++ Il2CppExceptionWrapper 유형으로 래핑됩니다. 생성된 코드가 해당 유형의 예외를 포착하면 관리되는 예외(유형이 Exception_t8인)의 C++ 표현을 언패킹합니다. 이 경우 InvalidOperationException만 찾고 있으므로 해당 유형의 예외를 찾지 못하면 C++ 예외의 복사본이 다시 던져집니다. 올바른 유형을 찾으면 코드가 캐치 핸들러의 구현으로 이동하여 예외 메시지를 출력합니다.
Goto!?!
이 코드에서 흥미로운 점을 발견할 수 있습니다. 저 레이블과 고토 문은 저기서 무엇을 하고 있나요? 이러한 구조는 구조화된 프로그래밍에서는 필요하지 않습니다! 그러나 IL에는 루프나 if/then 문과 같은 구조화된 프로그래밍 개념이 없습니다. 하위 수준이기 때문에 il2cpp.exe는 생성된 코드에서 하위 수준 개념을 따릅니다.
예를 들어 HelloWorld_Start_m3 메서드의 for 루프를 살펴봅시다:
IL_00a8:
{
V_2 = 0;
goto IL_00cc;
}
IL_00af:
{
ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));
int32_t L_20 = V_2;
Object_t * L_21 =
Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);
NullCheck(L_19);
IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);
ArrayElementTypeCheck (L_19, L_21);
*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;
Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);
V_2 = ((int32_t)(V_2+1));
}
IL_00cc:
{
if ((((int32_t)V_2) < ((int32_t)3)))
{
goto IL_00af;
}
}여기서 V_2 변수는 루프 인덱스입니다. Is는 0 값으로 시작하여 이 줄의 루프 하단에서 증분됩니다:
V_2 = ((int32_t)(V_2+1));그런 다음 루프의 종료 조건이 여기에서 확인됩니다:
if ((((int32_t)V_2) < ((int32_t)3)))V_2가 3보다 작으면 goto 문은 루프 본문의 맨 위에 있는 IL_00af 레이블로 이동합니다. 현재 il2cpp.exe가 중간 추상 구문 트리 표현을 사용하지 않고 IL에서 직접 C++ 코드를 생성하고 있음을 짐작할 수 있습니다. 짐작하셨다면 정답입니다. 위의 런타임 검사 섹션에서 생성된 코드 중 일부가 다음과 같이 보이는 것을 보셨을 것입니다:
float L_1 = (__this->___x_1);
float L_2 = L_1;분명히 여기서는 L_2 변수가 필요하지 않습니다. 대부분의 C++ 컴파일러는 이 추가 할당을 최적화할 수 있지만, 저희는 이 할당이 전혀 발생하지 않도록 하려고 합니다. 현재 AST를 사용하여 IL 코드를 더 잘 이해하고 로컬 변수와 루프가 포함된 경우에 더 나은 C++ 코드를 생성할 수 있는 가능성을 연구하고 있습니다.
맺는말
아주 간단한 프로젝트를 위해 IL2CPP 스크립팅 백엔드에서 생성된 C++ 코드의 표면을 살짝 살펴봤습니다. 아직 해보지 않았다면 프로젝트에서 생성된 코드를 자세히 살펴볼 것을 권장합니다. IL2CPP 스크립팅 백엔드의 빌드 및 런타임 성능을 개선하기 위해 지속적으로 노력하고 있으므로 생성된 C++ 코드는 향후 Unity 버전에서 다르게 보일 수 있다는 점을 유념하시기 바랍니다.
IL 코드를 C++로 변환함으로써 이식성과 성능 코드 간의 균형을 맞출 수 있었습니다. 관리형 코드의 개발자 친화적인 여러 기능을 사용하면서 다양한 플랫폼에서 C++ 컴파일러가 제공하는 고품질 머신 코드의 이점을 누릴 수 있습니다.
다음 글에서는 메서드 호출, 메서드 구현 공유, 네이티브 라이브러리 호출을 위한 래퍼 등 생성된 코드에 대해 더 자세히 살펴보겠습니다. 하지만 다음에는 Xcode를 사용하여 iOS 64비트 빌드에 대해 생성된 코드 중 일부를 디버그해 보겠습니다.
