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
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.
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.
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.
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.
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.
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.
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.
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
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.