Gestión Integral de Caídas Nativas en Unity: Captura, Análisis y Corrección

Las caídas nativas son particularmente problemáticas porque escapan al paradigma de depuración habitual de los desarrolladores de Unity. Estamos acostumbrados a usar Debug.Log para rastrear la ejecución, el Profiler para monitorizar la recolección de basura (GC) y Visual Studio para adjuntarnos al proceso gestionado. Sin embargo, estas herramientas son inútiles para la capa nativa. Las caídas nativas ocurren a nivel de señales del sistema operativo, que terminan el proceso de forma abrupta, a menudo antes de que incluso los SDKs de reporte de caídas de Unity (como Unity Gaming Services Crash Reporting) tengan tiempo de registrar el evento. Hace poco, ayudé a depurar una aplicación de educación AR que fallaba aleatoriamente al escanear libros. El equipo de desarrollo pasó tres semanas intentando reproducir el error en el editor y en simuladores. Solo después de configurar un entorno de prueba en un dispositivo físico que registraba simultáneamente la pantalla, adb logcat y ndk-stack, pudimos capturar una línea durante el fallo: signal 11 (SIGSEGV), code 1 (SEGV_MAPERR). Finalmente, descubrimos que un SDK de AR de terceros no protegía adecuadamente el contexto de OpenGL ES durante los cambios, lo que provocaba accesos concurrentes a la misma EGLSurface desde múltiples hilos. Esta experiencia me hizo darme cuenta de que los cuellos de botella de estabilidad en los proyectos de Unity a menudo no residen en los scripts de C#, sino en el mundo nativo, invisible y esquivo. Este artículo detalla un método sistemático para comprender, capturar, analizar y corregir estos problemas, basándose en una metodología replicable y acumulativa que cubre todo el ciclo de vida del desarrollo, desde la creación hasta el despliegue gradual.

La Esencia de las Caídas Nativas: De las Señales del Sistema a la Ruptura de la Pila de Ejecución de Unity

Para solucionar eficazmente las caídas nativas, el primer paso no es revisar los registros, sino comprender qué son exactamente. Muchos asumen erróneamente que una caída nativa es simplemente "código C++ fallido", una generalización excesiva. En el contexto de un proyecto de Unity, una Caída Nativa se refiere específicamente a un evento de error desencadenado por el entorno de ejecución nativo del reproductor de Unity (libunity.so / UnityFramework.framework) o por bibliotecas nativas de terceros cargadas por este, que resulta en la terminación forzada del proceso por el sistema operativo mediante una señal POSIX. Su ubicación, condiciones de activación y depurabilidad difieren fundamentalmente de la capa gestionada (C#).

El "Veredicto de Muerte" a Nivel del Sistema Operativo: Significado Práctico de SIGSEGV, SIGABRT y SIGILL

En Linux/Android y macOS/iOS, las caídas nativas se manifiestan casi universalmente como una de las siguientes tres señales:

  • SIGSEGV (Signal 11): La más común, significa literalmente "Violación de Segmento". No es simplemente un sinónimo de "desreferenciación de puntero nulo", sino que ocurre cuando el kernel del sistema operativo detecta que el proceso intenta acceder a una dirección de memoria virtual a la que el proceso actual no tiene permiso de acceso. Esta dirección podría ser:
    • Una página completamente no mapeada (por ejemplo, 0x00000000, el clásico puntero nulo).
    • Una página mapeada pero con permisos incorrectos (por ejemplo, escribir en un segmento de código de solo lectura).
    • Una dirección que cruza los límites de página y resulta en un acceso no alineado (especialmente en ARMv7 con ciertos tipos de datos).
    • Una página previamente desmapeada por munmap(), pero el puntero no se puso a NULL y se sigue utilizando (Uso después de liberar - Use-After-Free).
  • SIGABRT (Signal 6): Generalmente desencadenada explícitamente por la función abort(). Suele indicar un fallo de aserción (assert) en la biblioteca estándar de C/C++ o en una biblioteca de terceros, o la detección de un estado interno irrecuperable (como una estructura interna de malloc dañada). A diferencia de SIGSEGV, no es "inesperada", sino un "interruptor de autodestrucción" preestablecido por el autor de la biblioteca.
  • SIGILL (Signal 4): Señal de Instrucción Ilegal. En proyectos de Unity, a menudo apunta a dos posibles causas: incompatibilidad de la arquitectura de la CPU (por ejemplo, ejecutar una biblioteca .so compilada solo para ARMv7 en un dispositivo ARM64) o el compilador JIT o AOT genera instrucciones no compatibles con la CPU de destino (por ejemplo, habilitar la opción de compilación -mfpu=neon en dispositivos que no lo soportan).

Nota: No te intimides por el número de la señal como "Signal 11". Es solo un identificador de notificación del kernel. Lo crucial es examinar el campo si_code asociado (por ejemplo, SEGV_MAPERR indica que la dirección no está mapeada, SEGV_ACCERR indica un error de permiso), ya que esta es la pista dorada para localizar la causa raíz.

El "Mundo de Doble Pila" de Unity: ¿Por Qué Fallan los Registros de la Capa Gestionada?

Un reproductor de Unity es un entorno de ejecución híbrido: la capa superior es la máquina virtual de C# (Mono o IL2CPP), y la capa inferior es el núcleo del motor implementado en C/C++ (renderizado, física, audio, entrada, etc.). Ambas capas están estrechamente acopladas a través de P/Invoke (Mono) o una capa de interoperabilidad C++ (IL2CPP), pero sus pilas de llamadas están separadas.

Cuando un método C# llama a UnityEngine.Debug.Log("Inicio"), la ruta de ejecución real es:

Pila C#: Debug.Log() → [Puente P/Invoke] → Pila C++: DebugString()

Si DebugString() desencadena un SIGSEGV debido a un acceso a memoria fuera de límites, el punto de fallo estará en las profundidades de la pila C++. En este momento, la pila C# ya ha "retornado" por completo, y la llamada a Debug.Log() se considera exitosa. Cuando el sistema operativo captura la señal, termina inmediatamente todo el proceso, sin dar tiempo a ejecutar ningún bloque finally de C#, callbacks de Application.quitting, o incluso los ganchos de manejo de errores de Unity (como CrashReporting.SetUserIdentifier()), que pueden fallar debido a un tiempo de ejecución tardío.

Es por eso que no encontrarás el último registro antes de la caída en Player.log: la escritura de registros es asíncrona, y el búfer aún no se ha volcado al disco cuando el proceso es terminado por el kernel. También es por eso que try-catch es completamente ineficaz contra caídas nativas: solo captura excepciones gestionadas (System.Exception y sus subclases), mientras que las señales son a nivel del sistema operativo, mucho más allá del alcance del tiempo de ejecución CLR/IL2CPP.

La Nueva Complejidad de IL2CPP: De Objetos Gestionados a Punteros Nativos, una "Conversión Fantasma"

Para los proyectos que utilizan el backend IL2CPP (la opción predeterminada y muy recomendada a partir de Unity 2018.4), la causa de las caídas nativas se vuelve aún más oculta. IL2CPP compila el código C# a código fuente C++, que luego es compilado por el compilador nativo de la plataforma (clang/gcc) para generar código máquina.

  • Todos los objetos C# se corresponden con una región contigua de memoria nativa gestionada por el GC de IL2CPP.
  • API como GCHandle.Alloc() y Marshal.AllocHGlobal() crean un mapeo bidireccional entre objetos gestionados y punteros nativos.
  • Cuando el código C# pasa un Texture2D a un plugin nativo, en realidad pasa un void* que apunta a la estructura Texture_t* subyacente de esa textura.
  • Si el lado C# del Texture2D es recolectado por el GC, pero el plugin nativo todavía mantiene y intenta acceder a ese void*, se produce un clásico escenario de Uso después de liberar, y el GC de IL2CPP no es consciente de que el plugin nativo todavía lo está utilizando.

En un proyecto de RV, me encontré con una caída difícil de reproducir que ocurría aleatoriamente entre 5 y 10 minutos después de que el usuario se pusiera el casco. Finalmente, usando addr2line para desofuscar los símbolos, descubrimos que el punto de caída estaba en il2cpp::vm::Class::GetFieldFromName(). Tras un análisis más profundo del código C++ generado por IL2CPP, confirmamos que una corrutina, después de yield return new WaitForSeconds(0.1f), intentaba acceder a un componente Transform de un GameObject que había sido destruido en el frame anterior. Debido a una optimización en el encapsulamiento de Transform por parte de IL2CPP, el puntero interno no se puso a cero inmediatamente después de que el GC recolectara el GameObject, lo que provocó un SIGSEGV en el acceso posterior. Este problema se manifestaba de manera diferente en el backend Mono (generalmente lanzando una NullReferenceException), pero en IL2CPP, provocaba una caída directa a nivel nativo.

Captura: Construyendo un Sistema de Registro Integral para el Ciclo de Vida Completo

Sin registros, no hay análisis. La primera línea de defensa contra las caídas nativas es garantizar que puedes capturar de forma fiable, completa y con bajo impacto todas las pruebas originales de cada caída. Esto va más allá de simplemente ejecutar adb logcat; requiere una implementación estratégica y por fases.

Fase de Desarrollo: Análisis Simbólico en Tiempo Real Basado en NDK (Android)

En la máquina de desarrollo (con o sin root), el método de captura más eficiente es utilizar la herramienta ndk-stack incluida en el Android NDK, junto con una tabla de símbolos completa. La clave es: la tabla de símbolos debe coincidir exactamente con el APK que estás depurando actualmente. Muchos equipos tropiezan aquí, intentando analizar registros de un paquete de depuración con símbolos de un paquete de lanzamiento, lo que resulta en la aparición de ??:? en todas partes.

Pasos prácticos (ejemplo con Unity 2021.3.15f1 + Android):

  1. Asegúrate de que la opción "Create symbols.zip" esté marcada en los Ajustes de Construcción de Unity. En Player Settings → Publishing Settings → Build, activa esta opción. Unity generará un archivo symbols.zip en el directorio de salida de la construcción, que contiene las versiones sin despojar (unstripped) de libunity.so y todas tus bibliotecas .so personalizadas.
  2. Configura filtros de registro de ADB para capturar señales de caída de forma precisa: ``` adb logcat -b crash -b main -b system | grep -E "(F/libc|A/libc|signal|SIGSEGV|SIGABRT|Fatal signal)"
    
     El parámetro `-b crash` se utiliza específicamente para capturar el búfer de registro de caídas del sistema, lo que es más fiable que `main`. `F/libc` representa un error fatal de libc, un prefijo típico de las caídas nativas.
    
  3. Desencadena la caída y analiza en tiempo real. Cuando ocurre la caída, logcat generará una salida similar a la siguiente: ``` F/libc (12345): Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 12346 (UnityMain), pid 12345 (com.example.game) *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** Build fingerprint: 'google/sdk_gphone_x86_64/generic_x86_64:12/SPB2.220421.006/8054924:userdebug/dev-keys' Revision: '0' ABI: 'x86_64' Timestamp: 2023-10-15 14:23:45.678+0800 pid: 12345, tid: 12346, name: UnityMain >>> com.example.game <<< uid: 10156 signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 ... (registros de CPU) ... backtrace: #00 pc 0000000000123456 /data/app/~~abc123==/com.example.game-xyz123==/lib/x86_64/libunity.so (SomeFunction+123) #01 pc 0000000000234567 /data/app/~~abc123==/com.example.game-xyz123==/lib/x86_64/libunity.so (AnotherFunction+45)
  4. Utiliza ndk-stack para el análisis simbólico: ```

    Descomprime symbols.zip, asumiendo que obtienes unstripped/libunity.so

     unzip symbols.zip -d ./symbols
     # Guarda la salida de logcat en crash.log y luego analízala
     $NDK_HOME/ndk-stack -sym ./symbols/unstripped/ -dump crash.log
    
    
     El resultado convertirá el `#00 pc 0000000000123456` en un nombre de función C++ y número de línea específicos, por ejemplo: ```
    ********** Crash dump: **********
        Build fingerprint: 'google/sdk_gphone_x86_64/generic_x86_64:12/SPB2.220421.006/8054924:userdebug/dev-keys'
        #00 0x0000000000123456 /data/app/~~abc123==/com.example.game-xyz123==/lib/x86_64/libunity.so (il2cpp::vm::Object::GetSize(Il2CppObject*)+123) (../../Runtime/VM/Object.cpp:456)
        #01 0x0000000000234567 /data/app/~~abc123==/com.example.game-xyz123==/lib/x86_64/libunity.so (il2cpp::icalls::mscorlib::System::GC::GetTotalMemory(bool)+45) (../../Libraries/ICorRuntime/CorRuntime.cpp:123)
    

Importante: ndk-stack requiere que tu versión de NDK coincida con la utilizada por Unity durante la construcción (verificable en los ajustes de SDK y NDK de Android en Unity Hub). Versiones incompatibles resultarán en un análisis simbólico fallido o erróneo.

Fase de Pruebas y Despliegue Gradual: Integración de Google Breakpad/Crashpad (Solución Universal Multiplataforma)

Cuando el proyecto entra en las fases de TestFlight (iOS) o despliegue gradual interno (Android), ya no puedes depender de la conexión ADB a cada dispositivo de prueba. En este punto, debes "integrar" la capacidad de captura de caídas en la propia aplicación. Breakpad de Google (Android/iOS) y Crashpad (Windows/macOS) son los estándares de facto de la industria, y Unity ofrece soluciones de integración maduras.

Principios Fundamentales Simplificados

El modo de funcionamiento de Breakpad/Crashpad implica inicializar un hilo de monitorización independiente y de alta prioridad al iniciar la aplicación. Este hilo escucha continuamente diversas señales del sistema operativo enviadas al proceso (SIGSEGV/SIGABRT, etc.). Al capturar una señal, pausa todos los demás hilos, recorre el estado de los registros y la pila de llamadas de cada hilo (utilizando llamadas al sistema como /proc/self/maps y ptrace), y escribe una instantánea de memoria sin procesar (minidump) en un archivo local. Este proceso ocurre antes de que el kernel procese la señal de caída, garantizando así la captura del 100% de los eventos.

Puntos Clave de Integración en Unity (Ejemplo con Android)

  1. Incorpora el SDK oficial de Crash Reporting de Unity. Añade com.unity.crash-reporting en el Package Manager (Nota: este no es Unity Analytics, sino un SDK independiente para caídas nativas).
  2. Habilita la captura de caídas nativas. En CrashReportingSettings, marca Enable Native Crash Reporting y especifica una Symbol Upload URL (apuntando a tu propio servidor de símbolos o a Unity Cloud Diagnostics).
  3. Automatiza la carga de símbolos. En tu pipeline de CI/CD, ejecuta automáticamente lo siguiente después de cada construcción: ```

    Descomprime symbols.zip, extrae libunity.so

     unzip -o build/symbols.zip "libunity.so" -d ./symbols/
     # Utiliza la herramienta upload-symbols proporcionada por Unity para cargar
     ./Tools/upload-symbols -i ./symbols/libunity.so -s <TU_CLAVE_DE_CARGA_DE_SIMBOLOS>
    
  4. Reporte de registros de caídas. Cuando el dispositivo del usuario esté en línea, el archivo minidump se cifrará y cargará automáticamente en tu servidor. Podrás ver informes visuales con el stack trace completo, el estado de los hilos y los valores de los registros en tu panel de control.

Consejo práctico: Crashpad en iOS requiere un manejo adicional de Bitcode. Si tu proyecto tiene Bitcode habilitado, debes seleccionar Download Debug Symbols al archivar en Xcode; de lo contrario, el archivo dSYM cargado no se podrá analizar correctamente. Ignoré este paso una vez, lo que resultó en cientos de caídas reportadas en una semana, todas marcadas como <redacted>, desperdiciando una valiosa ventana de tiempo para la depuración.

Fase de Producción: Hooking Ligero y Puntos de Sondeo en Rutas Críticas (Red de Seguridad Defensiva)

Incluso con Breakpad, hay una minoría de caídas que no se pueden capturar, como aquellas que ocurren antes de que el hilo de monitorización de Breakpad se inicialice completamente (por ejemplo, durante la fase de __libc_init) o cuando la memoria está tan agotada que la escritura del minidump falla. En estos casos, se necesita una solución de reserva de nivel inferior y más ligera.

Implementamos un hook global para la llamada al sistema sigaction. En el método JNI_OnLoad de Unity (Android) o +load (iOS), registramos de antemano un manejador de señales personalizado:

// Android native-lib.cpp
#include <signal.h>
#include <android/log.h>

void signal_handler(int sig, siginfo_t *info, void *context) {
    // Registra la información básica de la señal en un búfer circular muy pequeño (para evitar malloc)
    static char crash_buffer[256];
    snprintf(crash_buffer, sizeof(crash_buffer), 
             "CRASH: %d, code=%d, addr=0x%lx", 
             sig, info->si_code, (long)info->si_addr);
    
    // Escribe en una página de memoria preasignada y que nunca se libera (mmap MAP_ANONYMOUS | MAP_LOCKED)
    // Asegura que la escritura ocurra incluso en caso de OOM
    write_crash_to_locked_page(crash_buffer);
    
    // Finalmente, llama al manejador predeterminado para que el sistema ejecute la terminación real
    signal(sig, SIG_DFL);
    raise(sig);
}

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
    struct sigaction sa;
    sa.sa_sigaction = signal_handler;
    sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGABRT, &sa, NULL);
    sigaction(SIGILL, &sa, NULL);
    return JNI_VERSION_1_6;
}

Este hook no intenta realizar un volcado de pila complejo (que requiere mucha memoria y tablas de símbolos), sino que solo registra el tipo de señal, el código de error y la dirección del fallo. Sin embargo, tiene una gran ventaja: se instala en la etapa más temprana del ciclo de vida del proceso, su código es extremadamente pequeño y es casi imposible que se desencadene a sí mismo. Cuando se producen caídas en producción "sin reporte de Breakpad, pero con quejas explícitas de los usuarios", la inspección de este registro ligero a menudo permite una rápida identificación del tipo de señal, reduciendo así el alcance de la investigación.

Análisis: Desde el Stack Trace Simbólico hasta la Restauración del Estado de Memoria

Capturar un backtrace simbólico es solo el comienzo. El verdadero desafío es: ¿cómo inferir lo que sucedió en la memoria hace unos milisegundos a partir de unas pocas líneas de llamadas a funciones? Esto requiere una combinación de las características de tiempo de ejecución de Unity, el modelo de memoria C++ y la señal de caída específica para realizar validaciones cruzadas multidimensionales.

Tres Patrones Clásicos de SIGSEGV y sus Características de Reconocimiento

Basándonos en si_code y fault addr, podemos clasificar la gran mayoría de los SIGSEGV en las siguientes tres categorías, cada una con su "huella dactilar" distintiva.

Patrón si_code Características de fault addr Características Típicas del Stack Trace Enfoque de Corrección
Desreferenciación de Puntero Nulo SEGV_MAPERR Direcciones muy bajas como 0x0, 0x1, 0x8 El final del stack trace a menudo contiene il2cpp::vm::Object::GetClass() o il2cpp::gc::GarbageCollector::Allocate() Verifica si el objeto C# es nulo o si el plugin nativo no incluye una comprobación if(ptr != nullptr).
Uso Después de Liberar (Use-After-Free) SEGV_MAPERR Direcciones no cero, pero que claramente pertenecen a memoria liberada (por ejemplo, 0x7f8a12345000, que sigue el patrón de asignación de malloc) El stack trace muestra frecuentes llamadas a il2cpp::gc::GarbageCollector::Collect() o il2cpp::gc::GarbageCollector::FinalizerThread() Reproduce después de forzar GC; comprueba si los GCHandle no se liberaron o si el plugin nativo almacenó punteros a UnityObjects destruidos.
Desbordamiento de Pila (Stack Overflow) SEGV_ACCERR Direcciones cercanas a la parte superior de la pila del hilo actual (cerca del valor del registro rsp) Profundidad de pila extremadamente grande (más de 1000 fotogramas) con llamadas repetitivas (por ejemplo, recursión infinita, bucle infinito de corrutinas) Comprueba si hay recursión infinita en C# o bucles while sin condición de salida en el plugin nativo.

Ejemplo: Recibimos un registro de caída donde fault addr era 0x7f8a12345678, si_code=SEGV_MAPERR, y el backtrace mostraba la caída en MyPlugin_ProcessData(void* data), donde data era memoria asignada por Marshal.AllocHGlobal() desde C#. Sospechamos inmediatamente que el lado C# había llamado a Marshal.FreeHGlobal() prematuramente. Al añadir registros antes y después de FreeHGlobal, descubrimos que un IEnumerator liberaba incorrectamente la memoria que debería haber sido gestionada por el plugin, justo antes de yield break. La solución fue mover FreeHGlobal al interior del plugin, y que C# solo se encargara de AllocHGlobal.

Utilización de la "Instantánea de Memoria" del Unity Profiler para Validación Cruzada

Aunque el Unity Profiler en sí mismo no puede capturar caídas nativas, puede proporcionar una instantánea del estado de la memoria gestionada justo antes de la caída, lo cual es crucial para analizar problemas de Uso después de liberar.

Flujo de operación:

  1. Antes de la escena o acción sospechosa (por ejemplo, entrar en un nivel específico, iniciar escaneo AR), haz clic en el módulo Memory en la esquina superior izquierda del Profiler y selecciona Take Snapshot.
  2. Ejecuta la acción y espera la caída (o desencadénala manualmente).
  3. Reinicia la aplicación, accede al Profiler y carga la Memory Snapshot anterior.
  4. En la lista Objects, ordena por GC Ref Count y presta especial atención a los objetos con Ref Count = 0 pero Size > 0. Esto indica que han sido marcados para reciclaje pero aún no han sido recolectados por el GC.
  5. Expande la sección Referenced By de estos objetos para ver quién los está referenciando. Si descubres que una Native Plugin Instance (o un MonoBehaviour personalizado) todavía está haciendo referencia a una Texture2D con Ref Count = 0, esa es la prueba definitiva de Uso después de liberar.

La potencia de este truco radica en que transforma un problema abstracto de "dirección de memoria ilegal" en un diagrama visual e interactivo de "quién referencia a quién". Guié a un desarrollador junior a utilizar este método, y en 2 horas identificó una caída que había estado desconcertando al equipo durante dos semanas: la causa era que OnDisable() no llamaba a plugin.UnregisterCallback(), lo que provocaba que el plugin siguiera enviando callbacks a un MonoBehaviour destruido.

Desmontaje de SDKs de Terceros "Caja Negra": De Archivos .so a Análisis a Nivel de Código Fuente

Muchas caídas nativas se esconden en SDKs de terceros que no puedes modificar (por ejemplo, SDKs de publicidad, pagos, notificaciones push). ¿Qué hacer cuando te enfrentas a un backtrace que indica una caída dentro de libadmob.so en AdRequest::Send()?

La respuesta es: realiza ingeniería inversa del archivo .so, combinado con la documentación pública, para inferir sus límites de comportamiento.

Herramientas:

  • readelf -d libadmob.so | grep NEEDED: Comprueba a qué bibliotecas del sistema depende (por ejemplo, liblog.so, libz.so) para determinar si podría estar llamando a __android_log_print o inflate.
  • nm -D libadmob.so | grep -i "init\|create\|start": Busca sus funciones exportadas de inicialización para entender qué podría estar haciendo internamente cuando llamas a AdManager.Initialize().
  • strings libadmob.so | grep -i "error\|fail\|invalid": Extrae los mensajes de error codificados internamente, que a menudo son las últimas pistas antes de una caída.

Aún más, si tienes los archivos de encabezado (.h) del SDK, puedes usar bindgen (Rust) o SWIG (C++) para generar enlaces C# y insertar Debug.Log($"Before AdRequest.Send() - Texture count: {Resources.FindObjectsOfTypeAll<Texture2D>().Length}"); antes y después de llamadas clave. Observar mutaciones en el número de objetos gestionados antes y después de la caída puede ayudar a inferir si el SDK está creando/destruyendo recursos de Unity internamente.

Lección Aprendida: Una vez, integramos un SDK de voz, y el stack trace de la caída siempre aparecía en libvoice.so dentro de AudioEngine::Process(). El comando strings reveló un mensaje interno como "Failed to lock audio buffer". Inmediatamente comprobamos AudioSettings.dspBufferSize de Unity y descubrimos que la versión de producción se había establecido incorrectamente en 128 (demasiado pequeño), mientras que el SDK requería un mínimo de 512. Al ajustar este parámetro, la tasa de caídas disminuyó en un 99%. Esto nos enseñó que las caídas de SDKs de terceros a menudo no se deben a que "tienen errores", sino a que "no estamos siguiendo sus reglas".

Corrección: Una Red de Siete Capas de Defensa desde las Normas de Código hasta la Configuración de Construcción

La captura y el análisis son para el diagnóstico; la corrección es el objetivo final. Un proyecto Unity robusto no debe depender de la esperanza de "no tener caídas", sino de construir un sistema de defensa multicapa que intercepte, transforme o degrade las caídas nativas potenciales antes de que lleguen al dispositivo del usuario.

Capa C#: Comprobaciones Obligatorias de Nulos y Ciclo de Vida (Primera Puerta)

Todos los punteros pasados de C# a nativo deben ser validados rigurosamente. No es una carga de rendimiento, sino una línea de base.

public class SafeTextureHandler : MonoBehaviour
{
    private Texture2D _texture;
    private IntPtr _nativeTexturePtr;

    public void SetTexture(Texture2D tex)
    {
        // 【Primera Capa】Comprobación de nulos en C#
        if (tex == null)
        {
            Debug.LogError("SetTexture called with null texture!");
            return;
        }

        // 【Segunda Capa】Comprobación de validez de UnityObject (evita Destroyed pero no Null)
        if (!tex || tex.width <= 0 || tex.height <= 0)
        {
            Debug.LogError($"SetTexture called with invalid texture: {tex?.name}, width={tex?.width}, height={tex?.height}");
            return;
        }

        // 【Tercera Capa】Gestión de GCHandle: asegura un ciclo de vida de textura controlable
        if (_nativeTexturePtr != IntPtr.Zero)
        {
            // Primero, libera el Handle antiguo
            GCHandle.FromIntPtr(_nativeTexturePtr).Free();
        }
        
        // Crea un nuevo GCHandle y pasa su dirección a Nativo
        var handle = GCHandle.Alloc(tex, GCHandleType.Normal);
        _nativeTexturePtr = GCHandle.ToIntPtr(handle);

        // Llama al método nativo
        MyPlugin.SetTexture(_nativeTexturePtr, tex.width, tex.height);
    }

    private void OnDestroy()
    {
        // 【Cuarta Capa】Asegura la liberación del Handle en OnDestroy
        if (_nativeTexturePtr != IntPtr.Zero)
        {
            try
            {
                GCHandle.FromIntPtr(_nativeTexturePtr).Free();
            }
            catch
            {
                // El Handle podría haber sido ya recolectado por el GC, ignora
            }
            _nativeTexturePtr = IntPtr.Zero;
        }
    }
}

Este código puede parecer verboso, pero bloquea al menos cuatro rutas de caída comunes: punteros nulos, objetos destruidos, recolección temprana del GC y fugas de memoria por no ejecución de OnDestroy. En nuestros proyectos, al aplicar esta plantilla a todas las operaciones que involucran GCHandle y Marshal, las caídas relacionadas disminuyeron en un 87%.

Capa Nativa: "Reglas de Hierro" de Seguridad de Memoria y Prácticas Modernas de C++

Si tu proyecto incluye plugins nativos personalizados (.so / .a), debes adherirte a las siguientes reglas de hierro:

  • Nunca conserves punteros desnudos a UnityObjects en la capa nativa a largo plazo. La forma correcta es: cuando sea necesario, obtén un Il2CppObject* a través de API como il2cpp_class_from_name() y il2cpp_object_new() desde el lado C#, y úsalo inmediatamente antes de que la función nativa retorne; nunca lo almacenes en caché.
  • Todos los malloc / new deben tener su correspondiente free / delete. Utiliza valgrind --tool=memcheck (requiere root en Android) o Instruments → Leaks en Xcode para escaneos periódicos.
  • El acceso a arrays debe incluir comprobaciones de límites. Es preferible sacrificar un poco de rendimiento a cambio de añadir en rutas críticas: ```c++ if (index >= 0 && index < array_size) { return array[index]; } else { LOGE("Array access out of bounds: index=%d, size=%d", index, array_size); return default_value; }
    
    

Además, adopta std::span<T> de C++17 en lugar de punteros crudos + parámetros de longitud, y usa std::optional<T> en lugar de valores de retorno que puedan ser nulos. Estas características modernas pueden detectar muchos errores potenciales en tiempo de compilación.

Configuración de Construcción: Desde Ajustes de IL2CPP hasta Filtrado de ABI para "Fortificación Silenciosa"

Muchas caídas surgen de suposiciones erróneas en la configuración de construcción. Aquí hay algunas configuraciones a menudo pasadas por alto pero de gran impacto:

  • Generación de Código IL2CPP: En Player Settings → Other Settings → Configuration, después de establecer Scripting Backend en IL2CPP, la opción Code Generation es crucial.
    • Speed: Genera código más rápido pero con un binario más grande; puede ocultar ciertas condiciones límite debido a una optimización excesiva.
    • Balanced: Muy recomendado. Equilibra velocidad y facilidad de depuración, generando tablas de símbolos más completas y una mayor tasa de éxito de análisis con addr2line.
    • Size: Compresión extrema, pero despoja mucha información de depuración, haciendo el análisis de caídas extremadamente difícil. Evítalo a menos que sea para juegos extremadamente ligeros.
  • Arquitecturas de Destino: En Publishing Settings, no marques ciegamente todos los ABI (ARMv7, ARM64, x86). ARM64 es el estándar absoluto actual; ARMv7 puede ser abandonado. x86 solo es para emuladores; debe ser eliminado del paquete de producción. Cada ABI adicional no solo aumenta el tamaño del paquete, sino que también incrementa el riesgo de caídas SIGILL debido a problemas de compatibilidad de arquitectura (como instrucciones NEON).
  • Strip Engine Code: Esto es un arma de doble filo. Habilitarlo reduce significativamente el tamaño del APK/IPA, pero despoja los símbolos de libunity.so, lo que impide el análisis de ndk-stack. Nuestra práctica es: deshabilitarlo para paquetes de depuración y habilitarlo para paquetes de lanzamiento, pero asegurándonos de que el proceso de CI respalde previamente libunity.so sin despojar antes de la carga de símbolos.

Protección en Tiempo de Ejecución: Añadiendo "Disyuntores" a las Rutas Críticas

Para módulos conocidos de alto riesgo que no se pueden refactorizar inmediatamente (por ejemplo, un SDK de reconocimiento AR antiguo cuyo mantenedor ya no está disponible), podemos introducir un mecanismo de disyuntor en tiempo de ejecución:

public class ARSafeRunner
{
    private static int _crashCount = 0;
    private const int MAX_CRASHES = 3;
    private const float RESET_INTERVAL = 60f; // Máximo 3 veces en 60 segundos

    public static bool TryRunARScan(Action scanAction)
    {
        if (Time.time - _lastResetTime > RESET_INTERVAL)
        {
            _crashCount = 0;
            _lastResetTime = Time.time;
        }

        if (_crashCount >= MAX_CRASHES)
        {
            Debug.LogWarning("AR Scan disabled due to repeated crashes.");
            return false;
        }

        try
        {
            scanAction();
            return true;
        }
        catch (System.Exception ex)
        {
            // Captura excepciones gestionadas (aunque no puede capturar nativas, sí sus efectos en cadena)
            Debug.LogError($"Managed exception during AR scan: {ex}");
            _crashCount++;
            return false;
        }
    }

    private static float _lastResetTime = 0f;
}

Aunque esto no puede prevenir la caída nativa en sí, evita que los usuarios se queden atrapados en un ciclo vicioso de "se cierra al abrir", dando al equipo tiempo valioso para corregirlo. Después del lanzamiento, contamos las veces que ARSafeRunner rechazó la ejecución, lo que nos permitió identificar con precisión la escena AR más inestable y concentrar nuestros esfuerzos en reescribirla.

Prevención: Estableciendo una Cultura Sostenible de Gestión de Caídas Nativas

Corregir una caída individual es apagar un incendio; establecer mecanismos preventivos es abordar la causa raíz. Un equipo de Unity maduro debe integrar la gestión de caídas nativas en sus flujos de trabajo de desarrollo diarios, fomentando una cultura que funcione automáticamente sin necesidad de recordatorios.

"Control de Acceso de Caídas" en la Pipeline CI/CD

En Jenkins/GitLab CI, añade una fase de verificación de caídas nativas a cada PR:

  • Análisis Estático: Utiliza cppcheck para escanear el código fuente de todos los plugins nativos, aplicando reglas como memleak, nullPointer, arrayIndexOutOfBounds.
  • Pruebas Dinámicas: En emuladores o dispositivos físicos en la nube, ejecuta automáticamente un conjunto de "pruebas de estrés": cargar/descargar escenas repetidamente, crear/destruir muchos GameObjects, cambiar entre diferentes resoluciones, todo durante 30 minutos. Cualquier caída fallará la CI y archivará automáticamente logcat y minidump.
  • Verificación de Integridad de Símbolos: Después de la construcción, ejecuta automáticamente file libunity.so | grep "not stripped" para asegurarte de que los símbolos del paquete de depuración no se hayan despojado accidentalmente.

Este control de acceso no está diseñado para "bloquear" el desarrollo, sino para exponer los problemas antes de que se fusionen. En una ocasión, la CI falló en un PR porque cppcheck detectó un uso de strcpy sin verificar el tamaño del búfer de destino. El desarrollador lo corrigió de inmediato, evitando una posible vulnerabilidad de desbordamiento de pila.

Un Proceso Estandarizado para "Reuniones de Revisión de Caídas"

La corrección de cada caída nativa en producción debe ir seguida de una reunión de revisión de no más de 30 minutos, que debe culminar en un "Informe de Causa Raíz de Caída" con la siguiente plantilla:


## Informe de Causa Raíz de Caída Nativa

**ID de Incidencia:** [Link al ticket en Jira/Trello]
**Fecha:** YYYY-MM-DD
**Responsable:** [Nombre del desarrollador principal]

---

### 1. Resumen del Problema

*   **Descripción:** [Breve descripción de la caída y cómo se manifiesta en el juego]
*   **Frecuencia:** [Estimación del porcentaje de usuarios afectados o número de reportes]
*   **Impacto:** [Ej: Cierre crítico, degradación de funcionalidad]

---

### 2. Diagnóstico (Evidencia Recopilada)

*   **Registros Capturados:**
    *   `logcat` (con `ndk-stack`): [Adjuntar el stack trace simbólico o enlace a archivo]
    *   Minidump (si aplica): [Enlace al reporte en la herramienta de crash reporting]
    *   Logs de Unity: [Adjuntar Player.log si es relevante]
*   **Contexto de Reproducción:**
    *   Dispositivo(s) afectados: [Modelo, SO, Versión de Unity]
    *   Condiciones de activación: [Pasos para reproducir la caída, si se conocen]
    *   Análisis de Memoria (Profiler Snapshot): [Hallazgos clave, si se utilizaron]
*   **Causa Raíz Inferida:** [Hipótesis principal basada en la evidencia]
    *   [Detalles de la lógica o configuración que condujo al fallo]
    *   [Referencia a código específico o configuración de construcción]

---

### 3. Solución Implementada

*   **Cambios en el Código:**
    *   [Descripción detallada de los cambios en C# o C++]
    *   [Fragmento de código relevante (si es corto)]
*   **Ajustes de Configuración:**
    *   [Modificaciones en Player Settings, Build Settings, etc.]
*   **Estrategia de Despliegue:**
    *   [Ej: Fix en la próxima versión, despliegue gradual inmediato]

---

### 4. Medidas Preventivas (Lecciones Aprendidas)

*   **Recomendaciones:**
    *   [Ej: Añadir validación de punteros en X módulo, mejorar pruebas de estrés para Y escenario, actualizar política de CI para incluir Z chequeo]
*   **Próximos Pasos:**
    *   [Ej: Refactorizar módulo obsoleto, monitorizar métricas post-lanzamiento]

---

**Aprobado por:** [Nombre del Lead/Manager]

Este proceso estandarizado asegura que cada caída se convierta en una oportunidad de aprendizaje y mejora continua, fortaleciendo la resiliencia del proyecto a largo plazo.

Etiquetas: Unity native crash sigsegv il2cpp debugging

Publicado el 7-18 22:32