jeudi 26 novembre 2015

Qu'est ce qu'un programme 1/2

Tout ce que vous avez toujours voulu savoir sur les programmes (sans jamais oser le demander).

Qu'est ce qu'un programme informatique ?

La réponse peut sembler évidente mais si on prend un peu de recul, cette question à priori simple amène des réponses potentiellement compliquées.

Et elle mérité d'être posée car comprendre ce qu'est "in fine" un programme permet de comprendre pas mal de choses sur le fonctionnement d'un ordinateur quel qu'il soit.

Préambule

Je vous invite éventuellement à (re)-lire cet article qui expose quelques notions de base (architecture processeur, système d'exploitation).

Rappelons rapidement qu'un processeur est, par construction, capable d'exécuter un nombre fini et limité d'opérations élémentaires. L'ensemble des ces opérations élémentaires constitue son "jeu d'instruction" et est un des éléments de base qui définit une architecture processeur. 

Notre première définition d'un programme est la suivante : "une suite d'instructions, chaque instruction étant une opération élémentaire disponible dans le jeu d'instruction du processeur".

Le processeur exécute séquentiellement les instructions qui ont préalablement été chargées en mémoire vive (RAM) par le système d'exploitation (OS). La suite d'instructions (le programme donc) a généralement été lue depuis un support de stockage (disque dur, DVD, etc.) où elle était stockée dans un fichier, appelé fichier exécutable par opposition aux fichiers de données qui stockent des informations et pas des instructions. 

Le processeur est donc capable d'exécuter tout programme, dès lors qu'il est exprimé dans son jeu d'instruction, ce qui en fait un outil très versatile (adaptable à de nombreux travaux). 


La vraie nature d'un programme

Un programme est donc une suite d'instructions, et chaque instruction est exprimée en langage machine, c'est à dire directement en binaire. Détaillons ceci.

A chaque instruction supportée par le processeur va correspondre un numéro unique qui l'identifie, par exemple 00000001 pour une addition ou 00100100 pour une soustraction. 

Les opérandes éventuelles d'un instruction sont également exprimée en binaire. Qu'entends on par opérande ? Hé bien par exemple les opérandes d'une instruction d'addition sont les deux valeurs à ajouter. 

En reprenant notre exemple précédent, si je veux ajouter 4 (00000100 en binaire) et 8 (00001000 en binaire), l'instruction sera un truc du genre 00000001 00000100 0000100, soit "ajouter" "4" "8" (cet exemple est bien sur fictif).

On peut déjà constater que ce code binaire, si il est directement compréhensible par le processeur, est totalement illisible d'un point de vue humain, du moins sans un effort considérable, et encore plus quand on sait qu'il existe des centaines d'instructions différentes, et que les êtres humains normalement constitués comptent en décimal et pas en binaire (et encore, nous avons simplifié la réalité).

Si on a programmé en binaire il y a des décennies sur des ordinateurs très simples munis de processeurs avec des jeux d'instructions très limités, il est bien évident que les possibilités sont très limitées d'un point de vue pratique (et uniquement d'un point de vue pratique) ; les processeurs modernes ont en outre des jeux d'instructions complexes rendant la tâche encore plus difficile.

Pour cette raison, on a inventé progressivement diverses techniques pour permettre aux programmeurs d'écrire des programmes sans devoir taper des suites de 0 et de 1.


Première étape : l'assembleur

On a commencé par attribuer un nom parlant à chaque instruction existante. Par exemple si 00000001 est l'instruction "addition", on lui a associé par exemple le libellé "ADD". 

Ensuite, comme exprimer des valeurs en binaire est vraiment trop chiant, on a donné la possibilité de les exprimer dans d'autres bases (en décimal - base 10 -, en octal - base 8 -, en hexadécimal - base 16 -, la plus utilisée étant l'hexadécimal pour des raisons d'ordre pratique sur lesquels je ne m'étendrais pas ici). 

Du coup, au lieu d'écrire 00000001 00000100 0000100 comme tout à l'heure, je peux écrire par exemple ADD 4 8. C'est déjà plus facile de comprendre que ça veut dire "additionne 4 et 8".

Mais le processeur lui ne comprend pas ADD 4 8, il ne comprend que 00000001 00000100 0000100. Ben oui, il est un peu obtus le machin.

Donc, il nous faut un programme qui va traduire ADD 4 8, qui est ce que le programmeur va écrire, en 00000001 00000100 0000100 qui est ce que le processeur va pouvoir comprendre et exécuter. Ce programme est facile à écrire car la traduction est immédiate, il suffit de remplacer chaque instruction "humaine" (ADD par exemple) en son équivalent binaire (00000001 par exemple) via une simple table de correspondance, et de traduire des valeurs exprimées en décimal/hexadécimal etc en binaire ce qui est une opération arithmétique très simple.

Ce programme c'est ce qu'on appelle un "assembleur". C'est aussi le nom qu'on donne au langage constitué par l'ensemble des mots comme "ADD" qu'on utilise en lieu et place des instructions binaires.

Cette première étape a permis d'écrire des programmes déjà beaucoup plus sophistiqués et intéressants que ce qu'il était humainement possible de faire en binaire.

Cependant, programmer en assembleur reste très compliqué car il faut avoir une connaissance intime du fonctionnement du processeur, et que le niveau d'abstraction des instruction reste très faible (notre exemple du ADD est volontairement simplifié). 

De fait, aujourd'hui la programmation assembleur est très peu utilisée, sauf pour des cas très précis où on a besoin absolument d'un programme très rapide et/ou le plus court possible : car l'assembleur a les qualités de ses défauts : étant proche du matériel, il permet d'écrire du code parfaitement optimisé à la fois pour la vitesse d'exécution et pour le nombre d'instructions nécessaire pour obtenir le résultat  souhaité. On recoure à l'assembleur pour les pilotes de matériel (drivers), le programme chargé de charger le système d'exploitation (bootloader) sur une carte mère équipé d'un firmware BIOS (voir cet article pour ceux qui veulent développer), et dans des contextes où il y a des contraintes  particulières de taille et de vitesse.


Seconde étape : les langages compilés

Après avoir créé l'assembleur, on a voulu aller plus loin. On a voulu pouvoir exprimer des programmes, donc des suites d'instructions, dans un langage le plus proche possible du langage "naturel" (c'est à dire du langage humain).

Alors, disons le tout de suite, programmer en langage naturel, ça reste encore de la science-fiction, et ça le restera sans doute toujours, car quel que soit le langage (Français, Anglais etc.) il reste beaucoup trop vague, trop riche, et beaucoup trop ambigu pour qu'on puisse le traduire en une suite d'instructions.

Du coup, on a créé de toutes pièces différents nouveaux langages ressemblant le plus possible à du langage humain, ainsi qu'un certain nombre de constructions intellectuelles, plus ou moins élaborées, permettant de faciliter l'écriture de programmes sophistiqués comportant un très grand nombre d'instructions.

Les paradigmes de programmation

Ces constructions intellectuelles, ou paradigmes, permettent d'organiser le grand volume d'instructions requis pour un programme de façon à ce que les choses restent gérables (que le code soit compréhensible, qu'il soit bien organisé, qu'on puisse mutualiser des sections de codes utilisables à plusieurs reprises, qu'on puisse faire évoluer un programme le plus facilement possible et sans casser ce qui marche déjà  etc.).

Il existe différents paradigmes de programmation, et il est probable qu'il en sorte d'autres des laboratoires de recherche en informatique dans les décennies à venir. Il n'y a pas de lien entre le fait qu'un langage s'appuie sur un paradigme donné et le fait qu'il soit compilé (nous verrons plus loin qu'il existe d'autres façon de produire des programmes que d'utiliser des compilateurs) mais nous introduisons cette notion dès à présent.

Je n'irais pas très loin dans le détail ici car il faudrait des encyclopédies entières pour décrire les différents paradigmes de programmation existants, je me contenterais de citer les plus importants et de les commenter succinctement :
  • paradigme de programmation impérative : c'est le plus ancien, le plus simple, le plus connu des non professionnels, et celui pour lequel il existe le plus de langages. Parmi les langages impératifs les plus connus citons le langage C, ainsi que la langage Pascal qui a longtemps été utilisé en France pour l'enseignement de la programmation. Le BASIC, langage initialement conçu pour les débutant (Basic est un acronyme où le B figure Beginner, débutant en Français) était un langage impératif à ses origines.
  • paradigme de programmation objet ; l'OOP (Object Oriented Programming, Programmation Orientée Objet en bon Français) est probablement le paradigme le plus utilisé aujourd'hui. On peut citer les langages Java, C#, C++ parmi les plus utilisés.
  • paradigme de programmation fonctionnelle : ce paradigme bénéficie depuis quelques temps d'un engouement certain, notamment du fait qu'il est adopté par le langage Javascript (avec le paradigme objet, javascript supportant plusieurs styles de programmation. Précision tout de même, javascript n'est pas un langage compilé). Java (le langage le plus utilisé dans le monde actuellement) depuis sa version 8 permet de faire de la programmation fonctionnelle.
  • paradigme de programmation déclarative : ici le développeur ne décrit pas une suite d'instruction qui permet d'atteindre un résultat, mais directement le résultat qu'il souhaite obtenir. Un très bon exemple est le langage HTML (qui bien sur n'est pas un langage compilé, mais qui est un bon exemple).

Si vous ne voyez pas ce que sont HTML et javascript, je vous conseille de consulter la série d'articles, de votre serviteur, sur le fonctionnement d'Internet et du Web :



Processus de fabrication d'un programme > compilation

Donc à nouveau, comme avec l'assembleur, les programmeurs ont la possibilité d'exprimer des instructions dans un langage qui n'est pas un langage machine. Et à nouveau, il est nécessaire de traduire ces programmes en langage machine pour que le processeur puisse les comprendre. Cette opération est réalisée par un programme appelé compilateur : c'est la compilation.

On peut le deviner, la compilation est bien plus complexe que la simple traduction directe d'un code assembleur. Les instructions étant plus abstraites, et l'expressivité (les capacité d'expression) du langage infiniment plus évoluées, le compilateur a fort à faire. Pour cette raison, le code binaire résultant est moins optimisé que celui qu'on aurait écrit en assembleur (ou en binaire) ; par contre, il est humainement possible de le produire, et en tout état de cause ça prend infiniment moins de temps et ça coûte bien sur infiniment moins cher (les développeurs ça coûte cher en pizza et en café).

Donc en résumé, on a un code source écrit dans un langage quelconque (il en existe des centaines), un compilateur qui le lit et qui produit un code binaire directement compréhensible par le processeur.

Processus de fabrication d'un programme > édition de lien

Il nous reste une dernière étape pour obtenir un programme exécutable qu'on peut lancer depuis un système d'exploitation, par exemple tout simplement en double cliquant sur le fichier exécutable sous Windows.

En effet, un programme exécutable, pour diverses raisons que je laisse ce côté car nécessitant de longues et complexes explications, n'est pas constitué uniquement d'une suite d'instructions destinées au processeur ; il comporte également un entête qui stocke diverses informations indispensables pour permettre au système d'exploitation de le charger en mémoire et de le rendre ainsi disponible pour exécution par le processeur. Notez bien que ceci est également vrai pour les programmes écrits en assembleur. 

Cette dernière opération qui permet de fabriquer cet entête, et de réaliser un certain nombre d'autres opérations indispensables, s'appelle l'édition de liens (ou linkage, ou linking). Elle est souvent confondue avec la compilation car les outils modernes utilisés par les développeurs réalisent souvent les deux phases de façon transparente.

Pour être complet, il y a un cas où on n'a pas besoin de cette phase : c'est le cas où le programme ne va pas être chargé par le système d'exploitation ce qui est bien sur un cas de figure très réduit puisque le système d'exploitation est lancé dès le démarrage de l'ordinateur. Mais, l'OS étant lui même un programme, il faut bien un programme pour le démarrer, c'est ce qu'on appelle un bootloader ; et ce bootloader n'a pas besoin d'entête et est composé uniquement d'instructions. Pour plus de détail sur ce qu'est un bootloader, vous pouvez lire l'article suivant :
Comment démarre un ordinateur

Quels sont les programmes écrits avec des compilateurs ?

L'écrasante majorité des programmes que vous utilisez tous les jours ont été fabriqués ainsi : les développeurs ont écrit le code source dans le langage de leur choix, puis ils ont lancé la compilation et l'édition de lien pour obtenir au final un fichier exécutable directement utilisable par le système d'exploitation cible (celui pour lequel ils ont choisi de compiler), voire différents fichiers pour différents systèmes d'exploitation si tel était leur choix (si ils voulaient fabriquer un exécutable pour différents OS).


Troisième étape : les librairies partagées

Dès qu'on a eu des compilateurs, on a commencé à les utiliser pour produire pleins de programmes : ben oui, on avait inventé un super outil, c'était bien pour s'en servir.

Et très vite on s'est rendu compte d'un truc tout con : certains aspects étaient à gérer de façon récurrente dans la quasi totalité des programmes.

Que faire alors ?
  • le développeur totalement abruti : il va ré-écrire, à chaque fois, dans chaque programme, la même chose ou à peu près. Pas très intelligent (même pas du tout)
  • le développeur fainéant : il va copier/coller le code qu'il a déjà écris une fois pour un premier programme, dans tous les suivants. C'est déjà mieux mais si il y a une erreur dans son premier code, il va la reproduire partout, et si il la corrige une fois dans un programme, il devra faire la même correction dans tous les autres où il a déjà copié/collé le truc
  • le développeur consciencieux : il va écrire une librairie et la réutiliser... D'où la question à 100 balles, mais c'est quoi une librairie ?

Alors une libraire c'est du code source, écrit dans un langage quelconque, et compilé en binaire avec un compilateur. Comme un programme, sauf que ce n'est pas un programme et qu'il ne peut pas être exécuté par un OS ; c'est juste une suite d'instructions.

Et cette librairie, il suffit de l'utiliser dans les différents programmes en la référençant. Dès lors, son utilisation sera prise en compte lors de la phase d'édition de lien qui suit la compilation dans la génération d'un fichier exécutable.

Et là, dernière subtilité importante, cette prise en compte peut se faire des deux façons suivantes.

Lien statique ou à la compilation

Le code de la librairie est ajouté dans l'exécutable généré. C'est comme le copier/coller du développeur fainéant, sauf qu'au lieu d'un copier/coller du code source par le développeur, c'est un copier/coller de code compilé par le compilateur (enfin l'éditeur de lien pour être précis).

Premier avantage : comme la taille du code source n'est pas augmentée, les temps de compilation ne sont pas impactés. Hé oui, compiler un gros programme, il fut un temps où ça pouvait prendre des heures ou des jours (moins vrai de nos jours avec la puissance des ordinateurs actuels et les progrès des compilateurs).

Second avantage : c'est une opération automatique et non manuelle, donc ça prend moins de temps, et le risque d'erreur est moindre.

Pour le reste, on a toujours les inconvénients de la solution "développeur fainéant". Si on corrige une erreur dans le code source de la librairie, il faut non seulement la recompiler mais également recompiler tous les programmes qui l'utilisent.

En outre, avec l'arrivée des OS multi-tâches, un autre problème s'est posé. Dans un OS multi-tâches, on peut exécuter simultanément plusieurs programmes. Il faut donc les monter tous en mémoire. Hors, si ils sont par exemple 3 à utiliser la même librairie qui fait 50 Ko, l'empreinte mémoire va être de 3 x 50 = 150 Ko. Hé oui, chaque programme embarque le code de la librairie, donc on le charge autant de fois que de programmes qui l'utilisent. Quand on avait des OS mono-tâches, on s'en foutait bien car on ne faisait tourner qu'un programme à la fois, mais ce temps est fini.

Donc au final, l'édition de lien statique à la compilation est peu utilisée. On lui préfère généralement l'édition de lien dynamique à l'exécution pour les raisons évoquées.

Lien dynamique ou à l'exécution

L'éditeur de lien ne vas plus copier/coller le code binaire de la librairie dans le programme exécutable. A la place il va modifier l'exécutable de telle façon que, lorsque le programme appelle une fonction de la librairie : 
  • le système d'exploitation charge la librairie en mémoire. Il fait en sorte de ne la charger qu'une seule fois même si plusieurs programmes y font appel (gros avantage par rapport au lien statique donc)
  • le programme principal exécute la code de la librairie
  • quand le code de la librairie a fini de s'exécuter, le programme principal reprend la main

Et voilà, le tour est joué. Si on a 3 programmes qui utilisent la librairie de 50 Ko, elle n'est chargée qu'une seule fois, et la taille des 3 fichiers exécutables n'est pas augmentée (en tout cas, pas dans la même mesure).

Le gros problème, car il faut bien qu'il y en ait un, c'est que désormais nos 3 programmes ne peuvent fonctionner que si et uniquement si la librairie dont ils sont chacun dépendants est bien installée sur le système... En outre, pour peu que la librairie existe en diverses versions car elle a évoluée dans le temps, et qu'ils utilisent chacun une version différente, ça peut vite devenir le bordel.

Les librairies dynamiques sous Windows, ce sont les fameux fichiers DLL (Dynamic Link Library), et une des raisons de l'instabilité des versions anciennes de Windows c'est justement la mauvaise gestion de ce problème (ce qu'on appelle le DLL Hell, l'enfer des DLL).

Maintenant vous comprenez pourquoi quelques fois, un programme A qui marchait très bien, ne fonctionne plus après que vous ayez installé un programme B : le programme B a une DLL en commun avec A, et lors de son installation il a écrasé la version présente dans le système (et utilisée par A) par une version plus ancienne (utilisée par B).

Versions portables

Certains programmes sont compilés en deux versions. Une version qui fait le lien avec la librairie à l'exécution, et une version qui fait le lien à la compilation. La seconde version est dite "portable", elle est plus grosse mais il suffira de copier l'exécutable sur un OS compatible pour que ça fonctionne (pas de nécessité d'installer les DLL des librairies qu'il utilise).

Compatibilité binaire

Le mécanisme d'édition de lien introduit un couplage entre un programme et le système d'exploitation pour lequel il est compilé : le format de l'entête est spécifique à l'OS cible, de même que le mécanisme de gestion des librairies partagées. 

C'est pour cette raison qu'il n'existe pas de compatibilité binaire : bien que le programme soit une suite d'instructions en langage machine, il ne sera pas possible d'utiliser un même programme sur deux machines disposant d'un même processeur (enfin de deux processeurs supportant la même architecture, et donc le même jeu d'instructions) mais tournant sous des OS différents.

La compatibilité binaire est envisageable entre diverses moutures d'une même famille d'OS, ou dans des conditions bien précises, mais ceci reste délicat et compliqué, il est généralement plus simple de ne pas chercher à finasser et d'installer la version spécifiquement compilée pour l'OS de votre machine. Ou au pire, de se procurer le code source et de la compiler pour votre OS (une opération qui n'est pas rarissime sous Linux).

Il existe dans le cas où on veut utiliser un même programme sur différents systèmes d'exploitation, et même sur différentes architectures processeurs, une solution largement utilisée et qui ne repose pas sur les langages compilés. Cette solution passe par la virtualisation du processeur, nous aborderons ceci dans un second article.


Conclusion temporaire 

Comme en informatique rien n'est jamais simple, il existe également des programmes qui ne sont pas compilés dans le langage machine compris par le processeur :
  • soit il ne sont pas compilés du tout, et on parle alors de langage interprété
  • soit ils sont compilés dans un code machine virtuel c'est à dire dans un langage binaire ne correspondant pas au jeu d'instruction d'un processeur, mais à celui d'un ordinateur virtuel (un programme qui fait de façon logicielle ce que fait un ordinateur avec des pièces électroniques).

Il y a bien sur des raisons à cela, des avantages, et des inconvénients également.

Nous expliquerons cela dans un second article.