(.NET / C# ) L'Assembleur IL

Il peut sembler désuet, à l’époque d’ASP.NET et de C#, de s’intéresser à un langage d’aussi bas niveau que l’Assembleur (asm pour les intimes). Il y a 15 ans, on m’en enseignait les rudiments avec, déjà, un certain recul, comme pour une science un peu dépassée mais dont les principes devraient faire partie de la culture générale de tout bon développeur... Et pourtant l’Assembleur permet justement de nous rappeler que tout est, en fin de compte, de l’électronique, une fois toutes les couches d’abstraction décompilées. Cela m’a d’ailleurs servi à comprendre ce que faisait concrètement un compilateur, et pourquoi certaines instructions étaient plus lentes à l’exécution que d’autres.

Pour trouver audience dans cette démonstration de l’intérêt de connaître l’asm, j’en appelle donc à tous ceux qui, comme moi, s’intéressent à ce qui se passe « sous le capot » ; le but n’étant pas de tout programmer en asm (quoique...) mais plutôt de s’en servir pour comprendre ce que fait le compilateur C# / VB.NET pour optimiser et comparer le code produit.

IL est l’assembleur de DotNet dont les instructions sont générées aussi bien à partir d’un source C# que VB.NET, du « managed » C++, du Python, du Forth... Il offre de ce fait une palette de possibilités bien plus large qu’en offrirait, par exemple, le C# à lui seul.

Le second intérêt, et de loin le plus pratique, de connaître l’asm est la possibilité offerte par le C# de générer lors du runtime du code assembleur, et cela s’avère dans certains cas très puissant (voir [1] par exemple).

Mais il semble aller de soi que, pour générer un tel code de cette façon, il faut au préalable connaître l’asm... et je n’ai jamais jusqu’à présent trouvé d’article en français sur le sujet.

« Mais où sont mes registres ? »

L’assembleur de DotNet est très différent des assembleurs standard. Premier choc : il n’y a pas de registre... Tout se passe dans la pile, à la façon des calculatrices HP. Il s’agit donc de « notation polonaise » : pour une simple addition de A et B, par exemple, on empile A, on empile B, puis l’opération d’addition est appelée, qui dépile B et A et en empile la somme.

Autre différence majeure, l’assembleur IL est orienté ... objet ! hé oui, même si cela peut sembler étrange aux puristes de l’assembleur, chaque routine appelée peut être dans une classe, elle-même dans une assembly, et l’instruction new fait partie des instructions de cette assembleur...

Voici un exemple de « Hello world » en asm IL :

1:.assembly extern mscorlib {} // dépendance (writeline)

2:.assembly test {}

3:.method public hidebysig static void Main(string[] args) cil managed

4:{

5: .entrypoint

6: .maxstack 1

7: ldstr "Hello World!" // mettre la chaine sur la pile

8: call void [mscorlib]System.Console::WriteLine(string) // l’afficher

9: ret // quitter le programme

10:}

Une fois tout cela écrit, comment compiler ?

Rien de plus simple : tapez en ligne de commande :

ilasm mon_fichier.asm

... et vous devriez voir apparaître mon_fichier.exe.

Mais commentons un peu le code ci-dessus.

Première constatation, très pratique : les commentaires sont « comme en C ».

Toujours dans la même veine, plutôt à la mode C# cette fois, la définition des méthodes est préfixée par une série de mot-clés « public » « static »...Ces mots clé sont définis dans le document « PARTITION II METADATA.DOC » [2]

Plus original, chaque méthode doit définir le maximum de pile qu’elle va utiliser (.maxstack 1), ceci probablement afin d’éviter tout risque d’attaque par « buffer overflow » de la part de potentiels hackers. Une maxstack trop petit ? c’est le plantage. Alors pourquoi a-t-il été placé à 1 dans le cas présent ?

Que doit-on mettre dans la pile ? une chaîne de caractères... ou plus exactement (pour ceux qui n’ont pas assez fait de C dans leur jeunesse) un « pointeur » sur une chaîne de caractères. Or, l’unité de base de .NET est « l’entier naturel », soit un pointeur. On précise donc à .NET que cette méthode empilera qu’une donnée.

Ligne 8, l’instruction call appelle une méthode, qu’elle soit normale, ou préfixée de static ou virtual.

Une caractéristique fondamentale à comprendre, c’est que contrairement au C# où c’est obligatoire, ici, aucun objet n’est créé : tout est « procédural ».

Un exemple plus utile

Voici maintenant comment la non moins classique méthode add, qui prend en paramètres 2 entiers et renvoie leur somme :

1:.method private hidebysig static int32 Add(int32 x, int32 y) cil managed

2:{

3: .maxstack 8

4: ldarg.0

5: ldarg.1

6: add

7: ret

8:}

9:

10:.method public hidebysig static void Main(string[] args) cil managed

11:{

12: .entrypoint

13: .maxstack 8

14: ldc.i4.3

15: ldc.i4.4

16: call int32 Add(int32, int32)

17: call void [mscorlib]System.Console::WriteLine(int32)

18: ret

19:}

Lignes 14 et 15, nous mettons sur la pile les valeurs constantes 3 et 4 (ldc), puis nous appelons notre méthode ADD. Celle-ci charge les deux arguments de la pile, les ajoute, et revient à l’appelant. Enfin, WriteLine pour afficher le résultat.

Mais regardons de plus près l’instruction lcd.i4.3. Ce mnémonique charge directement l’entier trois sur la pile. C’est une version spécialement « optimisée » pour 3 de l’instruction de base lcd.i4 qui charge 4 octets (soit un Int32) sur la pile.

L’un des buts avoués de l’asm MSIL était de fournir en sortie des binaires très compacts. Après un certain nombre d’analyses statistiques sur les codes sources, Microsoft a conclu à la propondérance des constantes entières de 0 à 8. On a donc réservé un traitement spécial à ces nombres : une instruction lcd.i4.x (x étant compris entre et 0 et 8) qui ne prend qu’1 octet. Il existe bien entendu une version générique de cet empilement d’entier : lcd.i4 3 (remarquer l’absence du point). Mais l’utilisation de ce dernier réserve par défaut un espace (ici inutile) de 4 octets entiers sur la pile...

Ceci est aussi vrai pour « ldarg.0 » qui est une version spéciale de ldarg, optimisée pour charger les 8 premiers arguments (il faut avouer que c’est souvent assez !). S’il y a davantage d’arguments, il faut utiliser « ldarg NumArg ».

Ligne 16, nous avons dans la pile les valeurs 3 et 4, que la méthode Add va extraire, additionner, et renvoyer sur la pile, remarquons qu’il n’y a aucun typage, autre caractéristique de MSIL ! Rendez-vous a [3] pour voir les règles de conversion implicites.

Utilisation de variable locale, création d’objet

Passons à un exemple beaucoup plus concret : nous allons créer notre objet, en passant par l’instruction « newobj » qui prends ses arguments sur la pile, alloue la mémoire nécessaire, appel un constructeur, et renvoie le « this » sur la pile.

// On déclare notre class.

.class public auto ansi beforefieldinit Counter

extends [mscorlib]System.Object

{

// Un champ float, en c#: public float Total

.field public float32 Total

// Le construteur, "ctor" en langage ASM

.method public hidebysig specialname rtspecialname instance void .ctor() cil managed

{

.maxstack 8

ldarg.0 // l'argument zero est toujours "this" dans un objet

call instance void object::.ctor()

ret

}

// Une methode qui incremente notre compteur

.method public hidebysig instance void AddToCounter(float32 f) cil managed

{

.maxstack 8

ldarg.0 // L'argument zero est toujours "this" dans un objet

dup // On le duplique

ldfld float32 Counter::Total // Charger la valeur du champ

ldarg.1 // Charger le flottant f

add // Faire la somme

stfld float32 Counter::Total // Stocke le resultat dans le champ Total

ret

}

}

.method private hidebysig static void Main(string[] args) cil managed

{

.entrypoint

.maxstack 2

newobj instance void Counter::.ctor() // allocation de la memoire+appel au constructeur, mise du resultat en pile

ldc.r4 5 // stocke la valeur "real" 5.0f

callvirt instance void Counter::AddToCounter(float32) // appel la methode de l'objet

ret

}

Quelques points à noter :

· Le constructeur s’appelle « .ctor », c’est une convention de MSIL.

· Quand on appelle une fonction d’un objet, l’argument 0 est toujours le this de l’objet.

· De même, quand on appelle une fonction de l’objet, on doit toujours commencer à pusher le this (sauf dans le cas d’une fonction statique bien sûr).

· Quand on définit une classe, il FAUT déclarer une assembly, alors que les programmes « non objet » ne le nécessitent pas.

Mise en application

L’un des intérêt s majeurs de connaître l’assembleur MSIL est de comprendre le fonctionnement interne…à quelques rares cas, programmer directement en MSIL n’est pas nécessaire, mais regarder le fonctionnement interne d’un programme est déjà plus intéressant. Pour cela il existe le programme ILDASM (Intermediate langage Deassembleur) qui prends en entrée un EXE ou DLL, et vous fournit le source, il est livré en standard avec C#. Cependant, je lui préfère un outil beaucoup plus agréable : Reflector , un outil vraiment obligatoire pour tout programmeur .NET qui se respecte. Vous le trouverez ici : http://www.aisto.com/roeder/dotnet/.

Une fois pris en main cette utilitaire, amusons nous a quelques tests :

FOR est-il plus rapide que FOREACH ?

Cette question est revenue plusieurs fois dans les forums, nous allons essayer de lui donner un début de réponse, en comparant le parcours d’une liste d’entier. Le programme C# correspondant est :

static int FOREACH(int[] tab)

{

int res=0;

foreach(int valeur in tab)

res+=valeur;

return res;

}

static int FOR(int[] tab)

{

int res=0;

for(int i=0;i

i+=tab[i];

return res;

}

Regardons maintenant dans Reflector le code généré :

.method private hidebysig static int32 FOR(int32[] tab) cil managed
{
      .maxstack 3
      .locals init (
            [0] int32 num1,
            [1] int32 num2,
            [2] int32 num3,
            [3] bool flag1)
      L_0000: nop 
      L_0001: ldc.i4.0 
      L_0002: stloc.0 
      L_0003: ldc.i4.0 
      L_0004: stloc.1 
      L_0005: br.s L_0011
      L_0007: ldloc.1 
      L_0008: ldarg.0 
      L_0009: ldloc.1 
      L_000a: ldelem.i4 
      L_000b: add 
      L_000c: stloc.1 
      L_000d: ldloc.1 
      L_000e: ldc.i4.1 
      L_000f: add 
      L_0010: stloc.1 
      L_0011: ldloc.1 
      L_0012: ldarg.0 
      L_0013: ldlen 
      L_0014: conv.i4 
      L_0015: clt 
      L_0017: stloc.3 
      L_0018: ldloc.3 
      L_0019: brtrue.s L_0007
      L_001b: ldloc.0 
      L_001c: stloc.2 
      L_001d: br.s L_001f
      L_001f: ldloc.2 
      L_0020: ret 

}

Cela semble énorme, et ça l’est ! En y regardant de plus près, on trouve des instructions bizarres, comme « nop », ou le br.s L_001F qui ne servent à rien. Pourquoi C# génère-t-il de telles horreurs ? Car nous avons oublié de compiler en Release, tout simplement. Pourquoi ce Nop, et ce saut inutile ? C’est une supposition de ma part mais je pense qu’il s’agit pour l’environnement d’une marque pour permettre de mettre des points d’arrêt sur les accolades en C#.

En effet, comment marchent les breakpoint ? Tout simplement en remplaçant une instruction par une instruction spéciale, « break », qui donne la main a l’Edi, qui garde en mémoire l’ancienne valeur, et l’exécute après. Faire du « pas a pas » signifie, en fait, mettre un opcode « break », exécuter l’instruction qui était sous le break, et déplacer d’un pas le break.

Revenons à notre comparatif :

.method private hidebysig static int32 FOR(int32[] tab) cil managed
{
      .maxstack 3
      .locals init (
            [0] int32 num1,
            [1] int32 num2)
      L_0000: ldc.i4.0 
      L_0001: stloc.0 
      L_0002: ldc.i4.0 
      L_0003: stloc.1 
      L_0004: br.s L_0010
      L_0006: ldloc.1  //DEBUT DE BOUCLE
      L_0007: ldarg.0 
      L_0008: ldloc.1 
      L_0009: ldelem.i4 
      L_000a: add 
      L_000b: stloc.1 
      L_000c: ldloc.1 
      L_000d: ldc.i4.1 
      L_000e: add 
      L_000f: stloc.1 
      L_0010: ldloc.1 
      L_0011: ldarg.0 
      L_0012: ldlen 
      L_0013: conv.i4 
      L_0014: blt.s L_0006 // FIN DE BOUCLE
      L_0016: ldloc.0 
      L_0017: ret 

}

Nous n’avons plus de flag de test de fin de tableau, moins de variables locales, le code semble beaucoup plus correct. Si nous cherchons la boucle, nous voyons qu’elle fait 15 instructions…Je vous fait grâce du source de foreach, qu’il lui fait 17 instructions.

Il semblerait donc que sur un tableau d’entier, for soit mieux que foreach….attention cependant de garder en tête que l’ASM IL est au final traduit en ASM natif, donc…Qui sait ?

[1] http://www.codeproject.com/csharp/dynmatrixmath.asp

[2] Vous pouvez trouver ce document dans le repertoire Microsoft.NET\ FrameworkSDK \Tool Developers Guide

[3] http://msdn.microsoft.com/library/fre/default.asp?url=/library/FRE/cpref/html/frlrfsystemreflectionemitopcodesclassaddtopic.asp

Aucun commentaire: