좀 열심히 쓴 글

Unity IL2CPP 분석

ch4rli3kop 2020. 3. 6. 12:19
반응형

Unity IL2CPP 분석

 

Unity IL2CPP

Unity 쉬운 C# 사용, 써드파티 라이브러리에 대한 접근 등의 이유로 Mono 사용했습니다. 그러나 Mono에는 가지 난제가 존재합니다.

  • C# runtime performance C/C++ 비해 여전히 느리다는 .
  • 뛰어난 최신 버전의 .NET 언어를 Unity Mono에서 지원하기 힘들다는 .
  • 다양한 플랫폼들의 아키텍처에 맞추려면 포팅, 유지보수 등의 작업이 필요한데 너무 많은 노력이 든다는 .
  • 실행 중에는 Garbage collection 멈출 수도 있다는 .

위의 문제점들을 해결할 있다며 Unity에서 내세운 기술인 IL2CPP 대해 살펴보도록 하겠습니다.

Background

IL2CPP 등장은 기존 Mono 방식의 단점을 개선하기 위함뿐만 아니라 웹과도 관련이 있습니다. Unity에서는 어떤 Plugin 설치도 없이, 브라우저에서도 Unity Content들을 즐길 있도록 하기 위해서, WebGL이라는 3D graphic rendering library 채택하였습니다. 다만 브라우저에서는 오직 Javascript만을 사용하는 만큼, 해당 라이브러리 역시 Javascript API 사용하였고, Unity 역시 코드가 Javascript 구성되어야만 했습니다.

c/c++ 만들어진 Unity runtime code 경우 emscripten compiler toolchain 이용하여 Javascript 코드로 cross-compile 진행할 있었지만, 사용자가 작성한 C# 코드나 UnityScript scripts 코드와 같은 .NET game 코드들을 대상으로는 cross-compile 불가능했습니다. Unity IL2CPP라는 신기술을 이용하여 .NET bytecode 해당하는 c++ source file 변환해줌으로써 문제를 해결하였습니다.

또한, IL2CPP 많이 사용된 이유 IOS 관련된 이유 역시 존재합니다. Unity Mono arm64(armv8) 지원하지 못하는데, IOS 스토어에 apk 등록 armv7 armv8 버전을 모두 등록해야 하는 정책이 생긴 것과, IOS JIT 컴파일 방식을 지원하지 않음에 따라서 AOT 컴파일 방식을 사용하는 IL2CPP 대두되었습니다. 현재 구글 플레이 역시 버전 모두 등록해야 하는 정책이 생겨 앞으로 mono 더욱 사용이 되지 않을 것으로 보입니다. 위처럼 IL2CPP 다양한 곳에서 활용되고 있습니다.

What is IL2CPP?

IL2CPP 방식은 개의 구성요소로 나뉩니다. 앞서 언급했던 AOT(Ahead Of Time) 컴파일러와 가상머신을 지원하는 runtime library입니다. AOT 컴파일러는 .NET 컴파일러의 IL C++ native code 변환해주고, runtime library garbage collector, 쓰레드 파일에 대한 접근, 내부 호출 등에 대한 서비스와 추상화를 제공합니다. 가지 구성요소에 대해 알아보며 IL2CPP 대해 설명하겠습니다.

AOT compiler

IL2CPP AOT 컴파일러의 역할은 il2cpp.exe 유틸리티를 이용함으로써 이루어지며, 해당 파일은 C#으로 작성되고 .NET Mono 컴파일러를 이용하여 컴파일된 실행 파일입니다.

해당 유틸리티는 Unity에서 제공하는 Mono 컴파일러로 컴파일된 어셈블리를 input으로 받아들여서 각각의 플랫폼에서 사용되는 컴파일러에게 C++ 코드를 생성하여 전달합니다.

Runtime Library

다른 부분은 VM 지원하기 위한 libil2cpp라는 이름의 runtime library입니다. 우리가 libil2cpp 호출했을 , 해당 라이브러리는 player executable 링크된 정적 라이브러리 형태로 제공됩니다. Mono에서 Il2cpp 넘어오면서 생긴 가장 feature 하나입니다.

 

Analysis

Summary

Il2Cpp 방식에서는 스트링, 메소드 정보 등을 직접 관리하지 않고, global-metadata.dat이라는 파일에 저장하여 관리합니다. 바이너리 실행 해당 파일을 메모리에 적재하여 배열로 관리하게 되는데, 프로세스는 해당 배열을 읽어 들여서 스트링 메소드 정보에 접근합니다. 아래의 예시는 함수 내에서 스트링 리터럴에 접근하는 모습입니다.

StringLiteral 접근

Analysis Initialization

다음은 Libil2cpp.so 내에서 존재하는 Global-metadata.dat 파일을 메모리에 로드하고, 스트링, 메소드를 찾아 사용하는 동작에 관하여 서술합니다. Il2cpp 자체가 오픈 소스는 아니지만, Unity 에디터가 켜져있는 상태에서는 프로젝트를 빌드할 사용했던 cpp 파일을 유지하기 때문에, 해당 파일을 백업하여 분석을 진행하였습니다.

Runtime::Init()

가장 먼저 Runtime 초기화 작업이 수행됩니다. 해당 작업은 Runtime::Init() 함수를 호출하여 수행되며, Image, Thread 등과 관련된 초기화 작업을 진행합니다. Global-metadata 관련된 작업은 MetadataCache::Initialize()에서 수행됩니다.

MetadataCache::Initialize()

MetadataCache::Initialize() 함수에서는 위의 그림과 같이 global-metadata.dat 파일을 메모리에 로드하여 인덱스 기반으로 메소드 스트링 데이터를 사용할 있도록 준비과정을 수행합니다. 다음의 MetadataLoader::LoadMetadataFile() 이용하여 지정한 위치에 존재하는 global-metadata.dat 파일을 메모리에 할당시킵니다.

MetadataLoader::LoadMetadataFile

, 시그니처와 버전 정보를 확인한 다음 스트링, 메소드 등의 정보를 다음 그림과 같은 Global-metadata 파일 포맷을 사용하여 앞서 선언해 놓은 테이블들에 저장하는 과정을 수행합니다.

MetadataHeader

Il2CppGlobalMetadataHeader 대한 정보는 il2cpp-metadata.h에서 찾을 있습니다.

String 데이터를 가져오는 과정

Global-metadata.dat 파일의 데이터에서 스트링 리터럴(string literal) 데이터를 읽어오는 과정은 다음의 GetStringLiteralFromIndex() 함수를 통해 이루어집니다.

MetadataCache::GetStringLiteralFromIndex()

함수는 인덱스를 기반으로 동작하며, 스트링 포인터를 관리하는 배열인 s_StringLiteralTable 사용합니다. 테이블에 등록된 스트링이 존재한다면 바로 리턴을 수행하고, 그렇지 않을 경우 s_GlobalMetadata 참조하여 스트링을 읽어 들인 s_StringLiteralTable 저장합니다.

s_GlobalMetadata g_MetadataUsages[] 나타냅니다.

g_MetadataUsages

앞서 언급했다시피, 프로젝트를 빌드할 , Unity에서는 에디터를 닫기 전까지는 tmp 디렉토리에 il2cpp.exe 이용하여 변환한 cpp파일을 저장하고 있습니다. Mono 마찬가지로 il2cpp에서도 역시 사용자의 코드는 Bulk_Assembly-CSharp_0.cpp 내에 저장됩니다. 다음은 Kart 움직임 시작을 담당하는 부분의 함수입니다.

KartMovement_Start()

함수가 호출되면 가장 먼저 메소드가 초기화가 되었는지 확인합니다. 처음 호출되었을 경우 il2cpp_codegen_initialize_method() 호출하여 초기화 과정을 한번 수행합니다.

해당 함수 역시 index(=id) 기반으로 동작하며, 메소드에 대한 index 값은Il2CppMetadataUsage.cpp 정의되어 있습니다.

Il2CppMetadataUsage_index

해당 함수는 MetadataCache::InitalizeMethodMetadata() 호출합니다.

il2cpp_codegen_initialize_method()

다음의 함수들은 MetadataCache.cpp 정의되어 있습니다. InitializeMethodMetadata()에서 전달받은 index 이용하여 Metadata 내에서의 시작 offset 길이를 구하고, IntializeMethodMetadataRange() 호출하여 해당 데이터가 어떻게 사용되는지(ex. 스트링 리터럴인지, 메소드 정의인지) 판단하여 s_Il2CppMetadataRegistration->metadataUsages[] 저장합니다.

MetadataCache::InitializeMethodMetadata()
MetadataCache::IntializeMethodMetadataRange

s_Il2CppMetadataRegistration->metadataUsages[] g_MetadataRegistration->g_MetadataUsages[] 다음 함수들의 과정 속에서 이름만 바뀌는 것이기 때문에 실제로 동일한 배열을 나타냅니다.

s_Il2CppCodegenRegistration() -> il2cpp_codegen_register() -> MetadataCache::Register()

따라서 IL2CPP에서는 스트링 리터럴 데이터를 g_MetadataUsages[] 배열에 저장하여, 사용 지정된 index(id) 통해 불러오는 것을 있습니다.

 

클래스 메소드 데이터를 읽어오는 과정

해당 과정은 MetadataCache::GetMethodInfoFromMethodDefinitionIndex() 통하여 이루어집니다. 먼저 GetMethodDefinitionFromIndex() GetTypeInfoFromTypeDefinitionIndex() 이용하여 클래스 정보를 구하고, Class::SetupMethods() 호출합니다.

GetMethodInfoFromMethodDefinitionIndex()

Class::SetupMethods() SetupMethodsLocked() 호출하는데, 함수 내에서 GetStringFromIndex(), GetMethodPointerFromIndex() 등의 함수가 호출되어 메소드의 이름, Pointer 주소 등을 가져오는 것을 있습니다.

SetupMethodsLocked()

MetadataCache::GetMethodPointerFromIndex() 함수에서는 s_Il2CppCodeRegistration->methodPointers[] 테이블을 이용하여 포인터를 반환하게 되는데, 테이블의 경우에도 앞서 스트링 데이터를 가져오는 과정과 비슷하게, 다음의 과정은 분석하면 s_Il2CppCodeRegistration->methodPointers[] g_CodeRegistration->g_MethodPointers 동일하다는 사실을 있습니다.

s_Il2CppCodegenRegistration() -> il2cpp_codegen_register() -> MetadataCache::Register()

 

GetMethodPointerFromIndex
g_MethodPointers

g_MethodPointers[] 배열은 실행 파일이 생성되었을 , 함수들의 주소를 갖는 테이블입니다. 이를 이용하여 함수의 이름과 주소를 매칭시키는 것이 가능합니다.

 

Ref.
https://docs.unity3d.com/kr/2018.1/Manual/IL2CPP.html
https://blogs.unity3d.com/kr/2015/05/06/an-introduction-to-ilcpp-internals/
https://www.nevermoe.com/2016/08/10/unity-metadata-loader/
https://www.nevermoe.com/2016/09/08/%e8%bf%98%e5%8e%9f%e4%bd%bf%e7%94%a8il2cpp%e7%9a%84symbol%ef%bc%88%e4%ba%8c%ef%bc%89/
https://github.com/nevermoe/unity_metadata_loader/blob/v24/unity_decoder/main.cpp

반응형