Portrait de Philippe PradosPrados.FRProgrammation, architecture & sécurité — depuis 1998

Au coeurs des microprocesseurs

D’expérience, peu de développeurs ont une connaissance, même superficielle, du fonctionnement d’un microprocesseur. C’est pour remédier à cela que je vous propose cet article

Par Philippe PRADOS - 2018
www.prados.fr

Mon objectif est de survoler le fonctionnement interne d’un microprocesseur, sans s’appuyer spécifiquement sur une marque ou un modèle particulier. L’idée est de survoler ce qu’il est nécessaire de comprendre pour mieux maîtriser sa programmation. Cet article n’a aucune vocation à être exhaustif et prendra quelques raccourcies afin de simplifier le discours.

Les fondamentaux

Pour bien comprendre le fonctionnement interne d’un microprocesseur, il est nécessaire de faire quelques rappels sur le format binaire. Chaque bit est porté par un fil, avec ou sans tension. La combinaison de fils, donc de bit, permet de représenter un nombre dont la valeur maximale est une puissance de 2. Par exemple, huits bits peuvent représenter deux-cent cinquante-six valeurs, de zéro à deux-cent cinquante-cinq. Chaque bit allumé (ou sous tension) indique la puissance de deux à ajouter pour obtenir le nombre décimal final. Par exemple, si les bits huit, cinq, quatre, deux et un sont allumés, le nombre décimal représenté est cent cinquante-cinq (Voir figure 1). Comme vous vous en doutez, le format binaire est au cœur des microprocesseurs.

Figure 1 : Le format binaire

Un processeur est composé de transistors, organisés de telle sorte à pouvoir choisir la bonne partie du processeur pour :

Il peut recevoir des signaux à l’aide de fils dédier (interruption) et communiquer via des bus d’entrée/sorties (sorte de réseau interne à la carte mère - Figure 2).

Figure 2: Bus

Pour fonctionner, le microprocesseur à besoin d’une horloge, qui va rythmer tous les signaux et transitions d’états. Elle permet d'ordonnancer toutes les parties du processeur. Toutes les transitions d’états doivent être terminées et stabilisées avant le prochain tic horloge. Le delta-t entre 2 tics est appelé “un cycle” du processeur. Plus la fréquence est élevée, plus le processeur va vite.

Afin de maintenir un état stable, lors des calculs, les processeurs utilisent généralement un flip/flop. C’est un montage avec un bit en entrée et une horloge, et qui produit un seul bit en sortie, mais uniquement au prochain tic horloge et pendant tout le cycle. Ainsi, il est possible de maintenir l’état d’un bit d’un registre, le temps de terminer le calcul. La modification du signal est visible uniquement au prochain front montant de l’horloge et pendant tout le prochain cycle (Cf. Figure 3). Ces montages servent à conserver les états des registres.

Figure 3 : Flip/Flop

Pendant de nombreuses années, chaque nouvelle génération de processeur avait une fréquence horloge plus importante que la génération précédente. Le temps qu’un développement soit terminé, il y avait des machines plus puissantes à disposition. Mais, les repas gratuits sont terminés. En effet, augmenter la fréquence c’est également augmenter l’échauffement. Si la courbe de progression avait été linéaire, vous auriez un grand trou sous votre portable, par la chaleur produite ! (Cf. Figure 4)

Figure 4 : Fréquence des processeurs

Pour fonctionner, les processeurs utilisent des combinaisons de transistors permettant d’effectuer des calculs de bases. Par exemple, un circuit XOR (ou exclusif, fromage (x)ou désert), prend deux bits en entrée et sort un seul bit. Si les deux bits d’entrée sont identiques, le bit de sortie est à zéro. Sinon, il est à un (Cf. Figure 5)

Figure 5 : XOR en NAND

Un schéma XOR peut lui-même être décomposé en un schéma composé uniquement d’opérateur NAND (NON-ET). Avec deux bits en entrées et un bit en sortie. Finalement, l’intégralité d’un microprocesseur peut être composée de NAND, afin de traduire tous les autres opérateurs binaires de bases.

Un opérateur d’addition binaire de base peut utiliser le schéma figure 6.

Figure 6 : Additionneur 1 bit

Il y a trois bits en entrée et deux bits en sortie. A et B représente les deux bits que l’on souhaite additionner. Cin est la retenue d’une addition précédente. Le schéma utilise deux XOR, deux AND et un OR. En sortie, nous avons S, qui correspond au bit de résultat (hors retenu), et Cout qui correspond à la retenue de l’opération.

Par exemple, si Cin=0, A=1 et B=0, le résultat sera S==1 et Cout=0. Par contre, si Cin=0, A=1 et B=1, il va falloir propager la retenue. En effet, S ne peut prendre que deux valeurs zéro ou un. Donc S==0. Comme il y a eu débordement, on retient cela en placer un dans Cout.

La table de vérité de ce schéma est présentée au tableau 1. Certains composants n’ont que des tables de vérités pour des schémas à trois bits d’entrée et deux bits de sorties. Ainsi, il est possible de programmer ce composant en l’alimentant de bits, puis lui faire faire ce que l’on veut. Des bits de routages permettent ensuite de connecter les micro-circuits les uns aux autres.

Tableau 1 : Table de vérité d’un additionneur 1 bit

ABCinSCout
00000
10010
01010
11001
00110
10101
01101
11111
A
B
Cin
S
Cout

0

0

0

0

0

1

0

0

1

0

0

1

0

1

0

1

1

0

0

1

0

0

1

1

0

1

0

1

0

1

0

1

1

0

1

1

1

1

1

1

Maintenant que nous avons une vague idée de comment s’effectue une opération binaire, nous allons regarder les instructions des microprocesseurs.

Les instructions

Chaque processeur possède un jeu d’instructions qui lui être propre, variable suivant les versions Les instructions sont de plus en plus nombreuses au fur et à mesure des générations. Une instruction est une valeur binaire plus ou moins longue, avec ou non des paramètres. Certains processeurs préfèrent utiliser une taille fixe pour toutes les instructions, d’autres utilisent une taille variable suivant les types et nombres de paramètres de l’instruction.

Une instruction prend plus ou moins de ‘cycle’ pour être exécuté et plus ou moins d’octets en mémoire pour être interprétée.

Une instruction est le plus petit traitement possible non interruptible. C’est-à-dire que lorsqu’une instruction commence, elle finit son traitement, sans jamais être interrompue. On traduit souvent cela en indiquant que les instructions sont atomiques. Nous avons la certitude qu’elle ne peut être exécutée qu' à moitié, ou qu’une case mémoire avec le résultat de l’instruction ne sera pas partiellement valorisée.

Les instructions permettent de déclencher des sous-circuits effectuant des calculs plus complexes. Par exemple, une division binaire s’exécute exactement comme les divisions en base 10 que nous avons apprises au collège. Sauf que c’est plus simple (Cf. Figure 7) ! (du point de vue du microprocesseur). En effet, il faut juste être capable de faire des soustractions et des décalages binaires (pousser les bits vers la gauche ou la droite). De simples circuits permettent cela.

Figure 7 : Division binaire

Pour aider les humains, les instructions ont une représentation textuelle. Pour le même processeur, il peut y avoir plusieurs syntaxes. Par exemple, la lecture d’une case mémoire référencée par le registre rdi, auquel on ajoute la valeur 0x38, pour placer le résultat dans le registre rax, s’écrit mov rax,[rdi+0x38] avec la syntaxe d’Intel, et mov 0x38(%rdi),%rax avec la syntaxe d’AT&T. Traduit en binaire, il s’agit strictement de la même instruction. Soit les instructions indiquent en premier le registre ou la mémoire où placer le résultat (syntaxe du type rax=...) soit la syntaxe indique vers où placer le résultat ( ...→rax).

Il est souvent astucieux d’utiliser certains bits de l’instruction pour référencer un registre particulier par exemple, activer ou non un sous-traitement. En figure 8, vous trouverez un extrait d’un jeu d’instruction d’un petit processeur.

Figure 8 : Extrait d’un jeu d’instructions

On y voit le nom de l’instruction, les paramètres dont elle a besoin, une description succincte, le nombre de cycles nécessaire à son utilisation, la valeur binaire de l'instruction. Certains bits sont omis, car ils doivent être valorisés suivant les paramètres (f, d ou k dans le tableau). Enfin, on trouve la liste des flags impactés par l’instruction (nous en parlons en peu plus bas).

Les registres

Pour effectuer des calculs, les microprocesseurs ont besoin de mémoire de travail. On les appelle “registre”. C’est une mémoire interne, au plus proche du hardware. Elle ne possède pas d’adresse mémoire, si ce n’est leurs noms dans les instructions. Le nombre de registres est limité en nombre et en taille. Les processeurs 32 bits utilisent des registres avec 32 bits pour chacun. Les processeurs 64 bits font de même. Pour rappel, cela correspond au nombre de fils associés pour pouvoir former une valeur numérique. Donc, les registres possèdent une nombre maximum au-delà duquel il n’est pas en capacité de mémoriser une valeur entière. Pour les valeurs négatives, le bit le plus élevé est utilisé comme bit de signe. Les autres bits permettent alors d’indiquer la valeur maximale en positive ou négative. Par convention, zéro est considéré comme positif (tous les bits sont à zéro, même le bit de signe).

Par exemple, avec 16 bits, la valeur maximum entière, en interprétant le registre comme une valeur non signée est 65535 (soit 65536 valeurs en tenant compte du zéro). En valeur signée, il faut perdre le dernier bit pour gérer le signe, nous avons donc des valeurs comprises entre -32768 et + 32767. Contrairement à l’intuition, Il y a bien le même nombre de valeurs positive que négative, car rappelez-vous, zéro est positif pour l’informatique. Pour gérer des valeurs plus grandes, il est possible de combiner plusieurs registres, afin de doubler le nombre de bits.

Il peut y avoir des registres spécifiques pour les calculs flottant (à virgule), pour les calculs sur des matrices (n ✕ 64 bits traités ensemble), etc.

Certains registres peuvent avoir des usages dédiés. Citons IP (Instruction Pointeur) qui est un registre qui pointe sur l’instruction à exécuter. C’est ce dernier qui indique l’instruction en cours d’exécution et qui va pointer sur la suivante. Ce pointeur est incrémenté de la taille de chaque instruction après son exécution.

SP (Stack Pointeur) est un registre qui gère une pile en mémoire. Il est utilisé pour permettre d’invoquer un sous-traitement et pour revenir à l’adresse de l’instruction suivante de l’appelant. Il a la particularité de faire plusieurs choses en même temps. Écrire ou lire en mémoire, et incrémenter ou décrémenter en conséquence sa mémoire (l’adresse qu’il pointe). C’est ce registre qui est à la base des appels de fonctions que nous connaissons dans tous les langages.

SI, DI servent d'indices pour des boucles utilisées par certaines instructions

RAX est souvent le registre utilisé par recueillir les résultats d’un calcul.

F (Flags) est un registre particulier composé de bit dont chacun à un rôle particulier. Nous avons évoqué la retenue dans les calculs. Et bien, c’est un des bits de ce registre. C’est le seul registre dont on ignore la valeur décimale qu’il représente. Seuls les bits individuels sont importants.

Vous trouverez figure 9, la liste des registres d’un processeur Intel moderne. Il est à noter, qu'à cause de la compatibilité ascendante depuis plusieurs dizaines d’années, des registres peuvent être utilisés avec plus ou moins de bits. Par exemple, le registre RAX 64 bits, peut être référencée comme le registre EAX (on ne prend que les 32 premiers bits), AX (on ne prend que les 16 premiers bits) AL ou AH (on ne prend que les 8 premiers ou les 8 bits suivants). Ainsi, modifier RAX c’est modifier tous les autres, possédant un A.

Figure 9 : Registres des processeurs Intel 64 bits

Certaines instructions peuvent avoir le même effet que d’autres. Par exemple, l’instruction JMP (Jump) permet de sauter à l’instruction se trouvant à l’adresse fournie en paramètre. Cela correspond à modifier la valeur du registre IP, ce que l’instruction MOV peut théoriquement faire.

JMP 0xFFF0 ⇒ MOV IP,0xFFF0

De même, les instructions de manipulation de pile peuvent avoir des traductions en une ou plusieurs autres instructions assembleurs.

PUSH 123 ⇒ MOV [SP],123 ; INC SP

POP AX ⇒ DEC SP ; MOV AX,[SP]

CALL 0xFFF0 - Ajoute sur le sommet de la pile, l’adresse de retour

⇒ MOV [SP],IP ; INC SP ; JMP 0xFFF0

RET ⇒ POP IP

Ces combinaisons peuvent être enchaînées à l'intérieur du microprocesseur pour les faire porter par une seule instruction.

Quelques flags

Le flag Z (Zéro)

Le flag Z est probablement le plus important. En effet, c’est le flag qui permet de gérer les branches, les IF. Sans ce flag, il n’est pas possible de prendre des décisions. Comment cela fonctionne ? En fait, à la fin de chaque opération, le résultat passe par un schéma qui consiste à faire un OR logique entre tous les bits, suivi d’un NOT. Le résultat est gardé dans le flag Z du registre de Flags (Cf. Figure 10). En gros, le flag est à 1 si tous les bits du résultat sont à zéro. D’où son nom.

Figure 10 : Opération puis calcul du ZF

Il est alors possible d’avoir des instructions qui vont s'exécuter, si et seulement si, le bit est allumé (JZ - Jump if zero ou JNZ - Jump if not zero).

Il manque une instruction de comparaison, pour comparer deux registres par exemple. Pour cela, il suffit d’en faire la soustraction, de valoriser Z suivant le résultat et ne pas placer le résultat dans le registre de sortie. CMP AX,BX ⇒ SUB AX,BX (sans modifier AX). Il est donc envisageable d’utiliser un bit de l’instruction pour différencier SUB et CMP. C’est pratiquement le même travail à effectuer !

Avez-vous maintenant compris pourquoi les chaînes de caractères du langage C finissent par le caractère zéro ? C’est justement pour pouvoir exploiter au mieux le flag Z ! Il n’est alors pas nécessaire de faire une comparaison de plus, l’instruction précédente à probablement déjà valorisé Z.

Le flag C (Carry - Retenu)

Nous avons déjà évoqué la retenue lors d’une addition à un seul bit. Pour faire une addition à plusieurs bits, il suffit d’enchainer les schémas (Cf. Figure 11). À la fin, il nous reste une retenue que nous ne savons que faire. Et bien c’est le rôle du flag C de la récupérer.

Figure 11 : Addition 8 bits avec carry

Il est alors possible d'enchaîner des additions en injectant la retenue à chaque étape. Par exemple, pour additionner le couple BX:AX avec DX:CX, il faut dans un premier temps demander une addition simple entre AX et CX, puis une addition avec retenue entre BX et DX.

# Addition des couples BX:AX + DX:CX

ADD AX,CX # Addition simple

ADC BX,DX # Addition avec Carry (Attention, non atomique !)

On peut donc facilement dépasser les limitations du nombre de bit dans un registre, en enchaînant autant d'instructions ADC que nécessaire.

Attention, il y a une petite subtilité. Nous avons évoqué que les instructions sont atomiques. Elles ne peuvent être interrompues pendant leurs exécutions. Mais ce n’est pas le cas entre deux instructions. Avec un exemple qui effectue des additions en cascade en mémoire (et non sur des registres), un autre processus peut voir l’addition en cours d'exécution, avec une mémoire modifiée partiellement, entre deux instructions.

Le choix des instructions

Les compilateurs connaissent parfaitement les spécificités de chaque instruction assembleur, suivant la cible de compilation. Ils savent alors exploiter les instructions les plus efficaces pour obtenir un résultat identique. Il est parfois difficile de comprendre le code optimisé produit par un compilateur.

Par exemple, pour mettre un registre à zéro, il y a plusieurs possibilités. La première idée la plus évidente est d’utiliser MOV EAX,0. Cela se décompose en la lecture de l’instruction MOV EAX, suivi de la lecture du paramètre zéro depuis la mémoire, puis le traitement de l’instruction et finalement l’écriture dans le registre EAX. C’est donc une instruction longue, devant gérer un paramètre de 32 bits. Il est plus efficace d’utiliser une instruction binaire très simple, XOR avec soi-même. La table de vérité de XOR est assez amusante (Figure 12). On constate que si on applique XOR avec les mêmes valeurs pour chaque bit, le résultat est zéro.

Figure 12 : Table de vérité XOR

C’est exactement ce que nous cherchons. Donc XOR EAX,EAX permet de forcer la valeur zéro au registre EAX, sans avoir besoin de lire un paramètre supplémentaire pour interpréter l’instruction. Finalement, l’instruction est plus rapide, car elle est plus courte à être interprétée par le microprocesseur.

De même, multiplier un registre par une puissance de deux (MUL EAX,2) peut être judicieusement remplacé par un décalage binaire à gauche (SHL EAX).

Les bons compilateurs ont plein de règles d'optimisation comme celle-ci. Ils choisissent judicieusement les instructions, mettent parfois à plat les boucles en répétant plusieurs fois le même code (cela évite les sauts inutiles), ou choisissent certains registres plutôt que d’autres.

Les Interruptions

Lorsque le processeur fonctionne, il consomme un flux d’instruction et avance son registre IP. Comment lui signaler qu’il y a quelque chose de plus urgent à faire ? À l’aide d'interruptions. Une interruption est un fil directement branché sur le microprocesseur qui lui signale d’une alerte en le mettant en tension. C’est le hardware autour du microprocesseur qui se charge de cela.

Lorsqu’une interruption est détectée par le microprocesseur, il va la traiter entre deux instructions (rappelez-vous de l’atomicité des instructions). Donc, entre deux instructions, le microprocesseur vérifie s’il n’y a pas un des bits d’interruptions qui est à un. Si c’est le cas, suivant le numéro de bit, le processeur détourne le flux normal de traitement. Une table spécifique en mémoire permet d’indiquer à quelle adresse mémoire il faut aller suivant le numéro du bit d’interruption déclenché. Le registre IP est alors valorisé à cette adresse pour pouvoir exécuter un traitement urgent. Généralement, le code commence par sauver tous les registres dans la pile, avant de s'intéresser à la cause de l’interruption (parfois, la sauvegarde est effectuée directement par le microprocesseur).

Le code peut alors gérer correctement l’urgence, puis restituer les registres dans leurs états, pour continuer le traitement interrompu. Dans les faits, c’est un peu plus compliqué, car il est possible d’avoir simultanément plusieurs interruptions au même moment, ou avoir une interruption lors du traitement d’une autre. Il existe des instructions pour désactiver momentanément le traitement des interruptions.

À quoi cela sert ? Généralement à traiter avec le hardware. Par exemple, un port série de type RS232 va recevoir des octets bit à bit, via deux fils. Lorsque huit bits sont arrivés, la valeur est déposée dans une case mémoire ou un port particulier. Il est alors temps d’informer le microprocesseur de venir chercher l’octet qui vient d’arriver. Une interruption dédiée se charge de cela. Si par malheur, l’interruption n’est pas exécutée ou exécutée trop lentement, il est possible qu’un nouvel octet soit arrivé entre-temps qui va écraser le premier octet à récupérer. En général, les hardwards modernes ont maintenant un petit tampon pour éviter la perte d’information.

Lorsqu’un paquet arrive sur la carte réseau, une interruption est généralement produite pour informer l’OS de cela.

Si on branche une horloge sur une interruption, il est alors possible d’exécuter en urgence un traitement périodique. C’est exactement ce que font les scheduleurs des systèmes d’exploitations. À chaque interruption horloge, ils sauvent les registres, mais au lieu de faire un traitement puis de restituer les registres pour continuer le traitement, les scheduleurs modifient le registre de pile avant de restituer l’état d’un autre traitement. C’est comme cela que le processeur bascule d’un thread à un autre. Juste en modifiant le registre SP à chaque tic d'horloge. Dans les faits, c’est un peu plus complexe, mais ce modèle a fonctionné pendant des années.

Les exceptions

Lorsqu’une instruction est exécutée, il est possible d’avoir une erreur. Comment gérer cela ? Il n’est pas évident ou efficace d’avoir des instructions qui vont retourner un résultat et/ou une erreur. Pour régler cela, les microprocesseurs recyclent le gestionnaire d’interruption pour injecter une interruption interne. Si une instruction effectue une division par zéro par exemple, une interruption spécifique est immédiatement déclenchée. Charge au code associé à l’exception de traiter l’erreur.

L’autre erreur interne qui est fortement sollicité concerne les problèmes d’accès à la mémoire. Chaque zone mémoire possède des privilèges d’accès, en lecture, écriture ou exécution. Si une instruction viole un de ces privilèges, une interruption est également injectée par le microprocesseur.

Les points d’arrêts

Lorsque vous utilisez votre debugger préféré, il est sympa de pouvoir placer des points d'arrêt dans le code. Mais, il ne faut pas dégrader les performances d’exécution de ce dernier, par une exécution pas à pas ! Pour résoudre cela, les débogueurs utilisent deux stratégies suivant si le point d’arrêt concerne un accès à la mémoire ou l’exécution d’un code particulier.

Pour identifier un accès à une case mémoire, il faut déclarer le segment de code comme inaccessible. Lorsqu’une instruction veut modifier une des cases mémoire de ce segment de code, une interruption est déclenchée. Le gestionnaire d’interruption peut alors identifier le code à l’origine de l’accès. Deux possibilités s’offrent à lui : soit le code concerne bien la case mémoire à inspecter, dans ce cas le débugger interrompt le programme ; soit le code concerne une autre case mémoire du même segment. Dans ce cas, le gestionnaire doit re-valider l’accès au segment mémoire, demander au processeur d'exécuter qu’une seule instruction, puis de modifier à nouveau les droits d’accès du segment en attente d’une prochaine interruption. Pour éviter toute cette gymnastique, il existe quatre hook dans le processeur permettant de déclencher une exception lors de la manipulation d’une case mémoire.

Pour identifier un point d'arrêt lors de l'exécution d’un code, la stratégie est différente. Le débogueur commence par autoriser l’accès en écriture sur le segment mémoire portant le code assembleur. Puis, à la position exacte de l’instruction du point d'arrêt, il injecte une instruction qui ne doit prendre qu’un seul octet. En effet, les instructions peuvent prendre plus ou moins de place en RAM. Il ne faut pas détruire les instructions suivant le point d'arrêt. Cette instruction déclenche par soft, le traitement d’une interruption. Dans la figure 13, nous avons utilisé des OP imaginaires pour que cela soit plus clair.

Figure 13 : Injection d’un point d'arrêt

Le code de l’interruption va alors modifier à nouveau le code pour restituer l’instruction d’origine avant de déclencher de débogueur. Lors de la continuation du programme, de même, une seule instruction est exécutée en pas à pas, puis le point d’arrêt est à nouveau injecté dans le code du programme.

Avec cette stratégie, il n’est pas coûteux d’utiliser des points d'arrêts. Par contre, comme le code a été modifié, les virus savent calculer un checksum d’une portion de leurs propres codes pour se rendre compte qu’un point d'arrêt a été injecté.

Les ports d’entrée/sorties

Être capable d’informer le microprocesseur d’une information urgente sur un seul bit, c’est bien, mais souvent pas suffisant pour communiquer avec le hardware.

Il existe alors un bus spécial pour communiquer avec le hardware (Figure 14) : les ports d’entrée/sortie. Vous pouvez voir cela comme des sortes de mémoires partagées par le hardware et le microprocesseur ou comme un réseau sur la carte mère. Deux instructions permettent d’écrire ou de lire sur une des adresses mémoire de ce bus : IN et OUT.

Figure 14 : Bus d’I/O

IN AL,3
OUT 3,AL

Les autres composants peuvent alors réagir suivant les valeurs et les ports utilisés.

Microcode

En 1994, un chercheur a trouvé un bug dans l’instruction fdiv de division flottante. Il n'était pas possible de corriger cela. Seul le remplacement physique du microprocesseur permettait de le corriger. Depuis cette date, les fondeurs rendent programmable leurs processeurs.

Nous avons vu que des instructions assembleurs peuvent être décomposées en plusieurs étapes, à exécuter ou non suivant les instructions. Par exemple, nous avons vu que l’instruction de comparaison est équivalente à une instruction de soustraction dont on ne garde pas de résultat.

Un interne, un processeur possède lui-même un processeur, pour organiser ces différentes étapes. Le code interne est initialement dans une ROM interne au composant. Elle est recopiée dans une RAM rapide interne. Moyennant des instructions spécifiques et une clé de chiffrement particulièrement sensible, il est possible de modifier le programme présent à l’intérieur du microprocesseur. Ainsi, il est maintenant possible de corriger une autre erreur de type fdiv.

Pendant une courte période, le microcode n’était pas crypté. Certains[1] ont alors essayé de le renverser pour pouvoir injecter une backdoor directement dans le chip. Ce n’est plus possible à présent.

Par contre, ne doutez pas que la NSA possède la clé permettant d’injecter toutes les modifications souhaitées pour avoir accès à toute la mémoire. Rien ne garantit, lorsqu’on utilise une machine sur le cloud, que le microprocesseur n’a pas été vérolé par un microcode de la NSA !

Concevoir un microprocesseur

Il existe de nombreuses façons de décrire le schéma d’un microprocesseur. Une des approches consiste à utiliser un langage spécialisé comme le VHDL (Virtual Hardware Description Language). Avec ce langage, nous allons décrire un additionneur quatre bits (Cf. Figure 15). On retrouve bien l'enchaînement des additionneurs un bit. Le premier bit prend une retenue à zéro. Les autres, prennent la retenue en sortie du schéma précédent (Figure 16).

Figure 15 : Additionneur 4 bits en VHDL

Figure 16 : Addition de 4 bits

À partir de ce fichier source, il est possible d’effectuer des simulations, de générer un schéma logique, d’exécuter le code via une unité programmable (FGPA) ou lancer la production d’une puce spécialisée (ASIC).

Résumé des fondamentaux

En synthèse

Les processeurs sont limités par

Ce que nous venons de voir existe depuis plus de 30 ans. Depuis, il y a eu de nombreuses améliorations internes pour optimiser les traitements, renforcer la sécurité, ajouter des cœurs, etc. Nous survolerons cela dans la suite de l’article.

L’accès à la mémoire

Pour bien comprendre les subtilités de l’accès à la mémoire, il faut d’abord comprendre l’organisation du microprocesseur, voir de plusieurs microprocesseurs au sein d’un seul Node (ou machine).

Un chip ou composant électronique est souvent appelé Socket. Ce qui correspond en fait au support d'accueil du composant. Dans une machine, il peut y avoir plusieurs sockets, plusieurs supports.

Dans un socket, il peut y avoir plusieurs core. Un core correspond à un microprocesseur simple, tel qu’ils existaient à l’époque et tel que décrit dans les fondamentaux.

Un core lui-même peut être décomposé en plusieurs virtual core (généralement deux, mais de plus en plus avec les dernières générations). Il s’agit de la capacité d’un seul core à exécuter simultanément plusieurs instructions en même temps, afin d’exploiter plus de partie du core en même temps. Suivant les instructions, ce n’est pas toujours possible.

La figure 17 montre bien cet emboîtement arborescent. Chaque niveau à un accès spécifique à la mémoire et une gestion particulière des caches.

Figure 17 : Architectures des (v)cores

Les caches

Dans un socket, il y a trois niveaux de cache et les différents registres pour travailler. Plus on se rapproche des registres, plus l’accès est rapide, mais moins il y a de données disponibles (cf. figure 18)

Figure 18 : Distribution des caches

Il n’est pas raisonnable d’avoir une gestion de cache basé sur chaque octet de la RAM. Un cache ne peut avoir la liste des adresses en mémoire qu’il cache et la valeur associée. Il faut alors fonctionner avec des grains plus importants. C’est pour cela que les caches sont organisés en ligne de cache. Il s’agit d’un ensemble d’octets qui se suivent en mémoire, qui seront montés ou descendus du cache en même temps. Une ligne de cache fait généralement 64 bytes, mais cela peut être différent suivant les modèles (Cf. Figure 19).

Figure 19 : Distribution des lignes de caches

Les temps d’accès aux différents caches sont variables. De quelques nanosecondes à plusieurs cycles (Cf tableau 2). Il est important de saisir que lire de la mémoire pour l’amener auprès de l’instruction à exécuter peut prendre plusieurs cycles. Comme nous l’avons vu, les cycles sont ce qui permet d'ordonnancer les exécutions. Donc la même instruction peut être très fortement ralentie si elle doit manipuler de la mémoire qui n’est pas dans un des caches, mais uniquement en RAM. Elle peut attendre plus de 200 cycles, le temps que la donnée remonte dans le processeur. C’est énorme !

Tableau 2 : Temps d’accès aux caches

Niveau de cacheTailleTemps d'accès en cycleConcurrenceTechnologieGéré par
Registre1/2 KBµvirtual coreCustom CMOSCompilateur
Niveau 18KB – 128 KB1coreSRAMHardware
Niveau 2256 KB3coreSRAMHardware
Niveau 33 MB – 32 MB10 – 20SocketSRAMHardware
RAM4 MB – 4 TB200+Chaque région séparémentVariableOS
Niveau de cache
Taille
Temps d'accès en cycle
Concurrence
Technologie
Géré par

Registre

1/2 KB

µ

virtual core

Custom CMOS

Compilateur

Niveau 1

8KB – 128 KB

1

core

SRAM

Hardware

Niveau 2

256 KB

3

core

SRAM

Hardware

Niveau 3

3 MB – 32 MB

10 – 20

Socket

SRAM

Hardware

RAM

4 MB – 4 TB

200+

Chaque région séparément

Variable

OS

Le processeur n’est pas directement connecté à la RAM. Il doit passer par un BUS (canal de communication dédié) pour envoyer des ordres à un contrôleur de mémoire. La RAM est copiée dans un cache, présent dans le processeur, au plus proche des transistors (Figure 20).

Figure 20 : Gestion de la mémoire

De même, les modifications de la mémoire sont effectuées dans le cache (et ne sont pas visibles en RAM). Lorsque le développeur le juge nécessaire, le cache est envoyé sur le bus pour être recopié en RAM. C’est seulement après le “flush” de la RAM (vidage de la zone du cache) que les autres nodes peuvent découvrir que la mémoire a été modifiée. La visibilité des modifications par les autres vcore ou core dépend de la localisation des modifications dans les différents niveaux de caches, jusqu'à la RAM.

Ce point est très important à maîtriser lorsque l’on conçoit des programmes multi-tâches. Suivant la localisation physique du traitement des différentes tâches, les impacts peuvent être différents. Un programme qui fonctionne avec un seul socket peut échouer avec plusieurs par exemple.

La figure 21 place les différents caches suivant les usages.

Figure 21 : Positionnement des caches dans les cores

Pour organiser tout cela, il faut que les différents niveaux communiquent entre eux. Des messages permettent de demander des morceaux de RAM sur le Bus. Si le socket d’à côté possède cette zone mémoire dans un de ses caches, il peut le diffuser sur le bus, plus rapidement que depuis la véritable RAM.

Des instructions assembleurs spécifiques permettent de demander l’écriture effective jusqu'à la RAM, afin de garantir que tout est visible et cohérent pour tous les sockets.

La mémoire virtuelle

En principe, chaque case mémoire de la RAM physique possède une adresse. Il est donc envisageable d’utiliser cette adresse pour les programmes. En fait, comme la mémoire est une denrée rare et partagée par les différents processus, les processeurs proposent une indirection avant d’accéder à la RAM physique. Ils introduisent la notion d’adresse virtuelle. C’est une adresse d’une case mémoire qui sera transformée en adresse physique avant de partir vers le bus d’accès à la RAM.

Cela offre de nombreux avantages :

Les OS ont besoin d’isoler chaque application les unes des autres et les applications de l’OS. Il ne faut pas permettre à une application de modifier la mémoire d’une autre, voir de lire les informations confidentielles d’un autre programme. Pour cela, les processeurs offrent deux tables de mapping entre la mémoire virtuelle et la mémoire physique.

Le table GDT (Global Descriptor Table) permet d’avoir un mapping global partagé par toutes les applications. On y retrouve généralement les zones mémoires dédiées à l’OS ou aux mémoires partagées.

La table LDT (Local Descriptor Table) est une autre table de mapping, mais spécifique à chaque processus. C’est dans cette table que l’OS va mapper des adresses virtuelles qui se suivent vers des adresses physiques parfois distribuées dans différentes parties. Avec ce mécanisme, chaque programme peut fonctionner aux mêmes adresses virtuelles. Chacun à l’illusion d’avoir un espace mémoire continu pour lui tout seul, alors que la mémoire physique est finalement partagée entre tous les processus.

Ces tables de mappings possèdent également de nombreux flags, permettant une gestion avancée de la RAM.

En général, un programme est découpé en plusieurs segments. Cela est indiqué dans le format du fichier à exécuter (.exe sous Windows). Il y a des segments de code, avec les instructions assembleurs à exécuter ; un segment de constante avec les données produites par le compilateur (les chaînes de caractères par exemple) ; les données pré-initialisées globale (des données initialisées pouvant évoluer au cours de l'exécution du programme) ; et les données non initialisées dont on souhaite pré-réserver l’espace en mémoire (Cf. figure 22).

Pour chacun des segments, il est possible d’indiquer s’il est exécutable, disponible en lecture et/ou écriture, s’il est mappé sur une portion d’un fichier, etc.

Figure 22 : Mapping de la mémoire virtuelle sur la mémoire physique

Par exemple, les segments de code et les constantes peuvent référencer des portions du fichier exécutable. Ainsi, si la mémoire vient à manquer, l’OS peut sacrifier les segments correspondant en supprimant l’association avec un segment de la RAM. Cela libère de la place pour d’autres programmes. Si plus tard, un bout de code de ce segment doit être exécuté, l’OS peut sacrifier un autre segment en mémoire d’un autre programme par exemple, puis remonter le code à partir de l’exécutable dans une autre partie de la RAM. En manipulant la LDT, il associe la nouvelle position du segment et le code peut s'exécuter à nouveau.

Tout cela est géré par le mécanisme d’interruption que nous avons évoqué. Lorsqu’une instruction veut accéder à une zone mémoire et que pour une raison ou une autre, l’accès lui est interdit, une interruption de violation de page est produite par le microprocesseur. L’instruction n’est pas exécutée. Le gestionnaire d’interruption peut alors rétablir la situation avant de demander la ré-exécution de l’instruction.

Niveau de sécurité

Les processeurs proposent quatre niveaux de privilèges. Certaines instructions ne peuvent être exécutées qu’avec le bon niveau. Les segments de RAM peuvent également être plus ou moins accessibles suivant le niveau du code. Dans les faits, seuls deux niveaux sont utilisés. Le niveau zéro, correspondant au code du kernel, et le niveau 3 correspondants au code des applications utilisateur (figure 23).

Figure 23 : Niveaux de privilèges

Un code d’un ring peut invoquer une fonction d’un ring inférieur moyennant l’utilisation d’une gateway spécifique, via une instruction assembleur spécifique. Le kernel est alors capable de consulter la mémoire du processus appelant et de sa propre mémoire. Généralement, il va commencer par recopier une partie de la RAM utilisateur vers un autre buffer présent au niveau kernel, avant de traiter la demande (écriture sur un fichier, envoi d’un paquet sur le réseau, etc.)

Depuis l'émergence des applications mobiles, les OS ont évolué vers une isolation plus forte des applications (Android, IOS ou pour le store de Windows 10). Une distribution des différents niveaux pourrait être : Ring 3 pour les applications en bac à sable, limité aux fichiers de l’application elle-même ; Ring 2 pour les applications classiques, pouvant partager l’espace utilisateur ; Ring 1 pour l’OS et le Ring 0 pour les hyperviseurs de machine virtuelle.

Ordre d’accès à la RAM

Nous avons vu que l’accès à la RAM n’est pas direct. Il faut passer par des caches, et les vider à des moments stratégiques. Le passage du processeur au mode : “je lis de la RAM” à “j’écris de la RAM” prend du temps, par la gestion des verrous dans les différentes zones mémoires à gérer, par la communication sur le BUS, etc.

Pour optimiser cela, le processeur est capable d’analyser les instructions à exécuter pour décider de réorganiser les instructions dans un ordre différent, mais produisant le même résultat, afin de réduire le nombre de basculements du mode lecture vers le mode écriture. L’objectif est de regrouper les lectures ensembles, et les écritures ensembles (Cf. Figure 24).

Figure 24 : Réorganisation des accès

Donc, ce n’est pas parce que votre code assembleur effectue des modifications dans un certain ordre que les modifications seront visibles dans le même ordre par un autre cœur du processeur. Cela peut avoir un impact sur la façon de coder. Il ne faut jamais présumer l’ordre d’exécution lors d’un algorithme multi-tâche. Le seul moyen de garantir un état spécifique est de demander au processeur de vider les caches à des moments clés de l’algorithme. Pas trop souvent, mais juste ce qu’il faut.

Dans les langages de haut niveau, les variables volatile sont des variables traitées de façon particulière par le compilateur. Il va injecter les instructions permettant de synchroniser les caches à chaque manipulation.

Décodage des instructions

Afin d’optimiser l’analyse des instructions, les processeurs utilisent généralement un pipeline pour paralléliser chaque étape de l’interprétation de l’instruction (cf. figure 25). Ainsi, le programme est déjà en train d’analyser les instructions suivantes pendant qu’il en existe d’autres.

Figure 25 : Pipeline de traitement

Le microprocesseur est capable d’exécuter plusieurs instructions dans le même cycle, à la condition que ces instructions ne doivent pas être exécutées en série. Il ne faut pas que la deuxième instruction ait besoin du résultat de la première. S’il est possible de paralléliser, le processeur exécute simultanément plusieurs instructions. Si ce n’est pas possible, une seule est exécutée (Cf. Figure 26).

Figure 26 : Parallélisme d’instructions

Afin d’augmenter les chances de pouvoir paralléliser les instructions, le processeur est capable de modifier les instructions pour isoler des morceaux de code en traitement indépendant. Par exemple, il possède des registres supplémentaires cachés, lui permettant de renommer les registres utilisés par des instructions pour permettre le parallélisme (Cf. figure 27).

Figure 27 : Rename des registres

De même, en attendant les 200 cycles pour lire la RAM, le processeur peut exécuter spéculativement les instructions suivantes. Ainsi, lorsque la RAM est enfin remontée dans le processeur, il peut exécuter l’instruction en attente, puis continuer immédiatement après la dernière instruction exécutée spéculativement. Cela fonctionne simplement lorsque les exécutions s'enchaînent. C’est plus difficile lorsqu’il y a plusieurs branches d’exécutions possibles. Dans ce cas, le processeur doit faire un choix. Il exécute spéculativement une des branches. Lorsque la RAM est remontée dans le microprocesseur, il est possible que le test qui suit mène vers une autre branche que celle qui a été choisie. Dans ce cas, tout le traitement spéculatif doit être abandonné, et le code exécuté classiquement (Cf. figure 28).

Figure 28 : Prédiction de branche

Pour éviter des erreurs de branches, plusieurs stratégies sont utilisées, de compteur associé à chaque IF pour savoir où il a été constaté que la branche allait généralement, à l’utilisation de machine learning pour prédire les branches.

L'exécution spéculative peut être visible par l’impact sur les données présentes dans les caches, et donc dans le temps d’accès à la mémoire. C’est cette faiblesse qui est utilisée par les attaques Meltdown et Spectre.

Virtualisation

La virtualisation est un mécanisme qui permet d’avoir plusieurs systèmes d’exploitations sur la même machine. Pour cela, il faut isoler chaque machine virtuelle, et simuler des composants hardwares (écran, carte disque, réseau, etc.) pour que chaque OS ait l’impression d’être seul au monde.

Depuis les premières solutions plus ou moins efficaces en termes de performance, les processeurs proposent une dizaine d’instructions spécifiques pour gérer cela. Elles permettent d’entrer ou de sortir du mode virtualiser, d’injecter des hooks sur certaines instructions sensibles, afin de pouvoir les simuler, etc. L’hyperviseur, en charge de la virtualisation peut être installé à un niveau plus privilégié que le Ring0 afin de distribuer les ressources entre les différents OS (cf. Figure 29). C’est lui va distribuer la RAM (cf. figure 30), utiliser un scheduler pour partager la CPU entre les OS. Souvent, l’hyperviseur trompe l’OS en lui indiquant qu’il a plus de cœur que ce que lui accorde le scheduler de l’hyperviseur. Cela peut avoir des impacts négatifs sur des algorithmes à haute fréquence.

Figure 29 : Organisation de priorité en mode virtualisé

Figure 30 : Double indirection de la mémoire

Référence d’un processeur

Vous savez maintenant comment interpréter les références et les caractéristiques d’un microprocesseur (Cf. Figure 31)

Figure 31 : Lecture d’une référence de processeur

Pour conclure

Au-delà des fondamentaux, les processeurs rivalisent d'ingéniosité en ajoutant toujours plus d’optimisations. Des optimisations pour l’accès la RAM (utilisation de cache, exécution spéculative, mémoire virtuelle) et pour paralléliser les traitements (parallélisme par instruction, virtuel, physique).

Finalement, le goulet d'étranglement en ce moment est essentiellement la RAM. Les processeurs vont trop vite pour les capacités d’accès à la RAM. Bien utilisés, les caches permettent d’avoir des performances plusieurs centaines de fois plus rapide qu’un algorithme naïf.

[1] https://www.syssec.rub.de/media/emma/veroeffentlichungen/2017/08/16/usenix17-microcode.pdf