Aller au contenu

Exécution d'un programme informatique

Avant d’étudier l’architecture interne des processeurs ARMv7, il est intéressant de réfléchir aux éléments de base d’un programme informatique et à comment un processeur les voit. Il existe certainement plusieurs réponses, cependant pour un processeur il est acceptable de réduire un programme informatique à une suite d’instructions manipulant des données.

Ok, bien, mais de quels outils le processeur doit-il disposer pour exécuter ce programme informatique ?

Selon l’architecture générale, le processeur utilise son unité de contrôle (CU) pour lire les instructions du programme, les décoder et finalement les exécuter. Il utilise son unité arithmétique et logique (ALU) avec sa banque de registres pour manipuler les données contenues dans sa mémoire centrale et dans les registres de ses contrôleurs de périphériques d’entrées/sorties.

Ceci dit, il reste encore quelques points ouverts:

  • Comment organiser le code et ses instructions ?
  • Comment lire les instructions ?
  • Comment organiser les données ?
  • Comment traiter les données ?

Organisation du code

En regardant un programme informatique, on constate rapidement que celui-ci utilise le principe de modularité consistant à fractionner le code en une suite de sous-programmes, lesquels sont finalement agrégés par un module principal. Ces sous-programmes sont généralement des collections de fonctions, pour un développement basé sur un paradigme impératif, ou de classes et méthodes, pour une réalisation orientée objet. Cette modularité apporte naturellement beaucoup d’avantages. En voici quelques-uns:

  • Réduction de la taille du programme principal et des sous-programmes
  • Création de bibliothèques de composants simplifiant le développement de grands programmes
  • Simplification de la vérification et validation du programme en effectuant des tests unitaires sur chacun des composants
  • Réutilisation de composants existants et parfaitement validés

Maintenant si l’on regarde de plus près le code exécuté par le processeur, il apparaît assez vite que quatre algorithmes de base sont nécessaires :

  • Suites d’instructions linéaires s’exécutant séquentiellement
  • Branchements conditionnels et inconditionnels
  • Boucles
  • Appels de fonction

Algorithme: suite linéaire d'instructions

Algorithme: branchements

Algorithme: boucle

Algorithme: appel de fonction

Les algorithmes ci-dessus présentent d’abord un code C, puis sa traduction en code assembleur avec le jeu d’instructions Thumb-2 et finalement le code machine prêt à s’exécuter sur un µC Cortex-M.

Compteur ordinal

Les exemples ci-dessus donnent une bonne vue d’ensemble des algorithmes nécessaires pour la réalisation d’un programme informatique, mais n’apportent pas toutes les réponses.

  • Comment le processeur connaît-il la prochaine instruction à charger ?
  • Comment le processeur procède-t-il lors de branchements ou de boucles ?
  • Comment le processeur poursuit-il l’exécution des instructions placées après l’appel d’une fonction ?

Pour répondre à la première question, le processeur utilise un de ses registres pour adresser et charger la prochaine instruction à exécuter de la mémoire centrale dans son unité de contrôle. Ce registre est nommé compteur ordinal (PC - Program Counter). Le µP utilise l’adresse contenue dans ce registre pour charger l’instruction dans l’unité de contrôle. Une fois l’instruction transférée dans l’unité de contrôle, le PC est incrémenté de la taille de l’instruction, ceci afin de pointer sur la prochaine instruction.

PC: chargement de l'instruction (fetch)

PC: incrémentation

Les branchements et les boucles provoquent une cassure dans le déroulement séquentiel de l’exécution du programme. Le processeur doit effectuer un saut (jump) dans le code pour pointer sur l’instruction correspondante au branchement. Pour effectuer ce saut, l’instruction de branchement remplace l’adresse contenue dans le registre PC par l’adresse de l’instruction pointée par le branchement.

PC: branchement

Dans l’exemple ci-dessus, la valeur du PC, après le chargement de l’instruction “b 2f” située à l’adresse 0x080001c4 est de 0x080001c6. Cependant, après l’exécution de cette instruction, la nouvelle valeur du PC est 0x080001c8. Ce changement permet de satisfaire l’instructuction de branchement “b 2f” pour pointer ainsi sur l’instruction “bx lr” correspondante au branchement souhaité.

Reste maintenant encore à résoudre le troisième problème, l’appel de fonction. Pour l’illustrer, il est intéressant d’étudier l’implémentation de la suite de Fibonacci à l’aide d’une fonction récursive. Comme le montre le code C, cette fonction effectue un double appel à elle-même.

PC: appel de fonction (Suite de Fibonacci)

Pour le processeur, ce double appel se traduit par l’exécution des deux instructions “bl fibonacci”, lignes 7 et 11. Le problème de l’adresse de retour apparaît très clair ici. Comment informer la fonction fibonacci qu’elle doit retourner à la ligne 8 après le premier appel et à la ligne 12 après le deuxième ? Il est évident que le compilateur et l’éditeur de liens ne peuvent pas le résoudre, car nous avons deux adresses différentes. Le problème se complique encore, car la fonction fibonacci est appelée par une ou plusieurs fonctions extérieures à ce code. Alors que faire ? La solution choisie par ARM est de stocker cette adresse de retour dans un de ses registres. Ce registre est nommé registre de lien (LR - Link Register). Cette opération s’effectue lors de l’appel de la fonction, grâce à l’instruction bl (branch and link). Dès lors, la fonction appelée peut revenir au programme appelant en utilisant par exemple l’instruction “bx lr”. Dans l’exemple ci-dessus, l’instruction conditionnelle “bxle lr” à la ligne 3 effectue cette opération. Un problème subsiste encore, les appels multiples. En effet, cette fonction, par sa récursivité, s’appelle elle-même plusieurs fois et à deux endroits distincts. Il ne suffit donc pas de sauver l’adresse de retour dans le registre LR. Si on se contente que de cela, l’adresse de retour sera perdue, car écrasée par les appels récursifs. Il est donc essentiel de préalablement sauver le contenu du registre LR avant d’effectuer l’appel et de le restaurer après l’appel. Le jeu d’instructions ARM et Thumb-2 implémente à cet effet les instructions push pour la sauvegarde de registres et pop pour leur restauration. Dans l’exemple ci-dessus, la sauvegarde est effectuée à la ligne 4, tandis que la restauration est effectuée à la ligne 13. La restauration s’accompagne avec le retour à la fonction appelante en plaçant la valeur sauvegardée du registre LR, “push {r4, lr}”, ligne 3, directement dans le registre PC “pop {r4, pc}”, ligne 13.

Organisation des données

Avec les langages de programmation évolués, toutes les données ont un type. Ce type définit la nature de la donnée, les valeurs qu’elle peut prendre, ainsi que les opérateurs qui permettent de les traiter et de les manipuler. Pour le processeur, il est possible de classer les données d’un programme informatique en quatre catégories.

Données: catégories

Les données constantes sont des données avec un accès en lecture seule. Sur les systèmes sur puce (SoC), ces données sont stockées avec le code dans la mémoire non volatile. Sur les systèmes embarqués, équipés d’une MMU, elles sont placées en RAM dans une zone protégée en écriture.

Les données globales sont des données volatiles accessibles par toutes les fonctions d’un composant logiciel voire même par l’ensemble des fonctions de l’application. Elles disposent d’un double accès en lecture et en écriture. L’éditeur de liens (linker) fixe leur emplacement en mémoire, leur adresse, lors de la génération de l’application. Cette adresse reste invariable durant toute la durée de l’exécution de l’application.

Les données locales sont des données volatiles accessibles en lecture et écriture que par la fonction dans laquelle elles sont déclarées. Ce sont des variables locales à la fonction ou des arguments passés à la fonction lors de son appel. Afin d’autoriser la récursivité et la réentrance, ces données ne sont pas placées à un endroit fixe dans la mémoire, mais au contraire sur une pile d’exécution (call stack), abrégée la pile. La location de ces données varie en fonction des appels.

Les données dynamiques sont des données créées à la volée par l’application lors de son exécution. L’emplacement de ces données s’obtient avec l’aide de fonctions mises à disposition par les langages de programmation. Ces fonctions gèrent une zone de la mémoire centraile, appelée le tas (heap). Cette zone se situe entre les données globales et la pile (voir l’architecture générale). En C, la bibliothèque standard du langage offre deux fonctions de base, malloc et free, pour la réservation et libération de l’espace mémoire nécessaire à stocker la donnée. En C++, les opérateurs new et delete offrent cette fonctionnalité.

Pile d’exécution

La pile d’exécution, communément appelée la pile, est une structure de données organisée sous la forme de pile. La pile (stack) fonctionne sur le principe “dernier arrivé, premier sorti” (LIFO - Last In, First Out), c’est-à-dire que la dernière donnée placée sur la pile sera la première donnée retirée. L’exemple ci-dessous montre le principe de fonctionne de la pile où l’on pousse successivement deux données avant de les retirer.

Pile: principe

Le pointeur de pile (SP - Stack Pointer) contient l’adresse mémoire de la dernière donnée placée sur la pile. Sur les processeurs ARM, la pile est une pile pleine descendante, c’est-à-dire que si une donnée doit être ajoutée sur la pile, le processeur décrémente le SP avant de stocker la donnée. Si la donnée doit être retirée, le processeur lit d’abord la donnée pointée par le SP avant de l’incrémenter.

Pile d'exécution

Les compilateurs utilisent la pile pour stocker le contexte (stack frame) des fonctions en cours d’exécution. Le contexte d’une fonction consiste à:

  • Paramètres d’appel
  • Sauvegarde des registres du processeur et en particulier l’adresse de retour
  • Données locales
  • Paramètres passés aux fonctions appelées.

Le jeu d’instructions des processeurs ARM implémente deux instructions pour la sauvegarde et la restauration des registres, l’instruction push et respectivement l’instruction pop. L’accès aux données fonctionne de façon similaire à celui des données globales, à la différence prêt que l’adresse pointant sur la donnée est le résultat de l’addition de la valeur du SP et d’une offset ou décalage correspondant à l’emplacement de la donnée dans le contexte de la fonction (stack frame). Cette offset est fixe, car elle ne dépend que du contexte. En cas d’appels multiples, ce n’est que la valeur du SP qui varie.

Les debuggers utilisent également la pile d’exécution pour obtenir une trace des appels (stack trace ou backtrace). Cette représentation de la pile facile grandement le déboggage des applications. Elle donne une vue d’ensemble sur toutes les fonctions appelées, leurs paramètres et données locales.

Traitement des données

La connaissance de trois attributs est nécessaire pour un traitement approprié de ces données par le processeur, soit la taille, la location en mémoire (l’adresse) et finalement le type. Ces trois attributs sont indispensables lors du transfert de la donnée entre la mémoire et les registres du processeur. Ils permettent de choisir l’instruction transfert appropriée. Quant à la connaissance du type, il est primordial afin d’appliquer la bonne opération de traitement sur cette donnée.

"Load and Store": principe

Avec leur architecture Load and Store, les processeurs ARM doivent impérativement charger les données à traiter de la mémoire centrale ou de registres des contrôleurs de périphériques d’entrées/sorties avant de pouvoir la traiter et finalement la stocker à nouveau en mémoire.

L’exemple ci-dessus montre l’incrémentation d’une donnée globale data. Cette simple opération exige l’exécution de quatre instructions.

  • Chargement de l’adresse de la donnée dans un premier registre, ligne 1
  • Chargement du contenu de la donnée de la mémoire dans un deuxième registre, ligne 2
  • Incrémentation de la donnée, ligne 3
  • Stockage de la donnée incrémentée dans la mémoire, ligne 4

"Load and Store" : exemple

Cette architecture divise le jeu d’instructions entre des opérations d’accès avec la mémoire (lire et écrire de données entre la mémoire et les registres du µP) et des opérations avec l’unité arithmétique et logique (ALU), laquelle ne travaille qu’avec des données stockées dans ses registres.