e.blog

主にUnity/UE周りのことについてまとめていきます

IL2CPP が出力した C# の文字列の扱いを探ってみた

この記事は Unity のアドベントカレンダー 5 日目の記事です。


はじめに

この記事に需要があるか分からないですがw IL2CPP が書き出した内容を分析することにハマっていて色々見ていく中で、文字列の扱いが面白かったのでまとめました。

普段、書き出された Xcode プロジェクトの中身ってあんまり見ないかと思います。特に IL2CPP が出力した部分は分かりづらい表記になっていますし。

例えば C# で定義したクラスはこんな感じで表現されます。

// C# 側で定義したフィールドと同じ名前のフィールドが定義されている。
struct ScriptTest_t4722BADE3094615A9E6B31588AB39A9699182583  : public MonoBehaviour_t532A11E69716D348D8AA7F854AFCBFCB8AD17F71
{
    int32_t ____age;
    float ____weight;
    String_t* ____name;
};

// こちらはコンストラクタ相当の処理をする関数
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void ScriptTest__ctor_m292EB1426D8DEE94DCFD8136DE806F79853C20D5 (ScriptTest_t4722BADE3094615A9E6B31588AB39A9699182583* __this, const RuntimeMethod* method) 
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&_stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38);
        s_Il2CppMethodInitialized = true;
    }
    {
        __this->____age = ((int32_t)20);
        __this->____weight = (60.0f);
        __this->____name = _stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38;
        Il2CppCodeGenWriteBarrier((void**)(&__this->____name), (void*)_stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38);
        MonoBehaviour__ctor_m592DB0105CA0BC97AA1C5F4AD27B12D68A3B7C1E(__this, NULL);
        return;
    }
}

関数やクラス、構造体など様々なものに謎の文字列が付与されているのが分かります。これは C# 側の名前空間などで区切られたものをフラットにした際に、名前の衝突などが起こらないようにするために付与されたハッシュ値です。

ぱっと見ただけではなにをしているか分かりづらいので余計、中身を見ようとは思わないでしょう。

今回はその中でも文字列の扱いが面白かったので処理を追っていきたいと思います。

ちなみに文字列の処理だけを抜粋すると以下の部分になります。

static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
    il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&_stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38);
    s_Il2CppMethodInitialized = true;
}

__this->____name = _stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38;

最初になにやら初期化処理をしてから使用していますね。実はこの初期化処理が大事で、最初の段階では文字列フィールドの _stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38 には文字列が設定されていません。



大枠の理解

ひとつずつ処理を見ていく前に、大枠の流れを理解しておきましょう。

上でも書いたように、文字列のフィールドには最初は文字列そのものは設定されていません。ではなにが設定されているのかというと「文字列を取り出すための情報」がエンコードされたものが設定されています。

具体的にどんな形で定義されているかを見てみると以下のようになっています。

// Il2CppMetadataUsage.c というファイルで定義されている
     .
     .
     .
String_t* _stringLiteralBD09A3DAA622349FF74F11ECD6DDD1B297C3FDF2 = (String_t*)(uintptr_t)2684368525;
String_t* _stringLiteralC54E9E453CAA8F5DCE5559DA89197E4A9C9B3C54 = (String_t*)(uintptr_t)2684368527;
String_t* _stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38 = (String_t*)(uintptr_t)2684368529;     // <- これが上で紹介した文字列フィールドに設定される元
String_t* _stringLiteral561357A43AFC43D221B9F230B04E836DD73101EB = (String_t*)(uintptr_t)2684368531;
String_t* _stringLiteralF27F76F2C38EFE61B0AD0108BAD5476B6693C4F2 = (String_t*)(uintptr_t)2684368533;
     .
     .
     .

こんな感じで、C# 側で使用している文字列やその他多くの情報が一覧で定義されています。そして見てもらうと分かる通り、実際に設定されている値は文字列そのものではなく (uintptr_t)2684368529 という謎の数値です。

これをどう使うかを詳細に見ていくのが今回の記事です。が、ここでは大まかに処理の流れを概観します。

  1. 文字列変数のポインタを初期化関数に渡す
  2. 初期化関数内では、ポインタから値を取り出してそこに埋め込まれている情報を元に文字列を検索する
  3. 検索で見つかった文字列への参照を、文字列変数の参照先に再設定する

特に (2) については、謎の数値だった部分から値を取り出して処理していくことになります。結論から言うと、変数の種類やデータへのオフセットなどの情報が「エンコード」された数値となります。

初期化の開始

ということでさっそく処理を見ていきましょう。

冒頭でも載せましたが改めて初期化処理を見ていきます。

static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
    il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&_stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38);
    s_Il2CppMethodInitialized = true;
}

s_Il2CppMethodInitializedstatic 変数なので一度初期化されたあとは 2 回目は処理されません。そして実際に初期化処理をしているのは il2cpp_codegen_initialize_runtime_metadata という関数です。

ちなみにこの関数は、出力されたプロジェクトの以下の場所に保存されています。

<XCODE_PROJECT_ROOT>/Il2CppOutputProject/IL2CPP/libil2cpp/codegen/il2cpp-codegen.cpp

処理を辿っていくと

void* il2cpp::vm::MetadataCache::InitializeRuntimeMetadata(uintptr_t* metadataPointer);
void* il2cpp::vm::GlobalMetadata::InitializeRuntimeMetadata(uintptr_t* metadataPointer, bool throwOnError) IL2CPP_DISABLE_TSAN;

を呼び出すようになっていて、最終的に初期化処理をしているのは il2cpp::vm::GlobalMetadata::InitializeRuntimeMetadata となります。

データの取り出し

まずは il2cpp::vm::GlobalMetadata::InitializeRuntimeMetadata の定義を見てみましょう。( Il2CppOutputProject/IL2CPP/libil2cpp/vm/GlobalMetadata.cpp で定義されている)

// This method can be called from multiple threads, so it does have a data race. However, each
// thread is reading from the same read-only metadata, so each thread will set the same values.
// Therefore, we can safely ignore thread sanitizer issues in this method.
void* il2cpp::vm::GlobalMetadata::InitializeRuntimeMetadata(uintptr_t* metadataPointer, bool throwOnError) IL2CPP_DISABLE_TSAN
{
    // This must be the only read of *metadataPointer
    // This code has no locks and we need to ensure that we only read metadataPointer once
    // so we don't read it once as an encoded token and once as an initialized pointer
    uintptr_t metadataValue = (uintptr_t)os::Atomic::ReadPtrVal((intptr_t*)metadataPointer);

    if (IsRuntimeMetadataInitialized(metadataValue))
        return (void*)metadataValue;

    uint32_t encodedToken = static_cast<uint32_t>(metadataValue);
    Il2CppMetadataUsage usage = GetEncodedIndexType(encodedToken);
    uint32_t decodedIndex = GetDecodedMethodIndex(encodedToken);

    void* initialized = NULL;

    switch (usage)
    {
        case kIl2CppMetadataUsageTypeInfo:
            initialized = (void*)il2cpp::vm::GlobalMetadata::GetTypeInfoFromTypeIndex(decodedIndex, throwOnError);
            break;
        case kIl2CppMetadataUsageIl2CppType:
            initialized = (void*)il2cpp::vm::GlobalMetadata::GetIl2CppTypeFromIndex(decodedIndex);
            break;
        case kIl2CppMetadataUsageMethodDef:
        case kIl2CppMetadataUsageMethodRef:
            initialized = (void*)GetMethodInfoFromEncodedIndex(encodedToken);
            break;
        case kIl2CppMetadataUsageFieldInfo:
            initialized = (void*)GetFieldInfoFromIndex(decodedIndex);
            break;
        case kIl2CppMetadataUsageStringLiteral:
            initialized = (void*)GetStringLiteralFromIndex(decodedIndex);
            break;
        case kIl2CppMetadataUsageFieldRva:
            const Il2CppType* unused;
            initialized = (void*)GetFieldDefaultValue(GetFieldInfoFromIndex(decodedIndex), &unused);
            {
                const size_t MappedFieldDataAlignment = 8; // Should match System.Reflection.Metadata.ManagedPEBuilder.MappedFieldDataAlignment
                IL2CPP_ASSERT(((uintptr_t)initialized % MappedFieldDataAlignment) == 0);
            }
            break;
        case kIl2CppMetadataUsageInvalid:
            break;
        default:
            IL2CPP_NOT_IMPLEMENTED(il2cpp::vm::GlobalMetadata::InitializeMethodMetadata);
            break;
    }

    IL2CPP_ASSERT(IsRuntimeMetadataInitialized(initialized) && "ERROR: The low bit of the metadata item is still set, alignment issue");

    if (initialized != NULL)
    {
        // Set the metadata pointer last, with a barrier, so it is the last item written
        il2cpp::os::Atomic::PublishPointer((void**)metadataPointer, initialized);
    }

    return initialized;
}

ちょっと処理が多いので少しずつ紐解いていきましょう。まず冒頭で行っている処理を見ていきます。

uintptr_t metadataValue = (uintptr_t)os::Atomic::ReadPtrVal((intptr_t*)metadataPointer);

if (IsRuntimeMetadataInitialized(metadataValue))
    return (void*)metadataValue;

最初に行っているのは、渡されたポインタの実際の値を取り出しています。冒頭で紹介した「謎の数値」を取り出しているわけですね。

そして次に行っているのが初期化済みかどうかの判定。この判定もトリックがあるので見ていきましょう。

コードを抜粋すると、以下のように処理されています。

template<typename T>
static inline bool IsRuntimeMetadataInitialized(T item)
{
    // Runtime metadata items are initialized to an encoded token with the low bit set
    // on startup and when initialized are a pointer to an runtime metadata item
    // So we can rely on pointer alignment being > 1 on our supported platforms
    return !((uintptr_t)item & 1);
}

この処理が行っているのは、対象の値の 1 ビット目にフラグが立っているかのチェックです。ですが、なぜこれが可能なのでしょうか。1 ビットでも変化すれば値がおかしくなるかもしれませんよね。

しかしこれは、システムの特性が絡んできます。ここで評価しているのは「文字列を参照するポインタ」です。ポインタはアドレスを保持する変数ですね。そしてこのアドレスは、32 ビットマシンであれば 4 バイト単位、64 ビットマシンであれば 8 バイト単位にデータが一定の境界に揃えられ(アラインメント)ています。


※ ここで言うアラインメントは、32 / 64 ビットマシンなどの CPU アーキテクチャの特性に基づいて行われます。OS は主にページ単位(数 KB 程度)でメモリを割り当てますが、それより細かい粒度(今回の 4 / 8 バイト)での整列は、コンパイラやリンカ、メモリアロケータ(malloc, newなど)といったプログラムにより行われます。これらは CPU 特性を考慮した効率化の観点から、変数や構造体、関数などを一定のバイト境界に揃えることを意味しています。(akeit0 さんに指摘いただき、誤解を招きそうだったので補足しました)


言い換えると、アドレスの値はきれいにそろっている、ということです。つまり、下位のビットは常に整列されるため 0 になるわけです。 例えば 32 ビットマシンの場合で考えて、スタートのアドレスが 0b00000 始まりとして次のアドレスは 4 バイト目、つまり 0b00100 が次のアドレスとなる。同様にその次は 0b01000, 0b01100, 0b10000 … と常に下位 2 ビットは 0 になっているのが分かります。

そのため、下位のビットは不要なわけなんですね。見なくても分かる。なぜなら整列されていることが前提になっているから。 なのでそこに目をつけて、ここをフラグとして利用している、というわけなんですね。

もしここのフラグが立っていたら 初期化済みとして 未初期化として判定している、というわけです。 (すみません、判定処理を少し勘違いしていました。正しくは「未初期化」かどうかの判定になります)

データをデコード

最初にも書いた通り、文字列変数( String_t* 型)にはエンコードされた数値が設定されているのでした。それをデコードしている処理を見てみましょう。

uint32_t encodedToken = static_cast<uint32_t>(metadataValue);
Il2CppMetadataUsage usage = GetEncodedIndexType(encodedToken);
uint32_t decodedIndex = GetDecodedMethodIndex(encodedToken);

まず最初に、取り出した値を uint32_t 型にキャストします。そして最初に取り出すデータが Il2CppMetadataUsage です。このあとの処理で、このタイプに応じて処理が変わります。 実装も見てみましょう。

static inline Il2CppMetadataUsage GetEncodedIndexType(EncodedMethodIndex index)
{
    return (Il2CppMetadataUsage)((index & 0xE0000000) >> 29);
}

これが行っているのは、引数に渡された index 変数から上位 3 ビットを取り出すことです。( index は前段の「謎の数値」)

0xE0000000 を 2 進数表現すると 0b11100000000000000000000000000000 です。上位 3 ビットだけ取り出し、それを 29 ビット右にシフトすることで上位 3 ビットの値を得ています。そしてこの 3 ビットに Il2CppMetadataUsage の値が設定されているというわけですね。

さて続いて GetDecodedMethodIndex 関数についても見ていきましょう。

static inline uint32_t GetDecodedMethodIndex(EncodedMethodIndex index)
{
    return (index & 0x1FFFFFFEU) >> 1;
}

この 0x1FFFFFFE を 2 進数表現にすると 0b00011111111111111111111111111110 です。上位 3 ビットが 0 なのは前段で使用している部分だからですね。そしてこのマスクと & を取ってさらに右に 1 ビットシフトさせた値が、文字列データへのインデックス(オフセット)の値となります。

文字列データを得る

エンコードされたデータから、Usage と インデックスを取り出すことができました。このデータを用いて、実際に文字列データを取り出す処理を見ていきましょう。

まず最初に Usage によってスイッチします。今回は kIl2CppMetadataUsageStringLiteral なので GetStringLiteralFromIndex 関数が実行されます。

switch (usage)
{
    // ... 略 ...
    case kIl2CppMetadataUsageStringLiteral:
        initialized = (void*)GetStringLiteralFromIndex(decodedIndex);
        break;
    // ... 略 ...
}

関数の実装は以下です。

static Il2CppString* GetStringLiteralFromIndex(StringLiteralIndex index)
{
    if (index == kStringLiteralIndexInvalid)
        return NULL;

    IL2CPP_ASSERT(index >= 0 && static_cast<uint32_t>(index) < s_GlobalMetadataHeader->stringLiteralSize / sizeof(Il2CppStringLiteral) && "Invalid string literal index ");

    if (s_StringLiteralTable[index])
        return s_StringLiteralTable[index];

    const Il2CppStringLiteral* stringLiteral = (const Il2CppStringLiteral*)((const char*)s_GlobalMetadata + s_GlobalMetadataHeader->stringLiteralOffset) + index;
    Il2CppString* newString = il2cpp::vm::String::NewLen((const char*)s_GlobalMetadata + s_GlobalMetadataHeader->stringLiteralDataOffset + stringLiteral->dataIndex, stringLiteral->length);
    Il2CppString* prevString = il2cpp::os::Atomic::CompareExchangePointer<Il2CppString>(s_StringLiteralTable + index, newString, NULL);
    if (prevString == NULL)
    {
        il2cpp::gc::GarbageCollector::SetWriteBarrier((void**)s_StringLiteralTable + index);
        return newString;
    }
    return prevString;
}

最初の方は変数のアサートなどですね。そして s_StringLiteralTable というテーブルに値がすでに存在している場合はそれを返しています。これは、このあとの処理で得られた文字列をテーブルに書き込み、以後の処理をスキップするための処置です。

実際に文字列データを取得しているのは以下の部分です。

const Il2CppStringLiteral* stringLiteral = (const Il2CppStringLiteral*)((const char*)s_GlobalMetadata + s_GlobalMetadataHeader->stringLiteralOffset) + index;

最初の行で行っているのは、なにかしらのデータのアドレスに文字列リテラルのオフセットを加えた位置に、さらに、前段で求めた index を足した位置を文字列として取り出しています。

ここを少し深堀りしましょう。 GlobalMetadata の名前から推測できるように、グローバルに保存されたメタデータが存在していて、そこから適切にオフセットさせることで文字列を取り出しています。そしてこのメタデータは実はバイナリファイルとして保存されているデータを読み込んだものへの参照となっています。

文字データの所在

上の stringLiteral 変数が参照している文字列を出力したところ以下のような文字列の塊でした。おそらく、C# 側で使われている文字列がすべて連結された状態で保存されており、そこからオフセットと文字列の長さを利用して文字列を取り出しているものと思われます。

"edoelelasticAnimationIntervalMselasticityeleelemelementelement-nameelementSelectorelementTypeelementsellipsisembedPackageemoji-fallback-supportemojiFallbackSupportenen-USenable-rich-textenableCompilationCachingenableRichTextenableValidityChecksenabledenabledInHierarchyenabledSelfencodedDataencoderFallbackencodingendendIndexendIndex cannot be greater than startIndex.enterenumTypeenumType must not be null and it must be an Enum typeenvenvoyInfoequalseraeraseresescapeeteueuc-cneuc-jpeuc-kreventCounteventDataeventPtreventPtr is NULL but eventCount is != 0eventTypeevt.isPropagationStoppedexexceptionexceptionObjectexcludeFromFocusRing should only be set on composite roots.expectedexportShaderVariantsexprextentsextraPaddingeyezff1f10f11f12f13f14f15f2f3f4f5f6f7f8f9fIsMarshalledfafa-IRfalsefccfec2b7369466d88502a9dd38505f4fcd9651ded40425995dfa6aeb78f1f1cfeatureNamefifieldCountfilfilefileNamefilePathfillfilterfirebrickfirstfirstLayoutNamefirstListfirstStatePtrfirstVisibleCharacterfixationPointfixed-item-heightfixed-pane"...

実際、ここの処理で取り出そうとしている文字列は edo で、出力した文字データの冒頭は確かに edo となっていることが分かります。ここから 3 文字取り出せば晴れて目的の文字列が手に入る、というわけですね。

上記のバイナリファイルは以下に保存されていました。

<IPA_APP>/Data/Managed/Metadata/global-metadata.dat

実際にこのバイナリファイルの中から文字列を出力してみたところ、上記と同じ文字列の塊が見つかりました。 また GlobalMetadata.cpp にはこのメタデータを読み込む処理が書かれており、確かに s_GlobalMetadata がその参照を保持していました。

bool il2cpp::vm::GlobalMetadata::Initialize(int32_t* imagesCount, int32_t* assembliesCount)
{
    s_GlobalMetadata = vm::MetadataLoader::LoadMetadataFile("global-metadata.dat");
    if (!s_GlobalMetadata)
        return false;
        
    // 後略
}

これで目的の文字列を見つけることが出来ました。

テーブルへ保存

最後に、取得した文字列をテーブルに保存し、そののちに文字列の参照を返して処理は終了です。

Il2CppString* newString = il2cpp::vm::String::NewLen((const char*)s_GlobalMetadata + s_GlobalMetadataHeader->stringLiteralDataOffset + stringLiteral->dataIndex, stringLiteral->length);
Il2CppString* prevString = il2cpp::os::Atomic::CompareExchangePointer<Il2CppString>(s_StringLiteralTable + index, newString, NULL);

取得した文字列の参照と文字数を用いて新しい Il2CppString* を生成し、それを前の値と比較します。が、ここではそもそも比較対象が NULL なので常に新しい値がテーブルに書き込まれます。

参照の書き換え

さぁ本当の最後の処理です。前段までで文字列を生成し、それをテーブルに保存してその参照を得ることができました。今回の目的は、元の文字列変数の参照先を本当の文字列への参照へ切り替えることだったことを思い出してください。

それを行っているのが以下の部分です。最初に取得した変数のポインタに対して、今回取得した参照( initialized )で上書きしているのが分かります。

if (initialized != NULL)
{
    // Set the metadata pointer last, with a barrier, so it is the last item written
    il2cpp::os::Atomic::PublishPointer((void**)metadataPointer, initialized);
}

これで以後は対象の変数 _stringLiteralF6C15C8F610D71383C2B2F4070B2867C10F83B38 はちゃんと文字列を参照するようになります。

さいごに

だいぶ回りくどい実装になっていますが、おそらくメモリ効率やセキュリティ的な観点からメリットがある実装なんだと思います。 調べる前はそのまま文字列が埋め込まれているのかと思っていたので、最初は謎の数値が保存されている変数を見て困惑しました。 が、処理が分かってくると色々な工夫があってとても学びのありました。

冒頭でも書いたように、この知識がなにかの役に立つかは不明ですが、もし誰かの参考になれば幸いです。