Code assembleur en ligne

Aujourd’hui, peu de programmeurs utilisent le langage assembleur. Des langages de plus haut niveau comme le C ou le C++ s’exécutent sur quasiment toutes les architectures et permettent une productivité plus importante lors de l’écriture et de la maintenance du code. Parfois, les programmeurs ont besoin d’utiliser des instructions assembleur dans leurs programmes, la GNU Compiler Collection permet aux programmeurs d’ajouter des instructions en langage assembleur dépendantes de l’architecture à leurs programmes.

Les instructions assembleur GCC en ligne ne doivent pas être utilisées de façon excessive. Les instructions en langage assembleur dépendent de l’architecture, aussi, des programmes utilisant des instructions x86 ne peuvent pas être compilés sur des PowerPC. Pour les utiliser, vous aurez besoin d’un dispositif permettant de les traduire dans le jeu d’instruction de votre architecture. Cependant, les instructions assembleur vous permettent d’accéder au matériel directement et peuvent permettre de produire du code plus performant.

L’instruction asm vous permet d’insérer des instructions assembleur dans des programmes C ou C++. Par exemple, l’instruction

asm ("fsin" : "=t" (answer) : "0" (angle));

est une façon spécifique à l’architecture x86 de coder cette instruction C1):

answer = sin (angle);

Notez que contrairement aux instructions assembleur classiques, les constructions asm vous permettent de spécifier des opérandes en utilisant la syntaxe du C.

Pour en savoir plus sur le jeu d’instructions x86, que nous utiliserons dans ce chapitre, consultez http://developer.intel.com/design/pentiumii/manuals/ et http://www.x86-64.org/documentation.

Quand utiliser du code assembleur

Bien qu’il ne faille pas abuser des constructions asm, elles permettent à vos programmes d’accéder au matériel directement et peuvent produire des programmes qui s’exécutent rapidement. Vous pouvez les utiliser lors de l’écriture du code faisant partie du système d’exploitation qui a besoin d’interagir avec le matériel. Par exemple, /usr/include/asm/io.h contient des instructions assembleur pour accéder aux ports d’entrée/sortie directement. Le fichier source du noyau situé dans /usr/src/linux/arch/i386/kernel/process.s offre un autre exemple en utilisant hlt dans une boucle d’inactivité. Consultez d’autres fichiers source du noyau Linux situés dans /usr/src/linux/arch/ et /usr/src/linux/drivers/.

Les instructions assembleur peuvent également accélérer la boucle de traitement interne de certains programmes. Par exemple, si la majorité du temps d’exécution d’un programme est consacré au calcul du sinus et du cosinus du même angle, il est possible d’utiliser l’instruction x86 fsincos2). Consultez par exemple /usr/include/bits/mathinline.h qui encapsule des séquences assembleur au sein de macros afin d’accélérer le calcul de certaines fonctions transversales.

Vous ne devriez utiliser des instructions assembleur en ligne pour accélérer l’exécution qu’en dernier ressort. Les compilateurs actuels sont sophistiqués et connaissent les détails des processeurs pour lesquels ils génèrent du code. Ainsi, ils peuvent souvent sélectionner des séquences de code qui paraissent contre-intuitives mais qui s’exécutent en fait plus vite que d’autres. À moins que vous ne compreniez le jeu d’instructions et les propriétés d’ordonnancement du processeur cible dans les détails, vous feriez sans doute mieux de laisser les optimiseurs du compilateur générer du code assembleur à votre place pour la plupart des opérations.

De temps à autre, une ou deux instructions assembleur peuvent remplacer plusieurs lignes de code d’un langage de plus haut niveau. Par exemple, déterminer la position du bit non nul le plus significatif d’un entier en utilisant le C nécessite une boucle ou des calculs en virgule flottante. Beaucoup d’architectures, y compris le x86, disposent d’une instruction assembleur (bsr) qui calcule cette position. Nous illustrerons son utilisation dans la Section 9.4, « Exemple ».

Assembleur en ligne simple

Nous allons maintenant présenter la syntaxe des instructions assembleur asm avec un exemple décalant une valeur de 8 bits vers la droite sur architecture x86:

asm ("shrl $8, %0" : "=r" (answer) : "r" (operand) : "cc");

Le mot-clef asm est suivi par une expression entre parenthèses constituée de sections séparées par deux-points. La première section contient une instruction assembleur et ses opérandes, dans cet exemple, shrl décale à droite les bits du premier opérande. Celui-ci est représenté par %0, le second est la constante immédiate $8.

La deuxième section indique les sorties. Ici, la première sortie de l’instruction sera placée dans la variable C answer, qui doit être une lvalue. La chaîne “=r” contient un signe égal indiquant un opérande de sortie et un r indiquant que answer est stocké dans un registre.

La troisième section spécifie les entrées. La variable C operand donne la valeur à décaler. La chaîne “r” indique qu’elle est stockée dans un registre mais ne contient pas le signe égal car il s’agit d’un opérande d’entrée et non pas de sortie.

La quatrième et dernière section indique que l’instruction modifie la valeur du registre de code condition cc.

Convertir un asm en instructions assembleur

Le traitement des constructions asm par GCC est très simple. Il produit des instructions assembleur pour traiter les opérandes de l‘asm et replace la construction asm par l’instruction que vous spécifiez. Il n’analyse pas du tout l’instruction.

Par exemple, GCC convertit cet extrait de code:

double foo, bar;
asm ("mycool_asm %1, %0" : "=r" (bar) : "r" (foo));

en la séquence d’instructions x86 suivante :

        movl -8(%ebp),%edx
        movl -4(%ebp),%ecx
#APP
        mycool_asm %edx, %edx
#NO_APP
        movl %edx,-16(%ebp)
        movl %ecx,-12(%ebp)

Souvenez-vous que foo et bar requièrent chacun deux mots d’espace de stockage dans la pile sur une architecture x86 32 bits. Le registre ebp pointe vers les données sur la pile.

Les deux premières instructions copient foo dans les registres EDX et ECX qu’utilise mycool_asm. Le compilateur a décidé d’utiliser les mêmes registres pour stocker la réponse, qui est copiée dans bar par les deux dernières instructions. Il choisit les registres adéquats, peut même les réutiliser, et copie les opérandes à partir et vers les emplacements appropriés automatiquement.

Syntaxe assembleur avancée

Dans les sous-sections qui suivent, nous décrivons les règles de syntaxe pour les constructions asm. Leurs sections sont séparées par deux-points.

Nous utiliserons l’instruction asm suivante qui calcule l’expression booléenne $x > y$:

asm ("fucomip %%st(1), %%st; seta %%al" :
     "=a" (result) : "u" (y), "t" (x) : "cc", "st");

Tout d’abord, fucomip compare ses deux opérandes x et y et stocke les valeurs indiquant le résultat dans le registre de code condition. Puis, seta convertit ces valeurs en 0 ou 1.

Instructions assembleur

La première section contient les instructions assembleur, entre guillemets. La construction asm exemple contient deux instructions assembleur, fucomip et seta, séparées par un point-virgule. Si l’assembleur n’autorise pas les points-virgules, utilisez des caractères de nouvelle ligne (\n) pour séparer les instructions. Le compilateur ignore le contenu de cette première section, excepté qu’il supprime un niveau de signes pourcent, %% devient donc %. La signification de %%st(1) et d’autres termes similaires dépend de l’architecture.

GCC se plaindra si vous spécifiez l’option -traditional ou -ansi lors de la compilation d’un programme contenant des constructions asm. Pour éviter de telles erreurs, utilisez le mot clé alternatif __asm__ comme dans les fichiers d’en-tête cités précédemment.

Sorties

La seconde section spécifie les opérandes de sortie des instructions en utilisant une syntaxe C. Chaque opérande est décrit par une contrainte d’opérande sous forme de chaîne suivie d’une expression C entre parenthèses. Pour les opérandes de sortie, qui doivent être des lvalues, la chaîne de contrainte doit commencer par un signe égal. Le compilateur vérifie que l’expression C pour chaque opérande de sortie est effectivement une lvalue.

Les lettres spécifiant les registres pour une architecture donnée peuvent être trouvées dans le code source de GCC, dans la macro REG_CLASS_FROM_LETTER. Par exemple, le fichier de configuration gcc/config/i386/i386.h de GCC liste les lettres de registre pour l’architecture x863). Le Tableau !!registresx86!! en fait le résumé.

Lettres correspondant aux registres sur l’architecture Intel x86

Lettre Registres effectivement utilisés par GCC
R Registre général (EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP)
q Registre de données général (EAX, EBX, ECX, EDX)
f Registre virgule flottante
t Registre en virgule flottante supérieur
u Second registre en virgule flottante
a Registre EAX
b Registre EBX
c Registre ECX
d Registre EDX
x Registre SSE (Streaming SIMD Extension)
y Registres multimédias MMX
A Valeur de 8 octets formée à partir de EAX et EDX
D Pointeur de destination pour les opérations sur les chaînes (EDI)
S Pointeur source pour les opérations sur les chaînes (ESI)

Lorsqu’une structure asm utilise plusieurs opérandes, ils doivent être décrits par une chaîne de contrainte et une expression C et séparés par des virgules, comme l’illustre l’exemple donné précédemment. Vous pouvez spécifier jusqu’à 10 opérandes, numérotés de %0 à %9 dans les sections d’entrée et de sortie. S’il n’y a pas d’opérandes de sortie ou de registre affectés, laissez la section de sortie vide ou signalez la avec un commentaire du type /* no outputs */.

Entrées

La troisième section décrit les entrées des instructions assembleur. La chaîne de contrainte d’un opérande d’entrée ne doit pas contenir de signe égal, ce qui indique une lvalue. Mis à part cela, leur syntaxe est identique à celle des opérandes de sortie.

Pour indiquer qu’un registre est à la fois lu et écrit par la même construction asm, utilisez l’indice de l’opérande de sortie comme chaîne de contrainte d’entrée. Par exemple, pour indiquer qu’un registre d’entrée est le même que le premier registre de sortie, utilisez 0. Spécifier la même expression C pour des opérandes d’entrée et de sortie ne garantit pas que les deux valeurs seront placées dans le même registre.

La section d’entrée peut être omise s’il n’y a pas d’opérandes d’entrée et que le section de déclaration des modifications est vide.

Déclaration des modifications

Si une instruction modifie les valeurs d’un ou plusieurs registres par effet de bord, spécifiez ces registres dans la quatrième section de la structure asm. Par exemple, l’instruction fucomip modifie le registre de code condition, qui est désigné par cc. Les registres modifiés sont décrits dans des chaînes individuelles séparées par des virgules. Si l’instruction est susceptible de modifier un emplacement mémoire arbitraire, spécifiez memory. En utilisant les informations sur la modification des registres, le compilateur détermine les valeurs qui doivent être restaurées après l’exécution du bloc asm. Si vous ne renseignez pas ces informations correctement, GCC pourrait supposer que certains registres contiennent des valeurs qui ont en fait été écrasées, ce qui pourrait affecter le fonctionnement de votre programme.

Exemple

L’architecture x86 dispose d’instructions qui déterminent les positions du bit non nul le plus significatif ou le moins significatif dans un mot. Le processeur peut exécuter ces instructions de façon relativement efficace. Par contre, l’implémentation de la même opération en C nécessite une boucle et un décalage binaire.

Par exemple, l’instruction assembleur bsrl calcule la position du bit le plus significatif de son premier opérande et place cette position (à partir de 0 qui représente le bit le moins significatif) dans le second. Pour placer la position du bit non nul le plus significatif de number dans position, nous pourrions utiliser cette structure asm:

asm ("bsrl %1, %0" : "=r" (position) : "r" (number));

Une façon d’implémenter la même opération en C, est d’utiliser une boucle de ce genre :

long i;
for (i = (number >> 1), position = 0; i != 0; ++position)
  i >>= 1;

Pour comparer les vitesses de ces deux versions, nous allons les placer dans une boucle qui calcule les positions pour de grands nombres. Le Listing !!bitposloop!! utilise l’implémentation en C. Le programme boucle sur des entiers, en partant de 1 jusqu’à la valeur passée sur la ligne de commande. Pour chaque valeur de number, il calcule la position du bit non nul le plus significatif. Le Listing !!bitposasm!! effectue les mêmes opérations en utilisant une construction assembleur en ligne. Notez que dans les deux versions, nous plaçons la position calculée à une variable volatile result. Cela permet d’éviter que l’optimiseur du compilateur ne supprime le calcul; si le résultat n’est pas utilisé ou stocké en mémoire, l’optimiseur élimine le calcul en le considérant comme du code mort.

Listing (bit-pos-loop.c) Recherche d’un Bit en Utilisant une Boucle

#include <stdio.h>
#include <stdlib.h>
 
int main (int argc, char* argv[])
{
  long max = atoi (argv[1]);
  long number;
  long i;
  unsigned position;
  volatile unsigned result;
 
  /* Répète l'opération pour un nombre important de valeurs. */
  for (number = 1; number <= max; ++number) {
    /* Décale le nombre vers la droite jusqu'à ce qu'il vaille
       zéro. Mémorise le nombre de décalage qu'il a fallu faire. */
    for (i = (number >> 1), position = 0; i != 0; ++position)
      i >>= 1;
    /* La position du nombre non nul le plus significatif est le nombre
       de décalages qu'il a fallu après le premier. */
    result = position;
  }
 
  return 0;
}

Listing (bit-pos-asm.c) Recherche d’un Bit en Utilisant bsrl

#include <stdio.h>
#include <stdlib.h>
 
int main (int argc, char* argv[])
{
  long max = atoi (argv[1]);
  long number;
  unsigned position;
  volatile unsigned result;
 
  /* Répète l'opération pour un nombre important de valeurs. */
  for (number = 1; number <= max; ++number) {
    /* Calcule la position du bit non nul le plus significatif
       en utilisant l'instruction assembleur bsrl. */
    asm ("bsrl %1, %0" : "=r" (position) : "r" (number));
    result = position;
  }
 
  return 0;
}

Compilons les deux versions avec les optimisations actives:

% cc -O2 -o bit-pos-loop bit-pos-loop.c
% cc -O2 -o bit-pos-asm bit-pos-asm.c

À présent, lançons-les en utilisant la commande time pour mesurer le temps d’exécution. Il est nécessaire de passer une valeur importante comme argument afin de s’assurer que chaque version mette un minimum de temps à s’exécuter.

% time ./bit-pos-loop 250000000
real    0m27.042s
user    0m24.583s
sys     0m0.135s
% time ./bit-pos-asm 250000000
real    0m1.472s
user    0m1.442s
sys     0m0.013s

Voyez comme la version utilisant l’assembleur s’exécute beaucoup plus vite (vos propres résultats peuvent varier).

Problèmes d'optimisation

L’optimiseur de GCC tente de réordonner et de réécrire le code du programme pour minimiser le temps d’exécution même en présence d’expressions asm. Si l’optimiseur détermine que les valeurs de sortie ne sont pas utilisées, l’instruction sera supprimée à moins que le mot clé volatile ne figure entre asm et ses arguments (par ailleurs, GCC ne déplacera pas une structure asm sans opérande de sortie en dehors d’une boucle). Toute structure asm peut être déplacée de façon difficile à prévoir, même d’un saut à l’autre. La seule façon de garantir l’ordre d’un bloc assembleur est d’inclure toutes les instructions dans la même structure asm.

L’utilisation de constructions asm peut limiter l’efficacité de l’optimiseur car le compilateur ne connaît pas leur sémantique. GCC est forcé de faire des suppositions qui peuvent interdire certaines optimisations. Caveat emptor!

Problèmes de maintenance et de portabilité

Si vous décidez d’utiliser des constructions asm non-portables et dépendantes de l’architecture, les encapsuler dans des macros peut aider à la maintenance et améliorer la portabilité.

Placer toutes ces macros dans un fichier et les documenter facilitera le portage de l’application vers une autre architecture, quelque chose qui arrive souvent même pour les programmes « à la va vite ». De cette façon, le programmeur n’aura besoin de réécrire qu’un seul fichier pour adapter le logiciel à une autre architecture.

Par exemple, la plupart des instructions asm du code source Linux sont regroupées dans les fichiers d’en-tête situés sous /usr/src/linux/include/asm/ et /usr/src/linux/include/asm-i386/ et les fichiers source situés sous /usr/src/linux/arch/i386/ et /usr/src/linux/drivers/.

1) L’expression sin (angle) est généralement implémentée par un appel à la bibliothèque math, mais si vous demandez une option -01 ou plus, GCC remplacera cet appel par une unique instruction assembleur fsin.
2) Les améliorations au niveau des algorithmes ou des structures de données sont souvent plus efficaces dans la réduction du temps d’exécution d’un programme que l’utilisation d’instructions assembleur.
3) Vous devrez avoir une certaine familiarité avec le fonctionnement de GCC pour comprendre le contenu de ce fichier.
 
livre/chap9/code_assembleur_en_ligne.txt · Dernière modification: 2007/09/03 07:43 par david
 
Recent changes RSS feed Creative Commons License Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki Hosted by TuxFamily