Aller au contenu

Ingénierie

La frontière où les deux côtés compilent et où l'un des deux ment

À travers une ABI C, une fonction mal orthographiée n'est pas une erreur de compilation et une fonction mal typée n'est même pas visible par nm. Une déclaration 32 bits sur une valeur 64 bits a transformé 23347 Mo en 2,8 Go — plausible, et faux d'un facteur huit.

Mis à jour 2026-09-12Mesuré 2026-09-11

Deux langages qui se rencontrent sur une ABI C n'ont pas de compilateur commun ; rien ne vérifie donc s'ils sont d'accord. Chacun déclare ce qu'il croit que l'autre fournit, tous les deux compilent, et le désaccord — s'il y en a un — devient une propriété de l'exécution.

Tout le monde le sait dans l'abstrait. Ce qui est moins évident, c'est à quel point les deux modes de défaillance diffèrent, parce que l'un est seulement agaçant et l'autre franchement dangereux.

Un mauvais nom échoue tard, mais il échoue

Déclarez runlix_generat là où la bibliothèque exporte runlix_generate, et personne ne proteste. Sur presque toutes les plateformes, la résolution des symboles est paresseuse : la bibliothèque se charge, le programme démarre, et l'appel échoue la première fois qu'on y arrive. Ce qui peut être un chemin que vous n'empruntez que le deuxième jour.

Agaçant, mais honnête. Quand cela arrive, l'erreur nomme le symbole, et vous corrigez une faute de frappe.

Le correctif qui passe à l'échelle n'est pas l'attention, c'est un test. Lisez la table des symboles de la bibliothèque construite et comparez-la aux déclarations, dans les deux sens :

  • chaque symbole que l'appelant déclare vouloir appeler doit être exporté par la bibliothèque ;
  • chaque symbole externe que la bibliothèque déclare vouloir doit exister dans ce que vous liez.

Les deux moitiés comptent. Nous avons eu la première pendant un temps et pas la seconde, et la moitié manquante était justement celle qui aurait attrapé un vrai défaut.

Un mauvais type n'échoue pas du tout

C'est le cas intéressant.

Déclarez le bon nom avec le mauvais type et la vérification de la table des symboles passe, parce qu'une table des symboles connaît les noms et pas les signatures. nm vous dira que runlix_memoria existe. Il ne vous dira pas qu'un côté croit écrire 64 bits pendant que l'autre croit en lire 32.

Voici le cas mesuré. Le moteur rapporte le plafond de mémoire recommandé par MLX sous forme de u64, écrit via un paramètre de sortie. Le côté C# déclarait ce paramètre comme out uint — 32 bits.

Les deux compilent. Rien ne plante. La valeur revient tronquée à ses 32 bits de poids faible.

Le vrai plafond sur la machine était de 23347 Mo. Ce qui est arrivé, c'était 2,8 Go.

Regardez ce qui rend cela grave. Ce n'est pas zéro, ce qui serait manifestement faux. Ce n'est pas énorme, ce qui serait manifestement faux. C'est un nombre qui ressemble exactement au plafond de mémoire d'un Mac plus petit — donc le code en aval y a cru, a dimensionné son budget là-dessus, et s'est mis à évincer des modèles pour lesquels la place était largement suffisante. Faux d'un facteur huit, et parfaitement plausible.

Pourquoi les défenses habituelles ne l'attrapent pas

Le compilateur ne peut pas. Chaque côté a compilé contre sa propre déclaration, et les deux étaient cohérentes en interne.

L'éditeur de liens ne peut pas. Le nom correspondait.

nm ne peut pas. Les signatures ne figurent pas dans la table des symboles.

Les tests souvent ne peuvent pas, et cela vaut la peine de s'y arrêter. Un test qui vérifie que la valeur est « raisonnable » passe : 2,8 Go est raisonnable. Un test qui vérifie qu'elle est supérieure à zéro passe. Un test qui vérifierait une valeur précise l'aurait attrapé — mais un plafond de mémoire précis, vous ne pouvez pas le vérifier, puisqu'il change d'une machine à l'autre.

Ce qui l'a attrapé, c'est d'avoir comparé les deux déclarations côte à côte, délibérément, comme une tâche. C'est-à-dire : un être humain les a lues toutes les deux et s'en est aperçu. Ce n'est pas une réponse satisfaisante, donc la question devient comment rendre cela reproductible.

De quoi la reproductibilité est faite

Trois choses, par ordre croissant d'effort et d'efficacité.

Générez un côté à partir de l'autre. Si les déclarations viennent d'une source unique, elles ne peuvent pas être en désaccord. Pour presque toute paire de langages il existe des outils, et si la frontière est assez petite, les générer à la main depuis une description partagée convient aussi. C'est le vrai correctif.

Vérifiez le tour complet quand vous le pouvez. Certaines valeurs se comparent à une source indépendante — un plafond de mémoire peut se comparer, grossièrement, à ce que déclare le système, et un écart d'un facteur huit fait confortablement échouer même une vérification large. Tout n'a pas une telle source, mais plus de choses que vous ne l'imagineriez.

Gardez la frontière petite et ennuyeuse. Chaque fonction est un endroit où les deux déclarations peuvent s'éloigner. Une frontière de quinze fonctions se lit en une séance ; une de deux cents, non, et celles que personne ne lit sont précisément là où cette chose vit.

Nous faisons la troisième, nous avons désormais la première moitié de la vérification des symboles dans les deux sens, et le plafond de mémoire a un contrôle de vraisemblance indépendant. Le désaccord sur le type, lui, a été trouvé en lisant.

À quoi ressemble en pratique la vérification des symboles

C'est moins de travail qu'il n'y paraît, et cela vaut la peine de l'écrire en entier, parce que l'implémentation évidente ne fait que la moitié du métier.

Lisez les symboles exportés de la bibliothèque construite — nm -gU sur macOS, nm -D sur Linux, ou l'équivalent de votre plateforme — et, dans la même sortie, lisez les symboles non définis. Puis comparez avec deux listes que vous avez déjà : les fonctions externes que l'appelant déclare, et les fonctions externes que la bibliothèque déclare vouloir.

La première comparaison attrape une faute de frappe du côté de l'appelant. La seconde attrape une faute de frappe du côté de la bibliothèque, et attrape aussi quelque chose de plus intéressant : une dépendance que vous croyiez lier et que vous ne liez pas. C'est la seconde moitié qu'on saute, et c'est celle qui a trouvé ici un vrai problème.

Deux détails font la différence entre une vérification qui fonctionne et une qui passe pour toujours. Prenez la liste des symboles dans l'artefact construit, pas dans les sources : analyser les sources reproduit exactement les déclarations que vous cherchez à vérifier, donc c'est d'accord avec soi-même par construction. Et faites-la tourner dans le même travail que la compilation, pas comme une étape manuelle séparée, parce qu'une vérification dont quelqu'un doit se souvenir est une vérification qui cesse de tourner la semaine où elle servirait.

Ce qui reste malgré tout à découvert

Mieux vaut être clair sur les limites, puisque le propos de cette note est justement que les défenses habituelles laissent passer des choses.

La vérification des symboles ne couvre pas la disposition des structures. Si les deux côtés les déclarent et que l'un ajoute un champ, les noms correspondent toujours et les tailles non. C'est la même défaillance avec un rayon plus large, et la seule vraie défense est de générer la structure depuis une définition unique.

Elle ne couvre ni la convention d'appel ni les durées de vie. Qui alloue, qui libère, si un pointeur survit à l'appel : rien de tout cela ne figure dans une table des symboles, et rien de tout cela ne figure même dans une signature. Cela vit dans les commentaires et dans la discipline, ce qui est un argument de plus pour une frontière assez petite pour tenir dans la tête.

L'autre raison de garder la frontière petite

Elle mérite d'être dite parce qu'elle a poussé la même décision depuis une autre direction : notre frontière C est une interface à interrogation et non à rappels. L'appelant demande le jeton suivant ; rien ne rappelle vers la partie managée.

Ce choix a été fait pour éviter qu'un fil d'exécution étranger n'entre dans un environnement à ramasse-miettes à un moment que personne n'a choisi. Mais il a un second effet qui s'est révélé plus important : une frontière à interrogation a moins de formes de fonctions, aucun pointeur de fonction qui la traverse, et aucune question sur qui possède un rappel. Moins de déclarations à garder d'accord, c'est la même propriété que moins d'endroits où se tromper.

La version courte

  • Une fonction externe au nom écorché n'est pas une erreur de compilation. La résolution est paresseuse.
  • Une fonction au mauvais type n'est même pas visible par nm, parce que les tables de symboles tiennent les noms, pas les signatures.
  • Mesuré : out uint sur un u64 a transformé un plafond de 23347 Mo en 2,8 Go — tronqué, plausible, faux d'un facteur huit.
  • Vérifiez les symboles dans les deux sens, générez les déclarations depuis une source unique quand vous le pouvez, et gardez la frontière assez petite pour que quelqu'un puisse la lire en entier.

Mesuré le 11 septembre 2026, sur une ABI C entre Rust et .NET. Rien de tout cela n'est propre à ce couple.