When building Unity projects for multi-platform environments (such as iOS, Android, WebGL, or consoles), using the IL2CPP (Intermediate Language to C++) scripting backend is highly recommended or even mandatory. IL2CPP converts C# code via intermediate language (IL) into highly optimized C++ code, which dramatically increases execution speed and makes reverse engineering significantly harder.

However, projects that run flawlessly in the Unity Editor often suffer from sudden runtime crashes on IL2CPP builds when executing specific code paths (such as serializing JSON using JsonUtility or Newtonsoft.Json, utilizing Dependency Injection containers, or calling generic methods via interfaces). This results in the infamous error: "ExecutionEngineException: Attempting to call method... for which no ahead of time (AOT) code was generated".

In this article, we will dissect the inner workings of AOT compilation and managed code stripping in IL2CPP, explain why these runtime failures occur at a code level, and provide concrete solutions using link.xml configuration and AOT code hinting for value types.

1. Root Cause: Mono (JIT) vs. IL2CPP (AOT) Code Generation

In the Unity Editor and PC Standalone builds (when using the Mono backend), Unity relies on JIT (Just-In-Time) compilation. JIT compilation compiles C# IL code into machine code on-the-fly at runtime. This allows the program to dynamically query types via reflection or instantiate generic classes with types that weren't explicitly used at compile time.

On the other hand, IL2CPP relies on AOT (Ahead-Of-Time) compilation. AOT compilation compiles all C# code into C++ source code during the build process. Consequently, it is impossible to compile or generate new code or new generic type combinations at runtime.

[Mono (JIT Compilation)]
  C# IL Code ──(Compile at Runtime)──> Machine Code (Can generate new types/methods anytime)

[IL2CPP (AOT Compilation)]
  C# IL Code ──(Compile at Build time)──> C++ Code ──> Static Binary (No compilation at runtime)
  *If a generic type/value combination is not used statically, it is omitted, causing a runtime crash!
  

Figure: Comparison of code generation timing between JIT and AOT compilation.

This architectural shift leads to two major issues unique to AOT environments:

  1. Managed Code Stripping: To reduce binary size, Unity statically analyzes code and strips away classes, methods, or properties that it deems unused. Reflection calls using strings or parameterless constructors invoked implicitly during JSON parsing are often marked as "unused" and deleted by the optimizer.
  2. Lack of AOT Generic Instantiations: Generic methods (e.g., void Process<T>(T data)) or generic classes require the compiler to generate corresponding C++ code for each type parameter T. For reference types (classes), IL2CPP can share a single C++ implementation because pointers share the same memory size. However, for value types (structs and primitives), the memory size and layout differ. The compiler must instantiate a separate C++ implementation for each value type. If there is no static call matching a specific value type combination in your C# code at compile time, the implementation is omitted, triggering an ExecutionEngineException at runtime.

2. Solution 1: Preventing Strip via link.xml

To prevent classes, methods, or constructors used implicitly by reflection or JSON libraries from being stripped, place a link.xml file in your project's Assets folder (or any assembly definition folder). This tells the compiler to explicitly preserve those elements during the build process.

Example link.xml Configuration

<linker>
  <!-- 1. Preserve an entire assembly from being stripped -->
  <assembly fullname="MyCustomLibrary" preserve="all" />

  <!-- 2. Granularly preserve specific namespaces or classes -->
  <assembly fullname="Assembly-CSharp">
    <!-- Preserve a class and all its members -->
    <type fullname="MyProject.Network.PacketData" preserve="all" />
    
    <!-- Preserve a type, but allow unused methods to be stripped -->
    <type fullname="MyProject.UI.DynamicWindow" preserve="nothing">
      <!-- Keep the parameterless constructor for reflection -->
      <method signature="System.Void .ctor()" />
    </type>
  </assembly>
</linker>

DTO (Data Transfer Object) classes deserialized by JSON libraries are prime targets for code stripping because their constructors are rarely called directly in C# code. Adding these DTO classes or their namespace to `link.xml` prevents unexpected parsing failures or NullReferenceExceptions in builds.

3. Solution 2: Forcing C++ Instantiation using AOT Hints for Value Types

While `link.xml` successfully prevents the "stripping" of existing types, it cannot force the AOT compiler to instantiate new C++ code for a generic method with a value type.
For example, if you dynamically pass a custom struct MyStruct to a generic method Process<T>() via an interface at runtime, the compiler needs to have pre-compiled the C++ code for Process<MyStruct>(). If this code wasn't compiled, a crash will occur regardless of your `link.xml` settings.

To resolve this, write dummy code known as "AOT Hints" anywhere in your project to force the AOT compiler to output the concrete implementation.

Example AOT Hint Implementation

using UnityEngine;

// Define a static helper class to hold dummy references (no need to call this at runtime)
public static class AOTPreserveHelper
{
    // A dummy method to force the AOT compiler to generate C++ code
    private static void EnsureAOTInstances()
    {
        // Wrap in a condition that is never true to avoid any actual execution overhead
        if (neverTrueCondition())
        {
            var processor = new DataProcessor();
            
            // Explicitly call the generic method with your custom value types (structs)
            // This forces the IL2CPP compiler to output C++ implementations for these types
            processor.Process<MyCustomStruct>(default(MyCustomStruct));
            processor.Process<Vector2Int>(default(Vector2Int));
            processor.Process<int>(default(int));
        }
    }

    private static bool neverTrueCondition()
    {
        // Returns false, but prevents the compiler from optimizing away the block as dead code
        return Random.value == -1f;
    }
}

// Example structs and class definitions
public struct MyCustomStruct
{
    public int Id;
    public float Weight;
}

public class DataProcessor
{
    public void Process<T>(T data) where T : struct
    {
        Debug.Log($"Processing type {typeof(T)}: {data}");
    }
}

By writing a dummy static method that simulates calls using target value types as type parameters, you instruct the AOT compiler to include the necessary C++ code in the final binary, preventing runtime exceptions.

4. Proactive Architecture and Design Guidelines

(1) Opt for AOT-Friendly Libraries

Libraries relying heavily on reflection are notorious for breaking on IL2CPP platforms. When using JSON in Unity, consider opting for AOT-optimized solutions like Unity's built-in JsonUtility, the AOT-patched Json.NET for Unity, or modern source-generator-based serialization frameworks like MemoryPack or MessagePack for C#. Source-generator-based libraries produce static serialization code during compilation, making them completely AOT-safe and dramatically faster.

(2) Run Regular Device Builds Early

IL2CPP AOT errors are completely silent in the Unity Editor. Setting up a CI/CD pipeline to compile and run automated tests on target devices (Android/iOS/PC builds) early in the development lifecycle is the most effective way to catch AOT anomalies before they compile into massive shipping blockers.

5. Summary

  1. IL2CPP Cannot Generate Code at Runtime: Its AOT nature requires all generic combinations and types to be pre-translated into C++ at build time.
  2. Use link.xml to Preserve Types from Stripping: Explicitly guard reflection-targeted DTO classes and parameterless constructors.
  3. Employ AOT Hints for Value-Type Generics: Prevent ExecutionEngineException by writing dummy generic calls using value types to force C++ compilation.
  4. Transition to Source Generators: Replacing reflection-based serialization with static serialization (e.g., MemoryPack) is the ultimate best practice for both performance and safety.