LdB Seance 6

De Ensiwiki
Aller à : navigation, rechercher
AttentionCette page est maintenue uniquement par les enseignants. Afin de ne pas perturber le déroulement des cours, elle n'a pas vocation à être modifiée par les élèves. Mais si vous avez des modifications à proposer, merci d'en discuter ou d'envoyer un e-mail aux auteurs de la page (cf. historique)

Laptop.png  Première Année  CDROM.png  Informatique 

Introduction

Le but de cette séance est de comprendre la gestion de la mémoire allouée à un programme dans un système Unix et comment on peut accéder aux différentes zones mémoire dans un programme en assembleur.

Vous trouverez les sources utilisées pendant cette séance dans cette archive Zip.

Attention : les directives présentées lors de cette séance sont très dépendantes de la plate-forme de destination. Même en utilisant le même assembleur GNU, il existe des différences entre Linux et MacOS par exemple. On ne présentera ici que des directives valables sur les 2 plates-formes.

Représentation d'un programme en mémoire

Principe général

Lorsqu'on compile ou assemble un programme, on obtient un fichier binaire contenant le code de ce programme en langage machine, ainsi que les données dont il aura besoin à l'exécution. Ce fichier binaire respecte un certain format, dont ELF et Mach-O par exemple.

Lorsqu'on lance l'exécution de ce programme (en tapant ./mon_prog dans un terminal par exemple), le fichier binaire est chargé en mémoire et un certain nombre d'opérations dépassant le cadre de ce cours sont effectuées afin de rendre le programme exécutable. Ce qui nous intéresse ici est la représentation finale du programme dans la mémoire, juste avant que le processeur commence à exécuter son code machine.

On parle traditionnellement de processus pour désigner un programme en cours d'exécution dans la mémoire du système. De façon volontairement simplifiée, un processus a en mémoire la structure suivante :

zone text
(code du programme)
zone data
(données statiques)
tas (heap)
(données dynamiques)
pile (stack)
(contexte d'exécution)

Lorsqu'on écrit un programme en C, on ne se préoccupe pas de savoir dans quelle zone du processus va se retrouver telle fonction ou telle donnée : c'est le travail du compilateur. Mais lorsqu'on écrit du code assembleur, on doit définir précisément la structure du programme. On utilise pour ça des directives, qui sont des commandes qui ne font pas partie du langage assembleur mais qui aide le programme d'assemblage à faire son travail.

La zone text

Cette zone contient le code en langage machine de toutes les fonctions du programme. Lorsqu'on écrit de l'assembleur, on est par défaut dans cette zone. Si l'on souhaite le préciser explicitement, on peut utiliser la directive .text juste avant de commencer à écrire du code assembleur, comme par exemple dans le code ci-dessous :

    .globl main
    .globl _main

    .text
main:
_main:
    enter $0, $0
    leave
    // return 0
    movl $0, %eax
    ret

La zone data

Structure de la zone

Cette zone contient les données allouées statiquement dans le programme, c'est à dire les variables globales du langage C. En pratique, cette zone est souvent découpées en sous-zones, mais ce découpage est très dépendant de l'architecture de destination. On trouve souvent :

  • la zone data a proprement parler qui contient les données initialisées modifiables ;
  • la zone rodata qui contient des données en lecture seule (read-only) ;
  • la zone bss qui contient des données modifiables mais non-initialisées.

Par exemple, dans le programme C ci-dessous :

int x = 5;

int y;

char *chaine = "toto";

int main(void)
{
    return 0;
}
  • la variable globale x sera placée dans la zone data ;
  • la variable globale y sera placée dans la zone bss ;
  • la chaine de caractères sera (peut-être) placée dans la zone rodata.

En pratique, de nombreux systèmes ne supportent pas l'existence de la zone rodata : on ne l'utilisera pas dans le cadre du cours de Logiciel de Base, et on placera toutes les données initialisées dans la zone data.

La zone bss quant à elle existe en général, mais sous des noms assez différents selon le système utilisé. On s'en servira principalement en Logiciel de Base pour réserver de la place mémoire lors de la définition de tableaux (dans le cadre de l'exemple C ci-dessus, on peut aussi bien placer y en zone data et l'initialiser à 0 par exemple).

La directive assembleur permettant de signifier le début de la zone data est tout simplement .data. Si l'on veut réserver de l'espace mémoire dans la zone bss, on utilisera la directive .lcomm dont on définira la syntaxe plus bas.

Directive de « typage »

En assembleur (pas plus que dans la mémoire physique de la machine), il n'existe pas de types de données à proprement parler. On travaille toujours en termes d'espace mémoire réservé pour les variables : on ne sait pas si x est un entier ou un flottant, on sait juste que cette variable occupe 4 octets en mémoire. En pratique, on n'utilisera que des entiers, des tableaux d'entiers et des chaines de caractères dans le cours de Logiciel de Base.

Dans la zone data du programme, on utilise fréquemment les directives suivantes pour réserver de l'espace pour les variables globales du programme :

  • .long val permet de réserver 4 octets consécutifs de mémoire et de les initialiser avec la valeur val ;
  • .short val permet de réserver 2 octets consécutifs de mémoire et de les initialiser avec la valeur val ;
  • .byte val permet de réserver 1 octet de mémoire et de l'initialiser avec la valeur val ;
  • .ascii "chaine" réserve autant d'octets consécutifs de mémoire qu'il y a de caractères dans la chaine et les initialise avec les caractères de la chaine ;
  • .asciz "chaine" est similaire à .ascii mais ajoute en plus le caractère '\0' à la fin de la chaine : cette directive permet donc de traduire directement les chaines de caractères du C.

Les directives ci-dessus permettent de réserver de l'espace mémoire pour les différentes variables du programme. Mais pour pouvoir les utiliser, on doit pouvoir les nommer : on utilise pour cela des étiquettes, comme pour les fonctions.

Soit par exemple la zone data d'un programme assembleur quelconque :

    .data
x:  .long 12345678
y:  .short 20000
z:  .byte 0xFF
c:  .ascii "a"
ch: .asciz "toto"

.lcomm tab, 10 * 4

Dans cet exemple, on trouve :

  • une variable globale x qui représente un entier (4 octets) valant initialement 12345678 ;
  • une variable globale y qui représente un entier court (2 octets) valant initialement 20000 ;
  • une variable globale z qui représente un octet valant initialement 0xFF (c'est à dire aussi bien -1 que 255 selon si on considère cet octet comme signé ou non) ;
  • un caractère isolé c qui vaut initialement 'a' ;
  • une chaine de caractère ch qui contient initialement les caractères 't', 'o', 't', 'o', '\0'.

On voit aussi que la directive lcomm, qui permet de réserver de la place pour des données non-initialisées, a une syntaxe légèrement différente : on indique d'abord la directive, puis ensuite l'étiquette suivie de la place à réserver après une virgule. Ici, on réserve 40 octets à partir d'une étiquette appelée tab : on est donc vraisemblablement en train d'allouer un tableau de 10 entiers (sur 4 octets chacun). L'assembleur est capable d'effectuer des opérations arithmétiques simples, on peut s'en servir pour rendre le code plus clair.

Le tas

Le tas (heap en anglais) est la zone dans laquelle les fonctions malloc et calloc prennent la mémoire à réserver dynamiquement quand on les appelle. Cette zone a en général une taille prédéfinie lors de la création du processus et peut potentiellement grandir grace à un mécanisme appelé « mémoire virtuelle » que vous étudierez dans le cours de système en 2A.

La pile

La pile (stack en anglais) d'exécution est la zone dans laquelle on stocke notamment les variables locales aux différentes fonctions du programme (on rappelle que la zone data ne contient que les variables globales). Cette zone est organisé comme une pile (au sens algorithmique du terme), et part donc de la fin de l'espace mémoire du processus pour remonter vers le début à chaque fois que l'on empile des valeurs.

La pile a une taille fixe qui est généralement une constante dépendant du système utilisé. A noter que dans de nombreux systèmes, on ne vérifie pas que l'on n'empile pas plus de données qu'il n'y a d'espace réservé pour la pile, et on peut parfaitement arriver à écraser d'autres données, ou même du code, si on en empile trop : on parle alors de « débordement de pile » (stack overflow en anglais), une technique couramment utilisée pour exploiter des failles de sécurité dans les systèmes.

On verra plus tard dans ce cours comment on peut utiliser des variables locales et gérer la pile d'exécution.

Modes d'adressages

Maintenant que l'on sait définir des variables, il faut apprendre à les utiliser dans le code des programmes assembleur. On va donc devoir écrire des instructions qui font des accès (lecture ou écriture) en mémoire.

Le processeur Pentium étant un processeur CISC, il existe beaucoup de façon d'accéder à la mémoire. On ne les verra que les plus couramment utilisées ici. On notera que les noms des différents modes d'adressage ne sont pas standardisés et peuvent donc varier d'un auteur à l'autre.

L'adressage immédiat

Ce n'est pas un accès mémoire à proprement parler, car il sert à mettre une constante dans un registre (la constante étant codée directement dans l'instruction comme vous l'avez vu en cours d'Architecture au premier semestre).

Par exemple : movl $5, %eax charge la constante (sur 32 bits) 5 dans le registre %eax.

On a parfois besoin de manipuler directement l'adresse d'une variable. On peut le faire très simplement en assembleur en utilisant l'opérateur $ devant l'étiquette représentant la variable, vu que l'adresse d'une variable globale est en fait un entier constant sur 4 bits.

Par exemple : movl $x, %eax charge l'adresse de la variable x dans le registre %eax.

L'adressage direct

C'est le mode le plus simple, car il consiste à donner directement l'adresse mémoire de la valeur qu'on souhaite accéder.

Par exemple : movb 0x12345678, %al va chercher la valeur de l'octet à l'adresse 0x12345678 dans la mémoire et stocke cette valeur dans le registre %al.

En pratique, il est très rare qu'on donne directement une adresse explicitement, vu qu'on ne sait pas où le processus sera placé dans la mémoire. On utilise beaucoup plus souvent une étiquette pour représenter symboliquement l'adresse de la donnée à utiliser.

Par exemple le code suivant va aussi chercher la valeur de l'octet x pour le stocker dans %al :

    .text
    ...
    movb x, %al
    ...

    .data
x:  .byte 0x12

Attention au piège classique illustré dans le code suivant :

    .text
    ...
    movl y, %eax
    ....
    movl $y, %eax
    ...

    .data
y:  .long 12345678

Le premier mov copie la valeur de la variable y dans %eax. Par contre, le deuxième copie l'adresse de la variable y dans %eax.

L'adressage indirect

Le principe de ce mode d'adressage est simplement de lire dans un registre l'adresse de la variable à accéder.

Par exemple, si on suppose que %ebx contient l'adresse de la variable : movl $5, (%ebx) ou movl (%ebx), %eax.

L'adressage indirect avec déplacement

Dans ce mode, on ajoute une constante entière quelconque à l'adresse contenue dans le registre.

Par exemple : movw -8(%ebx), %dx va chercher un entier court à l'adresse calculée en enlevant 8 au contenu de %ebx, et stocke cet entier court dans %dx.

Le registre contenant l'adresse est souvent appelé « registre de base ».

L'adressage indirect avec index et déplacement

Dans ce mode, l'adresse de la variable est répartie entre deux registres et le déplacement. On fait la somme des contenus des deux registres et de la constante pour calculer l'adresse de la variable.

Par exemple : movw $20000, 7(%ebx, %ecx) ou movl -3(%ebx, %ecx), %eax.

Si le déplacement est 0, on peut l'omettre et écrire directement par exemple : movl (%ebx, %ecx), %eax.

Le premier registre est souvent appelé « registre de base » et le deuxième « registre d'index ».

L'adressage indirect avec index typé et déplacement

Dans ce mode, l'adresse de la variable est calculée selon la formule : adresse = base + index * type + deplacement où :

  • la base est la valeur contenue dans le premier registre ;
  • l'index est la valeur contenue dans le deuxième registre ;
  • le type est une constante entière valant forcément 1, 2, 4 ou 8 ;
  • le déplacement est une constante entière quelconque.

Par exemple : movl %edx, -5(%ebx, %ecx, 4) copie la valeur contenue dans le registre %edx dans la case mémoire (de 32 bits) dont l'adresse est calculée en faisant la somme entre le contenu de %ebx, le contenu de %ecx multiplié par 4 et le déplacement qui vaut ici -5.

Ce mode d'adressage est très utile pour accéder à des tableaux : le registre de base contient l'adresse du début du tableau, le type représente la taille d'un élément du tableau et le registre d'index contient l'indice de la case à accéder.

Si le déplacement vaut 0, on peut l'omettre et écrire par exemple : movw (%ebx, %ecx, 2), %ax.

Il existe une forme dégénérée de ce mode dans laquelle on ne donne pas de registre de base (ce qui revient à considérer que la base vaut 0). Ce mode est souvent utilisé pour accéder à des tableaux définis comme des variables globales, en utilisant une étiquette à la place du déplacement.

Par exemple : movl $100000, tab(, %ecx, 4) copie la constante 100000 à l'adresse calculée en faisant la somme de l'adresse représentée par l'étiquette tab et le contenu de %ecx multiplié par 4. Ca a un sens si tab représente un tableau d'entiers (sur 4 octets chacun) dont on veut accéder à la « ecx^{ieme} » case.

Exercices

Exercice 1

Soit le programme assembleur ci-dessous :

    .globl main
    .globl _main
main:
_main:
    enter $0, $0
    movl $4, %ecx
bcl:
    cmpl $0, %ecx
    jl fin_bcl
    movl x, %eax
    movl %eax, tab(, %ecx, 4)
    subl $1, x
    subl $1, %ecx
    jmp bcl
fin_bcl:   
    leave
    movl $0, %eax
    ret

    .data
x:  .long 15

.lcomm tab, 5 * 4

Essayez de deviner ce que fait ce programme (en déroulant son exécution) et écrivez un programme C équivalent.

Exercice 2

Mêmes questions pour le programme suivant :

    .globl main
    .globl _main
main:
_main:
    enter $0, $0
    movb car, %al
    movl $chaine, %ecx
bcl:
    cmpb $0, (%ecx)
    je fin_bcl
    cmpb %al, (%ecx)
    je fin_bcl
    addl $1, %ecx
    jmp bcl
fin_bcl:
    leave
    movl $0, %eax
    ret

    .data
car:    .ascii "c"
chaine: .asciz "abcdcba"