Quick answer: On Android, Unity IL2CPP converts your C# to C++ and compiles it through the NDK, so a managed null reference appears as a SIGSEGV in libil2cpp.so. Upload the per architecture symbols.zip for every build, capture native signals and ANRs through separate paths, and lock your stripping settings so an offset always resolves back to the C# method you actually wrote.

On Android, Unity IL2CPP converts your C# into C++ and compiles it to a native shared library with the NDK. That means a managed null reference can manifest as a SIGSEGV in libil2cpp.so, and your crash report arrives as a raw native backtrace rather than a friendly C# stack. A separate failure class, the application not responding dialog, is not a signal at all but a main thread stall, and the two need entirely different capture. Making addresses meaningful again, and never conflating an ANR with a hard crash, is the central challenge of crash reporting on this platform. This post covers the IL2CPP pipeline, the symbolication workflow, ANR watchdogs, and the multi architecture reality of shipping to Android.

What IL2CPP does to your stack

IL2CPP is an ahead of time pipeline. It takes the intermediate language produced by the C# compiler and emits C++, which the NDK then compiles into libil2cpp.so. The upside is performance and no just in time compilation; the cost is that your managed types and methods are flattened into generated C++ functions with synthesized names. A crash in your gameplay code therefore appears as a native frame inside libil2cpp.so at a byte offset, not as a line in your MonoBehaviour, which is jarring the first time you see it in a field report.

This is why a plain stack trace string is nearly useless in release. The Android runtime hands you a backtrace of module names and offsets, and only the matching unstripped symbols can translate an offset back to a generated function and, through a method map, back to your C# method. Without those artifacts you can see that you crashed in libil2cpp.so and learn nothing more specific than that, which is roughly as helpful as knowing the crash happened on a Tuesday.

Native signals versus ANRs

Two very different failures dominate Android. A native signal such as SIGSEGV or SIGABRT is a hard crash; the OS writes a tombstone, and you want the NDK backtrace with register state. These come from real memory faults, often a null managed reference or a use after free in a plugin, and they kill the process immediately, so your handler has one shot to record state before everything is gone.

An ANR, application not responding, is the other class and is not a crash at all in the signal sense. The main thread blocked for too long, often because you ran heavy work in Update or blocked on a synchronous network call, and the system offered to kill the app. ANRs require a main thread stack at the moment of the stall, not a tombstone, so your tooling must capture both kinds and never conflate them. A queue full of ANRs labelled as crashes will send you hunting memory bugs when the real problem is a slow frame.

Capturing tombstones and backtraces

When a native signal fires, the safest handler does almost nothing: it records the signal number, the faulting address, and the register set, then unwinds the stack without allocating, because the heap may be corrupt. Writing a compact record to disk and uploading on the next clean launch is more reliable than trying to transmit from inside a crashing process, which often dies again before the network call completes. The Android tombstone in the system log is a useful cross check when you have device access.

For ANRs you need a watchdog. A background thread periodically pings the main thread, and if the main thread fails to respond within the threshold you capture its stack. That stack, even unsymbolicated at first, tells you which system stalled, and combined with the IL2CPP symbols it points at the exact method holding the main thread. Capturing the garbage collection state and frame time around the stall often reveals an allocation spike as the real cause, which is a code smell you can fix directly rather than a vague performance complaint.

Multiple ABIs and stripping levels

Android ships multiple CPU architectures, and a modern build targets arm64-v8a, often alongside armeabi-v7a. Each ABI produces its own libil2cpp.so with its own symbols, so you must keep and upload symbols per architecture. Symbolicating an arm64 crash against armeabi symbols silently produces wrong frames, which is worse than no symbolication because it sends you chasing a fiction with full confidence that you are looking at the right method.

Stripping level matters too. The managed stripping setting and the IL2CPP code generation option change how aggressively code is removed and how names are generated, so symbols from a build with different settings will not align. Lock these settings per release, archive the exact symbols.zip for every store build, and never overwrite them, because Google Play keeps old versions live and you will need them weeks later when a player on a stale build files the crash that finally explains a signature you have been ignoring.

Setting it up with Bugnet

Add the Bugnet Unity package and initialize it from a bootstrap script in your first loaded scene, before the rest of your systems come up, so a crash during early initialization is still recorded. The SDK installs a native signal handler through the NDK alongside the managed exception hook, which means both unhandled C# exceptions and hard native signals flow into the same pipeline with a shared session id. Its in game report button captures device and platform context automatically when a player reports a non fatal issue.

For symbolication you upload the symbols.zip Unity produces when you enable the create symbols option, indexed by version and build. Bugnet uses those unstripped symbols and the IL2CPP line mapping to turn an offset in libil2cpp.so into your actual C# method and line, then folds crashes together with occurrence grouping. You get per device model and per OS breakdowns, and you can filter ANRs separately from native signals so the two never pollute each other, with occurrence counts telling you which signature is worth the next sprint.

Operating it in production

Field crashes on Android cluster hard by device. A signature that appears only on a single chipset or a single manufacturer skin usually points at a driver or a vendor specific quirk rather than your logic, and sorting by device model alongside occurrence count reveals that pattern quickly. The head of the distribution is almost always a handful of signatures, so fix those first and you clear a large fraction of your total crash volume in a single release.

Watch for version correlated spikes, especially after a plugin or Unity editor upgrade, which can change the IL2CPP output and shift your entire symbol layout. A post release check that confirms symbol upload succeeded for every ABI, that ANR capture is still firing, and that the top signatures resolve to real C# methods will catch a broken symbolication step before it costs you a release worth of blind reports. On Android, where you cannot read a player device directly, that server side aggregation is your only honest window into the field.

Unity IL2CPP on Android turns C# faults into native backtraces in libil2cpp.so. Upload the per ABI symbols.zip for every build, capture native signals and ANRs through separate paths, and lock your stripping settings so offsets always resolve to your C#.