Bugs, bugs, bugs, I'm looking for a good time

La calculatrice NumWorks est dotée d’un logiciel appelé Epsilon. Ce logiciel est principalement codé en C et C++, et il est compilé pour plusieurs plateformes : la calculatrice elle-même, le simulateur web et des applications pour smartphone (iOS et Android). Lors de son développement, les bugs sont fréquents et nous utilisons un large éventail d’outils pour nous aider à les détecter : des tests unitaires ou le fuzzer par exemple.

Les bugs les plus “faciles” à résoudre sont ceux qui sont reproductibles sur toutes les plateformes et qui ne sont pas volatiles ; dans ces situations, la ligne de code C ou C++ responsable est rapidement localisée. Cependant, parfois certains bugs plus retors n’apparaissent que sur une plateforme particulière. D’autres apparaissent et disparaissent lorsqu’on change des lignes de code qui ne sont en rien liées à la cause du bug ou lorsqu’on modifie le niveau d’optimisation à la compilation ou lors de l’édition de lien. Dans ces cas-là, le suspect principal est souvent un problème de gestion de la mémoire. En effet, ce type de bugs peut soit n’avoir aucune conséquence significative, soit générer une “hard fault” et donc un “crash” selon la zone de mémoire qui est touchée. Nous avons récemment eu affaire à un bug plutôt coriace qui n’apparaissait que sur la calculatrice. Il s’agissait en fait d’un bug du compilateur !

Nous sommes parvenus à reproduire le bug avec le code minimal suivant :

typedef struct {
  double m_a;
  double m_b;
  double m_c;
  double m_d;
} AtLeast32BytesObject;

AtLeast32BytesObject __attribute__((noinline)) CalledFunction() {
  AtLeast32BytesObject result = {1.1, 2.2, 3.3, 4.4};
  return result;
}

void __attribute__((noinline)) _start() {
  volatile AtLeast32BytesObject result = CalledFunction();
  while(1) {}
}

On compile ce code avec le compilateur gcc de la chaîne de compilation de GNU pour ARM (gcc version 9.3.1 20200408) en utilisant les options suivantes :

arm-none-eabi-gcc -Os -flto -mthumb -mfloat-abi=hard -mcpu=cortex-m4 -ffreestanding -nostdlib -lgcc

L’assembleur généré pour le symbole CalledFunction sont les suivantes :

Code assembleur généré pour CalledFunction

En fin de procédure, la valeur de retour de CalledFunction est stockée dans les registres r0-r7 (cf les instructions localisées dans l’intervalle d’adresses 0x0000805e-0x0000806e dans la fonction désassemblée). Cependant, comme décrit dans standard des appels de procédure pour l’architecture ARM, une sous-procédure doit préserver le contenu des registres r4-r8, r10, r11 et sp1. Les registres r4-r7 sont qualifiés de “callee-save” : leur valeur est préservée lors de l’appel à une sous-routine. Avant de retourner à la procédure appelante, les registres r4 et r7 sont donc réécrits avec leur précédente valeur (cf l’instruction à l’adresse 0x00008072). Le résultat retourné par la fonction CalledFunction est par conséquent corrompu.

Dans notre cas, le compilateur garantit le rôle de “callee-save” de r4-r7 mais ne respecte pas les conventions d’appel et de retour des procédures. En effet, le résultat retourné par CalledFunction est un registre candidat au co-procésseur (“Co-processor Register Candidate (CPRC)”) pour l’extension VFP (Vector Floating Point). En effet, c’est un agrégat homogène dont le type de base est un “double”2. Selon le même document, tout résultat dont le type satisfait les critères pour être un CPRC du VFP est passé via les registres VFP en commençant par le registre d’index le plus faible (s0, d0, q0)3.

Quelques remarques supplémentaires

Lorsqu’on compile sans activer les optimisations faites à l’édition de lien (Link-Time Optimizations, LTO), c’est-à-dire en enlevant l’option -flto, l’objet retourné par CalledFunction est stocké dans les registres d0-d3, respectant ainsi le standard. Et ainsi, le bug disparaît ! Étrangement, les optimisations LTO augmentent par ailleurs le nombre d’instructions émises.

Code assembleur généré pour CalledFunction sans LTO

Un autre point intéressant : si on compile avec les optimisations LTO mais en désactivant le VFP (Vector Floating Point unit), c’est-à-dire en enlevant l’option de compilation -mfloat-abi=hard, le résultat de CalledFunction est maintenant stocké sur la stack à une adresse dont la valeur est retournée par le registre r0. Le standard est encore cette fois respecté : un type composite de taille supérieure à 4 octets est stocké en mémoire à une adresse passée en argument additionnel lorsque la fonction est appelée4.

Code assembleur généré pour CalledFunction sans VFP

On a informé les développeurs de GNU gcc du bug précédent. En attendant, si quelqu’un parvient à trouver l’origine de ce bug, l’équipe de NumWorks serait intéressée de le comprendre.

Rapport de bug sur GCC Bugzilla

Notez bien que ce type de bug est extrêmement rare. Habituellement, lorsqu’on trouve un bug, ce n’est jamais la faute du compilateur !

Voici les informations pertinentes pour reproduire le bug :

  • Compilateur : arm-none-eabi-gcc (GNU Arm Embedded Toolchain 9-2020-q2-update) 9.3.1 20200408 (release) disponible sur https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm/downloads
  • Le code minimal a été donné précédemment
  • Les commandes pour compiler le code puis désassembler l’exécutable sont les suivantes (avec le code minimal dans un fichier main.c) :
    $ arm-none-eabi-gcc main.c -Os -flto -mthumb -mcpu=cortex-m4 -mfloat-abi=hard -ffreestanding -nostdlib -lgcc -o a.elf
    $ arm-none-eabi-gdb -batch -ex 'file a.elf' -ex 'disassemble CalledFunction'
    
  1. En anglais dans Procedure Call Standard for the Arm® Architecture : “A subroutine must preserve the contents of the registers r4-r8, r10, r11 and SP (and r9 in PCS variants that designate r9 as v6).” 

  2. En anglais dans Procedure Call Standard for the Arm® Architecture : “A Homogeneous Aggregate with a Base Type of a single- or double-precision floating- point type with one to four Elements.” 

  3. En anglais dans Procedure Call Standard for the Arm® Architecture : “Any result whose type would satisfy the conditions for a VFP CPRC is returned in the appropriate number of consecutive VFP registers starting with the lowest numbered register (s0, d0, q0).” 

  4. En anglais dans Procedure Call Standard for the Arm® Architecture : “A Composite Type larger than 4 bytes, or whose size cannot be determined statically by both caller and callee, is stored in memory at an address passed as an extra argument when the function was called (Parameter Passing (page 24), rule A.4 (page 25)). The memory to be used for the result may be modified at any point during the function call.”