Ir al contenido

Ingeniería

La frontera donde compilan los dos lados y uno de ellos miente

A través de una ABI de C, una función mal escrita no es un error de compilación y una mal tipada no la ve ni nm. Una declaración de 32 bits sobre un valor de 64 convirtió 23347 MB en 2,8 GB: plausible, y equivocado por un factor de ocho.

Actualizado 2026-09-12Medido 2026-09-11

Dos lenguajes que se encuentran sobre una ABI de C no tienen un compilador en común, así que no hay nada que compruebe si están de acuerdo. Cada uno declara lo que cree que el otro le ofrece, los dos compilan, y el desacuerdo —si lo hay— se convierte en una propiedad del tiempo de ejecución.

Eso lo sabe todo el mundo en abstracto. Lo que resulta menos evidente es hasta qué punto difieren las dos formas de fallar, porque una es solo molesta y la otra es genuinamente peligrosa.

Un nombre equivocado falla tarde, pero falla

Declara runlix_generat donde la biblioteca exporta runlix_generate y no protesta nadie. En casi todas las plataformas la resolución de símbolos es perezosa: la biblioteca se carga, el programa arranca, y la llamada falla la primera vez que se llega hasta ella. Que puede ser en un camino que recorres el segundo día.

Molesto, pero honesto. Cuando ocurre, el error nombra el símbolo, y tú corriges una errata.

La corrección que escala no es la atención: es una prueba. Lee la tabla de símbolos de la biblioteca construida y compárala con las declaraciones, en los dos sentidos:

  • todo símbolo que el llamante declara querer llamar tiene que estar exportado por la biblioteca;
  • todo símbolo externo que la biblioteca declara querer tiene que existir en aquello que enlazas.

Cuentan las dos mitades. Nosotros tuvimos la primera durante un tiempo y no la segunda, y la mitad que faltaba era justamente la que habría cazado un defecto de verdad.

Un tipo equivocado no falla en absoluto

Este es el caso interesante.

Declara el nombre correcto con el tipo equivocado y la comprobación de la tabla de símbolos pasa, porque una tabla de símbolos conoce los nombres y no las firmas. nm te dirá que runlix_memoria existe. No te dirá que un lado cree escribir 64 bits mientras el otro cree leer 32.

Este es el caso medido. El motor informa del techo de memoria que MLX recomienda, como un u64 escrito a través de un parámetro de salida. El lado de C# declaraba ese parámetro como out uint: 32 bits.

Los dos compilan. No revienta nada. El valor vuelve truncado a sus 32 bits bajos.

El techo real en la máquina era de 23347 MB. Lo que llegó fueron 2,8 GB.

Mira qué es lo que lo hace grave. No es cero, que sería evidentemente erróneo. No es enorme, que sería evidentemente erróneo. Es un número que se parece exactamente al techo de memoria de un Mac más pequeño, así que el código de más abajo se lo creyó, dimensionó su propio presupuesto con él y empezó a desalojar modelos para los que había sitio de sobra. Equivocado por un factor de ocho, y del todo plausible.

Por qué las defensas habituales no lo cazan

El compilador no puede. Cada lado compiló contra su propia declaración, y las dos eran internamente coherentes.

El enlazador no puede. El nombre coincidía.

nm no puede. Las firmas no están en la tabla de símbolos.

Las pruebas a menudo no pueden, y en esto vale la pena detenerse. Una prueba que comprueba que el valor sea «razonable» pasa: 2,8 GB es razonable. Una que comprueba que sea mayor que cero pasa. Una que comprobara un valor concreto lo habría cazado, pero un techo de memoria concreto no se puede comprobar, porque cambia de una máquina a otra.

Lo que lo cazó fue comparar las dos declaraciones una al lado de la otra, deliberadamente, como tarea. Es decir: un ser humano las leyó las dos y se dio cuenta. No es una respuesta satisfactoria, así que la pregunta pasa a ser cómo hacerla repetible.

De qué está hecha la repetibilidad

Tres cosas, en orden creciente de esfuerzo y de eficacia.

Genera un lado a partir del otro. Si las declaraciones vienen de una sola fuente, no pueden estar en desacuerdo. Para casi cualquier pareja de lenguajes existen herramientas, y si la frontera es lo bastante pequeña, también sirve generarlas a mano desde una descripción compartida. Esa es la corrección de verdad.

Verifica el recorrido completo donde puedas. Algunos valores se pueden comparar con una fuente independiente: un techo de memoria se puede contrastar, a grandes rasgos, con lo que declara el sistema, y una desviación de ocho veces hace fallar cómodamente incluso a una comprobación generosa. No todo tiene una fuente así, pero sí más cosas de las que imaginarías.

Mantén la frontera pequeña y aburrida. Cada función es un sitio donde las dos declaraciones pueden separarse. Una frontera de quince funciones se lee en una sesión; una de doscientas, no, y las que nadie lee son justo donde esto vive.

Nosotros hacemos la tercera, ahora tenemos la primera mitad de la comprobación de símbolos en los dos sentidos, y el techo de memoria tiene una comprobación de sensatez independiente. El desacuerdo sobre el tipo, en cambio, se encontró leyendo.

Cómo es en la práctica la comprobación de símbolos

Es menos trabajo de lo que parece, y vale la pena escribirlo entero porque la implementación obvia hace solo la mitad del oficio.

Lee los símbolos exportados de la biblioteca construida —nm -gU en macOS, nm -D en Linux, o el equivalente de tu plataforma— y de esa misma salida lee los no definidos. Después compara con dos listas que ya tienes: las funciones externas que declara el llamante y las funciones externas que la biblioteca declara querer.

La primera comparación caza una errata del lado del llamante. La segunda caza una errata del lado de la biblioteca, y caza además algo más interesante: una dependencia que creías estar enlazando y no estás enlazando. Es la segunda mitad la que se salta, y es la que aquí encontró un problema de verdad.

Dos detalles marcan la diferencia entre una comprobación que funciona y una que pasa para siempre. Toma la lista de símbolos del artefacto construido, no del código fuente: analizar el fuente reproduce las mismas declaraciones que estás intentando verificar, así que está de acuerdo consigo mismo por construcción. Y hazla correr en el mismo trabajo que construye, no como un paso manual aparte, porque una comprobación que alguien tiene que recordar es una comprobación que deja de correr la semana en que hacía falta.

Qué queda igualmente al descubierto

Conviene ser claro con los límites, ya que el sentido de esta nota es precisamente que las defensas habituales dejan pasar cosas.

La comprobación de símbolos no cubre la disposición de las estructuras. Si las declaran los dos lados y uno añade un campo, los nombres siguen coincidiendo y los tamaños no. Es el mismo fallo con un radio mayor, y la única defensa de verdad es generar la estructura desde una sola definición.

No cubre ni la convención de llamada ni los tiempos de vida. Quién reserva, quién libera, si un puntero sobrevive a la llamada: nada de eso está en una tabla de símbolos, ni siquiera en una firma. Vive en los comentarios y en la disciplina, que es un argumento más a favor de una frontera lo bastante pequeña como para tenerla en la cabeza.

El otro motivo para tener la frontera pequeña

Vale la pena decirlo porque empujó la misma decisión desde otra dirección: nuestra frontera C es una interfaz por sondeo y no por retrollamadas. El llamante pide el siguiente token; nada vuelve a llamar hacia dentro de la parte gestionada.

Esa elección se tomó para evitar que un hilo ajeno entre en un entorno con recolector de basura en un momento que no ha elegido nadie. Pero tiene un segundo efecto que ha resultado más importante: una frontera por sondeo tiene menos formas de función, ningún puntero a función que la atraviese y ninguna pregunta sobre quién es dueño de una retrollamada. Menos declaraciones que mantener de acuerdo es la misma propiedad que menos sitios donde equivocarse.

La versión corta

  • Una función externa con el nombre destrozado no es un error de compilación. La resolución es perezosa.
  • Una con el tipo equivocado no la ve ni nm, porque las tablas de símbolos guardan nombres, no firmas.
  • Medido: out uint sobre un u64 convirtió un techo de 23347 MB en 2,8 GB: truncado, plausible, equivocado por un factor de ocho.
  • Comprueba los símbolos en los dos sentidos, genera las declaraciones desde una sola fuente donde puedas, y mantén la frontera lo bastante pequeña como para que alguien pueda leerla entera.

Medido el 11 de septiembre de 2026, sobre una ABI de C entre Rust y .NET. Nada de esto es específico de esa pareja.